本文对volatile的概念、原子性、指令重排、内存屏障、使用与场景等知识做说明,试图为读者理解volatile提供帮助。
一. 概念
volatile字面意思是易变的、不稳定的。
在Java中关键字volatile是一个类型修饰符,使用方式如:
static volatile int i=0;
其作用是告诉虚拟机该变量是极有可能多变的,此处免于一些优化措施,不能随意变动目标指令,并保障该变量上操作的原子性。
volatile修饰的变量有“可见性”,其含义是变量被修改后,应用程序范围内的所有线程都能够知道这个改动。
volatile是非排他的,常常用于多线程之间的共享变量。volatile并非锁的替代品,但在一定条件下它比锁更合适,性能开销比锁更少。
二. 原子性
原子性是说一个操作是不可中断的,不可分割的。
并非简单的操作就是原子性的,或者复杂的操作就是非原子性的。
i++是非原子性的,该操作实际上是一个read-modify-write操作,在执行过程中其他线程可能已经修改了i的值,因此该操作不具备不可分割性,也就不是原子操作。但若i是一个局部变量,保证了read-modify-write过程不被其他线程干扰,那么该操作就是一个原子的操作。
一般而言,对volatile变量的赋值操作,只要表达式右边涉及非局部变量,该操作就不是一个原子操作。
又如这样一个赋值操作:
volatile HashMap map=new HashMap();
可以分解为如下伪代码:
obj=allocate(HashMap.Class);//子操作1,分配内存
Constructor(obj);//子操作2,初始化
map=obj;//子操作3,将对象引用写入map
虽然volatile只保障了子操作3是一个原子操作,但是由于子操作2和子操作3仅涉及局部变量二未涉及共享变量,因此对map变量的赋值操作仍然是一个原子操作。
又如对一个long型变量赋值:
pulic class Test { private static long lo=0; public Change(long v){ this.lo=v; } }
由于long型长64位,在32位系统中,对long型变量赋值需要2次才能完成,期间可能有别的线程干扰,因此语义上即使只是对基本类型的一步赋值,该操作也是非原子性的。
综上,得出认识:volatile仅保障被修饰变量本身的读、写操作的原子性。如要保障volatile变量赋值语句的原子性,那么该语句中的操作不能涉及任何对共享变量—包括volatile变量本身–的访问。
三. 指令重排
happens-before原则中有一条程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
通俗的讲,代码的执行是要保证从先往后,依次执行的—这是非常自然的事情。
这里的顺序其实是从代码的语义方面讲的,在程序实际执行时,出于效率等目的在指令这一层面会对指令的先后顺序进行重排。在单线程情况下,指令重排不会影响语义逻辑,但多线程时,指令重排没有义务也无法确保多线程间的语义也一致。
一条指令的执行有很多细节,但简单地说,可以分为几步:
取指 IF
译码和取寄存器操作数 ID
执行或者有效地址计算 EX
存储器访问 MEM
写回WB
完成指令需要分配CPU时间片,也涉及不同的硬件,如寄存器、算术逻辑单元ALU等。因此在执行各种指令时,使用的是一种流水线技术。
流水线能使CPU高效执行,满载时效能客观。但一旦被中断就使所有硬件都进入一个停顿期,再次满载需要几个周期。为避免效能损失,需要想办法尽量不让流水线中断。而指令重排的目的就是为了供给连续紧凑的指令,尽量少的中断流水线。
下面以A=B+C这个操作为例:
上图中左边的是指令,LW表示load,LW R1,B 表示把B加载到寄存器R1中。ADD指令是加法,第三条指令是R1,R2中的值相加后放入寄存器R3。SW表示store,该处命令是将R3的值保存到变量A。
右边是流水线的情况,注意到有两个红叉,表示流水线在这里停顿,原因是数据没有准备好,如第三条ADD在等待寄存器R2的值。由于ADD的停顿,后面所有的指令都要慢一拍。
看一个更复杂的例子:
a=b-c
x=y+z
上面的代码执行如下:
由于SUB减和ADD加指令,这里有不少停顿。而在这里先进行LW Ry,y和LW Rz,z这两条加载指令对程序逻辑是没有影响的,既然有停顿,不如利用停顿时间完成这两步操作:
指令重排后,所有的停顿消除,流水线顺畅:
四. 内存屏障
volatile能保障有序性和可见性,因为写线程对volatile变量做写入操作时会产生一个类似释放锁的效果,读线程对volatile变量做读取操作时会产生一个类似获取锁的效果。
写线程对volatile变量做写入操作时,虚拟机在该操作前插入一个内存释放屏障Release Barrier,在操作完成后插入一个内存存储屏障Store Barrier。
释放屏障禁止写操作及其之前的操作有指令重排,这保证了volatile写操作前的任何读、写操作都先序提交,也就是说当其他线程看到写操作对volatile变量的更新时,之前其他执行操作的结果对此时的读线程都是可见的。
volatile变量读操作时,Java虚拟机会在操作前插入一个装载屏障Load Barrier,在操作后插入一个获取屏障Acquire Barrier。
装载屏障通过冲刷处理器缓存,使当前执行线程所在的处理器将其他处理器对共享变量作的更新同步到该处理器的高速缓存中。获取屏障禁止指令重排。
写操作的释放屏障与读操作的装载屏障一起使用保障了volatile的可见性。这类似于锁对可见性的保障,但volatile是非排他的即非阻塞的,因而volatile读取并不能保证是时时最新的值,可能在读取的同时有写操作在更新共享变量。
为有助于理解,可以把释放屏障(Release Barrier)设想成是打开的屏障,正在接受之前的操作完成与提交,或是根据字面意思,在release前当然要求所有操作完成;加载屏障Load Barrier是从其他线程所在的处理器里获取最新,并加载到当前的处理器缓存中。
五. 数组与对象
如果volatile修饰数组,那么volatile只能对数组引用本身起作用,无法对数组中的元素起作用。
volatile int[] oneArrary //假设修饰一个数组
int i=oneArrary[0]; //操作1
ineArrary[1]=1;//操作2
volatile int[] twoArrary=oneArrary;//操作3
操作1中,实际上可分解为2个子操作,子操作(1)读取数组引用地址,由于volatile修饰数组,这步子操作是volatile有效的,子操作(2)在取到数组引用后根据下标读取具体元素,这步与volatile是无关的。因此操作1无法保障volatile。
操作2中,元素与volatile无关,volatile不起作用。
操作3中,操作的都是数组引用,volatile是起作用的。
类似地,对于引用型的对象,volatile只是能保障对象的引用,至于该引用所指向的对象实例中的值volatile无法保障。
如果要使数组内的元素也能触发volatile作用,可以使用AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray。
六. 典型场景
需要注意一点,未用volatile修饰共享变量时,当虚拟机在Client模式下,JIT不会做足够的优化,有时共享变量的更新反而对线程可见,当虚拟机在Server模式下,JIT的进行足够的优化,有些数据副本及缓存的措施,共享变量对线程不具可见性。
public class temp { private static boolean ok;//注意这里
private static int num; private static class WorkerThread extends Thread{ public void run(){ while (!ok){ System.out.println(num); } } } public static void main(String[] args) throws InterruptedException{ new WorkerThread().start(); Thread.sleep(1000); num=42; ok=true; Thread.sleep(10000); } }
上述代码在Client模式下WorkerThread可以发现判断变动,退出程序。但在Server模式下时,优化后无法发现变量的改动,导致程序无法退出。
此时,需要把ok变量修饰为volatile即可。这个场景也是volatile使用的典型场景–基于对共享变量的原子操作。
参考:
[1] 葛一鸣 郭超 Java高并发程序设计
[2] 黄文海 Java多线程编程实战指南
今天的文章volatile详解「建议收藏」分享到此就结束了,感谢您的阅读,如果确实帮到您,您可以动动手指转发给其他人。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/55107.html