深入理解volatile关键字 - Sanarous的博客

深入理解volatile关键字

在 JSR-133 (也即 JDK1.5 )之前,volatile 关键字一直饱受争议,因为使用这个关键字会造成一些不可预料的后果,从 JSR-133 开始,专家组对这个关键字的语义进行了增强,从而使得现在使用 volatile 关键字的环境越来越多,目前它也被称为是“轻量级的 synchronized”。

并发编程中的三个概念

volatile 虽然从字面意思上理解比较简单,但是在实际环境中能正确的使用该变量并不容易,只有了解其背后的原理,我们才能发挥出这个关键字的重要作用。在理解原理之前,我们有必要先了解一下并发编程中三个常见的概念:原子性、可见性和顺序一致性。

原子性

原子性理解比较简单,与数据库系统中原子性意思基本一致,即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

一个很经典的例子就是银行账户转账问题:

比如从账户 A 向账户 B 转 1000 元,那么必然包括 2 个操作:从账户 A 减去 1000 元,往账户 B 加上 1000 元。

试想一下,如果这 2 个操作不具备原子性,会造成什么样的后果。假如从账户 A 减去 1000 元之后,操作突然中止。然后又从 B 取出了 500 元,取出 500 元之后,再执行往账户 B 加上 1000 元的操作。这样就会导致账户 A 虽然减去了 1000 元,但是账户 B 没有收到这个转过来的 1000 元。

所以这 2 个操作必须要具备原子性才能保证不出现一些意外的问题。

同样地反映到并发编程中会出现什么结果呢?

举个最简单的例子,大家想一下假如为一个 64 位的变量赋值过程不具备原子性的话,会发生什么后果?

1
long i = 9L;

假若一个线程执行到这个语句时,我暂且假设为一个 64 位的变量赋值包括两个过程:为低 32 位赋值,为高 32 位赋值。那么就可能发生一种情况:当将低 32 位数值写入之后,突然被中断,而此时又有一个线程去读取 i 的值,那么读取到的就是错误的数据。

可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。即一个线程修改的值能对其它线程可见。

可见性的原理涉及到 Java 内存模型,关于其中的 Java 内存模型可以参考我的另外一篇博客,以便于下文的理解。

有了 JMM 的概念后,可以举个简单的例子,看下面这段代码:

1
2
3
4
5
6
//线程1执行的代码
int i = 0;
i = 10;

//线程2执行的代码
j = i;

假若执行线程 1 的是 CPU1 ,执行线程 2 的是 CPU2。由上面的分析可知,当线程 1 执行 i = 10 这句时,会先把 i 的初始值加载到 CPU1 的高速缓存中,然后赋值为 10,那么在 CPU1 的高速缓存当中 i 的值变为 10 了,却没有立即写入到主存当中。

此时线程 2 执行 j = i,它会先去主存读取i的值并加载到 CPU2 的缓存当中,注意此时内存当中 i 的值还是 0,那么就会使得 j 的值为 0,而不是 10。

这就是可见性问题,线程 1 对变量 i 修改了之后,线程 2 没有立即看到线程 1 修改的值。

顺序一致性

即程序执行的顺序按照代码的先后顺序执行。举个简单的例子,看下面这段代码:

1
2
3
4
int i = 0;              
boolean flag = false;
i = 1; //语句1
flag = true; //语句2

上面代码定义了一个 int 型变量,定义了一个 boolean 类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句 1 是在语句 2 前面的,那么 JVM 在真正执行这段代码的时候会保证语句 1 一定会在语句 2 前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。

下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

比如上面的代码中,语句 1 和语句 2 谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句 2 先执行而语句 1 后执行。

但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同。关于指令重排序的具体细节可以参考我的另外一篇文章

volatile的定义

有了并发编程的三个基本概念后,我们就可以看一下 volatile 相关的定义了。这里引用 JSR-133 中对 volatile 关键字的定义:

The Java programming language allows threads to access shared variables (§17.1). As a rule, to ensure that shared variables are consistently and reliably updated, a thread should ensure that it has exclusive use of such variables by obtaining a lock that, conventionally, enforces mutual exclusion for those shared variables.

The Java programming language provides a second mechanism, volatile fields, that is more convenient than locking for some purposes.

A field may be declared volatile, in which case the Java Memory Model ensures that all threads see a consistent value for the variable (§17.4).

简单翻译一下:

Java编程语言中允许线程访问共享变量。为了确保共享变量能被一致地和可靠的更新,线程必须确保它是排他性的使用此共享变量,通常都是获得对这些共享变量强制排他性的同步锁。

Java编程语言提供了另一种机制,volatile 域变量,对于某些场景的使用这要更加的方便。

可以把变量声明为 volatile,以让 Java 内存模型来保证所有线程都能看到这个变量的同一个值。

简而言之,volatile 相当于提供了一种同步机制,从而保证被 volatile 关键字声明的变量对所有线程的可见性,并且 volatile 不会引起线程上下文的切换和调度,比 synchronized 的执行成本要更低。

volatile内存语义

有了 volatile 的定义后,我们比较疑惑的地方就是, volatile 提供了一种什么样的“同步机制”,是如何保证变量对所有线程的可见性的。只要理解了这两个问题的原理,volatile 关键字就没有那么可怕了。

实际上, 一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile 修饰之后,那么就具备了两层语义:

  1. 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的
  2. 禁止进行指令重排序

对上面两层语义,我们逐步进行解析。

volatile 的特性

当我们声明共享变量为 volatile 后,对这个变量的读 / 写将会很特别。理解 volatile 特性的一个好方法是:把对 volatile 变量的单个读 / 写,看成是使用同一个监视器锁对这些单个读 / 写操作做了同步。下面我们通过具体的示例来说明,请看下面的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class VolatileFeaturesExample {
volatile long vl = 0L; // 使用 volatile 声明 64 位的 long 型变量

public void set(long l) {
vl = l; // 单个 volatile 变量的写
}

public void getAndIncrement () {
vl++; // 复合(多个)volatile 变量的读 / 写
}

public long get() {
return vl; // 单个 volatile 变量的读
}
}

假设有多个线程分别调用上面程序的三个方法,这个程序在语意上和下面程序等价:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class VolatileFeaturesExample {
long vl = 0L; // 64 位的 long 型普通变量

public synchronized void set(long l) { // 对单个的普通 变量的写用同一个监视器同步
vl = l;
}

public void getAndIncrement () { // 普通方法调用
long temp = get(); // 调用已同步的读方法
temp += 1L; // 普通写操作
set(temp); // 调用已同步的写方法
}
public synchronized long get() {
// 对单个的普通变量的读用同一个监视器同步
return vl;
}
}
如上面示例程序所示,对一个 volatile 变量的单个读 / 写操作,与对一个普通变量的读 / 写操作使用同一个监视器锁来同步,它们之间的执行效果相同。

监视器锁的 happens-before 规则保证释放监视器和获取监视器的两个线程之间的内存可见性,这意味着对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入。

监视器锁的语义决定了临界区代码的执行具有原子性。这意味着即使是 64 位的 long 型和 double 型变量,只要它是 volatile 变量,对该变量的读写就将具有原子性。如果是多个 volatile 操作或类似于 volatile++ 这种复合操作,这些操作整体上不具有原子性。

简而言之,volatile 变量自身具有下列特性:

  • 可见性:对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入。
  • 原子性:对任意单个 volatile 变量的读 / 写具有原子性,但类似于 volatile++ 这种复合操作不具有原子性。

volatile 写 - 读建立的 happens before 关系

上面讲的是 volatile 变量自身的特性,对程序员来说,volatile 对线程的内存可见性的影响比 volatile 自身的特性更为重要,也更需要我们去关注。

从 JSR-133 开始,volatile 变量的写 - 读可以实现线程之间的通信。

从内存语义的角度来说,volatile 与监视器锁有相同的效果:volatile 写和监视器的释放有相同的内存语义;volatile 读与监视器的获取有相同的内存语义。

请看下面使用 volatile 变量的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class VolatileExample {
int a = 0;
volatile boolean flag = false;

public void writer() {
a = 1; //1
flag = true; //2
}

public void reader() {
if (flag) { //3
int i = a; //4
……
}
}
}

假设线程 A 执行 writer() 方法之后,线程 B 执行 reader() 方法。根据 happens before 规则,这个过程建立的 happens before 关系可以分为两类:

  1. 根据程序次序规则,1 happens before 2; 3 happens before 4。
  2. 根据 volatile 规则,2 happens before 3。
  3. 根据 happens before 的传递性规则,1 happens before 4。

上述 happens before 关系的图形化表现形式如下:

在上图中,每一个箭头链接的两个节点,代表了一个 happens before 关系。黑色箭头表示程序顺序规则;橙色箭头表示 volatile 规则;蓝色箭头表示组合这些规则后提供的 happens before 保证。

这里 A 线程写一个 volatile 变量后,B 线程读同一个 volatile 变量。A 线程在写 volatile 变量之前所有可见的共享变量,在 B 线程读同一个 volatile 变量后,将立即变得对 B 线程可见。

volatile 写 - 读的内存语义

volatile 写的内存语义如下:

  • 当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存。

以上面示例程序 VolatileExample 为例,假设线程 A 首先执行 writer() 方法,随后线程 B 执行 reader() 方法,初始时两个线程的本地内存中的 flag 和 a 都是初始状态。下图是线程 A 执行 volatile 写后,共享变量的状态示意图:

如上图所示,线程 A 在写 flag 变量后,本地内存 A 中被线程 A 更新过的两个共享变量的值被刷新到主内存中。此时,本地内存 A 和主内存中的共享变量的值是一致的。

volatile 读的内存语义如下:

  • 当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

下面是线程 B 读同一个 volatile 变量后,共享变量的状态示意图:

如上图所示,在读 flag 变量后,本地内存 B 已经被置为无效。此时,线程 B 必须从主内存中读取共享变量。线程 B 的读取操作将导致本地内存 B 与主内存中的共享变量的值也变成一致的了。

如果我们把 volatile 写和 volatile 读这两个步骤综合起来看的话,在读线程 B 读一个 volatile 变量后,写线程 A 在写这个 volatile 变量之前所有可见的共享变量的值都将立即变得对读线程 B 可见。

下面对 volatile 写和 volatile 读的内存语义做个总结

  • 线程 A 写一个 volatile 变量,实质上是线程 A 向接下来将要读这个 volatile 变量的某个线程发出了(其对共享变量所在修改的)消息。
  • 线程 B 读一个 volatile 变量,实质上是线程 B 接收了之前某个线程发出的(在写这个 volatile 变量之前对共享变量所做修改的)消息。
  • 线程 A 写一个 volatile 变量,随后线程 B 读这个 volatile 变量,这个过程实质上是线程 A 通过主内存向线程 B 发送消息。

volatile 内存语义的实现

下面,让我们来看看 JMM 如何实现 volatile 写 / 读的内存语义。

前文我们提到过重排序分为编译器重排序和处理器重排序。为了实现 volatile 内存语义,JMM 会分别限制这两种类型的重排序类型。下面是 JMM 针对编译器制定的 volatile 重排序规则表:

是否能重排序第二个操作
第一个操作普通读 / 写volatile 读volatile 写
普通读 / 写NO
volatile 读NONONO
volatile 写NONO

举例来说,第三行最后一个单元格的意思是:在程序顺序中,当第一个操作为普通变量的读或写时,如果第二个操作为 volatile 写,则编译器不能重排序这两个操作。

从上表我们可以看出:

  • 当第二个操作是 volatile 写时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。
  • 当第一个操作是 volatile 读时,不管第二个操作是什么,都不能重排序。这个规则确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。
  • 当第一个操作是 volatile 写,第二个操作是 volatile 读时,不能重排序。

为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,JMM 采取保守策略。下面是基于保守策略的 JMM 内存屏障插入策略:

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的 volatile 内存语义。

下面是保守策略下,volatile 写插入内存屏障后生成的指令序列示意图:

上图中的 StoreStore 屏障可以保证在 volatile 写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为 StoreStore 屏障将保障上面所有的普通写在 volatile 写之前刷新到主内存。

这里比较有意思的是 volatile 写后面的 StoreLoad 屏障。这个屏障的作用是避免 volatile 写与后面可能有的 volatile 读 / 写操作重排序。因为编译器常常无法准确判断在一个 volatile 写的后面,是否需要插入一个 StoreLoad 屏障(比如,一个 volatile 写之后方法立即 return)。为了保证能正确实现 volatile 的内存语义,JMM 在这里采取了保守策略:在每个 volatile 写的后面或在每个 volatile 读的前面插入一个 StoreLoad 屏障。从整体执行效率的角度考虑,JMM 选择了在每个 volatile 写的后面插入一个 StoreLoad 屏障。因为 volatile 写 - 读内存语义的常见使用模式是:一个写线程写 volatile 变量,多个读线程读同一个 volatile 变量。当读线程的数量大大超过写线程时,选择在 volatile 写之后插入 StoreLoad 屏障将带来可观的执行效率的提升。从这里我们可以看到 JMM 在实现上的一个特点:首先确保正确性,然后再去追求执行效率

下面是在保守策略下,volatile 读插入内存屏障后生成的指令序列示意图:

上图中的 LoadLoad 屏障用来禁止处理器把上面的 volatile 读与下面的普通读重排序。LoadStore 屏障用来禁止处理器把上面的 volatile 读与下面的普通写重排序。

上述 volatile 写和 volatile 读的内存屏障插入策略非常保守。在实际执行时,只要不改变 volatile 写 - 读的内存语义,编译器可以根据具体情况省略不必要的屏障。下面我们通过具体的示例代码来说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class VolatileBarrierExample {
int a;
volatile int v1 = 1;
volatile int v2 = 2;

void readAndWrite() {
int i = v1; // 第一个 volatile 读
int j = v2; // 第二个 volatile 读
a = i + j; // 普通写
v1 = i + 1; // 第一个 volatile 写
v2 = j * 2; // 第二个 volatile 写
}

// 其他方法
}

针对 readAndWrite() 方法,编译器在生成字节码时可以做如下的优化:

注意,最后的 StoreLoad 屏障不能省略。因为第二个 volatile 写之后,方法立即 return。此时编译器可能无法准确断定后面是否会有 volatile 读或写,为了安全起见,编译器常常会在这里插入一个 StoreLoad 屏障。

上面的优化是针对任意处理器平台,由于不同的处理器有不同“松紧度”的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以 x86 处理器为例,上图中除最后的 StoreLoad 屏障外,其它的屏障都会被省略。

前面保守策略下的 volatile 读和写,在 x86 处理器平台可以优化成:

前文提到过,x86 处理器仅会对写 - 读操作做重排序。X86 不会对读 - 读读 - 写写 - 写操作做重排序,因此在 x86 处理器中会省略掉这三种操作类型对应的内存屏障。在 x86 中,JMM 仅需在 volatile 写后面插入一个 StoreLoad 屏障即可正确实现 volatile 写 - 读的内存语义。这意味着在 x86 处理器中,volatile 写的开销比 volatile 读的开销会大很多(因为执行 StoreLoad 屏障开销会比较大)。

JSR-133 为什么要增强 volatile 的内存语义

在 JSR-133 之前的旧 Java 内存模型中,虽然不允许 volatile 变量之间重排序,但旧的 Java 内存模型允许 volatile 变量与普通变量之间重排序。在旧的内存模型中,VolatileExample 示例程序可能被重排序成下列时序来执行:

在旧的内存模型中,当 1 和 2 之间没有数据依赖关系时,1 和 2 之间就可能被重排序(3 和 4 类似)。其结果就是:读线程 B 执行 4 时,不一定能看到写线程 A 在执行 1 时对共享变量的修改。

因此在旧的内存模型中 ,volatile 的写 - 读没有监视器的释放 - 获所具有的内存语义。为了提供一种比监视器锁更轻量级的线程之间通信的机制,JSR-133 专家组决定增强 volatile 的内存语义:严格限制编译器和处理器对 volatile 变量与普通变量的重排序,确保 volatile 的写 - 读和监视器的释放 - 获取一样,具有相同的内存语义。从编译器重排序规则和处理器内存屏障插入策略来看,只要 volatile 变量与普通变量之间的重排序可能会破坏 volatile 的内存语意,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。

由于 volatile 仅仅保证对单个 volatile 变量的读 / 写具有原子性,而监视器锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,监视器锁比 volatile 更强大;在可伸缩性和执行性能上,volatile 更有优势。如果想在程序中用 volatile 代替监视器锁,请一定谨慎。

volatile的使用场景

在了解了 volatile 关键字的原理之后,我们可以做一个小结,volatile 关键字可以为一个变量提供一种同步访问机制,如果一个变量被声明为 volatile ,那么编译器和 JVM 就知道该变量是可能被另一个线程并发更新的。

那么 volatile 关键字能提供这种机制的原理就是,如果写入 volatile 声明的变量,JMM 会通过强制将本地内存刷新到主内存中,如果读取 volatile 声明的变量, JMM 会将本地内存置为无效,同时强制从主内存中读取该变量数据到本地内存中,从而通过这个机制保证 volatile 变量的可见性。另一方面,volatile 变量在读/写时会在每个操作前后插入一个内存屏障,从而禁止编译器和处理器指令重排序以保证其语义。

那么可以引申出 volatile 变量的使用场景,要使 volatile 变量提供理想的线程安全,必须同时满足下面两个条件:

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

作为状态标志

使用 volatile 声明的布尔类型变量,可以在一些情景中达到很好的效果,比如如下从一个线程终止另外一个线程。

反例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private static boolean stopThread;
public static void main(String[] args) throws InterruptedException {
Thread th = new Thread(new Runnable() {
@Override
public void run() {
int i = 0;
while (!stopThread) {
i++;
}
}
});
th.start();
TimeUnit.SECONDS.sleep(2);
stopThread = true;
}

运行后发现该程序根本无法终止循环,原因是,Java 语言规范并不保证一个线程写入的值对另外一个线程是可见的,所以即使主线程 main 函数修改了共享变量 stopThread 状态,但是对 th 线程并不一定可见,最终导致循环无法终止。

正例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private static volatile boolean stopThread;
public static void main(String[] args) throws InterruptedException {
Thread th = new Thread(new Runnable() {
@Override
public void run() {
int i = 0;
while (!stopThread) {
i++;
}
}
});
th.start();
TimeUnit.SECONDS.sleep(2);
stopThread = true;
}

通过使用关键字 volatile 修饰共享变量 stopThread,根据 volatile 的可见性原则可以保证主线程 main 函数修改了共享变量 stopThread 状态后对线程 th 来说是立即可见的,所以在两秒内线程 th 将停止循环。

双重检查锁

可以作为单例模式的一种实现方法,具体为什么要这么实现可以参考我的另外一篇博客

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Singleton {
private volatile static Singleton uniqueSingleton;

private Singleton() {
}

public Singleton getInstance() {
if (null == uniqueSingleton) {
synchronized (Singleton.class) {
if (null == uniqueSingleton) {
uniqueSingleton = new Singleton();
}
}
}
return uniqueSingleton;
}
}

参考文章

  1. 方腾飞等 著 《Java 并发编程的艺术》
  2. Java理论与实践:正确使用 Volatile 变量
  3. 聊聊并发(一)深入分析Volatile的实现原理
  4. Java并发编程:volatile关键字解析
  5. Java关键字volatile的理解与正确使用
  6. JSR-133: JavaTM Memory Model and Thread Specification
  7. The JSR-133 Cookbook for Compiler Writers
如果这篇文章对您很有帮助,不妨
-------------    本文结束  感谢您的阅读    -------------
0%