Java内存模型JMM之四volatile关键字

前言

volatile关键字是JVM提供的最轻量级的同步机制,但是由于其不容易被正确地、完整的理解,以至于许多程序员都习惯不去使用它,而总是选择synchronized重量级的锁机制来进行同步,本文将弄清楚volatile关键字的真正语义。

当一个变量被定义成volatile之后,它将具备两种语义特性:可见性有序性

一、可见性

当一条线程修改了一个volatile变量的值,新值是立即对其他线程可知的。这是普通变量所不能保证的,通过前文的JMM内存模型可知,普通变量的值在线程之间由于各个线程有各自的工作内存的原因,并不能做到随时同步。

       导致volatile不能被容易理解的地方也就在这里,虽然对一个volatile变量的读取,都能保证获取到任意线程对这个volatile变量最后的写入,但是对volatile变量的复合操作(例如volatile++,  volatile= volatile * x)仍然不具有原子性,因为volatile++这种复合操作实际上包含三个操作:读取、加1、将加1的结果赋值回写。volatile关键字只能保证第一个操作“读取”的结果是正确的,但是在执行后面两个操作的时候,其他线程依然可以甚至已经改变了volatile变量的值,使的现在操作的volatile变量已经是过期的数据。Volatile只能保证对修饰的变量的单次读或者写操作是原子性的(包括long和double类型)。

       因此在不符合以下两条规则的运算场景中,我们仍然要通过加锁来保证对volatile变量操作的原子性。

          1. 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。

          2. 变量不需要与其他状态变量共同参与不变约束。

如下这种场景就非常适合使用volatile变量来控制并发。当shutdown()方法被调用时,能保证所有线程中执行的doWork()方法都能立即停下来。

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

总的来说,当一个变量被volatile修饰后,表示着线程本地内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,当其他线程读取共享变量时,它会直接从主内存中读取。 

 

二、有序性

volatile变量的有序性通过禁止指令重排序优化来保证(volatile屏蔽指令重排序的语义在JDK1.5中才被完全修复,此前的JDK中即使将变量声明为volatile,也仍然不能完全避免重排序所导致的问题)。通过前文的重排序内容我们知道,普通的变量仅仅会保证在方法执行过程中所有依赖该变量赋值结果的地方通过禁止重排序保证都能获取正确的结果,而如果变量的赋值操作不被后面的操作所依赖,由于在方法的执行过程中无法感知到变量值的改变,所以这时候是可以进行重排序的,也就是as-if-serial语义所描述的行为。通过如下代码示例可以说明为何指令重排序可能会干扰程序的并发执行:

 1 Map configOptions;  
 2   
 3 // 此变量必须定义为volatile   
 4 volatile boolean initialized = false;  
 5 // 假设以下代码在线程A中执行  
 6 // 模拟读取配置信息,读取完成之后,设置initialized为true来通知其他线程配置可用  
 7   
 8 configOptions  = readConfigOptions(fileName);  
 9 initialized = true;  
10   
11 // 假设以下代码在线程B中执行  
12 // 等待initialized 为true,代表线程A已经初始化完配置信息  
13 while(!initialized){  
14     sleep();  
15 }  
16   
17 // 使用线程A初始化好的配置信息  
18 doSomethingUseConfig(); 

如果initialized变量没有被定义为volatile,就可能由于指令重排序的优化导致最后一句的“initialized = true”被提前执行,从而线程B中使用配置信息的代码就可能出现错误,而volatile关键字可以避免这样的情况

 

三、底层实现原理

由上可知:
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新到主内存中。
当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量。
通过JMM之二内存屏障章节我们也可以知道,内存屏障可以禁止指令重排序以及影响数据可见性,这不正是volatile关键字的两层语义吗?其实,JVM底层就是采用“内存屏障”来实现volatile的语义。更多内容可以参考http://gee.cs.oswego.edu/dl/jmm/cookbook.html
JMM针对编译器制定了如下的volatile重排序限制策略:
 
是否能重排序 第二个操作
 第一个操作  普通读/写  volatile读  volatile写
 普通读/写      NO
 volatile读  NO  NO  NO
 volatile写    NO  NO

 

  1. 当第一个操作是volatile读时,不论第二个操作是什么,都不能重排序。
  2. 当第二个操作是volatile写时,不论第一个操作是什么,都不能重排序。
  3. 当第一个操作是volatile写,第二个操作是volatile读或写时,亦不能重排序。
        为了实现以上策略,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序,下面是基于保守策略的JMM内存屏障插入策略(在实际中,只要不改变volatile写-读得内存语义,编译器可以根据具体情况优化,省略不必要的屏障):
  1. 在每一个volatile读操作后面插入一个LoadLoad屏障,用于禁止处理器把前面的volatile读和后面的普通读重排序。
  2. 在每一个volatile读操作后面插入一个LoadStore屏障,用于禁止处理器把前面的volatile读和后面的普通写重排序。
  3. 在每一个volatile写操作前面插入一个StoreStore屏障,用于保证在volatile写之前,把前面的所有普通写操作都刷新到主内存中。
  4. 在每一个volatile写操作后面插入一个StoreLoad屏障,用于将本次的volatile写刷新到主内存,并且禁止处理器把前面的volatile写和后面可能有的volatile读或写操作重排序。
 

四、happens-before\Volatile运用分析之单例模式DCL机制

DCL即双重检查加锁,下面是一个典型的在单例模式中使用DCL的例子:

 1 public class LazySingleton {    
 2     private int someField;    
 3         
 4     private static LazySingleton instance;    
 5         
 6     private LazySingleton() {    
 7         this.someField = new Random().nextInt(200)+1;         // (1)    
 8     }    
 9         
10     public static LazySingleton getInstance() {    
11         if (instance == null) {                               // (2)    
12             synchronized(LazySingleton.class) {            // (3)    
13                 if (instance == null) {                // (4)    
14                     instance = new LazySingleton();// (5)    
15                 }    
16             }    
17         }    
18         return instance;                                       // (6)    
19     }    
20         
21     public int getSomeField() {    
22         return this.someField;                                 // (7)    
23     }    
24 }    

这里得到单一的instance实例是没有问题的,问题的关键在于Singleton.getInstance().getSomeField()有可能返回someField的默认值0。

 

 

分析:假设线程Ⅰ是初次调用getInstance()方法,紧接着线程Ⅱ也调用了getInstance()方法和getSomeField()方法,线程Ⅱ在执行getInstance()方法的语句(2)时,由于对instance的访问并没有处于同步块中,因此线程Ⅱ可能观察到也可能观察不到线程Ⅰ在语句(5)时对instance的写入,也就是说instance的值可能为空也可能为非空。

 

情况一:线程Ⅱ在(2) 观察到了线程Ⅰ对instance的写入。那么instance非空,线程Ⅱ将不会执行(3),即不会进入同步块,直接执行(6)返回instance,然后对这个instance调用getSomeField()方法即语句(7),该方法也是在没有任何同步情况被调用,因此整个线程Ⅱ的操作都是在没有同步的情况下调用 ,这时我们便无法通过happen-before的8条规则得出线程Ⅰ的操作和线程Ⅱ的操作之间存在任何有效的happen-before关系,那么线程Ⅰ的语句(1)和线程Ⅱ的语句(7)之间自然也不存在happen-before关系,这就意味着线程Ⅱ在执行语句(7)完全有可能观测不到线程Ⅰ在语句(1)处对someFiled写入的值,所以根据happen-before原则可知,这种DCL设计是存在问题的。

 

情况二:线程Ⅱ在(2) 没有观察到线程Ⅰ对instance的写入。那么instance为空,线程Ⅱ将会执行(3)和(4),接下来根据happen-before规则可以得出如下关系:

锁定规则:        线程Ⅰ语句(5) happen-before 线程Ⅱ语句(3)   因为语句(5)处有unlock操作

程序次序规则: 线程Ⅱ语句(3) happen-before 线程Ⅱ语句(4)

传递性:            线程Ⅰ语句(5) happen-before 线程Ⅱ语句(4)

所以线程Ⅱ在执行语句(4)时一定能够观察到线程Ⅰ在语句(5)时对Singleton的写入值,所以线程Ⅱ执行语句(4)时将发现instance不为空,直接执行(6)返回由线程Ⅰ初始化的instance。

但是语句5实际上不是一个原子操作,它包含三个操作:

  ①.给LazySingleton分配内存地址。

  ②.初始化LazySingleton构造方法(即语句1)。

  ③.将instance对象指向分配的内存空间(在这一步的时候instance变成非null).

虽然根据程序次序规则,这三个操作之间存在happen-before 原则,但是根据不影响单线程运行结果,重排序是允许的。这里②和③两个操作之间不存在数据依赖,所以②和③可能会进行重排序执行(只有在X86架构下不存在对写人的重排序)。

在这种情况下,线程Ⅱ可能会拿到一个还未初始化完成的instance实例,所以依然可能无法在操作(7) 时获取到正确的值(当然在X86架构下应该不会出问题)。

 

 

对DCL的分析也告诉我们一条经验原则:对引用(包括对象引用和数组引用)的非同步访问,即使得到该引用的最新值,却并不能保证也能得到其成员变量(对数组而言就是每个数组元素)的最新值.

 

解决方案:

1. 利用“JSL保证的------一个类直到被使用时才被初始化,而类初始化的过程是非并行的”的思想借助静态内部类,这也是最简单而且安全的解决方法:

 1 public class Singleton {    
 2         
 3     private Singleton() {}    
 4     
 5     // Lazy initialization holder class idiom for static fields    
 6     private static class InstanceHolder {    
 7         private static final Singleton instance = new Singleton();    
 8     }    
 9     
10     public static Singleton getSingleton() {     
11         return InstanceHolder.instance;     
12     }    
13 } 

2. 利用volatile关键字,将成员变量instance声明为volatile:

1 private volatile static LazySingleton instance;  

然后再次分析其happen-before关系:        

volatile变量规则:线程Ⅰ语句(1) happen-before 线程Ⅰ语句(5),线程Ⅰ语句(5) happen-before 线程Ⅱ语句(2)

程序次序规则: 线程Ⅱ语句(2) happen-before 线程Ⅱ语句(6),线程Ⅱ语句(6) happen-before 线程Ⅱ语句(7)

传递性:              线程Ⅰ语句(1) happen-before 线程Ⅱ语句(7)

 

进一步的解释是,当instance被volatile修饰之后,操作1将不会被重排序到操作5中的将instance对象指向分配的内存空间之后,并且操作5完成之后还会立即对someField字段的写入操作以及对instance的赋值写入操作刷新到主存。在线程Ⅱ执行操作2或者4的时候,将会立即重新从主存加载instance对象以及其成员someField字段,所以当线程Ⅱ能够看到instance不为空时,也必定能够拿到someField字段最新的值。

 

这表示线程Ⅱ在语句(7)能够观察到线程Ⅰ在语句(1)时对someFiled的写入值,程序能够得到正确的行为。

posted @ 2021-05-11 15:34  莫待樱开春来踏雪觅芳踪  阅读(102)  评论(0)    收藏  举报