Java - volatile

Java的关键字 volatile 用于将变量标记为“存储于主内存中”。更确切地说,对 volatile 变量的每次读操作都会直接从计算机的主存中读取,而不是从 cpu 缓存中读取;同样,每次对 volatile 变量的写操作都会直接写入到主存中,而不仅仅写入到 cpu 缓存里。

实际上,从 Java 5 开始关键字 volatile 除了能确保 volatile 变量直接从主存中进行读写,还有以下几个作用。

可见性保证

关键字 volatile 能确保数据变化在线程之间的可见性。

在多线程的应用中多个线程对 non-volatile 变量进行操作,线程在对它们进行操作的时候为了提高性能会将变量从主存复制到 cpu 缓存中。如果你的电脑包含的 cpu 不止一个, 那么每个线程可能会运行于不同的 cpu 上。这意味着,不同线程会将变量复制到不同 cpu 的缓存里。如下图:

no-volatile 变量不能保证 Java 虚拟机(JVM)何时从主存中将数据读入cpu 缓存,也不能保证何时将数据从 cpu 缓存写入到主存中。这会带来一些问题,我将在下面解释。

想象一个场景,两个或两个以上线程可访问同一个共享对象,这个对象含有一个如下的计数器变量:

public class SharedObject{
  public int counter = 0;
}

再假设有2个线程 Thread1 和 Thread2,只有 Thread1 能增大 counter ,而 Thread1 和 Thread2 都可以在任何时刻读取 counter 的值。
如果 counter 没被声明为 volatile ,将不能保证什么时候 counter 变量的值会从 cpu 缓存回写到主存内。也就是说,变量 counter 在 cpu 缓存中的值可能和主存内的不一致。 如下图:

这种由于线程还未将变量的值回写到主存而导致其他线程不能看到该变量的最新值的问题,称为可见性问题。一个线程的更新操作对其他线程不可见。

通过将变量 counter 声明为 volatile ,对其进行的所有写操作都会马上回写至主存中。同时,所有 counter 的读操作也将直接在主存中进行。声明方式如下:

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

这样,将变量声明为 volatile 保证了写操作对其他线程的可见性。

Happens-before 保证

自 Java 5 之后,关键字 volatile 不仅仅保证变量写入主存和从主存中的读取。实际上,volatile 保证了以下几点:

  • 如果线程A写 volatile 变量(下文用 volatile 简称 volatile 变量), 然后线程B 读取这个 volatile ,那么在写 volatile 之前对线程A可见的变量也将在线程B 读取这个 volatile 之后可见。
  • 对 volatile 变量的读取和写入指令不能被 JVM 重排序(只要 JVM 识别出程序的行为在重排序后不会改变,它就会对指令进行重排序以提高性能)。操作volatile 之前和之后的指令可以重排序,但是不能将其和这些指令混在一起重排序。任何发生在 volatile 的读写操作之后的指令一定发生在读写操作之后。(具体的可以看本文底部的 “正确使用volatile” 里的说明)

我们来做对以上叙述做进一步的说明:
当线程写入 volatile 时,不单单是将这个 volatile 写入主存中。这个线程在写此
volatile 变量之前改变的所有的变量也将刷新到主存中。当另一个线程读取这个 volatile 变量时,它也能从主存中读取到随 volatile 一起被刷入主存的其他所有变量。

看看这个例子:

Thread A:
  sharedObject.nonVolatile = 123;
  sharedObject.counter = sharedObject.counter + 1; // volatile
Thread B:
  int counter = sharedObject.counter;
  int nonVolatile = sharedObject.nonVolatile;

由于线程A 在写 volatile 的变量 sharedObject.counter 之前写 non-volatile 变量 sharedObject.nonVolatilesharedObject.countersharedObject.nonVolatile 会在 写 sharedObject.counter 的时候一起写入到主存中。
由于线程B 开始时先读取 volatile 变量 sharedObject.counter, 那么 sharedObject.countersharedObject.nonVolatile 会直接从主存读取到供线程B 使用的 cpu 缓存中 。这个时候,线程B 读到的 sharedObject.nonVolatile 就是线程A 写入的新值。

开发人员可以利用这扩展的可见性保证来优化线程间变量的可见性。只将一个或者几个变量声明为 volatile ,而不是把每个变量都声明成 volatile, 比如常用的标记位变量 flag 就可以放心的在处理完相应才操作后置为 true 了 。利用这个原则来简单地重写 Exchanger 类:

public class Exchanger{
  private Object object = null;
  private volatile hasNewObject = false;
  
  public void put(Object newObject){
    while(hasNewObject){
      // 等待 , 不要去覆盖object字段
    }  
    object = newObject;     // 在写 volatile 之前进行的普通写
    hasNewObject =  true; // 写 volatile 
  }

  public Object take(){
    while(! hasNewObject){ // 读 volatile
       // 等待, 不获取旧的 object或null
    }
    Object obj = object;      // 再写 volatile 之前进行的普通读
    hasNewObject = false; // 写 volatile
    return obj;
  }
}

执行场景为线程A 不断调用 put() 方法塞入新对象,线程B 不断调用 take() 方法获取新对象。如果仅有线程A 调用 put()并且仅有线程B 调用 take(), 那么这个 Exchanger 只要使用 volatile 变量就能正常运行了(不需要使用 synchronized 同步代码块)。

然而,如果 JVM 对指令进行重排序后不影响其执行语义,它就会对 Java 指令进行重排序以提高性能。如果 JVM 调整 put()
take() 内读写指令的执行顺序,会发生什么? 如果 put() 实际上是按如下顺序执行的会怎样?

while(hasNewObject){
  // 等待 , 不要去覆盖object字段
}
hasNewObject = true; // 写 volatile
object = newObject;

注意,现在上面示例中写 volatile 在新的 object 赋值前就执行了。对 JVM 来说这也许看起来完全合法,因为这两个写操作的值彼此之间没有依赖。
不过, 以上的重排序会损害 volatile 变量 object 的可见性。首先,线程B 可能在线程给变量 object 赋新值之前就看到 hasNewObject 已经是 true 了。其次,现在已经不能保证 object 的新值被回写到主存了(也许是下次线程A 在某处写volatile的时候)。
为了阻止如上情形的发生,关键字 volatile 还提供了 happes before 保证。happens-before 保证对 volatile 变量的读写指令不会被重排序。可以重排序在其之前和之后发生的指令,但是对 volatile 变量的读写指令不能同先于或后于它发生的任何指令一起重排序。
看看如下例子:

sharedObject.nonVolatile1 = 123;
sharedObject.nonVolatile2 = 456;
sharedObject.nonVolatile3 = 789;

sharedObject.volatile = true;  // volatile 变量

int someValue1 = sharedObject.nonVolatile4;
int someValue2 = sharedObject.nonVolatile5;
int someValue3 = sharedObject.nonVolatile6;

JVM 会重排序前面的3个指令,只要保证它们都在 写 volatile 之前发生就可以(它们必须在写 volatile 指令前执行)。

类似地,JVM 也会重排序最后的 3 个指令,只要保证它们都在写 volatile 之后发生就可以。最后的 3 个指令都不能重排序至 写volatile指令之前。
这是 Java volatile happens-before 保证的基本含义。

volatile对于重排序的禁止操作主要是java编译器在生成指令序列时会在适当的位置插入内存屏障来阻止重排序,具体说明可以参考《java并发编程艺术》3.1 和 3.4.3小节。

volatile 并不能满足所有场景

即使关键字 volatile 能保证对它的所有读操作都是直接从主存中读取,所有写操作也都是直接写入主存中,还是有些仅将变量声明为 volatile 不能满足的场景。

在前面讨论的例子中只有线程A 对共享变量 counter 进行写操作,将 counter 声明为 volatile 就足以保证线程B 能看到新写入的值。

实际上,多线程甚至在同时对共享的 volatile 变量进行写操作时,只要新值的写入不依赖它之前的值,就仍然能保持主存中的值是正确的。换句话说,一个线程将一个值写入共享 volatile 变量中,不需要首先读取原来值来计算下一个新值。

只要线程需要先读取 volatile 的值, 然后基于这个值来生成新值,那么这个 volatile 变量就不再能保证其正确的可见性。读取 volatile 和写入新值之间的时间间隙产生了 竞态条件 ,多个线程可能读取到相同的值, 然后生成新值,接着在将值回写到主存中的时候就会覆盖彼此的值了。

多线程增加同一个计数器 counter 就恰好是 volatile 不够用的一个场景。接下来我们来更详细的解释这个例子。

假设线程1 读取值为0的共享变量 counter 到它的 cpu 缓存中,增加值到1,但还没把改变的值回写到主存中。 线程2 接着也能从主存中读取值还是0这个 counter,并将其存入它自己的 cpu 缓存里。接着线程2 也将 counter 的值增加到1,同样也还没回写主存。这个场景如下图:


线程1 和线程2 现在就是切实的不同步。共享变量 counter 实际的值应该 2,但是每个线程在各自的 cpu 缓存中的值为 1,主存中的值还是 0。乱成一团了!即使最后线程都把它们持有的值回写到主存中,counter 的值也是错的。

什么时候单单使用 volatile 就够了?

就如之前提到的,如果两个线程都对共享变量进行读写,那么只使用关键字 volatile 就不能满足要求了。这种情况你需要用 synchronized 来保证读写变量的原子性。对 volatile 变量的读写操作并不会阻塞其他线程的读写。如果需要阻塞,你就必须在临界区周围使用关键字 synchronized

如果不想用 synchronized 代码块,你可以从包java.util.concurrent中找到很多有用的原子数据类型,如 AtomicLong,AtomicReference 或者其他。

假设只有一个线程同时读写 volatile 变量,其他线程只读取,那么只读线程一定能看到最新写入到 volatile 变量的值。如果不将变量声明为 volatile ,这就的不到保证。

关键字 volatile 确定能在 32位和64位变量上正常运行。

volatile 的性能考虑

volatile 变量的读取和写入操作导致变量直接在主存中读写。从主存中读取和写入到主存中比在 cpu 缓存中代价更高 。访问 volatile 变量也阻止了常规的性能优化技术对指令的重排序。所以,你应该只在确实需要加强变量的可见性的时候使用 volatile

references:

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

推荐阅读更多精彩内容

  • 从三月份找实习到现在,面了一些公司,挂了不少,但最终还是拿到小米、百度、阿里、京东、新浪、CVTE、乐视家的研发岗...
    时芥蓝阅读 42,015评论 11 349
  • Volatile 这个关键字可能很多朋友都听说过,但是可能不敢用,毕竟这个关键字非常不好控制,干脆不用为好。Vol...
    杨文杰阅读 617评论 1 21
  • “钱”是什么?是创造美好生活的工具,对,它只是一个工具,别给它那么多压力! ...
    嘉儿Violetta阅读 691评论 0 1
  • 在刚决定要援疆时我心中有许多的不安,因为不了解新疆总觉得它是那么遥远而陌生、因为听说援疆的从来没有女同胞、因为家人...
    3b9cccf024d6阅读 185评论 0 0
  • 这本来是一部励志悲喜剧,抨击了填鸭式教育和等级式教育对学生的摧残,批判了家长对孩子的独权教育,唾弃了哪些追名逐...
    空浮阅读 404评论 0 0