Volatile关键字解析

本文参考来源 海子的博客

volatile在Java 5之前一直是个备受争议的关键字,直到Java 5之后才焕发新机。虽然volatile的含义如此简洁,但是深入理解它则需要了解Java的内存模型。

内存模型

高速缓存

在计算机中,执行程序时每条指令都是最终在CPU执行的,而指令在执行过程中,必然会和数据的读取和写入相关。由于程序运行时的临时数据是储存在主内存(RAM物理内存)中的,CPU的执行速度很快,而从内存中读取和写入相对CPU速度慢的多,于是CPU就有自己的存储结构——高速缓存。

CPU一般的结构由控制单元、执行单元、存储单元组成。

CPU结构

也就是说,在程序运行的过程中,CPU运算所需的数据需要从主存中复制一份到高速缓存中,然后通过读取-计算-写入,CPU完成计算写入后再将数据刷新到主存中。举个例子:

int i = i + 1;

当线程执行这段代码时,会先从主存中读取i的值,复制一份到高速缓存中,然后CPU执行对i+1的操作,再将数据写入高速缓存,最后将高速缓存中的i的值刷新到主存中。

在这个过程中,如果是单线程执行是没有问题的,但是在多线程中,会出现 缓存不一致 的问题。

缓存不一致

在多核心CPU中,每个线程独立运行于自己的CPU中,因此线程都会有属于自己的高速缓存。对于单核CPU而言,也会有这个问题,通过线程调度执行产生。这边以多核CPU为例:

int i = i + 1;

比如有2个线程同时执行这段代码,初始值为0,希望结果是i=2。大多数情况下是OK的,但是可能也会出现这种情况:初始时,两个线程分别将i的值从主存读取复制到各自的高速缓存中,然后线程1进行+1操作,i为1写入高速缓存并刷新内存,此时线程2的高速缓存仍然是0,进行+1操作后,i为1同样写入高速缓存并刷新内存。出现了最终结果是1而不是2的问题。

通常这样多个线程访问的变量被称为共享变量。

为了解决缓存不一致的问题,一般有以下两个方法:

  1. 通过在总线加LOCK#锁。
  2. 通过缓存一致性协议。
  • 以上都是在硬件层面解决问题。

早期是通过在总线加LOCK#锁解决缓存不一致的问题。因为CPU和外部的通信是通过总线进行的,于是加锁则能保证同步一致,阻塞了其他CPU对部件通信(访问内存),虽然可以解决,但是效率太低了,因为访问其他部件的操作相对CPU的执行速度而言效率太低。于是就出现了缓存一致性协议。

缓存一致性协议:最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。
它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为 无效状态 ,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

缓存一致性协议

并发编程

并发编程中,经常要解决以下问题:原子性问题、可见性问题、有序性问题。

原子性

  • 原子性是指一个操作或多个操作,要么全部执行并且不会被打断,要么不执行。

反应这个概念最简单的模型就是“转账取款”的问题。

如果反映到并发编程中,以java举例,i = 999;,i是int为32位,赋值过程中,前16位写入后,中断,此时另一个线程读取i,则值会错乱。

可见性

  • 可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得见修改后的值。
1
2
3
4
5
6
//thread 1
int i = 0; //语句1
i = 10; //语句2

//thread 2
int j = i;

假若线程1是CPU1,线程2是CPU2,当线程1执行到语句2时,此时CPU1高速缓存变为10,但是未刷新到主存中。则此时线程2执行,读取主存中的i仍然是0,使得j也变为0,出错。

可见性问题是在线程1修改变量后,线程2没有立刻看到线程1中的值。

有序性

  • 有序性是指程序执行的顺序按照代码的先后顺序执行。
1
2
3
4
int i = 0;   //语句1
int j = 1; //语句2
i = i + 1; //语句3
j = j + i; //语句4

按照正常的程序执行顺序,应该是1234顺序执行语句指令,但是在JVM中,会对指令优化处理,发生 指令重排 ,顺序可能变为2134。

虽然指令重排会使程序执行顺序发生变化,但是还是会有依赖顺序,不会出现语句4在语句3(或语句2)之前执行。因为在执行语句时,类似拓扑排序,会检查调用到的其他参数,保证其他参数执行完毕返回结果后,才会执行此语句。

对于单线程而言,不会产生问题。但是对于多线程,则会出现问题。比如:

1
2
3
4
5
6
7
//thread1
Context context = getContext(); //语句1
boolean init = true; //语句2

//thread2
while(!init) sleep(); //语句3
doSomething(context); //语句4

此时语句1语句2无依赖关系,则线程1中,很有可能会语句2先于语句1执行,此时线程2则会错误的认为context已经初始化ok,然后跳出循环执行语句4,实际context要在语句2才执行,进而导致异常。

因此可以看出,JVM指令重排对于单线程没有影响,但是多线程会导致可能出现错误。

在并发编程中,若未保证以上三原则中的任何一个,都会产生不可预知的问题。

Java内存模型

在JVM虚拟机中,试图定义一种Java内存模型(Java Memory Model,JMM),以此解决跨硬件和不同操作系统上的内存访问差异,完善跨平台性。其中,JMM主要定义了程序中访问变量的访问规则,即程序的执行顺序(有序性)。

但是,为了更好的性能,JMM并没有禁止指令重排,也没有限制CPU在指令的处理上使用寄存器和高速缓存。所以,JMM也会产生缓存一致性问题和指令重排的问题。

JMM规定所有变量都是存在于主存中(物理内存),所有线程都有自己的工作内存(类似CPU的高速缓存),线程对变量的操作必须在工作内存中完成,无法直接对主存操作,并且不能访问其他线程的工作内存。

例如i = 10,规定线程从主存中复制i到自己的工作内存中,然后在工作内存中赋值i的值为10,最后再刷新主存中的i的值。注意,线程不是直接操作主存中的变量。

原子性

  • 在Java中,对基本数据类型的变量进行简单的读取和赋值是原子性的,不可被中断的。
1
2
3
4
x = 1;      //语句1
y = x; //语句2
x++; //语句3
x = x + 1; //语句4

以上4个语句,只有语句1变量赋值是原子操作。语句2涉及读取x、赋值y的两步操作。虽然单步是原子操作,但是两步合起来就不是了。同样语句3和语句4都是读取x、执行x+1、赋值给x,三步。

不过这里有一点需要注意:在32位平台下,对64位数据的读取和赋值是需要通过两个操作来完成的,不能保证其原子性。但是好像在最新的JDK中,JVM已经保证对64位数据的读取和赋值也是原子性操作了。

  • 对于其他代码块的原子性问题,可以通过synchronizedLock来锁块。

可见性

对于可见性,Java使用volatile来保证。也就是本章的主题。

当一个共享变量被volatile修饰时,会保证在被修改的时候立刻存入主存,当有其他线程需要读取时,会重新到主存中读取新的值。

而未修饰的共享变量则无法保证可见性,因为写入主存的时间不固定。

另外,通过synchronizedLock也可以保证可见性,因为它们可以保证同一时刻只有一个线程操作变量(对象),执行代码直至释放锁。在释放锁之前,会将对变量的修改写入主存中。

有序性

之前提到过JVM的指令重排问题。这里可以用volatile保证一定的有序性(原因见下)。当然,synchronizedLock一样可以,原因不再赘述。

Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

happens-before 原则

  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
  • 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作
  • volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
  • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
  • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
《深入理解Java虚拟机》
  1. 单线程内程序的运行结果和代码的书写顺序一致。这里并不是指程序执行过程一致,因为JVM有重排机制,但是输出的结果是肯定一致的。
  2. 也就是说,不论单线程还是多线程,都会等待线程锁释放后再执行获取锁的操作。
  3. volatile的关键特性,简单的说就是,如果一个线程先去写一个变量,另一个线程进行读取,那么volatile保证写入一定在读取之前。
  4. happens-before具有传递性。

后面4条比较简单,就不赘述了。

剖析volatile关键字

两层语义

一旦一个共享变量被volatile修饰后,则具备以下两个语义:

  1. 保证了不同线程对这个变量操作的可见性,即一个线程修改了变量的值,在对另一个线程是立即可见的,读取变量时会重新进入主存中读取。
  2. 禁止了指令重排。
1
2
3
4
5
6
//thread 1
boolean stop = false;
while(!stop) doSomething();

//thread2
stop = true;

这是一个常见的带有中断的多线程任务流程。按照代码执行,线程2一定会使线程1中断吗?

不一定。由于之前所说的JMM规则(可见性)导致的这一问题。这种bug概率虽极低,但是一旦出现,不好排查且后果严重,会造成无限循环、死锁等。

具体描述是,线程1有自己的工作线程,读取stop值后复制到自己的工作内存中,此时执行工作。如果此时线程2读取主存的stop值,然后在自己的工作内存中改变stop,在写入主存之前,被中断或进行其他工作。此时线程1则会无法知道stop已经被改变,进而引发问题。

但是如果被volatile修饰后,则:

  1. 强制修改变量的值立刻写入主存。
  2. 当线程2进行修改时,会导致线程1工作内存的stop缓存无效(映射到硬件层的话,就是CPU的L1或L2缓存中的对应缓存行无效)。
  3. 由于线程1中stop缓存无效,则线程1会去主存中重新读取stop的值。程序运行正常。

保证原子性

  • volatile无法保证对变量的任何操作的原子性。

当然,基于JMM,原子操作的原子性肯定是可以保证的。

此时可以采用synchronizedLock来解决代码块的原子性问题。对于基础数据结构,Java还提供了原子数据结构(比如AtomicInteger),对自增、自减、加法、减法进行封装,保证这些操作的原子性。

AtomicInteger利用CAS(Compare And Swap)来实现原子操作的,CAS实际是利用处理器提供的CMPXCHG指令实现的,而处理器执行CMPXCHG是原子操作。

保证有序性

  • volatile禁止指令重排,所以一定程度上可以保证有序性。

这里有两层含义:

  1. 当程序执行到volatile修饰的变量(读写)时,在其之前的操作保证一定完成,且结果对后续程序可见(写入主存),在其后面的语句还未执行。
  2. JVM将指令优化时,无法将volatile修饰的变量进行重排(无法改变变量执行时的顺序)
1
2
3
4
5
6
7
//thread1
Context context = getContext(); //语句1
volatile boolean init = true; //语句2

//thread2
while(!init) sleep(); //语句3
doSomething(context); //语句4

比如之前的代码,若volatile对init修饰,则不会出现语句2在语句1之前执行,程序正确执行。

原理和实现机制

“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”

《深入理解Java虚拟机》

lock前缀指令相当于内存屏障(内存栅栏),提供3个功能:

  1. 执行指令时,前面的操作已经全部完成。
  2. 强制缓存修改参数立刻写入主存。
  3. 如果是写操作,会导致其他CPU中对于的缓存无效(可见性)。

volatile使用场景

synchronized可以防止多个线程执行一段代码块,但是影响程序执行效率,而volatile在某些情况下的性能要优于synchronized。但是之前说过,volatile无法保证操作的原子性,所以无法替代synchronized。一般来说,使用volatile必须具备以下条件:

  1. 对变量的写操作不依赖当前值。
  2. 该变量没有包含在具有其他变量的不变式中。

以上表明,可以被写入volatile变量的有效值是独立于程序的任何状态,包括变量的当前状态。

其实主要上述是为了保证操作是原子操作,否则在并发时无法得到正确执行。

以下举例常用场景:

中断线程任务
1
2
3
4
5
6
7
8
9
10
volatile boolean flag = false;

//thread 1
while(!flag) {...}

//thread2
public void setFlag(boolean flag)
{
this.flag = flag;
}

这个之前讨论过了,就不赘述了。

double-check
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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;
}
}

双重检查源自Java的Singleton单例模式(同步延迟加载)。关于单例模式,有空再总结一下。


参考文献:《Java编程思想》《深入理解Java虚拟机》

文章目录
  1. 1. 内存模型
    1. 1.1. 高速缓存
    2. 1.2. 缓存不一致
  2. 2. 并发编程
    1. 2.1. 原子性
    2. 2.2. 可见性
    3. 2.3. 有序性
  3. 3. Java内存模型
    1. 3.1. 原子性
    2. 3.2. 可见性
    3. 3.3. 有序性
    4. 3.4. happens-before 原则
  4. 4. 剖析volatile关键字
    1. 4.1. 两层语义
    2. 4.2. 保证原子性
    3. 4.3. 保证有序性
    4. 4.4. 原理和实现机制
  5. 5. volatile使用场景
|