JAVA volatile 关键字

volatile 关键字能把 Java 变量标记成"被存储到主存中"。这表示每一次读取 volatile 变量都会访问计算机主存,而不是 CPU 缓存。每一次对 volatile 变量的写操作不仅会写到 CPU 缓存,还会刷新到主存中。

实际上从 Java 5 开始,volatile 变量不仅会在读写操作时访问主存,他还被赋予了更多含义。

变量的可见性问题

Java volatile 关键字保证了线程对变量改动的可见性。

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

JAVA volatile 关键字

若果不使用 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 缓存的值和主存中的不一致:

JAVA volatile 关键字

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 操作,也没来得及回写到主存。情形如下图所示:

JAVA volatile 关键字

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。

原文 

https://segmentfault.com/a/1190000016847832

本站部分文章源于互联网,本着传播知识、有益学习和研究的目的进行的转载,为网友免费提供。如有著作权人或出版方提出异议,本站将立即删除。如果您对文章转载有任何疑问请告之我们,以便我们及时纠正。

PS:推荐一个微信公众号: askHarries 或者qq群:474807195,里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多

转载请注明原文出处:Harries Blog™ » JAVA volatile 关键字

赞 (0)
分享到:更多 ()

评论 0

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址