如何理解 JAVA 中的 volatile 关键字

最近在重新梳理多线程,同步相关的知识点。关于 volatile 关键字阅读了好多博客文章,发现质量高适合小白的不多,最终找到一篇英文的非常通俗易懂。所以学习过程中顺手翻译下来,一方面巩固知识,一方面希望能帮到有需要的伙伴。该文章并非完全逐字翻译,英文不错的可以选择阅读原文:Java Volatile Keyword

基本用法

JAVA 语言里的 volatile 关键字是用来修饰变量的,方式如下入所示。表示:该变量需要直接存储到主内存中。

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

被 volatile 关键字修饰的 int counter 变量会直接存储到主内存中。并且所有关于该变量的读操作,都会直接从主内存中读取,而不是从 CPU 缓存。(关于主内存和CPU缓存的区别,如果不理解也不用担心,下面会详细介绍)

这么做解决什么问题呢?主要是两个问题:

  1. 多线程间可见性的问题,
  2. CPU 指令重排序的问题

注:为了描述方便,我们接下来会把 volatile 修饰的变量简称为“volatile 变量”,把没有用 volatile 修饰的变量建成为“non-volatile”变量。

理解 volatile 关键字

变量可见性问题(Variable Visibility Problem)

Volatile 可以保证变量变化在多线程间的可见性。
在一个多线程应用中,出于计算性能的考虑,每个线程默认是从主内存将该变量拷贝到线程所在CPU的缓存中,然后进行读写操作的。现在电脑基本都是多核CPU,不同的线程可能运行的不同的核上,而每个核都会有自己的缓存空间。如下图所示(图中的 CPU 1,CPU 2 大家可以直接理解成两个核):



这里存在一个问题,JVM 既不会保证什么时候把 CPU 缓存里的数据写到主内存,也不会保证什么时候从主内存读数据到 CPU 缓存。也就是说,不同 CPU 上的线程,对同一个变量可能读取到的值是不一致的,这也就是我们通常说的:线程间的不可见问题。比如下图,Thread 1 修改的 counter = 7 只在 CPU 1 的缓存内可见,Thread 2 在自己所在的 CPU 2 缓存上读取 counter 变量时,得到的变量 counter 的值依然是 0。



而 volatile 出现的用意之一,就是要解决线程间不可见性,通过 volatile 修饰的变量,都会变得线程间可见。
其解决方式就是文章开头提到的:

通过 volatile 修饰的变量,所有关于该变量的读操作,都会直接从主内存中读取,而不是 CPU 自己的缓存。而所有该变量的写操都会写到主内存上。

因为主内存是所有 CPU 共享的,理所当然即使是不同 CPU 上的线程也能看到其他线程对该变量的修改了。

volatile 不仅仅只保证 volatile 变量的可见性

volatile 在可见性上所做的工作,实际上比保证 volatile 变量的可见性更多:

  1. 当 Thread A 修改了某个被 volatile 变量 V,另一个 Thread B 立马去读该变量 V。一旦 Thread B 读取了变量 V 后,不仅仅是变量 V 对 Thread B 可见, 所有在 Thread A 修改变量 V 之前 Thread A 可见的变量,都将对 Thread B 可见。
  2. 当 Thread A 读取一个 volatile 变量 V 时,所有对于 Thread A 可见的其他变量也都会从主内存中被读取。

初次读这两句话可能会有些绕口,这里举个例子:

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;
    }
}

这个 MyClass 类中有一个 update 方法,会更新该类的所有3个变量:years,months,days。其中仅 days 是 volatile 变量。当 this.days = days 执行时,也就是当 days 变量的修改被写到主内存时,所有该 Thread 可见的其他变量 years,months 也都会被写到主内存中。换句话说,当 days 被修改后,years 和 months 的修改也会被其他线程可见。

再看一个关于读的例子:

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;
    }
}

如果我们用另一个 Thread 调用同一个 MyClass 对象的 totalDays() ,在 int total = this.days 这一行被执行时,因为 days 是 volatile 变量,我们会从主内存中去读取 days 的值,同时,所有对于该 Thread 可见的其他变量 months 和 years 也都会从主内存中被读出。换句话说,该线程能够获取到最新的 days,months,years 的值。

以上就是关于 volatile 解决可见性问题的内容。

指令重排序挑战

出于计算性能的考虑,JVM 和 CPU 允许在保证程序语义一致的范围类,对程序内的指令进行重排序。举个例子:

int a = 1;
int b = 2;

a++;
b++;

该代码在经过重排序后可能会变成:

int a = 1;
a++;

int b = 2;
b++;

这两段代码的只能顺序虽然不一样,但是语义是相同的——都是定义两个变量(int a = 1 和 int b = 2),然后分别 +1。乍一看,这种重排序没有任何问题,但其实如果咱们把其中一个变量定义为 volatile 变量,此时我们再结合前面提到的可见性的延伸问题来看,大家可能会发现端倪。
还是以可见性问题中的 MyClass 类为例:

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 对此处的指令进行了重排序会发生什么?假设指令重排序后的 update() 执行过程如下:

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

days 的修改被提到了最前面,此时,months 和 years 的修改还没有做,换句话说,此处 months 和 years 的修改并不能保证对其他线程可见。这么一来 volatile 关于可见性保证的延伸是不是就失效了?关于这一问题我们在实际使用 volatile 时并不会碰到,因为 JAVA 已经有解决方案:Happens-Before 规则。

Java volatile Happens-Before 规则

面对指令重排序对可见性的调整,volatile 采用 Happens-Before 规则解决:

  1. 任何原始执行顺序中,在 volatile 变量写指令之前的其他变量读写指令,在重新排序后,不可以被放到 volatile 写指令之后。
    所有原本就应该 volatile 变量写指令前发生的其他变量读写指令,必须依然在其之前发生(Happens-Before)。
  2. 任何原始执行顺序中,在 volatile 变量读指令之后的其他变量读写指令,在重新排序后,不可以被放到 volatile 读指令之前。

有了以上两条 Happens-Before 规则,我们就避免了指令重排序对 volatile 可见性的影响。

volatile 不能保证原子性

多线程并发中我们经常提到的“三性”:可见性,有序性,原子性。虽然 volatile 可以保证可见性,有序性,但其并不能保证原子性。
当两个线程 Thread 1 和 Thread 2 同时修改统一对象下的 volatile 变量 counter 时,比如同时执行 counter++。此时两个线程读取到的 counter 值可能都是 0,经过各线程的计算,他们认为 counter + 1 后的结果都是 1。最终虽然我们分别用两个线程对 counter 变量做了 + 1 操作,可最终结果不是 2 而是 1。因此我们说 volatile 并不能保证该变量读写操作的原子性。



如果希望避免该问题,我们需要使用 synchronized 关键字。用 synchronized 关键字来修饰我们对变量读写操作(counter++)的方法/代码块,保证该读写操作的原子性。

除了 synchronized 关键字,我们还可以直接只用 AtomicInterger 类型定义 counter 变量。AtomicInteger 提供了针对 Integer 的原子操作。类似的类还有 AtomicBoolean 和 AtomicLong。
synchronized 和 AtomicXXX 类都可以保证原子性,前者是基于锁的原理实现的原子性(悲观锁),而后者则是基于 CAS 原则(乐观锁)。

什么场景下我们只需要 volatile 就足够呢?比如:当某个变量只会被一个线程修改,其他并行线程只会执行读操作时,我们使用 volatile 就足以。

关于 Volatile 的性能问题

如果大家了解 CPU 的多级缓存机制,(不了解应该也能猜到),从主内存读取数据的效率一定比从 CPU 缓存中读取的效率低很多。包括指令重排序的目的也是为了提高计算效率,当重排序机制被限制时,计算效率也会相应收到影响。因此,我们应该只在需要保证变量可见性和有序性时,才使用 volatile 关键字。

References

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,445评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,889评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,047评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,760评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,745评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,638评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,011评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,669评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,923评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,655评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,740评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,406评论 4 320
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,995评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,961评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,023评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,483评论 2 342

推荐阅读更多精彩内容