Fork me on GitHub

JMM之volatile深入分析

  JMM比较庞大,不是上面一点点就能够阐述的。
  volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的。
  上面那段话,有两层语义
  1).保证可见性、不保证原子性
  2).禁止指令重排序

volatile两种特性

  volatile是JVM提供的最轻量级的同步机制。
  当一个变量定义为volatile之后,将具备两种特性:
1.保证可见性、不保证复合操作的原子性
  保证此变量对所有线程的可见性,即当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。
  不能得到”基于volatile变量的运算在并发下是安全的“这个结论。volatile变量在各个线程的工作内存中不存在一致性问题,但Java里面的运算并非原子运算,导致volatile变量的运算在并发下一样是不安全的。如i++.
  由于volatile变量只能保证可见性,在 不符合以下两条规则 的运算场景中,我们仍然要通过加锁(使用synchronized 或J.U.C中的原子类)来保证原子性。
  1).运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
  2).变量不需要与其他的状态变量共同参与不变约束。
  如下这类场景就很适合使用volatile变量来控制并发,当shutdown方法被调用时,能保证所有线程中执行的doWork()方法都立即停下来。

1
2
3
4
5
6
7
8
9
10
volatile boolean shutdownRequested;
public void shutdown(){
shutdownRequested = true;
}
public void doWork() {
while(!shutdownRequested){
// do stuff
}
}

2.禁止指令重排序
  在执行程序时为了提高性能,编译器和处理器通常会对指令做重排序:
  1).编译器重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
  2).处理器重排序。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
  指令重排序对单线程没有什么影响,他不会影响程序的运行结果,但是会影响多线程的正确性。既然指令重排序会影响到多线程执行的正确性,那么我们就需要禁止重排序。那么JVM是如何禁止重排序的呢?这个问题稍后回答,我们先看另一个原则happens-before,happen-before原则保证了程序的“有序性”,它规定如果两个操作的执行顺序无法从happens-before原则中推到出来,那么他们就不能保证有序性,可以随意进行重排序。定义如下:JMM之happens-before深入分析
  我们着重看volatile规则:对volatile变量的写操作 happen-before 后续的读操作。为了实现volatile内存语义,JMM会重排序,其规则如下:
  对happen-before原则有了稍微的了解,我们再来回答这个问题JVM是如何禁止重排序的?
  
  观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令。lock前缀指令其实就相当于一个内存屏障。内存屏障是一组处理指令,用来实现对内存操作的顺序限制。volatile的底层就是通过内存屏障来实现的。下图是完成上述规则所需要的内存屏障:
  内存屏障:指重排序时不能把后面的指令重排序到内存屏障之前的位置。
  JVM底层采用“内存屏障”来实现volatile语义,下面就通过happens-before原则和volatile的内存语义两个方向介绍volatile。

volatile与happens-before

  在上面的内存模型之happend-before中阐述了happens-before是用来判断是否存数据竞争、线程是否安全的主要依据,它保证了多线程环境下的可见性。
  下面我们就那个经典的例子来分析volatile变量的读写建立的happens-before关系。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class VolatileTest {
int i = 0;
volatile boolean flag = false;
//Thread A
public void write(){
i = 2; //1
flag = true; //2
}
//Thread B
public void read(){
if(flag){ //3
System.out.println("---i = " + i); //4
}
}
}

  根据happens-before原则,就上面程序得到如下关系:
  .依据happens-before程序顺序原则:1 happens-before 2、3 happens-before 4;
  .根据happens-before的volatile原则:2 happens-before 3;
  .根据happens-before的传递性:1 happens-before 4
  操作1、操作4存在happens-before关系,那么1一定是对4可见的。可能有同学就会问,操作1、操作2可能会发生重排序啊,会吗?volatile除了保证可见性外,还有就是禁止重排序。所以A线程在写volatile变量之前所有可见的共享变量,在线程B读同一个volatile变量后,将立即变得对线程B可见。

volataile的内存语义及其实现

  在JMM中,线程之间的通信采用共享内存来实现的。volatile的内存语义是:

当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新到主内存中。
当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量

  所以volatile的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取。
  那么volatile的内存语义是如何实现的呢?对于一般的变量则会被重排序,而对于volatile则不能,这样会影响其内存语义,所以为了实现volatile的内存语义JMM会限制重排序。其重排序规则如下:
  翻译如下:
  1.如果第一个操作为volatile读,则不管第二个操作是啥,都不能重排序。这个操作确保volatile读之后的操作不会被编译器重排序到volatile读之前;
  2.当第二个操作为volatile写是,则不管第一个操作是啥,都不能重排序。这个操作确保volatile写之前的操作不会被编译器重排序到volatile写之后;
  3.当第一个操作volatile写,第二操作为volatile读时,不能重排序。

  volatile的底层实现是通过插入内存屏障,但是对于编译器来说,发现一个最优布置来最小化插入内存屏障的总数几乎是不可能的,所以,JMM采用了保守策略。如下:
  .在每一个volatile写操作前面插入一个StoreStore屏障
  .在每一个volatile写操作后面插入一个StoreLoad屏障
  .在每一个volatile读操作后面插入一个LoadLoad屏障
  .在每一个volatile读操作后面插入一个LoadStore屏障
  StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作都已经刷新到主内存中。
  StoreLoad屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。
  LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。
  LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。
  下面我们就上面那个VolatileTest例子分析下(下面通过一个例子稍微演示了volatile指令的内存屏障图例。):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class VolatileTest {
int i = 0;
volatile boolean flag = false;
public void write(){
i = 2;
flag = true;
}
public void read(){
if(flag){
System.out.println("---i = " + i);
}
}
}

  

  volatile的内存屏障插入策略非常保守,其实在实际中,只要不改变volatile写-读得内存语义,编译器可以根据具体情况优化,省略不必要的屏障。如下(摘自方腾飞 《Java并发编程的艺术》):

1
2
3
4
5
6
7
8
9
10
11
12
13
public class VolatileBarrierExample {
int a = 0;
volatile int v1 = 1;
volatile int v2 = 2;
void readAndWrite(){
int i = v1; //volatile读
int j = v2; //volatile读
a = i + j; //普通读
v1 = i + 1; //volatile写
v2 = j * 2; //volatile写
}
}

没有优化的示例图如下:
  
  我们来分析上图有哪些内存屏障指令是多余的
  1:这个肯定要保留了
  2:禁止下面所有的普通写与上面的volatile读重排序,但是由于存在第二个volatile读,那个普通的读根本无法越过第二个volatile读。所以可以省略。
  3:下面已经不存在普通读了,可以省略。
  4:保留
  5:保留
  6:下面跟着一个volatile写,所以可以省略
  7:保留
  8:保留
  所以2、3、6可以省略,其示意图如下
  


参考资料
  周志明 :《深入理解Java虚拟机》
  方腾飞:《Java并发编程的艺术》

-----------------本文结束,感谢您的阅读-----------------