一、volatile关键字介绍及底层原理
1.volatile的特性(内存语义)
当一个变量被定义成volatile之后,它将具备两项特性:第一项是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量并不能做到这一点,普通变量的值在线程间传递时均需要通过主内存来完成。比如,线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成了之后再对主内存进行读取操作,新变量值才会对线程B可见。
使用volatile变量的第二个语义是禁止指令重排序优化,普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在同一个线程的方法执行过程中无法感知到这点,这就是Java内存模型中描述的所谓“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics)。
2.volatile底层原理
volatile关键字修饰的变量可以保证可见性与有序性,无法保证原子性。那么volatile关键字的底层原理是什么呢?我们可以通过查看Java代码的汇编指令去看一下volatile的底层原理:查询Java代码的汇编指令需要设置JVM允许参数:-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp;如果你的jdk版本小于等于8还要在jdk里面添加Hsdis插件,将该插件目录里面的两个文件(hsdis-amd64.dll,hsdis-i386.dll)复制到 %JAVA_HOME%\jre\bin\server 下,然后运行你的Java程序,就可以看到控制台里面一堆的汇编指令代码输出了。
public class Singleton {
private volatile static Singleton myinstance;
public static Singleton getInstance() {
if (myinstance == null) {
synchronized (Singleton.class) {
if (myinstance == null) {
myinstance = new Singleton();//对象创建过程,本质可以分文三步
}
}
}
return myinstance;
}
public static void main(String[] args) {
Singleton.getInstance();
}
}
上面所示是一段标准的双锁检测(Double Check Lock,DCL)单例代码,可以观察加入volatile和未加入volatile关键字时所生成的汇编代码的差别。不加volatile关键字时在控制台输出指令搜索myinstance可以看到如下两行
0x00000000038064dd: mov %r10d,0x68(%rsi)
0x00000000038064e1: shr $0x9,%rsi
0x00000000038064e5: movabs $0xf1d8000,%rax
0x00000000038064ef: movb $0x0,(%rsi,%rax,1) ;*putstatic myinstance
; - com.it.edu.jmm.Singleton::getInstance@24 (line 22)
加了volatile关键字后,变成下面这样了:
0x0000000003cd6edd: mov %r10d,0x68(%rsi)
0x0000000003cd6ee1: shr $0x9,%rsi
0x0000000003cd6ee5: movabs $0xf698000,%rax
0x0000000003cd6eef: movb $0x0,(%rsi,%rax,1)
0x0000000003cd6ef3: lock addl $0x0,(%rsp) ;*putstatic myinstance
; - com.it.edu.jmm.Singleton::getInstance@24 (line 22)
通过对比发现,关键变化在于有volatile修饰的变量,赋值后(前面movb $0x0,(%rsi,%rax,1)这句便是赋值操作)多执行了一个“lock addl $0x0,(%rsp)”操作,这个操作的作用相当于一个内存屏障(Memory Barrier或Memory Fence,指重排序时不能把后面的指令重排序到内存屏障之前的位置,只有一个处理器访问内存时,并不需要内存屏障;但如果有两个或更多处理器访问同一块内存,且其中有一个在观测另一个,就需要内存屏障来保证一致性了。
这里的关键在于lock前缀,它的作用是将本处理器的缓存写入了内存,该写入动作也会引起别的处理器或者别的内核无效化(Invalidate,MESI协议的I状态)其缓存,这种操作相当于对缓存中的变量做了一次前面介绍Java内存模式中所说的“store和write”操作。所以通过这样一个操作,可让前面volatile变量的修改对其他处理器立即可见。lock指令的更底层实现:如果支持缓存行会加缓存锁(MESI);如果不支持缓存锁,会加总线锁。
二、volatile——可见性
volatile修饰变量之后,可以保证可见性,下面通过一个程序示例演示一下:
public class VolatileVisibilitySample {
private volatile boolean initFlag = false;
static Object object = new Object();
public void refresh(){
this.initFlag = true;
System.out.println("线程:"+Thread.currentThread().getName()+":修改共享变量initFlag");
}
public void load(){
int i = 0;
while (!initFlag){
// synchronized (object){
// i++;
// }
}
System.out.println("线程:"+Thread.currentThread().getName()+"当前线程嗅探到initFlag的状态的改变"+i);
}
public static void main(String[] args) throws InterruptedException {
VolatileVisibilitySample sample = new VolatileVisibilitySample();
Thread threadA = new Thread(()->{
sample.refresh();
},"threadA");
Thread threadB = new Thread(()->{
sample.load();
},"threadB");
threadB.start();
Thread.sleep(2000);
threadA.start();
}
}
可以看到共享变量被volatile修饰之前,线程B中调用的方法中 “当前线程嗅探到initFlag的状态的改变” 这句输出是打印不出来的,也就意味着线程A中将initFlag改为true,但是线程B并没有获取到最新值,程序一直在循环空跑。此时JMM操作如下图:虽然线程A中将initFlag改为了true并且最终会同步回主内存,但是线程B中循环读取的initFlag一直都是从工作内存读取的,所以会一直进行死循环无法退出。
添加了volatile修饰之后,“当前线程嗅探到initFlag的状态的改变” 这句话就会被打印出来,因为添加volatile关键字后,就会有lock指令,使用缓存一致性协议,线程B中会一直嗅探initFlag是否被改变,线程A修改initFlag后会立即同步回主内存,这时候会通知线程B将缓存行状态改为I(无效状态),需要重新从主内存读取。如下图所示:
我们将上面的代码的load()方法进行修改——去掉volatile关键字,添加synchronized同步块,即修改为下面这样的情况,会达到跟添加volatile关键字相同的效果,这是因为添加了锁同步块,CPU会分配时间片,线程进行锁竞争导致线程上下文切换,重新读取主存的变量。
public void load(){
int i = 0;
while (!initFlag){
synchronized (object){
i++;
}
}
System.out.println("线程:"+Thread.currentThread().getName()+"当前线程嗅探到initFlag的状态的改变"+i);
}
三、volatile——无法保证原子性
由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(使用synchronized、java.util.concurrent中的锁或原子类)来保证原子性:
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
- 变量不需要与其他的状态变量共同参与不变约束
下面通过一个示例演示一下:10个线程,每个线程加1000次(counter++不是一个原子性的操作,可以通过javap命令查看底层指令,可以看到有加载变量数据、将变量放到操作数栈顶、执行加法运算等操作)。运行几次发现,有时运行结果是小于10000的。下面分析一下:
- 1.首先counter不加volatile修饰时:因为10个线程同时对变量进行自加1运算,每个运算一次后去写会主内存,会覆盖其他线程的运算结果,所以运行结果可能会小于10000。
- 2.counter添加volatile修饰时:添加volatile修饰之后,变量被修改后会立即同步回主存,一直嗅探其他线程是否对变量进行过修改,修改后重新从主存读取变量。但是正因为添加了volatile关键字时MESI缓存一致性协议生效了,当一个变量执行加1操作后,需要同步回主存,这是会锁缓存行,通知其他线程变量已经被修改过了,将本地缓存行改为I无效状态,这样被改为无效状态的线程本地加1操作的结果被丢弃了,没有写回主内存,也就是白加了一次,所以运行结果也可能会小于10000。
想要实现原子性操作,可以通过synchronized,ReentrantLock加锁,或者使用AtomicInteger进行原子性运算。
public class VolatileAtomicSample {
private static volatile int counter = 0;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(()->{
for (int j = 0; j < 1000; j++) {
counter++;
}
});
thread.start();
}
Thread.sleep(1000);
System.out.println(counter);
}
}
四、volatile——禁止指令重排
1.指令重排
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。java语言规范规定JVM线程内部维持顺序化语义。即只要程序的最终结果与
它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。指令重排序的意义是什么?JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。
下图为从源码到最终执行的指令序列示意图
指令重排主要有两个阶段:
1.编译器编译阶段:编译器加载class文件编译为机器码时进行指令重排
2.CPU执行阶段: CPU执行汇编指令时,可能会对指令进行重排序
2.as-if-serial语义
as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
通过一个程序代码,演示一下指令重排的效果:只有x=0并且y=0的情况下才会跳出循环
public class VolatileReOrderSample {
private static int x = 0, y = 0;
private static int a = 0, b =0;
static Object object = new Object();
public static void main(String[] args) throws InterruptedException {
int i = 0;
for (;;){
i++;
x = 0; y = 0;
a = 0; b = 0;
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
a = 1;
x = b;
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
b = 1;
y = a;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
String result = "第" + i + "次 (" + x + "," + y + ")";
if(x == 0 && y == 0) {
System.err.println(result);
break;
} else {
System.out.println(result);
}
}
}
}
通过分析,会有三种可能的输出:[0,1],[1,0],[1,1]。
- 输出可能1——[0,1]:线程1先执行完,线程2再执行,则会出现x=0,y=1
- 输出可能1——[1,0]:线程2先执行完,线程1再执行,则会出现x=1,y=0
- 输出可能1——[1,1]:线程1、线程2交替执行,a=1,b=1,然后执行x=1,y=1,则会出现x=1,y=1
当运行之后会发现上面分析的三种情况确实出现了,但是程序最终跳出了循环,也就是出现了x=0并且y=0的情况,这说明出现了指令重排的情况,即线程1中a=1 x=b的指令出现了顺序调整或线程2中b=1 y=a的指令出现了顺序调整。
当我们给变量a和b添加volatile关键字修饰后(private volatile static int a = 0, b =0;),再次运行发现程序一直在循环输出,没有出现x=y=0的情况从而退出循环。
volatile可以禁止指令重排的原因是因为添加了lock指令,会添加内存屏障。
五、volatile与内存屏障(Memory Barrier)
1.内存屏障(Memory Barrier)
内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。总之,volatile变量正是通过内存屏障(lock指令)实现其在内存中的语义,即可见性和禁止重排优化。
上面的程序示例:synchronized+volatile实现的DCL模式的单例模式,就是利用了volatile禁止指令重排的特性。因为myinstance = new Singleton();这句代码本质上是有三步:1.为对象分配内存空间;2.实例化对象数据;3.将引用指向对象实例的内存空间。如果第一个线程执行创建对象时出现了指令重排,比如3排到了2之前,那么线程2在最外层代码判断myinstance!=null为true返回对象引用,但是实际上这时候对象尚未初始化完成,这样是有问题的,需要通过添加volatile关键字去禁止指令重排。
2.volatile的内存语义实现
前面提到过重排序分为编译器重排序和处理器重排序。为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。下图是JMM针对编译器制定的volatile重排序规则表。
举例来说,第三行最后一个单元格的意思是:在程序中,当第一个操作为普通变量的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。
从上图我们可以看出:
- 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
- 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
- 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义。
下面是保守策略下,volatile写插入内存屏障后生成的指令序列示意图,如图所示。
上图中StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。
而volatile写后面的StoreLoad屏障,作用是避免volatile写与后面可能有的volatile读/写操作重排序
下图是在保守策略下,volatile读插入内存屏障后生成的指令序列示意图
上图中LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。
上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变 volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。
六、JMM对volatile的特殊规则定义
最后我们再Java内存模型中对volatile变量定义的特殊规则的定义。假定T表示一个线程,V和W分别表示两个volatile型变量,那么在进行read、load、use、assign、store和write操作时需要满足如下规则:
只有当线程T对变量V执行的前一个动作是load的时候,线程T才能对变量V执行use动作;并且,只有当线程T对变量V执行的后一个动作是use的时候,线程T才能对变量V执行load动作。线程T对变量V的use动作可以认为是和线程T对变量V的load、read动作相关联的,必须连续且一起出现。
这条规则要求在工作内存中,每次使用V前都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量V所做的修改。
只有当线程T对变量V执行的前一个动作是assign的时候,线程T才能对变量V执行store动作;并且,只有当线程T对变量V执行的后一个动作是store的时候,线程T才能对变量V执行assign动作。线程T对变量V的assign动作可以认为是和线程T对变量V的store、write动作相关联的,必须连续且一起出现。
这条规则要求在工作内存中,每次修改V后都必须立刻同步回主内存中,用于保证其他线程可以看到自己对变量V所做的修改。
假定动作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修饰的变量不会被指令重排序优化,从而保证代码的执行顺序与程序的顺序相同。
下一篇预告——并发编程三大特性:原子性,可见性,有序性,happen-before原则
到此这篇关于详解Java volatile 内存屏障底层原理语义的文章就介绍到这了,更多相关Java volatile 内容请搜索编程网以前的文章或继续浏览下面的相关文章希望大家以后多多支持编程网!