1.volatile变量的特殊规则
1.1 volatile是什么
volatile英文单词意思的易变的,不稳定的。在Java中作为类型修饰符用来修饰变量,所以volatile变量的表面含义是易变的变量,进一步想体现的是共享变量的意思。在代码层面使用关键字volatile修饰变量,延伸到JVM层面是告诉线程这个变量是一个共享变量,线程就不能假定在工作内存中的变量副本是最新的值了,所以在一些操作上有一些特殊的限制或者规定:线程必须将这个共享变量的更新操作通知到其他线程。进一步通俗的说就是线程在工作内存中修改volatile变量后必须立刻写回到主内存;线程在使用volatile变量前必须立刻从主内存加载到工作内存中。
1.2 表现到JVM层面的volatile变量的特殊规则
在博客Java内存模型中提到对volatile变量定义的特殊规则如下:
线程对volatile变量的use动作可以认为是和线程对volatile变量的load、read动作相关联,必须连续一起出现。这条规则要求在工作内存中,每次使用volatile变量前必须从主内存中刷新最新值,用于保证能够看见其他线程对volatile变量所作的修改后的值。
线程对volatile变量的assign动作可以认为是和线程对volatile变量的store、write动作相关联,必须连续一起出现。这条规则要求在工作内存中,每次修改volatile变量的值后必须立刻同步回主内存,用于保证其他线程可以看到自己对volatile变量所做的修改。
假定V、W分别表示两个volatile变量。动作A是线程T对变量V实施的use(assign)操作,动作F是和动作A相关联的load(store)操作,动作P是和动作F对应的变量V的read(write)动作;类似的,动作B是线程T对变量W实施的use(assign)操作,动作G是和动作B相关联的load(store)操作,动作Q是和动作G对应的变量W的read(write)动作。如果A先于B,那么P先于Q。这条规则要求volatile修饰的变量不会被指令重排序优化,保证代码的执行顺序与程序的顺序相同。
在此,对应到happens-before原则即为volatile变量原则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,"后面"是指时间上的先后顺序。
2.volatile的性质及实现原理
volatile变量具有两种性质:一种是可见性,一种是有序性;变量的性质是通过volatile保证的。我们需要明白volatile是如何保证的,即实现原理是什么样的。
2.1 volatile保证可见性
volatile保证可见性是指当一个线程修改了volatile变量的值,新的值对于其他线程是可以立即得知的。在这里立即得知是通过线程在工作内存中修改volatile变量后必须立刻写回到主内存;线程在使用volatile变量前必须立刻从主内存加载到工作内存中得来的。而为什么能立刻写回,为什么能主动加载,需要进一步实现了解原理。
2.2 volatile保证有序性
保证有序性是指线程不能对volatile变量的读操作,写操作进行重排序,在一定程度上保证volatile变量的赋值顺序与程序代码的顺序一致。在这里保持一致是通过禁止指令重排序得来的。而为什么能立刻写回,为什么能主动加载,也需要从实现原理来看。
2.2 volatile不保证原子性
不能保证原子性是指无法使原来的非原子操作变为原子操作。如i++是非原子操作,而volatile变量i的i++操作也是非原子操作。原因根据的我的理解如下:当两个线程P、Q并行操作volatile变量i,当线程P读取volatile变量i的时候,从主内存中读取,保证此时读取的是最新的值;接着,线程Q读取volatile变量i的时候,从主内存中读取,保证此时读取的也是最新的值;接着,线程P对i进行自增操作,并写回到主内存,稍晚一点的时候(如线程P已做完自增操作,正在写回主内存的时候),线程Q对i进行自增操作,自增操作完成的时候,线程P已完成写回操作,主内存中i的值已经是最新的了,此时引起缓存失效(缓存失效在实现原理来讲),线程Q操作栈顶的值变成过期失效的值,但是并不妨碍线程Q将自增操作后的值进一步写回到主内存中。导致实际主内存中的值只加1。这里说是线程Q的可见性的来到晚于线程Q读volatile变量i的操作,线程Q无法利用可见性来保证原子性。
2.3 volatile实现原理
volatile变量的实现上是通过硬件提供支持,归根结底是硬件层面之上的内存屏障提供支持。IA-32架构体系中volatile保证可见性和有序性的实现原理从本质上说是一条lock指令的作用。在线程的工作内存中对volatile变量赋了新的值以后,会执行一条"lock addl $0x0,(%esp)"汇编指令(这条指令看汇编代码不是立即执行,但是也算起来紧随其后),这条指令相当于是一条内存屏障指令,是一种StoreLoad Barriers。
这条内存屏障指令到底有什么用?执行完发生了什么?执行内存屏障指令是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。指的是如果在程序执行过程中,发现指令集中存在内存屏障指令,那么内存屏障前的指令都会在执行内存屏障指令前执行完;内存屏障后的指令都会在执行内存屏障指令后开始指令,无法将内存屏障前后的指令进行重排序,这就是volatile禁止指令重排序的由来,是保证有序性的根本。同时内存屏障指令执行完产生下列相应事件:使得当前执行线程的CPU将其缓存(处理器缓存,包括线程工作内存)写入主内存,这一步使得线程在工作内存中修改volatile变量后立刻写回到主内存;同时,使得别的CPU或者别的核缓存失效,这一步使得线程在使用volatile变量前必须立刻从主内存加载到工作内存中,这就是volatile保证可见性的根本。
IA-32架构是采取"lock addl $0x0,(%esp)"指令起到和内存屏障一样的作用,其他硬件架构也会有相应的内存屏障指令,保证了volatile变量的可见性和有序性。
3.内存屏障知识引申
在volatile的实现原理上我们提到了StoreLoad Barriers,这里我们进一步对内存屏障进行引申。
首先介绍广义上的内存屏障分类:
写屏障(store barrier):所有在store barrier之前的所有store指令,都要在该store barrier之前执行(这里需要将修改的值都要刷新到缓存),并发送缓存失效的信号。所有在store barrier指令之后的store指令,都必须在store barrier之前的指令执行完后再被执行。
读屏障(load barrier):所有在load barrier之前的所有load 指令,都要在该load barrier之前执行(这里需要将Invalid Queue中的消息执行完毕)。所有在load barrier指令之后的load 指令,都必须在load barrier之前的指令执行完后再被执行。
全屏障(Full Barrier):所有在storeload barrier之前的store/load指令,都在该屏障之前被执行
所有在该屏障之后的的store/load指令,都在该屏障之后被执行。
进一步介绍JVM中的具体的内存屏障分类:
屏障分类 | 指令示例 | 说明 |
---|---|---|
LoadLoad Barriers | load1; LoadLoad;load2 | 确保load1数据的装载优先于load2及所有后续装载指令的装载 |
StoreStore Barriers | store1;StoreStore;store2 | 确保store1数据对其他处理器可见优先于store2及所有后续存储指令的存储 |
LoadStore Barriers | load1;LoadStore;store2 | 确保load1数据装载优先于store2以及后续的存储指令刷新到内存 |
StoreLoad Barriers | store1; StoreLoad;load2 | 确保store1数据对其他处理器变得可见, 优先于load2及所有后续装载指令的装载 |
PS:这里的内存屏障知识由于作者作者水平有限,具有局限性,想深入了解请见参考链接。这里我们需要了解的是IA-32架构的lock指令相当于StoreLoad Barriers,是一种全屏障。且在某些架构上,JVM使用的是全屏障保证volatile的语义,有些架构上则分别使用写屏障和读屏障保证volatile的语义。
4.volatile的使用场景
volatile的使用场景必须符合以下两条规则:
规则一:运算结果并不依赖变量的当前值,或者能够确保只有单一的线程来修改变量的值。
规则二:变量不需要与其他的状态共同参与不变约束。
其中《Java并发编程实战》提到了规则三:在访问变量时不需要加锁(都需要使用到重量级的加锁操作了那还需要轻量级的volatile干嘛呢?!)。
典型用法:检查某个状态标记以判断是否执行相应操作,volatile变量常用作某个操作完成、发生中断或者状态的标志。
5.DCL(Double Check Lock)单例模式
只有synchronized 的单例模式,不保证线程安全:
public class Singleton {
private static Singleton instance = null;
public static Singleton getInstance() {
if(null == instance) {
synchronized (Singleton.class) {
if(null == instance) {
instance = new Singleton();
}
}
}
return instance;
}
}
DCL单例模式,保证线程安全:
public class Singleton {
private volatile static Singleton instance = null;
public static Singleton getInstance() {
if(null == instance) {
synchronized (Singleton.class) {
if(null == instance) {
instance = new Singleton();
}
}
}
return instance;
}
}
为什么?因为instance = new Singleton(); 只是由synchronized保证的一个复合原子操作。具体的,在《深入理解Java虚拟机》2.3.1的对象的创建一节中可以发现在这一步其实做了相当多的工作。但简单的我们分为三步:
1.给对象分配内存内存空间;
2.初始化对象;
3.对对象的内存地址赋给对象的引用。
BUT,在重排序中我们提到了编译器优化重排序。那么在程序实际执行的过程中,真正的三步的执行顺序可能是这个样子:
1.给对象分配内存内存空间;
2.对对象的内存地址赋给对象的引用;
3.初始化对象。
这时当线程P执行完第二步的时候,这时候线程Q就会检测到null != instance;那么就会返回还未初始化完成的对象。DCL单例模式是线程安全原因是可以禁止编译器优化重排序;保证了单例对象可以完整的初始化完成后才被返回。
《Java并发编程实战》
《深入理解Java虚拟机》
http://www.pianshen.com/article/5046152256/
http://0xffffff.org/2017/02/21/40-atomic-variable-mutex-and-memory-barrier/
https://blog.csdn.net/qq_26222859/article/details/52240256