浅析Java中的volatile关键字

185 阅读12分钟

介绍

volatile是Java中的一个关键词,单词的本意是'易变的',有时候也被别人成为轻量级的Synchronized,在Java 中,我们常用它来标记被多个线程共享的变量,以此来确保线程之间对于该值一定是可见最新的。这篇文章,我 将分析该关键字背后的原理,并且梳理出与其有关的一系列知识点,帮助你更好的理解。

volatile关键字与Java中的内存模型紧密相连,因此我会先介绍到Java的内存模型之后再去介绍volatile关键字是如何做到可见性、与原子性的。

Java内存模型

Java内存模型(Java Memory Model,简称JMM。是Java虚拟机对多线程程序中内存访问和操作进行规范的一种抽象,注意,它其实并不是真实存在的。它定义了线程如何与主存(共享内存)和工作内存(线程私有内存)进行交互,以及如何同步和互斥的访问共享数据。

设计JMM的原因

现代CPU中存在缓存层

现代的CPU处理速度非常快,相比之下,内存的读写速度就显得很慢,所以为了提高CPU的利用率,在CPU和内存之间存在着缓存层,虽然缓存的容量比内存小,但是缓存的速度却比内存的速度要快得多,其中 L1 缓存的速度仅次于寄存器的速度。结构示意图如下所示:

图片.png

在图中,从下往上分别是内存,L3 缓存、L2 缓存、L1 缓存,寄存器,然后最上层是 CPU 的 4个核心。从内存,到 L3 缓存,再到 L2 和 L1 缓存,它们距离 CPU 的核心越来越近了,越靠近核心,其容量就越小,但是速度也越快。正是由于缓存层的存在,才让我们的 CPU 能发挥出更好的性能。

其实,线程间对于共享变量的可见性问题,并不是直接由多核引起的,而是由我们刚才讲到的这些 L3 缓存、L2 缓存、L1 缓存,也就是多级缓存引起的:每个核心在获取数据时,都会将数据从内存一层层往上读取,同样,后续对于数据的修改也是先写入到自己的 L1 缓存中,然后等待时机再逐层往下同步,直到最终刷回内存。

假设 core 1 修改了变量 a 的值,并写入到了 core 1 的 L1 缓存里,但是还没来得及继续往下同步,由于 core 1 有它自己的的 L1 缓存,core 4 是无法直接读取 core 1 的 L1 缓存的值的,那么此时对于 core 4 而言,变量 a 的值就不是 core 1 修改后的最新的值,core 4 读取到的值可能是一个过期的值,从而引起多线程时可见性问题的发生。

主内存与工作内存

Java 作为高级语言,屏蔽了 L1 缓存、L2 缓存、L3 缓存,也就是多层缓存的这些底层细节,用 JMM 定义了一套读写数据的规范。我们不再需要关心 L1 缓存、L2 缓存、L3 缓存等多层缓存的问题,我们只需要关心 JMM 抽象出来的主内存和工作内存的概念。为了更方便你去理解,可参考下图:

图片.png

每个线程只能够直接接触到工作内存,无法直接操作主内存,而工作内存中所保存的正是主内存的共享变量的副本,主内存和工作内存之间的通信是由 JMM 控制的。

主内存和工作内存的关系

JMM 有以下规定:

(1)所有的变量都存储在主内存中,同时每个线程拥有自己独立的工作内存,而工作内存中的变量的内容是主内存中该变量的拷贝;

(2)线程不能直接读 / 写主内存中的变量,但可以操作自己工作内存中的变量,然后再同步到主内存中,这样,其他线程就可以看到本次修改;

(3) 主内存是由多个线程所共享的,但线程间不共享各自的工作内存,如果线程间需要通信,则必须借助主内存中转来完成。

听到这里,你对上图的理解可能会更深刻一些,从图中可以看出,每个工作内存中的变量都是对主内存变量的一个拷贝,相当于是一个副本。而且图中没有一条线是可以直接连接各个工作内存的,因为工作内存之间的通信,都需要通过主内存来中转。

正是由于所有的共享变量都存在于主内存中,每个线程有自己的工作内存,其中存储的是变量的副本,所以这个副本就有可能是过期的,我们来举个例子:如果一个变量 x 被线程 A 修改了,只要还没同步到主内存中,线程 B 就看不到,所以此时线程 B 读取到的 x 值就是一个过期的值,这就导致了可见性问题。

通过了解了JMM,我想你现在已经知道为什么会出现可见性问题的根本原因。下面我们从并发编程三个特性可见性、有序性、原子性分析volatile关键字究竟有什么用。

可见性、有序性和原子性

线程与线程之间的通信在Java中采用的是共享内存的方式。

可见性问题

可见性问题指的是当有某一个线程修改了多个线程间共享的变量,那么其余的线程都应当读到这个共享变量的最新值。

下面看一个典型的例子

//Thread A
while(condition){
	//退出循环
}
//Thread B
condition = false;

如果采用了正常变量condition,那么是无法退出循环的,在线程B运行的过程中始终是看不到condition的变化的。如果采用了volatile关键字,则其会采用类似于强制刷新主内存的方式,确保在共享内存中的值一定是最新版的。

所以,如果一个变量被volatile修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其它处理器的缓存由于遵守了缓存一致性协议(比如MSI、MESI、MOSI、Synapse、Firefly及Dragon Protocaol等),也会把这个变量的值从主存加载到自己的缓存中,这就保证了一个volatile在并发编程中,其值在多个缓存中是可见的。

有序性

volatile除了可以保证数据的可见性之外,还有一个强大的功能,那就是它可以禁止指令重排序,所以能在一定程度上保证有序性。

定义:一个线程中的所有操作必须按照程序的顺序来执行。

volatile的有序性是它本身的特性——禁止指令重排实现的,而禁止指令重排又是由内存屏障来实现的,这个内存屏障我们目前不深究。

指令重排

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型。

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应 机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序,如图:

图片.png

举例说明

最经典的例子当然是双重检测实现单例的例子了,如下:

public class Singleton{
	private static Singleton instance = null;
	//构造器私有,避免直接被外部new出来
    private Singleton(){}
    //对外提供一个公有的方法,返回实例对象
    public static  Singleton getInstance(){
	    if(instance == null){
		    Synchronized(Singleton.class){
				if(instance == null){
					instance = new Singleton();
				}
		    }
	    }
        return instance;
    }
}

以上代码,我们通过使用synchronized对Singleton.class加锁,可以保证同一时间只有一个线程可以执行到同步代码块中的内容,这就实现了一个单例。

但是,在极端情况下,上述的单例对象可能发生空指针异常,那么这是如何发生的呢?

我们假设线程1和线程2同时请求getSingleton()方法的时候:

    线程1执行到instance=new Singleton();,开始初始化。
    线程2执行到“第一次检测”的位置,判断singleton == null。
    线程2经过判断发现singleton !=null,于是就直接执行return instance。
    线程2拿到singleton对象后,开始执行后续的操作。

以上过程看似没有什么问题,但在第4步执行后续操作的时候,是有可能抛空指针异常的,这是因为在第3步的时候,线程2拿到的singleton对象并不是一个完整的对象。

很明显instance=new Singleton();,这段代码出现了问题,那我们来分析一下,这个代码的执行过程可以简化成3步:

  • JVM为对象分配一块内存M。
  • 在内存上为对象进行初始化。
  • 将内存M的地址赋值给singleton变量。

因为将内存的地址赋值给singleton变量是最后一步,所以线程1在这一步骤执行之前,线程2在对singleton == null判断一直都是true,那么它会一直阻塞,直到线程1执行完。

但是这个过程并不是一个原子操作,并且编译器可能会进行重排序,如果以上步骤被重排序为:

    JVM为对象分配一块内存M。
    将内存M的地址赋值给singleton变量。
    在内存上为对象进行初始化。

这样的话线程1会先内存分配,再执行变量赋值,最后执行初始化。也就是说在线程1执行初始化之前,线程2对singleton == null的判断会提前得到一个false,于是便返回了一个不完整的对象,所以在执行后续操作时,就发生了空指针异常。

很明显,这是指令重排造成的问题,要解决的话,直接禁止它指令重排就行了,所以volatile就派上用场了,只需要用volatile修饰一下instance即可。

public class Singleton{
    private static volatile Singleton instance = null;
    //构造器私有,避免直接被外部new出来
    private Singleton(){}
    //对外提供一个公有的方法,返回实例对象
    public static  Singleton getInstance(){
	    if(instance == null){
		    Synchronized(Singleton.class){
				if(instance == null){
					instance = new Singleton();
				}
		    }
	    }
        return instance;
    }
}

原子性问题

我们知道对于变量来说,纯粹的赋值操作是线程安全的。类似下面这种

i = 10;

不过,对于自增操作而言,一定是线程不安全的。一条自增语句被编译之后会出现三个语句。

i++
  • 加载该变量的值
  • 将该变量进行自增
  • 将该变量存储回去

图片.png

我们根据箭头指向依次看,线程 1 首先拿到 i=1 的结果,然后进行 i+1 操作,但假设此时 i+1 的结果还没有来得及被保存下来,线程 1 就被切换走了,于是 CPU 开始执行线程 2,它所做的事情和线程 1 是一样的 i++ 操作,但此时我们想一下,它拿到的 i 是多少?实际上和线程 1 拿到的 i 结果一样,同样是 1,为什么呢?因为线程 1 虽然对 i 进行了 +1 操作,但结果没有保存,所以线程 2 看不到修改后的结果。

然后假设等线程 2 对 i 进行 +1 操作后,又切换到线程 1,让线程 1 完成未完成的操作,即将 i+1 的结果 2 保存下来,然后又切换到线程 2 完成 i=2 的保存操作,虽然两个线程都执行了对 i 进行 +1 的操作,但结果却最终保存了 i=2,而不是我们期望的 i=3,这样就发生了线程安全问题,导致数据结果错误,这也是最典型的线程安全问题。

volatile 和 synchronized 的区别

volatile 本质是在告诉 JVM 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取。synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
volatile 仅能使用在变量级别。synchronized 则可以使用在变量、方法、和类级别的。
volatile 仅能实现变量的修改可见性,不能保证原子性。而 synchronized 则可以保证变量的修改可见性和原子性。
volatile 不会造成线程的阻塞。synchronized 可能会造成线程的阻塞。
volatile 标记的变量不会被编译器优化。synchronized 标记的变量可以被编译器优化。

总结

在本文当中,为了介绍volatile关键字,聊到了JVM规范的JMM,它将操作系统中的缓存层屏蔽了并且抽象出了工作内存和共享内存,还介绍了Java并发编程中的三个特性,可见性、有序性和原子性。而volatile可以保证做到可见性和有序性。

  • volatile通过某种手段,修改后强制刷新所有缓存,确保做到最新的值,以保证可见性。
  • volatile通过禁止指令重排,来确保不会因为指令重排导致结果发生变化。
  • volatile不能保证原子性,并且要注意 原子操作 + 原子操作 != 原子操作。

参考资料:

《Java并发编程的艺术》

《Java并发编程实战》