volatile这个关键字很不起眼,其使用场景和语义不像synchronized、wait()和notify()那么明显。正因为其隐晦,volatile关键字可能是多线程编程中被误解最多的一个。而关键字越隐晦,背后隐含的往往越复杂、越深刻。接下来将进一步由浅入深地从使用场景讨论到其底层实现。

1. 64位写入的原子性(Half Write)

举一个简答的例子,对于一个long类型变量的赋值和取值操作而言,在多线程场景下,线程A调用set(100),线程调用B调用get(),在某些场景下,返回值可能不是100.

public class Example1{
  
  private long a = 0;
  
  // 线程A调用set(100)
  public void set(long a){
    this.a = a;
  }
  
  // 线程B调用get(),返回值是不是一定为100?
  public void get(){
    return this.a;
  }
}

这有点反直觉,如此简单的一个赋值和取值操作,在多线程下面为什么会不对呢?这是因为JVM的规范并没有要求64位的long或者double的写入是原子的。在32位的机器上,一个64位变量的写入可能被拆分成两个32位的写操作来执行。这样一来,读取的线程就可能读到“一般的值”。解决办法也很简单,在long前面加上volatile关键字。

2.内存的可见性

不仅64位,32位或者位数更小的赋值和取值操作,其实也有问题。在之前讲过,线程关闭的标志位stopped为例,它是一个boolean类型的数组,也可能出现主线程把它设置成true,而工作线程读到的却还是false的情形,这就更反直觉了。

注意,这里并不是说永远读到的都是false,而是说一个线程写完之后,另外一个线程立即去读,读到的是false,但之后能读到true,也就是“最终一致性”,不是“强一致性”。这种特性,对于之前的例子没有太大影响,但是如果想实现无锁算法,例如实现一把自旋锁,就会出现一个线程把状态设置为true,另外一个线程读到的却还是false,然后两个线程都会拿到这把锁的问题。

所以,我们所说的“内存可见性”,指的是“写完之后立即对其他线程可见”,它的反面不是“不可见”,而是“稍后才能可见”。解决这个问题很容易,给变量加上volatile关键字即可。

“内存可见性”问题的出现,跟现代CPU的架构密切相关,后面会详细探讨。

3. 重排序:DCL问题

单例模式的线程安全写法不止一种,常用写法为DCL(Double Checking Locking),如下所示:

public class Singleton{
  
  private static Singleton instance;
  
  public static SIngleton getInstance(){
    if(instance == null){
      // 为了性能,延迟使用synchronized
      synchronized(Singleton.class){
        
        if(instance == null){
          // 有问题的代码!!!
          instance = new Instance();
        }
 
      }
    } 
    return instance;
  }
}

上述的**instance = new Instance()**代码会有问题:其底层会分为三个操作:

1.分配一块内存

2.在内存上初始化成员变量

3.把instance引用指向内存

在这三个操作中,操作2和操作3可能重排序,即先把instance指向内存,再初始化成员变量,因为二者并没有先后的依赖关系。此时,另外一个线程可能拿到一个未完成初始化的对象。这时,直接访问里面的成员变量,就可能出错。这就是典型的”构造函数溢出“问题。解决办法很简单,就是为instance变量加上volatile修饰。

通过上面的例子,可以总结出volatile的三重功效:64位写入的原子性、内存可见性和进制重排序。接下来,我们进入volatile原理的探究。