Java中的volatile关键字详解

volatile这个关键字可能很多朋友都听说过,或许也都用过。在Java 5之前,它是一个备受争议的关键字,因为在程序中使用它往往会导致出人意料的结果。在Java 5之后,volatile关键字才得以重获生机。

Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。

在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。

变量的可见性问题

Java volatile 关键字保证了线程对变量改动的可见性。
举个例子,在多线程 (不使用 volatile) 环境中,每个线程会从主存中复制变量到 CPU 缓存 (以提高性能)。如果你有多个 CPU,不同线程也许会运行在不同的 CPU 上,并把主存中的变量复制到各自的 CPU 缓存中,像下图画的那样

若果不使用 volatile 关键字,你无法保证 JVM 什么时候从主存中读变量到 CPU cache,或把变量从 CPU cache 写回主存。这会导致很多并发问题,我会在下面的小节中解释。
想像一下这种情形,两个或多个线程同时访问一个共享对象,对象中包含一个用于计数的变量:

public class SharedObject {
    public int counter = 0;
}

如果 Thread-1 会增加 counter 的值,而 Thread-1 和 Thread-2 会不时地读取 counter 变量。在这种情形中,如果变量 counter 没有被声明成 volatile,就无法保证 counter 的值何时会 (被 Thread-1) 从 CPU cache 写回到主存。结果导致 counter 在 CPU 缓存的值和主存中的不一致:

Thread-2 无法读取到变量最新的值,因为 Thread-1 没有把更新后的值写回到主存中。这被称作 "可见性" 问题,即其他线程对某线程更新操作不可见。

volatile 保证了变量的可见性

volatile 关键字解决了变量的可见性问题。通过把变量 counter 声明为 volatile,任何对 counter 的写操作都会立即刷新到主存。同样的,所有对 counter 的读操作都会直接从主存中读取。

public class SharedObject {
    public volatile int counter = 0;
}

还是上面的情形,声明 volatile 后,若 Thread-1 修改了 counter 则会立即刷新到主存中,Thread-2 从主存读取的 counter 是 Thread-1 更新后的值,保证了 Thread-2 对变量的可见性。

volatile 完全可见性

volatile 关键字的可见性生效范围会超出 volatile 变量本身,这种完全可见性表现为以下两个方面:

  • 如果 Thread-A 对 volatile 变量进行写操作,Thread-B 随后该 volatile 变量进行读操作,那么 (在 Thread-A 写 volatile 变量之前的) 所有对 Thread-A 可见的变量,也会 (在 Thread-B 读 volatile 变量之后) 对 Thread-B 可见。
  • 当 Thread-A 读一个 volatile 变量时,所有其他对 Thread-A 可见的变量也会重新从主存中读一遍。

很抽象?让我们举例说明:

public class MyClass {
    private int years;
    private int months
    private volatile int days;
    
    public int totalDays() {
        int total = this.days;
        total += months * 30;
        total += years * 365;
        return total;
    }

    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

上面的 update() 方法给三个变量赋值 (写操作),其中只有 days 是 volatile 变量。完全可见性在这的含义是,当对 days 进行写操作时,线程可见的其他变量 (在写 days 之前的变量) 都会一同回写到主存,也就是说变量 months 和 years 都会回写到主存。

上面的 totalDays() 方法一开始就把 volatile 变量 days 读取到局部变量 total 中,当读取 days 时,变量 months 和 years (在读 days 之后的变量) 同样会从主存中读取。所以通过上面的代码,你能确保读到最新的 days, months 和 years。

指令重排的困扰

为了提高性能,JVM 和 CPU 会被允许对程序进行指令重排,只要重排的指令语义保持一致。举个例子:

int a = 1;
int b = 2;

a++;
b++;

上述指令可能被重排成如下形式,语义跟先前保持一致:

int a = 1;
a++;

int b = 2;
b++;

然而,当你使用了 volatile 变量时,指令重排有时候会产生一些困扰。让我们再看下面的例子:

public class MyClass {
    private int years;
    private int months
    private volatile int days;

    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

update() 方法在写变量 days 时,对变量 years 和 months 的写操作同样会刷新到主存中。但如果 JVM 执行了指令重排会发生什么情况?就像下面这样:

public void update(int years, int months, int days){
    this.days   = days;
    this.months = months;
    this.years  = years;
}

当变量 days 发生改变时,months 和 years 仍然会回写到主存中。但这一次,days 的更新发生在写 months 和 years 之前,导致 months 和 years 的新值可能对其他线程不可见,使程序语义发生改变。对此 JVM 有现成的解决方法,我们会在下一小节讨论这个问题。

volatile 的 Happen-before 机制

为了解决指令重排带来的困扰,Java volatile 关键字在可见性的基础上提供了 happens-before 这种担保机制。happens-before 保证了如下方面:

  • 如果其他变量的读写操作原本发生在 volatile 变量写操作之前,他们不能被指令重排到 volatile 变量的写操作之后。注意,发生在 volatile 变量写操作之后的读写操作仍然可以被指令重排到 volatile 变量写操作之前。happen-after 重排到 (volatile 写操作) 之前是允许的,但 happen-before 重排到之后是不允许的。
  • 如果其他变量的读写操作原本发生在 volatile 变量读操作之后,他们不能被指令重排到 volatile 变量的读操作之前。注意,发生在 volatile 变量读操作之前的读操作仍然可以被指令重排到 volatile 变量读操作之后。happen-before 重排到 (volatile 读操作) 之后是允许的,但 happen-after 重排到之前是不允许的。

happens-before 机制确保了 volatile 的完全可见性

volatile 并不总是行得通

虽然关键字 volatile 保证了对 volatile 变量的读写操作会直接访问主存,但在某些情况下把变量声明为 volatile 还不足够。
回顾之前举过的例子 —— Thread-1 对共享变量 counter 进行写操作,声明 counter 为 volatile 并不足以保证 Thread-2 总是能读到最新的值。

实际上,可能会有多个线程对同一个 volatile 变量进行写操作,也会把正确的新值写回到主存,只要这个新值不依赖旧值。但只要这个新值依赖旧值 (也就是说线程先会读取 volatile 变量,基于读取的值计算出一个新值,并把新值写回到 volatile 变量),volatile 关键字不再能够保证正确的可见性 (其他文章会把这称为原子性)。

在多线程同时共享变量 counter 的情形下,volatile 关键字已不足以保证程序的并发性。设想一下:Thread-1 从主存中读取了变量 counter = 0 到 CPU 缓存中,进行加 1 操作但还没把更新后的值写回到主存。Thread-2 同一时间从主存中读取 counter (值仍为 0) 到他所在的 CPU 缓存中,同样进行加 1 操作,也没来得及回写到主存。情形如下图所示:


Thread-1 和 Thread-2 现在处于不同步的状态。从语义上来说,counter 的值理应是 2,但变量 counter 在两个线程所在 CPU 缓存中的值却是 1,在主存中的值还是 0。即使线程都把 counter 回写到主存中,counter 更新成1,语义上依然是错的。(这种情况应该使用 synchronized 关键字保证线程同步)

什么时候使用 volatile

像之前的例子所说:如果有两个或多个线程同时对一个变量进行读写,使用 volatile 关键字是不够用的,因为对 volatile 变量的读写并不会阻塞其他线程对该变量的读写。你需要使用 synchronized 关键字保证读写操作的原子性,或者使用 java.util.concurrent 包下的原子类型代替 synchronized 代码块,例如:AtomicLong, AtomicReference 等。

如果只有一个线程对变量进行读写操作,其他线程仅有操作,这时使用 volatile 关键字就能保证每个线程都能读到变量的最新值,即保证了可见性。

volatile 的性能

volatile 变量的读写操作会导致对主存的直接读写,对主存的直接访问比访问 CPU 缓存开销更大。使用 volatile 变量一定程度上影响了指令重排,也会一定程度上影响性能。所以当迫切需要保证变量可见性的时候,你才会考虑使用 volatile。

我有一个微信公众号,经常会分享一些Java技术相关的干货;如果你喜欢我的分享,可以用微信搜索“Java团长”或者“javatuanzhang”关注。

参考:

阅读更多
换一批

没有更多推荐了,返回首页