前言

volatile关键字可以说是JAVA比较难理解的一个关键字了,很多书感觉讲的都不太清楚。这篇博客主要梳理一下它的含义,是对自己学习的一个总结,参考了不少资料和博客,希望可以到帮助别人。本文的主要讲一下下面几件事:

  • JAVA内存模型简介
  • volatile的语义:可见性禁止重排序
  • 为什么volatile不能保证一致性。
  • volatile的应用场景举例。

JAVA内存模型简介

这块知识必须要对JAVA内存模型有个基本的认识,所以先简单讲一下JAVA内存模型。


Java内存模型规定了所有的变量都存储在主内存中。每条线程中还有自己的工作内存,线程的工作内存中保存了被该线程所使用到的变量(从主内存中拷贝而来)。线程对变量的所有操作(读取,赋值)都必须在工作内存中进行。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。由于这种内存模型结构,在多线程情况下会产生很多问题。比如执行如下代码:

1
i = 8

程序必须先在自己的工作内存中把i进行赋值,然后再将i的值写入到主存中。在这个过程中如果有两个线程A,B均对i进行自增操作,期望得到的值是10,在没有同步机制的情况下可能会有如下的情形发生:A和B同时从主存中读取i,然后分别在自己的工作内存中进行自增操作,然后先后写回主存,则此时主存中的值为9,与我们预期不符。

volatile的语义

可见性

可见性的含义是指:一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。还是之前那个例子:

1
i = 8

如果i用volatile修饰的话,当有一个线程在主存中读取i,并在自己的工作内存中进行修改的时候,修改后的值会立即强制同步到主存中,并且其他线程中这个值的缓存也都无效。相比之下普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
原理:如果对声明了volatile变量进行写操作时,JVM会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写会到系统内存。 这一步确保了如果有其他线程对声明了volatile变量进行修改,则立即更新主内存中数据。
但这时候其他处理器的缓存还是旧的,所以在多处理器环境下,为了保证各个处理器缓存一致,每个处理会通过嗅探在总线上传播的数据来检查 自己的缓存是否过期,当处理器发现自己缓存行对应的内存地址被修改了,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作时,会强制重新从系统内存把数据读到处理器缓存里。 这一步确保了其他线程获得的声明了volatile变量都是从主内存中获取最新的。

禁止重排序

重排序的含义是:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。这样听着比较拗口,举个例子吧:比如我们要计算23+36+17+44的值,灵活一点的人肯定会这么算(23+17)+(36+44)-->40+80 = 120,而不会按照顺序来算,应为这样进行重排序之后更加便于人脑计算,并且变换顺序之后最终的结果和顺序计算的结果是一致的。总之,重排序就是在保证最后结果一样的情况下,为了处理器的运行效率而对代码执行顺序进行优化的一种操作。
volatile禁止指令重排序的含义:

  • 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行
  • 在进行指令优化时,不能将在对volatile变量的读操作或者写操作的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
    举例说明:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    //x,y为普通变量
    //volflag被volatile修饰
    x = 10; //语句1
    y = 3; //语句2
    volflag = true; //语句3
    x= 5; //语句4
    y = 9; //语句5

这个例子中,由于volflag被volatile修饰,所以语句3不会被重排到语句1、语句2前面,也不会被重排到语句4、语句5的后面,但语句1、2和语句4、5的顺序是不能保证的。
另外volatile可以保证在执行到语句3的时候语句1、2是执行完毕的,语句4、5是没有执行的,并且语句1、2的执行结果是对语句4、5是可见的。
原理:Lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。

为什么volatile不能保证一致性?

首先,我们要知道保证一致性要满足三个条件:原子性,有序性,可见性。

  • 原子性:即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
  • 可见性:可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
  • 有序性:即程序执行的顺序按照代码的先后顺序执行。
    前面已经讲了volatile的可见性和有序性,但它不能保证原子性。下面设想一个情景:
    假如某个时刻变量i的值为10

线程1对变量进行自增操作,线程1先读取了变量i的原始值,然后线程1被阻塞了;
然后线程2对变量进行自增操作,线程2也去读取变量i的原始值,由于线程1只是对变量i进行读取操作,而没有对变量进行修改操作,所以不会导致线程2的工作内存中缓存变量i的缓存行无效,也不会导致主存中的值刷新,所以线程2会直接去主存读取inc的值,发现i的值时10,然后进行加1操作,并把11写入工作内存,最后写入主存。
然后线程1接着进行加1操作,由于已经读取了i的值,注意此时在线程1的工作内存中inc的值仍然为10,所以线程1对inc进行加1操作后i的值为11,然后将11写入工作内存,最后写入主存。
那么两个线程分别进行了一次自增操作后,i只增加了1。

可以看出,volatile变量是比锁弱一级的同步机制。当一个线程获取锁之后,别的线程就不能对其进行读取,修改等任何操作,但是获取一个volatile变量之后,只会让该线程对改变量的任何修改对其他线程都可见,但无法阻止其他线程对该变量的执行读取、修改等操作。锁和volatile的对比就好比中国和联合国,有人想干涉中国内政,中国可以发表声明并且同时使用武装力量强制抵挡入侵,但如果是联合国遇到干涉内政的问题就只能发表发表声明了。

volatile的应用场景举例

单例模式中的double check:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Singleton{
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if(instance==null) {
synchronized (Singleton.class) {
if(instance==null)
instance = new Singleton();
}
}
return instance;
}
}

为什么要使用volatile 修饰instance?
主要在于instance = new Singleton()这句,这并非是一个原子操作,事实上在 JVM 中这句话大概做了下面 3 件事情:

1.给 instance 分配内存

2.调用 Singleton 的构造函数来初始化成员变量

3.将instance对象指向分配的内存空间(执行完这步 instance 就为非 null 了)。

但是在 JVM 的即时编译器中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在 3 执行完毕、2 未执行之前,被线程二抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程二会直接返回instance,然后使用,然后理所当然地报错。

参考资料