并发编程之并发三要素
一、原子性问题在下面的案例中演示了两个线程分别去去调用 demo.incr 方法来对 i 这个变量进行叠加预期结果应该是20000但是实际结果却是小于等于20000的值。public class AtomicDemo{inti0;public voidincr(){i;}public static void main(String[]args)throws InterruptedException{AtomicDemoadnew AtomicDemo();Thread[]threadnew Thread[2];for(int i0;i2;i){thread[i]new Thread(()-{for(intk0;k10000;k){ad.incr();}});thread[i].start();}thread[0].join();thread[1].join();System.out.println(Result:ad.i);}}运行结果如下原因这个就是典型的线程安全问题中原子性问题的体现。那什么是原子性呢在上面这段代码中count是属于Java高级语言中的编程指令而这些指令最终可能会有多条CPU指令来组成而count最终会生成3条指令 通过javap -v xxx.class 查看字节码指令如下。这三个操作如果要满足原子性那么就需要保证某个线程在执行这个指令时不允许其他线程干扰然后实际上确实会存在这个问题。本质前面我们说过一个CPU核心在同一时刻只能执行一个线程如果线程数量远远大于CPU核心数就会发生线程的切换这个切换动作可以发生在任何一条CPU指令执行完之前。对于 i 这三个cpu指令来说如果线程A在执行指令1之后做了线程切换假设切换到线程B线程B同样执行CPU指令执行的顺序如下图所示。就会导致最终的结果是1而不是2.这就是在多线程环境下存在的原子性问题那么怎么解决这个问题呢大家认真观察上面这个图表面上是多个线程对于同一个变量的操作实际上是count这行代码它不是原子的。所以才导致在多线程环境下出现这样一个问题。也就是说我们只需要保证count这个指令在运行期间在同一时刻只能由一个线程来访问就可以解决问题可以使用同步锁Synchronized。二、可见性2.1 不可见问题这个案例比较简单就是t1线程中用到了stop这个属性接在在main线程中修改了 stop 这个属性的值来使得t1线程结束但是t1线程并没有按照期望的结果执行。public class VolatileExample{public static booleanstopfalse;public static void main(String[]args)throws InterruptedException{Threadt1new Thread(()-{inti0;while(!stop){i;}});t1.start();System.out.println(begin start thread);Thread.sleep(1000);stoptrue;}}运行结果2.2 volatile解决不可见问题stop变量加上volatile修饰public class VolatileExample{public volatile static booleanstopfalse;public static void main(String[]args)throws InterruptedException{Threadt1new Thread(()-{inti0;while(!stop){i;}});t1.start();System.out.println(begin start thread);Thread.sleep(1000);stoptrue;}}运行结果2.3 不可见原因2.3.1 cup缓存由来在整个计算机的发展历程中除了CPU、内存以及I/O设备不断迭代升级来提升计算机处理性能之外还有一个非常核心的矛盾点就是这三者在处理速度的差异。CPU的计算速度是非常快的其次是内存、最后是IO设备比如磁盘也就是CPU的计算速度远远高于内存以及磁盘设备的I/O速度。如下图所示计算机是利用CPU进行数据运算的但是CPU只能对内存中的数据进行运算对于磁盘中的数据必须要先读取到内存CPU才能进行运算也就是CPU和内存之间无法避免的出现了IO操作。而cpu的运算速度远远高于内存的IO速度比如在一台2.4GHz的cpu上每秒能处理2.4x109次每次处理的数据量如果是64位操作系统那么意味着每次能处理64位数据量。虽然CPU从单核升级到多核甚至到超线程技术在最大化的提高CPU的处理性能但是仅仅提升CPU性能是不够的如果内存和磁盘的处理性能没有跟上就意味着整体的计算效率取决于最慢的设备为了平衡这三者之间的速度差异最大化的利用CPU。所以在硬件层面、操作系统层面、编译器层面做出了很多的优化CPU增加了高速缓存操作系统增加了进程、线程。通过CPU的时间片切换最大化的提升CPU的使用率编译器的指令优化更合理的去利用好CPU的高速缓存每一种优化都会带来相应的问题而这些问题是导致线程安全性问题的根源那接下来我们逐步去了解这些优化的本质和带来的问题。2.3.2 cup缓存布局CPU在做计算时和内存的IO操作是无法避免的而这个IO过程相对于CPU的计算速度来说是非常耗时基于这样一个问题所以在CPU层面设计了高速缓存这个缓存行可以缓存存储在内存中的数据CPU每次会先从缓存行中读取需要运算的数据如果缓存行中不存在该数据才会从内存中加载通过这样一个机制可以减少CPU和内存的交互开销从而提升CPU的利用率。对于主流的x86平台cpu的缓存行cache分为L1、L2、L3总共3级。2.3.3 缓存一致性问题CPU高速缓存的出现虽然提升了CPU的利用率但是同时也带来了另外一个问题–缓存一致性问题这个一致性问题体现在多线程环境中当多个线程并行执行加载同一块内存数据时由于每个CPU都有自己独立的L1、L2缓存所以每个CPU的这部分缓存空间都会缓存到相同的数据并且每个CPU执行相关指令时彼此之间不可见就会导致缓存的一致性问题2.3.4 缓存一致性协议为了达到数据访问的一致需要各个处理器在访问缓存时遵循一些协议在读写时根据协议来操作常见的协议有MSIMESIMOSI等。最常见的就是MESI协议。接下来给大家简单讲解一下MESI。MESI表示缓存行的四种状态分别是M(Modify[ˈmɒdɪfaɪ]) 表示共享数据只缓存在当前CPU缓存中并且是被修改状态也就是缓存的数据和主内存中的数据不一致E(Exclusive[ɪkˈskluːsɪv]) 表示缓存的独占状态数据只缓存在当前CPU缓存中并且没有被修改S(Shared[ʃerd]) 表示数据可能被多个CPU缓存并且各个缓存中的数据和主内存数据一致I(Invalid[ˈɪnvəlɪd]) 表示缓存已经失效在CPU的缓存行中每一个Cache一定会处于以下三种状态之一SharedExclusiveInvalid注意:MESI协议只对汇编指令中执行加锁操作的变量有效表现到java中为使用voliate关键字定义变量或使用加锁操作三、有序性3.1 指令重排序问题public class SeqExample{private static intx0,y0;private static inta0,b0;public static void main(String[]args)throws InterruptedException{inti0;for(;;){i;x0;y0;a0;b0;Threadt1new Thread(()-{a1;xb;//xb;a1;});Threadt2new Thread(()-{b1;ya;//ya;b1;});/** * 可能的结果 *1和1 *0和1 *1和0 * ---- *0和0 */ t1.start();t2.start();t1.join();t2.join();Stringresult第i次(x,y);if(x0y0){System.out.println(result);break;}else{}}}}运行结果3.2 volatile解决指令重排序问题public class SeqExample{private volatile static intx0,y0;private volatile static inta0,b0;public static void main(String[]args)throws InterruptedException{inti0;for(;;){i;x0;y0;a0;b0;Threadt1new Thread(()-{a1;xb;//xb;a1;});Threadt2new Thread(()-{b1;ya;//ya;b1;});/** * 可能的结果 *1和1 *0和1 *1和0 * ---- *0和0 */ t1.start();t2.start();t1.join();t2.join();Stringresult第i次(x,y);if(x0y0){System.out.println(result);break;}else{}}}}运行结果(程序不会停)3.3 内存屏障CPU在性能优化道路上导致的顺序一致性问题在CPU层面无法被解决原因是CPU只是一个运算工具它只接收指令并且执行指令并不清楚当前执行的整个逻辑中是否存在不能优化的问题也就是说硬件层面也无法优化这种顺序一致性带来的可见性问题。因此在CPU层面提供了写屏障、读屏障、全屏障这样的指令在x86架构中这三种指令分别是SFENCE、LFENCE、MFENCE指令sfence也就是save fence写屏障指令。在sfence指令前的写操作必须在sfence指令后的写操作前完成。lfence也就是load fence读屏障指令。在lfence指令前的读操作必须在lfence指令后的读操作前完成。mfence也就是modify/mix混合屏障指令在mfence前得读写操作必须在mfence指令后的读写操作前完成。在Linux系统中将这三种指令分别封装成了, smp_wmb-写屏障 、 smp_rmb-读屏障 、 smp_mb-读写屏障三个方法被volatile修饰的变量在编译成字节码文件时会生成多个lock指令该指令在执行过程中会生成相应的内存屏障以此来解决可见性跟重排序的问题。内存屏障的作用1.在有内存屏障的地方会禁止指令重排序即屏障下面的代码不能跟屏障上面的代码交换执行顺序。2.在有内存屏障的地方线程修改完共享变量以后会马上把该变量从本地内存写回到主内存并且让其他线程本地内存中的该变量副本失效使用MESI协议四、JMM下图所示在不同的CPU架构中为了避免因为指令重排序或者缓存一致性问题都提供了不同的内存屏障指令。同时在不同的操作系统中也都会实现封装一个内存屏障的实现。那么我们写的Java线程如何能够在不同的硬件、不同操作系统下仍然能够保证线程安全性呢这就要引出JMMJava 内存模型它就是为了屏蔽操作系统和硬件的差异让一套代码在不同平台下都能达到线程安全的访问目的。4.1 JMM介绍首先我们都知道Java程序是运行在Java虚拟机上的同时我们也知道JVM是一个跨语言跨平台的实现也就是Write Once、Run Anywhere。那么JVM如何实现在不同平台上都能达到线程安全的目的呢所以这个时候JMM出来了Java内存模型Java Memory Model ,JMM就是一种符合内存模型规范的屏蔽了各种硬件和操作系统的访问差异的保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。Java内存模型规定了所有的变量都存储在主内存中每条线程还有自己的工作内存线程的工作内存中保存了这个线程中用到的变量的主内存副本拷贝线程对变量的所有操作都必须在工作内存中进行而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行流程图如下再总结一下 JMM定义了共享内存中多线程程序读写操作的行为规范在虚拟机中把共享变量存储到内存以及从内存中取出共享变量的底层实现细节。目的是解决由于多线程通过共享内存进行通信时存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。本地内存是JMM的一个抽象概念并不真实存在。它涵盖了缓存写缓冲区寄存器以及其他的硬件和编译器优化。实际上不难发现JMM的整个模型实际上同CPU高速缓存和内存交互的模型是一致的因为不管软件怎么设计最终还是由硬件来执行。而这个抽象模型的意义就在于它可以针对不同平台来保证并发场景下的可见性问题以及有序性问题。4.2 JMM在JVM中实现的相关源码java内存屏障inline void OrderAccess::loadload(){acquire();}inline void OrderAccess::storestore(){release();}inline void OrderAccess::loadstore(){acquire();}inline void OrderAccess::storeload(){fence();}orderAccess_linux_x86.inlineinline void OrderAccess::fence(){if(os::is_MP()){// always use locked addl since mfence is sometimes expensive#ifdef AMD64__asm__ volatile(lock; addl$0,0(%%rsp):::cc,memory);#else__asm__ volatile(lock; addl$0,0(%%esp):::cc,memory);#endif}}orderAccess_linux_sparc.inlineinline void OrderAccess::fence(){__asm__ volatile(membar #StoreLoad:::);}OrderAccess::storeload();ACC_VOLATILE bool is_volatile()const{return(_flagsJVM_ACC_VOLATILE)!0;}五、Happens-Before模型前面说了这么多都是为了讲解清楚到底是什么原因导致了在多线程环境下的可见性和有序性问题。并且也了解了volatile解决可见性问题的本质。那么有没有哪些情况是不需要通过增加volatile关键字也能保证在多线程环境下的可见性和有序性的呢从JDK1.5开始引入了一个happens-before的概念来阐述多个线程操作共享变量的可见性问题。所以我们可以认为在JMM中如果一个操作执行的结果需要对另一个操作可见那么这两个操作必须要存在happens-before关系。这两个操作可以是同一个线程也可以是不同的线程。happens-before字面意思先行发生以Java层面来理解的话就是上一行代码的结果会被下一行代码所使用到这就是先行发生。5.1 程序顺序规则一个线程中的每个操作happens-before这个线程中的任意后续操作可以简单认为是as-if-serial。as-if-serial的意思是不管怎么重排序单线程的程序的执行结果不能改变。处理器不能对存在依赖关系的操作进行重排序因为重排序会改变程序的执行结果。对于没有依赖关系的指令即便是重排序也不会改变在单线程环境下的执行结果。具体来看下面这段代码A和B允许重排序但是C是不允许重排因为存在依赖关系。根据as-if-serial语义在单线程环境下 不管怎么重排序最终执行的结果都不会发生变化。inta2;//A intb2;//B intca*b;//C5.2 传递性规则仍然看下面这段代码根据程序顺序规则可以知道这三者之间存在一个happens-before关系。inta2;//A intb2;//B intca*b;//CA happens-before B。B happens-before C。A happens-before C。这三个happens-before关系就是根据happens-before的传递性推导出来的。不是说A和B之间允许重排序吗那是不是A happens-before B不一定存在也可能是B可以重排序在A之前执行呢没错确实是这样JMM不要求A一定要在B之前执行但是他要求的是前一个操作的执行结果对后一个操作可见。这里操作A的执行结果不需要对操作B可见并且重排序操作A和操作B后的执行结果与A happens-before B顺序执行的结果一致这种情况下是允许重排序的。5.3 volatile变量规则对于volatile修饰的变量的写操作一定happens-before后续对于volatile变量的读操作这个是因为volatile底层通过内存屏障机制防止了指令重排这个规则前面已经分析得很透彻了所以没什么问题我们再来观察如下代码基于前面两种规则再结合volatile规则来分析下面这个代码的执行顺序假设两个线程A和B分别访问writer方法和reader方法那么它将会出现以下可见性规则。public class VolatileExample{inta0;volatile booleanflagfalse;public voidwriter(){a1;//1flagtrue;//2}public voidreader(){if(flag){//3 intia;//4}}}1 happens before 2、 3 happens before 4 这个是程序顺序规则2 happens before 3、 是由volatile规则产生的对一个volatile变量的读总能看到任意线程对这个volatile变量的写入。1 happens before 4 基于传递性规则以及volatile的内存屏障策略共同保证。那么最终结论是如果在线程B执行reader方法时如果flag为true那么意味着 i1成立。这里有人可能会有疑问说你前面讲的程序顺序规则中在单线程中如果两个指令之间不存在依赖关系是允许重排序的也就是1 和 2的顺序可以重排那么是不是意味着最终4输出的结果是0呢这里也是因为volatile修饰的重排序规则的存在导致1和2是不允许重排序的在volatile重排序规则表中如果第一操作是普通变量的读/写第二个操作是volatile的写那么这两个操作之间不允许重排序。5.4 监视器锁规则一个线程对于一个锁的释放锁操作一定happens-before与后续线程对这个锁的加锁操作。public class Synchronizedrule{private Object obnew Object();int x10;private voidtestRule(){synchronized(ob){// 此处自动加锁 // x 是共享变量, 初始值10System.out.println(x);if(this.x12){this.x12;}}// 此处自动解锁}public static void main(String[]args)throws InterruptedException{Synchronizedrule synchronizedrulenew Synchronizedrule();Thread thread1new Thread(()-{synchronizedrule.testRule();});Thread thread2new Thread(()-{synchronizedrule.testRule();});thread1.start();thread2.start();thread1.join();thread2.join();}}假设x的初始值是10线程A执行完代码块后x的值会变成12执行完成之后会释放锁。 线程B进入代码块时能够看到线程A对x的写操作也就是B线程能够看到x12。5.5 start规则如果线程A执行操作ThreadB.start(),那么线程A的ThreadB.start()之前的操作happens-before线程B中的任意操作。public class StartDemo{static intx0;public static void main(String[]args){Thread t1new Thread(()-{// 主线程调用 t1.start()之前 // 所有对共享变量的修改此处皆可见 // 此例中x10System.out.println(x);});// 此处对共享变量 x修改 x10;// 主线程启动子线程 t1.start();}}5.6 join规则join规则如果线程A执行操作ThreadB.join()并成功返回那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功的返回。public class JoinRule{static int x10;public static void main(String[]args)throws InterruptedException{Thread t1new Thread(()-{// 此处对共享变量 x 修改 x100;});// 例如此处对共享变量修改 // 则这个修改结果对线程 t1 可见 // 主线程启动子线程 t1.start();t1.join();// 子线程所有对共享变量的修改 // 在主线程调用 t1.join()之后皆可见 // 此例中x100System.out.println(x);}}六、DCL重排序6.1 DCL错误写法public class Singleton{private static Singleton singleton;privateSingleton(){}public static SingletongetInstance(){if(singletonnull){synchronized(Singleton.class){if(singletonnull)singletonnew Singleton();}}returnsingleton;}}这个代码看起来perfect1、如果检查第一个singleton不为null则不需要再执行加锁动作极大的提高了性能2、如果第一个singleton为null即使有多个线程同时判断但是由于synchronized的存在只有一个线程能创建对象3、当第一个获取锁的线程创建完成singleton对象后其他的线程在第二次判断singleton一定不会为null则直接返回已经创建好的singleton对象DCL看起来非常完美但其实这个是不正确的。逻辑没问题分析也没问题但为何是不正确的不妨我们先回顾一下创建对象的过程1、为对象分配内存空间2、初始化对象3、将内存空间的地址赋值给对应的引用但由于jvm编译器的优化产生的重排序缘故步骤2、3可能会发生重排序1、为对象分配内存空间2、将内存空间的地址赋值给对应的引用3、初始化对象如果2、3发生了重排序就会出现第二个线程访问的时候最外层的判断singleton ! null的情况但是它其实仅仅只是一个地址而已此时对象还没有被初始化所以return的singleton对象是一个没有被初始化的对象。6.2 DCL正确写法利用volatile的特性即可阻止重排序和可见性public class Singleton{private volatile static Singleton singleton;privateSingleton(){}public static SingletongetInstance(){if(singletonnull){synchronized(Singleton.class){if(singletonnull)singletonnew Singleton();}}returnsingleton;}}