前言:volatile关键字在面试中经常被问到,从volatile关键字可以引申出许多知识点,因此有必要对此进行总结。本文根据《深入理解Java虚拟机——JVM高级特性与最佳实践》中的相关章节,整理得来。
相关名词
TPS(Transactions Per Second):每秒事务处理数,衡量一个服务性能好坏的评判标准。
JMM(Java Memory Model):Java内存模型。
1.硬件上解决数据一致性
由于CPU有高速缓存机制,所以在程序运行时,会将需运算的数据从主存中复制一份到高速缓存中,在CPU进行计算时,直接在缓存中进行数据的读取和写入,在运算完成后,再将缓存中的数据刷新到主存中。这种模式在单线程中是没有任何问题的,但在多线程中会出现数据不一致的问题,如i++问题。对变量的操作涉及三个步骤:读取值、操作值和重新写入新值,这就是在多线程出现数据不一致的原因。
解决方式:
①在总线上加LOCK锁的方式
在运算时,直接在总线上加锁,可以避免缓存不一致问题,但是CPU利用率极低。
②缓存一致协议,参考:https://www.cnblogs.com/yjf512/p/5166415.html
2.JMM内存模型
JMM:用来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。
JMM主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量在主存和从内存中如何取出的规则。此处的变量包括实例字段、静态字段和构成数组对象的元素,不包括局部变量和方法参数(线程私有)。
JMM中规定变量都存在于主存中,线程都有自己的工作内存,线程对变量(是对主存中变量的副本拷贝)的操作必须在工作内存中,而不能直接对主存中数据进行操作,并且每个线程不能访问其他线程的工作内存,线程间变量值的传递需要通过主内存来完成。也就是说线程在工作内存中进行数据的操作,然后再更新主存中的值。因此在多线程中可能出现i++问题。
对于主内存与工作内存中变量之间的交互,JMM中定义了8种操作来完成,这8种操作具有原子性。
①lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程的独占状态。
②unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
③read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load操作使用。(工作在主存,传输数据到从存)
④load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
⑤use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将执行该操作。
⑥assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行该操作。
⑦store(存储):作用于工作内存的变量,它把工作内存中的一个变量的值传递到主内存中,以便随后的write操作使用。(工作在从存,传输数据到主存)
⑧write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量值放入主内存的变量中(更新操作)。
注:从主内存到工作内存,需顺序执行read和load,从工作内存到主内存,需顺序执行store和wirte操作,JMM只要求这些操作是顺序执行的,并不保证连续执行。
要保证并发的正确执行,就需要保证原子性、可见性和有序性。只要有一个条件未满足,就可能导致程序运行结果不正确。
在JMM中是如何保证并发执行的正确性的,有如下三点:
①JMM只保证基本的读取和赋值是原子性操作,如i=10;如果需要更大范围的原子性,则需要synchronized和Lock的帮助了。
②可见性:由volatile关键字提供,当然通过synchronized和Lock也可以实现。
③有序性:JMM中具备一些先天的“有序性”(happens-before原则),通过volatile也可保证一定的有序性,当然通过synchronized和Lock也可保证有序性
3.volatile关键字
被volatile修饰的变量,具有两种特性:
①保证变量对所有线程是可见的,当一条线程修改了变量的值时,新值对于其他线程来说是可以立即得知的。
对变量进行修改涉及两个操作:a.修改线程工作内存中的值;b.将修改后的值更新到主内存。
当A线程对volatile变量的值修改时,会导致其他线程中的变量缓存无效,所以其他线程再次读取volatile变量的值时,会从主存中获取。
②禁止指令重排序优化
指令重排序优化是指处理器为了提高程序运行的效率,可能会对输入的代码进行优化,它不保证代码的执行顺序与书写顺序一致,但保证程序的最终执行结果和书写顺序的结果一致。指令
volatile能在一定程度上保证有序性,为什么说一定程度上看下面解释。
禁止指令重排序优化有两层意思:
a.当程序执行到volatile变量的读操作或者写操作时,在其前面的操作全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行。
从该点可以看出volatile只能在一定程度上保证有序性:A->volatile->B,整体上按照这种顺序执行,但是在A操作里还是可能存在指令重排序,只要其操作对后续操作无影响。
b.在进行指令优化时,不能将volatile变量的语句放在volatile位置之前执行,也不能把volatile变量语句放到volatile语句之后执行。可能这句话有点难以理解,通过下面伪代码简单说明。
//a、b为非volatile变量
//flag为volatile变量 a = 1; //语句1
b = 2; //语句2
flag = true; //语句3
a = 3; //语句4
b = 4; //语句5
因为flag为volatile变量,所以执行指令重排序优化时,不能将语句3放在在语句1、2的前面;也不能将语句3放在语句4、5的后面执行。并且执行到语句3时,语句1、2已经执行完毕,并且其结果对语句3、4、5是可见的。注意语句1、2和语句4、5的顺序不做任何保证(这里也说明了volatile只能在一定程度上保证有序性)。
volatile的两种特性,主要由内存屏障提供(volatile原理和实现机制)。
加入volatile关键字时,会多出一个lock前缀指令(反编译),该lock前缀指令实际上相当于一个内存屏障,内存屏障会提供3个功能:
1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。
2)它会强制将对缓存的修改操作立即写入主存。
3)如果是写操作,它会导致其他CPU中对应的缓存行无效。
volatile不能保证原子性,其原因可通过i++操作来分析:
i初始值为0,并且被volatile修饰,当线程A读取i后,还没对i进行自增操作时被阻塞,此时线程B进来,虽然volatile保证了线程B是从主存中读取的数据,但是由于线程A并未对i值进行修改,所以线程B不会发现修改后的i值,这就是volatile不保证原子性的根源。
volatile的使用场景:
①对变量的写操作不依赖于当前值。如i++场景,就不能使用volatile。
②该变量没有包含在具有其他变量的不变式中。通俗理解:就是volatile变量不参加与其他变量的共同计算。
4.happens-before原则
happens-before(先行发生原则)主要是用来保证JMM的先天“有序性”,如果两个操作的执行顺序不能遵循happens-before原则,则虚拟机就可以对其进行重排序优化。
①程序次序规则:一个线程内,按照代码顺序,前面的操作对后面的操作先行发生。(主要保证单线程执行结果的正确性)
②锁定规则:一个unLock操作后,才能对同一个锁Lock操作,也就说对于同一锁,需要先unLock,其次才进行Lock。
③volatile变量规则:对一个变量的写操作对后面对这个变量的读操作先行发生。
④传递规则:如果操作A对操作B先行发生,操作B对操作C也先行发生,则可以得出操作A对操作C先行发生。(传递性)
⑤线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作。
⑥线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。
⑦线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行。
⑧对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始。
对于happens-before原则,前四条非常重要。
总结:
volatile关键字引申出来的知识点非常多,笔者认为主要有以下几点:
①硬件层面上解决数据一致性的方式(两种)。
②JMM的相关知识,主要理解主内存(主存)和工作内存,以及read和load、store和write这两对操作。
③在JMM中如何保证并发程序执行的正确性的(原子性、可见性(volatile)、有序性(happens-before原则))。
④volatile的底层原理:内存屏障。
by Shawn Chen,2018.6.4日,上午。