探索并发编程源头
1、 谈到并发编程,大家可能首先想到的就是多线程,我们通过多线程执行来充分利用系统资源提升执行效率。
2、 而一个线程可以看作是一个任务的执行者,有了多个任务执行者那么我们相应的就需要采用分而治之的思想,根据业务将大任务拆分成小任务并分配给多个执行者,分工执行。
3、 在他们分工执行任务的时候,我们还需要考虑线程之间是否需要协作,比如生产消费者模型里,当任务队列满的时候,生产线程等待,当队列不满时,生产线程需要被唤醒执行,消费者线程反之。
4、 在分工与协作保证了高效的情况下,我们还要考虑一个更重要的问题,那就是正确性,就是常说的线程安全。
5、 当多个线程同时访问同一个变量的时候,就会产生线程安全问题,因为结果是不确定的,而且往往不是我们预期的.那怎么解决这类问题呢?
6、 其实问题出现的原因很明显,就是:
a、 多个线程同时访问 b、 访问同一个变量
只要我们打破这两个条件中的任一个,就可以解决问题。
7、 在java语言中,它也是通过破环这种条件来保证线程安全的,比如java语言本身的synchronized,以及SDK里的各种Lock都是打破条件1来保证安全的,通过加锁来让多个线程访问共享变量时互斥(所谓互斥,指的就是同一时刻,只允许一个线程访问共享变量);
8、 而java中的ThreadLocal就是打破条件2来保证安全的,在多线程访问时,会为每一个线程针对共享变量生成一个副本,各自使用各自的副本。
9、 当然还有一些利用其他方式来保证线程安全的,比如通过final关键字,Copy-on-write模式使共享变量只读。
10、方法内联
比如addA()方法内部调用addB()方法的时候 如果addB()方法是final的 就会产生方法内联,减少一次栈帧的分配 所以大家在类中写方法的时候,如果这个方法是这个类私有的 那就用private声明 此时默认就会是final的
总结:并发编程的三个要点,分工、协作与互斥。
11、而编写并发程序,往往更容易出问题,致使一些同学即便知道 用多线程可以提升性能,但由于对相关知识了解薄弱,也只能用串行的方式实现。
12、这些年随着硬件技术的发展,CPU、内存、I/O设备都在不断更新,计算速度、读写速度都越来越快。
13、但是,无论怎么发展,有一个问题一直都未解决,那就是这三者的速度差异。CPU与内存的速度可以相差上百倍,而内存与硬盘的速度就差的更大了。
14、为了解决这个问题,平衡三者的速度差异,主要从计算机体系结构,操作系统,编译层面做了处理。
15、为CPU增加了缓存,以均衡与内存的速度差异。
16、操作系统增加了进程、线程、以及分时复用CPU,进而均衡CPU与I/O设备的速度差异。
17、编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
18、这些优化在很大程度上解决了三者的速度差异,但天下没有免费的午餐,在我们享受着这份成果的同时,也为写并发程序埋下了深深的隐患。
19、下来,我们来分析一下有哪些问题,它们都是怎么产生的。
首先,第一个:
a、 隐患一:缓存带来的可见性问题:
单核时代
所有的线程使用的是同一颗CUP,共享同一个缓存,所以一个线程对缓存中数据的写操作,对另一个线程是可见的,这种情况也很容易保证缓存与内存数据的一致性。如下图线程A与线程B操作同一个CUP缓存,线程A更新了变量i的值,线程B之后再去访问,一定会得到新值。这就保证了可见性。
而到了当今,也就是多CPU的时期每个线程所获得的是不同的CPU,操作着不同的CPU缓存。如下图:
多核时代
此时线程A操作CPU-1的缓存,B操作着CPU-2的缓存,很容易看出,线程A对变量i的写操作,对线程B是不可见的。(这里就可以用volatile关键字来保证可见性)
例子:我们可以用一段代码,来验证可见性问题
public class TestDemo01 { private int count=0; public int getCount(){ return count; } private void add(){ for (int i = 0; i < 1000 ; i++) { count+=1; } } public static void main(String[] args) { try { final TestDemo01 testDemo01=new TestDemo01(); //创建两个线程,执行add操作 Thread thA=new Thread(()->{ testDemo01.add(); }); Thread thB=new Thread(()->{ testDemo01.add(); }); //启动两个线程 thA.start(); thB.start(); //等待两个线程执行结束 thA.join(); thB.join(); System.out.println(testDemo01.getCount()); } catch (InterruptedException e) { e.printStackTrace(); } } }
我们期望的结果是20000,但实际上结果却是10000到20000之间的随机数。可以结合刚刚对可见性的理解来分析一下,假设线程thA和线程thB同时开始执行,第一次读入到各自缓存的count值都是0,执行完count+=1后,各自CPU缓存里的值都是1,同时写入内存后,内存中是1,而不是我们期望的2。之后由于各自的CPU缓存里都有了count的值,所以两个线程都是基于CPU缓存里的count来计算的,最终导致count的值都是小于20000的。
隐患二、线程切换带来的原子性问题:
1、 由于IO读写的速度较内存与CPU实在太慢,所以多进程,多线程便应运而生,帮助我们在使用单核CPU的情况下,仍然可以同时开启多个应用,比如边听歌,边浏览网页。
2、 操作系统会允许某个线程执行一小段时间,比如100毫秒,过了100毫秒操作系统就会重新选择一个线程来执行,这100毫秒称为时间片。
JVM的运行时数据区中的程序计数器,它的作用就是为了保存指令的,就是因为一个程序没有执行完,但是它所占用的时间片已经到了,CPU该服务其他程序了。 所以它需要把下一条要执行的指令保存起来 以便CPU再分配到它这里时,继续执行。如图(CPU的执行过程)
当然,除了时间片用完了要切换,还有一种情况,也会出现CPU的调度。比如在一个时间片内,线程要执行IO操作,这个时候它会把自己标记为休眠状态,并让出CPU的使用权,以便在等待IO操作时,CPU可以做别的事情,为别的线程服务,来提高CPU的使用率。
到这里我们再思考一下,上面那段代码里count+=1操作,是不是在单核CPU下就是安全的呢?
要分析这个问题,我们还要清楚count+=1是不是一个原子操作,对CPU来说一个原子操作就是一条CPU指令,线程的切换会发生在任何一条CPU指令执行完的时候,而JAVA,作为高级语言,它的一条语句,往往是由多条指令完成的,比如count+=1,至少需要三条CPU指令。
1. 将count从内存加载到CPU寄存器。 2. 在寄存器中执行+1操作。 3. 最后将结果写入内存
此时一样会出现两个线程都执行count+=1操作,但结果却不是我们想要的2。
这就是由于线程切换而带来的原子性问题,而我们往往会以为java的一条语句是不可分割,一次执行的,而为原子性问题埋下祸根。
隐患三:编译优化带来的有序性问题。
A、编译器为了优化性能,经常会改变程序中语句的先后顺序。
B、代码在编译时期,会进行排序,在运行期,也会进行指令重排。
(JIT及时编译器大家可以去了解一下)
C、是怎样重排呢?
例如、两个赋值语句:"a=1;b=2;" 编译器可能会优化成 "b=2;a=1;",改变了赋值的先后顺序,但不会影响最终结果。
(要注意,这个不会影响最终结果的指标,只在单线程场景下)
D、在并发场景下,就会产生我们不易发现的错误。我们通过一个例子来说明一下:
public class Singleton{ private static Singleton instance=null; private Singleton(){ } private static Singleton getInstance(Singleton instance){ if (instance ==null){ synchronized (Singleton.class){ if (instance == null){ instance=new Singleton(); } } } return instance; } }
现在假设有两个线程A、B先后调用getInstance()方法,当A执行instance = new Singleton()时,B正好进行第一个if(instance == null)的判断,执行到这里,你可能会觉得一切正常,并没有什么问题。
但如果你了解了new一个对象的步骤,以及编译器对执行顺序的优化,一切自然就真相大白了。
正常情况下new 创建对象的步骤: 1. 分配一块内存M 2. 在内存M上初始化Singleton对象 3. 然后将M的地址赋给变量instance 但执行优化后,很有可能变为: 1. 分配一块内存M 2. 然后将M的地址赋给变量instance 3. 最后在M上初始化Singleton对象 这样优化后会有什么问题呢? 此时在A还未执行第三步,但B判断instance不为空,就去使用,便会报空指针异常。(这里就引出了volatile关键字的另一个 作用。就是禁止指令重排序。)
好了,到此为止,并发编程的三大隐患问题,可见性问题、原子性问题、有序性问题,就已经讲完了。