大家好,我是悟空。最近看了Eureka的源码,发现很多地方都用到了volatile,那就复习一下吧。这篇文章全面讲解了volatile的用法和细节。建议阅读前收藏转发。1.Volatile怎么读?看到这个词,一直不知道怎么念。英语[?v?l?ta?l]美式[?vɑ?l?tl]adj.[化学]易挥发;不稳定;爆炸物;任性。Java中的volatile有什么作用?怎么样?二、Java中的volatile有什么用?Volatile是Java虚拟机提供的轻量级同步机制(三大特性)。保证可见性,不保证原子性,禁止指令重排。要了解三大特性,就必须知道Java内存模型(JMM),什么是JMM?三、什么是JMM?这是一份精心总结的Java内存模型思维导图,感谢您的收下。示意图1-Java内存模型3.1为什么需要Java内存模型?Why:屏蔽各种硬件和操作系统的内存访问差异JMM即Java内存模型,即JavaMemoryModel,简称JMM,它本身是一个抽象的概念。事实上,它并不存在。它描述了一组规则或规范,通过这些规则或规范定义了程序中各种变量(包括实例字段、静态字段和构成数组对象的元素)的访问方法。3.2Java内存模型到底是什么?1.定义程序中各种变量的访问规则2.在内存中存储变量值的底层细节3.从内存中取出变量值的底层细节3.3Java内存模型的两大内存是什么?示意图2-两大内存主内存Java堆的对象实例数据部分对应物理硬件的内存工作内存的Java栈中的一些区域优先存放在寄存器和缓存中3.4Java内存模型是怎样的工作?Java内存模型的几个规范:1.所有变量都存储在主存中2.主存是虚拟机内存的一部分3.每个线程都有自己的工作内存4.线程的工作内存持有一份变量的主内存5.线程对变量的操作必须在工作内存中进行。6.不同线程之间不能直接访问对方工作内存中的变量。7、线程间变量值的传递需要通过主存完成。当一个线程被创建时,JVM会为它创建一个工作内存(有些地方称为堆栈空间)。工作内存是每个线程的私有数据区,Java内存模型规定所有变量都存放在主内存中,主内存是共享的。内存区可以被所有线程访问,但是线程对变量的操作(读赋值等)必须在工作内存中进行。首先,必须将变量从主存复制到自己的工作内存空间,然后对变量进行操作。完成后将变量写入主存,不能直接操作主存中的变量。每个线程的工作内存都保存了主内存中变量的副本,所以不同的线程不能互相访问对方的工作内存。通信(传值)必须通过主存,其访问过程简述:示意图3-Java内存模型3.5Java内存模型的三大特点可见性(当一个线程修改一个共享变量的值时,其他线程可以立即知道这个修改)原子性(一个操作或一系列操作是不可分割的,要么同时成功,要么同时失败)有序性(变量赋值操作的顺序与线程中的执行顺序一致程序代码)关于有序性:如果在本线程中观察,所有操作都是有序的;如果在一个线程中观察另一个线程,则所有操作都是无序的。前半句指的是“Within-ThreadAs-If-SerialSemantics”(线程内As-If-SerialSemantics),后半句指的是“指令重排序”和“同步”现象工作记忆和主记忆之间的延迟”。4.能否举例说明如何使用volatile?考虑这样的场景:有一个对象,字段number初始化值=0,这个对象有一个public方法setNumberTo100()可以设置number=100。当主线程通过子线程调用setNumberTo100()后,是否主线程知道数字值改变了吗?答:如果number变量没有用volatile定义,主线程不知道子线程更新了number的值。(1)如上所述定义对象:ShareDataclassShareData{intnumber=0;publicvoidsetNumberTo100(){this.number=100;}}(2)在主线程中初始化一个子线程,命名为sub-thread子线程先休眠3s,然后设置number=100。主线程不断检查number值是否等于0,如果不等于,则退出主线程。publicclassvolatileVisibility{publicstaticvoidmain(String[]args){//资源类ShareDatashareData=newShareData();//子线程实现Runnable接口,lambda表达式newThread(()->{System.out.println(Thread.currentThread().getName()+"\tcomein");//线程休眠3秒,假设正在执行一个操作try{TimeUnit.SECONDS.sleep(3);}catch(InterruptedExceptione){e.printStackTrace();}//修改number的值myData.setNumberTo100();//输出修改后的值System.out.println(Thread.currentThread().getName()+"\tupdatenumbervalue:"+myData.number);},"childthread").start();while(myData.number==0){//主线程一直在这里等待循环,直到number的值不等于0}//这个值是合理的打印不出来,因为主线程运行的时候,number的值为0,所以一直循环//如果能输出这句话,说明子线程休眠3秒后,更新的number的值被重写到主存,被主线程感知System.out.println(Thread.currentThread().getName()+"\t主线程感知到number不等于0");/***最终输出:*sub-threadcomein*sub-threadupdatenumbervalue:100*final线程没有停止,并没有并行输出“主线程知道number不等于0”这句话,说明即没有使用volatile修饰的变量,变量的更新是不可见的*/}}不使用volatile(3)我们使用volatile修饰变量numberclassShareData{//volatile修饰的关键字是为了增加多线程之间的可见性,只要一个线程修改内存中的值,其他线程可以立即感知到volatileintnumber=0;publicvoidsetNumberTo100(){this.number=100;}}输出结果:sub-threadcomeinsub-threadupdatenumbervalue:100mainmainthreadknowsthatnumberisnotequalto0Processfinishedwithexitcode0mark"总结:说明用volatile修饰的变量,当一个线程更新该变量,其他线程也可以感知它。”五、为什么其他线程能感知到变量更新?实际上,这里使用的是“窥探”协议。在说“窥探”协议之前,我们先说说缓存一致性。5.1缓存一致性当多个CPU持有的缓存来自同一个主存副本,当其他CPU偷偷更改主存数据时,其他CPU不知道,复制的内存就会与主存不一致,这就是缓存不一致.那么我们如何保证缓存的一致性呢?这里需要操作系统共同制定一个同步规则来保证,这个规则有MESI协议。如下图,CPU2偷偷把num改成了2,内存中的num也改成了2,但是CPU1和CPU3并不知道num的值变了。SchematicFigure4-CacheConsistency15.2MESI当CPU写入数据时,如果发现被操作的变量是共享变量,即在其他CPU中有该变量的副本,系统会发送信号通知其他CPU内存变量的缓存行的CPU设置为无效。如下图,CPU1和CPU3中num=1无效。SchematicFigure5-CacheConsistency2当其他CPU读取这个变量,发现缓存这个变量的cacheline无效时,会重新从内存中读取。如下图,当CPU1和CPU3发现缓存的num值无效时,再次从内存中读取,num值更新为2。示意图6-缓存一致性35.3总线嗅探其他CPU如何知道更新缓存无效?下面介绍总线嗅探技术。每个CPU不断地嗅探总线上传输的数据,以检查其缓存值是否已过期。如果处理器发现自己的缓存行对应的内存地址被修改,就会将当前处理器的缓存行置为无效状态。当处理器修改数据时,它会重新从内存中读取数据到处理器缓存中。示意图7-CacheConsistency45.4BusStorm总线嗅探技术有哪些缺点?由于MESI缓存一致性协议,需要不断的对主线进行内存嗅探,大量的交互会导致总线带宽达到峰值。所以不要滥用volatile,可以改用锁,看场景~六、能不能演示一下为什么volatile不保证原子性?原子性:一个操作或一系列操作是不可分割的,要么同时成功,要么同时失败。“这个定义和volatile有什么关系?我完全看不懂?给我看代码!”考虑这样一个场景:20个线程同时将number加1,执行1000次,number的值是多少?在单线程场景下,答案是20,000。如果是多线程场景呢?答案可能是20,000,但在很多情况下小于20,000。示例代码:packagecom.jackson0714.passjava.threads;/**volatile不保证原子性的演示*@create:2020-08-1309:53*/publicclassVolatileAtomicity{publicstaticvolatileintnumber=0;publicstaticvoidincrease(){number++;}publicstaticvoidmain(String[]args){for(inti=0;i<50;i++){newThread(()->{for(intj=0;j<1000;j++){increase();}},String.valueOf(i)).start();}//当所有累计线程结束while(Thread.activeCount()>2){Thread.yield();}System.out.println(number);}}执行结果:第一个时间19144,第二次20000,第三次19378。volatile的第一次执行结果volatile的第二次执行结果volatile的第三次执行结果我们来分析increase()方法,通过反编译工具javap得到如下汇编代码:publicstaticvoidincrease();Code:0:getstatic#2//fieldnumber:I3:iconst_14:iadd5:putstatic#2//fieldnumber:I8:returnnumber++实际执行了3条指令:getstatic:取number的原值iadd:加1操作putfield:写回加1后的值,执行时getstatic指令number的值被取到操作栈顶,volatile关键字保证此时number的值是正确的,但是在执行iconst_1和iadd指令时,其他线程可能已经改变了number的值,而操作栈顶的值就变成了过期数据,所以执行完putstatic指令后可能会将较小的数值同步回主存。总结如下:在执行number++这行代码时,即使number变量被volatile修饰,在执行过程中仍然有可能被其他线程修改,不保证原子性。7、如何保证输出结果为20000?7.1synchronized同步代码块我们可以通过使用synchronized同步代码块来保证原子性。使得结果等于20000publicsynchronizedstaticvoidincrease(){number++;}synchronized同步代码块的执行结果但是使用synchronized太重了,会造成阻塞,只能有一个线程进入这个方法。我们可以使用JavaConcurrencyPackage(JUC)中的AtomicInterger工具包。7.2AtomicInterger原子操作下面看一下AtomicInterger原子自增方法getAndIncrement()AtomicIntergerpublicstaticAtomicIntegeratomicInteger=newAtomicInteger();publicstaticvoidmain(String[]args){for(inti=0;i<20;i++){newThread(()->{for(intj=0;j<1000;j++){atomicInteger.getAndIncrement();}},String.valueOf(i)).start();}//当所有累积线程结束while(Thread.activeCount()>2){Thread.yield();}System.out.println(atomicInteger);}多次运行结果为20000getAndIncrement的执行结果8.什么是禁止指令重排?说到指令重排,大家一定知道为什么要重排,有哪些重排。如下图所示,指令执行顺序为1>2>3>4。重排后,执行顺序更新为指令3->4->2->1。Schematic8-Instructionrearrangement,你会不会觉得指令的顺序被重排打乱了,这样好吗?大家可以回忆一下小学的数学题:2+3-5=?,如果把运算顺序换成3-5+2=?,结果是一样的。因此,指令重排是为了保证单线程下程序结果不变。8.1为什么我们需要重新排序?当计算机执行程序时,为了提高性能,编译器和处理器经常对指令进行重新排序。8.2重排有哪几种?1.编译器优化重排:编译器可以在不改变单线程程序语义的情况下,重新排列语句的执行顺序。2、指令级并行重排:现代处理器采用指令级并行技术,将多条指令重叠执行。如果没有数据依赖,处理器可以改变语句对应机器指令的执行顺序。3.内存系统的重新排序:由于处理器使用高速缓存和读/写缓冲区,这使得加载和存储操作看起来是乱序执行的。示意图9-三种重排注意:在单线程环境下,保证最终的执行结果与代码序列的结果一致。当处理器进行重新排序时,它必须考虑指令之间的数据依赖性。在多线程环境中,线程是交替执行的。由于编译器优化重排的存在,两个线程中使用的变量是否能保证一致性是不确定的,结果也是不可预测的。8.3比如说说多线程中的指令重排?想象这样一个场景:定义了变量num=0和变量flag=false,线程1调用初始化函数init()执行后,线程调用add()方法,当另一个线程判断flag=true并执行num+100操作,那么我们的预期结果是num会等于101,但是因为有可能指令重排,num=1和flag=true的执行顺序可能会颠倒,所以至于num可能等于100publicclassVolatileResort{staticintnum=0;staticbooleanflag=false;publicstaticvoidinit(){num=1;flag=true;}publicstaticvoidadd(){if(flag){num=num+5;System.out.println("num:"+num);}}publicstaticvoidmain(String[]args){init();newThread(()->{add();},"子线程").start();}}先看线程1的指令重排:数字=1;标志=真;执行顺序变为flag=true;num=1;,如时序图示意图10-线程1指令重排如果线程2num=num+5在线程1设置num=1之前执行,那么num的值线程2的变量为5,时序图如下图所示。示意图11-线程2在num=1之前执行8.4volatile如何禁止指令重排?我们使用volatile来定义flag变量:staticvolatilebooleanflag=false;《如何实现禁止指令重排:》原理:通过在指令序列前后插入volatile内存屏障(MemoryBarries)来禁止处理器重排。》有四种内存屏障,如下:《四种内存屏障》如何在volatile写场景中插入内存屏障:》在每一个volatile写操作的前面插入一个StoreStore屏障(write-writebarrier)。在每个易失性写操作之后插入一个StoreLoad屏障(写-读屏障)。示意图12-volatile写场景下如何插入内存屏障Storebarrier可以保证在volatile写之前(标志赋值操作flag=true),之前所有正常的写操作(num赋值操作num=1)都已经执行完毕任何processor都可以看出,所有正常写入都保证在volatile写入之前刷新到主存。《Howtoinsertamemorybarrierinavolatilereadscenario:》在每次volatile读操作之后插入一个LoadLoad屏障(read-readbarrier)。在每个易失性读取操作之后插入一个LoadStore屏障(读写屏障)。示意图13-volatile读场景如何插入内存屏障LoadStore屏障可以保证后面所有正常的写操作(num赋值操作num=num+5)必须在volatile读(if(flag))之后执行。10、volatile的常见应用这里是一个应用,单例模式,双检测加锁包com.jackson0714.passjava.threads;-08-17*/classVolatileSingleton{privatestaticVolatileSingletoninstance=null;privateVolatileSingleton(){System.out.println(Thread.currentThread().getName()+"\t我是构造函数SingletonDemo");}publicstaticVolatileSingletongetInstance(){//首先detectionif(instance==null){//锁定代码块synchronized(VolatileSingleton.class){//二次检测if(instance==null){//实例化对象instance=newVolatileSingleton();}}}returninstance;}}代码看起来不错,但是instance=newVolatileSingleton();其实可以看成三个伪代码:memory=allocate();//1、分配对象内存空间instance(memory);//2、初始化对象instance=memory;//3、设置instance指向内存地址刚刚分配,此时instance!=nullstep2和step3之间没有数据依赖,而且无论重排前后,程序的执行结果在单线程中不会发生变化,所以允许这种重排优化.memory=allocate();//1、分配对象内存空间instance=memory;//3、设置instance指向刚刚分配的内存地址,此时instance!=null,但是对象还没有初始化instance(内存);//2.初始化对象如果另一个线程执行:if(instance==null),返回刚刚分配的内存地址,但是对象还没有初始化,得到的实例是假的。如下图所示:示意图14-双重检查锁并发问题的解决方案:将instance定义为volatile变量privatestaticvolatileVolatileSingletoninstance=null;十一、volatile不保证原子性,为什么我们还要用它?奇怪的是,Volatile不保证原子性,为什么我们还要用呢?Volatile是一种轻量级的同步机制,与synchronized相比,它对性能的影响更小。典型用法:检查一个状态标志以确定是否退出循环。例如,线程试图通过类似于数羊的传统方法进入睡眠状态。为了让这个例子正确执行,sleep必须是一个volatile变量。否则当sleep被其他线程修改时,执行判断的线程是找不到的。“那我们为什么不直接用synchronized和lock呢?他们既能保证可见性,又能保证原子性,为什么不用呢?”因为synchronized和lock都是排它锁(悲观锁),如果多个线程需要访问这个变量,就会产生竞争,只有一个线程可以访问这个变量,其他线程阻塞,会影响程序的性能。注意:当且仅当满足以下所有条件时,使用volatile变量写入变量不应依赖于变量的当前值,或者可以确保只有单个线程更新变量的值。该变量不与其他状态一起包含在不变条件中。访问变量时不需要加锁。12、volatile和synchronized的区别volatile只能修改实例变量和类变量,synchronized可以修改方法和代码块。volatile不保证原子性,synchronized保证原子性。volatile不会造成阻塞,而synchronized可能会造成阻塞。volatile轻量级锁、synchronized重量级锁volatile和synchronized都保证了可见性和有序性。保证可见性:当一个线程修改共享变量的值时,其他线程可以立即知道修改。volatile确保指令不会在单个线程下重新排列:通过插入内存屏障来保证指令执行的顺序。Volatitle不保证原子性。a++等自增操作存在并发风险,如扣库存、发放优惠券等。volatile类型的64位long和double变量具有读取/写入变量的原子性。volatile可以用在doublechecklock的单例模式下,比synchronized有更好的性能。volatile可用于检查状态标志以确定是否退出循环。代码已经提交到github/码云:https://gitee.com/jayh2018/PassJava-Learning参考资料:《深入理解Java虚拟机》《Java并发编程的艺术》《Java并发编程实战》本文转载自微信公众号“悟空聊天架构”,大家可以关注下以下二维码。转载本文请联系悟空聊天架构公众号。
