Gavin on Backend Technology

backend and more

重新认识volatile

误解

一直以来,我是这么理解volatile关键字的:该修饰符修饰的变量将不会被缓存(寄存器、处理器二级缓存等),而是直接写入主内存中或从直接主内存中读取。如此,不同的线程间对该变量的读/写到的值都是最新的,也可确保在多线程条件下的正确性,至于什么才叫正确、为什么必须要正确,之前并没有多想。

最近拜读了infoq的mini书:《深入理解Java内存模型》(http://www.infoq.com/cn/minibooks/java_memory_model ),让我重新认识了Volatile关键字的真实意义和虚拟机的实现机制。本文以本人对volatile的目的、实现等为线索,对该书中volatile部分叙述重新整理,并结合相关资料,试图对volatile做一个个人总结,希望对读者也有所裨益。

Volatile的真正作用

开门见山地说,Volatile修饰的变量可以作为线程间协作的“信号量”,此信号量从一个线程被“传递”到另外一个线程,以达到线程协作的目的。从这个角度上说,volatile与“锁”或Synchronized关键字类似,都能达到线程协作的目的。

但是,从volatile的基本含义(Java官方文档: http://docs.oracle.com/javase/specs/jls/se8/html/jls-8.html#jls-8.3.1.4) 并不能得出上述结论:

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).

由上述定义可见,volatile是一个仅仅针对被修饰变量(的值)在不同线程中的一致性、可靠性而采取的效果类似“锁”的一种机制,但并未提及为什么通过对变量可见性的控制,就能达到线程协作的目。为解答这个问题,需要引入一个概念:

指令重排

这篇文章: http://kenwublog.com/illustrate-memory-reordering-in-cpu 介绍了指令重排的背景。简单来说,编译器、CPU出于性能优化的目的会对程序员的指令(java源代码)的顺序进行适当调整,调整的规则不同的处理器类型有不同的策略,但都一个一条底线:有数据依赖的指令不会被重排。所谓数据依赖性是指: 两个操作访问同一个变量,并且两个操作之中至少有一个是写操作,则这两个操作具有数据依赖。 注:常见的x86处理器,不仅在有数据依赖的指令间不能重排,而且禁止了绝大部分指令重排,只允许使用StoreLoad类型的重排,即“写读”操作的重排。

所以Java虚拟机面临的问题是,如何既保证程序按照程序员的意图和顺序被执行(as-if-serial),又能利用编译器、处理器的重排机制实现代码的优化。Java通过在指令之间插入内存屏障指令(以下省略“指令”,直接称为内存屏障)来达到这一目的。内存屏障是能够被处理器识别的,确保屏障指令前/后的用户指令不被重排的特殊指令。读和写的两种操作排列组合形成如下可能:

  • LoadLoad: 确保该指令之前的 “读取” 指令,在该指令之后的 “读取” 指令之前执行,从内存角度来说:“之前的指令” 首先从主内存装载变量之后,”之后的指令“ 才能装载
  • LoadStore: 确保该指令之前的 “读取” 指令,在该指令之后的 “写入” 指令之前执行,从内存角度来说:“之前的指令” 首先从主内存装载变量之后,”之后的指令“ 才能被写入主内存。
  • StoreStore:确保该指令之前的 “写入“ 指令,在该指令之后的 “写入” 指令之前执行,从内存角度来说:“之前的指令” 首先写入主内存(对其他线程可见)之后,“之后的指令”才能写入到主内存中。
  • StoreLoad: 确保该指令之前的 “写入” 指令, 在该指令之后的 “读取” 指令之前执行,从内存角度来说: “之前的指令” 写入主内存,对其他线程可见(也可理解为尽到了通知的义务)之后,“之后的指令” 才能执行。

Volatile的重排规则

至此,我们可以回头看看Volatile的基本特性:

  • 当写一个Volatile变量时,要求该线程把变量对应的值刷新到主内存中。
  • 当读一个Volatile变量时,线程要将该变量在本地内存中的值置为无效,而从主内存中读取。

为实现上述特性,Java内存模型(具体来说是JSR133)给Volatile定义了如下重排规则:

  1. 当第一个操作是Volatile读时,不能与其后的任何操作进行重排
  2. 当第二个操作是Volatile写时,不能与之前的任何操作进行重排(此处有些疑问,应该只需不与写操作重排即可)
  3. 当 第一个操作是Volatile写,第二个操作是Volatile读时,不能进行重排。

通过以上的规则,结合如下代码: Thread-write:

Thread-read:

不难得出,volatile除了保证自身的值是volatile的之外,还可控制其他变量的行为,实现线程协作。

延伸:重排规则的实现

由于Java内存模型通过插入内存屏障到达控制处理器的重排规则的目的,为了达成如上的规则,需要在二进制指令中的合适位置插入合适的屏障指令,下表列出了Volatile 的3个重排规则需要实现的屏障:

  1. 第一个操作是Volatile读时:在该指令之后插入:
    1. LoadLoad指令(控制之后的读操作不重排);
    2. 插入LoadStore指令(控制之后的写操作不重排)
  2. 第二个操作是Volatile写时:在该指令之前插入一个StoreStore指令(控制之前的写操作不重排)
  3. 第一个操作是Volatile写 :在该指令之后插入StoreLoad指令,以控制之后的Volatile读操作不被重排

补充条件: 由于很难判断Volatile读/写操作是否为线程执行的最后/第一个指令,所以:

  • 在Volatile写之后插入StoreLoad指令
  • 在Volatile读之前插入StoreLoad指令

如此一来,涉及Volatile的指令前后将布满了内存模型要求的内存屏障变量,这样的结果虽然可以确保程序的正确性(as-if-serial),但却影响了性能,所以Java内存模型规定还可以在此基础上进行优化,比如将两个内存屏障(其中一个为上一个指令之后的屏障,另一个为下一个指令之前的屏障)进行合并、省略。

Written with StackEdit.

Comments