volatile关键字作用与内存可见性、指令重排序概述[JAVA]

在理解 volotile 关键字的作用之前,先粗略解释下内存可见性与指令重排序。

1. 内存可见性

Java 内存模型规定,对于多个线程共享的变量,存储在主内存当中,每个线程都有自己独立的工作内存,并且线程只能访问自己的工作内存,不可以访问其它线程的工作内存。工作内存中保存了主内存中共享变量的副本,线程要操作这些共享变量,只能通过操作工作内存中的副本来实现,操作完毕之后再同步回到主内存当中,其 JVM 模型大致如下图。

volatile关键字作用与内存可见性、指令重排序概述[JAVA]_第1张图片
JVM 模型规定:1) 线程对共享变量的所有操作必须在自己的内存中进行,不能直接从主内存中读写; 2) 不同线程之间无法直接访问其它线程工作内存中的变量,线程间变量值的传递需要通过主内存来完成。这样的规定可能导致得到后果是:线程对共享变量的修改没有即时更新到主内存,或者线程没能够即时将共享变量的最新值同步到工作内存中,从而使得线程在使用共享变量的值时,该值并不是最新的。这就引出了内存可见性。

内存可见性指:当一个线程修改了某个状态对象后,其它线程能够看到发生的状态变化。比如线程 1 修改了变量 A 的值,线程 2 能立即读取到变量 A 的最新值,否则线程 2 如果读取到的是一个过期的值,也许会带来一些意想不到的后果。那么如果要保证内存可见性,必须得保证以下两点:

  1. 线程修改后的共享变量值能够及时刷新从工作内存中刷新回主内存;
  2. 其它线程能够及时的把共享变量的值从主内存中更新到自己的工作内存中;

为此,Java 提供了一种稍弱的同步机制,即 volatile 变量,用来确保将变量的更新操作通知到其它线程。当把共享变量声明为 volatile 类型后,线程对该变量修改时会将该变量的值立即刷新回主内存,同时会使其它线程中缓存的该变量无效,从而其它线程在读取该值时会从主内中重新读取该值(参考缓存一致性)。因此在读取 volatile 类型的变量时总是会返回最新写入的值。

除了使用 volatile 关键字来保证内存可见性之外,使用 synchronizer 或其它加锁也能保证变量的内存可见性。只是相比而言使用 volatile 关键字开销更小,但是 volatile 并不能保证原子性,大致原理如下:

JAVA内存模型规定工作内存与主内存之间的交互协议,其中包括8种原子操作:

  1. lock:将主内存中的变量锁定,为一个线程所独占
  2. unclock:将lock加的锁定解除,此时其它的线程可以有机会访问此变量
  3. read:将主内存中的变量值读到工作内存当中
  4. load:将read读取的值保存到工作内存中的变量副本中。
  5. use:将值传递给线程的代码执行引擎
  6. assign:将执行引擎处理返回的值重新赋值给变量副本
  7. store:将变量副本的值存储到主内存中。
  8. write:将store存储的值写入到主内存的共享变量当中。

其中lock和unlock定义了一个线程访问一次共享内存的界限,而其它操作下线程的工作内存与主内存的交互大致如下图所示。

volatile关键字作用与内存可见性、指令重排序概述[JAVA]_第2张图片

从上图可以看出,read and load 主要是将主内存中数据复制到工作内存中,use and assign 则主要是使用数据,并将改变后的值写入到工作内存,store and write 则是用工作内存数据刷新主存相关内容。但是以上的一系列操作并不是原子的,也既是说在 read and load 之后,主内存中变量的值发生了改变,这时再 use and assign 并不是取的最新的值。所以尽管 volatile 会强制工作内存与主内存的缓存更新,但是却仍然无法保证其原子性。

2. 指令重排序

首先看下以下线程A和线程B的部分代码:

线程A:
content = initContent();	//(1)
isInit = true;				//(2)
线程B
while (isInit) {			//(3)
    content.operation();    //(4)
}

从常规的理解来看,上面的代码是不会出问题的,但是JVM可以对它们在不改变数据依赖关系的情况下进行任意排序以提高程序性能(遵循as-if-serial语义,即不管怎么重排序,单线程程序的执行结果不能被改变),而这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不会被编译器和处理器考虑,也即是说对于线程A,代码(1)和代码(2)是不存在数据依赖性的,尽管代码(3)依赖于代码(2)的结果,但是由于代码(2)和代码(3)处于不同的线程之间,所以JVM可以不考虑线程B而对线程A中的代码(1)和代码(2)进行重排序,那么假设线程A中被重排序为如下顺序:

线程A:
isInit = true;				//(2)
content = initContent();	//(1)

对于线程B,则可能在执行代码(4)时,content并没有被初始化,而造成程序错误。那么应该如何保证绝对的代码(2) happens-before 代码(3)呢?没错,仍然可以使用volatile关键字。

volatile关键字除了之前提到的保证变量的内存可见性之外,另外一个重要的作用便是局部阻止重排序的发生,即保证被volatile关键字修饰的变量编译后的顺序与 也即是说如果对isInit使用了volatile关键字修饰,那么在线程A中,就能保证绝对的代码(1) happens-before 代码(2),也便不会出现因为重排序而可能造成的异常。

3. 总结

综上所诉,volatile关键字最主要的作用是:

  1. 保证变量的内存可见性
  2. 局部阻止重排序的发生

4. 附录 - happens-before原则

英文原文:

  • Each action in a thread happens before everyaction in that thread that comes later in the program’s order.
  • An unlock on a monitor happens before everysubsequent lock on that same monitor.
  • A write to a volatile field happens before everysubsequent read of that same volatile.
  • A call to start() on a thread happens before anyactions in the started thread.
  • All actions in a thread happen before any otherthread successfully returns from a join() on that thread.

中文描述:

  • 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。
  • 监视器锁规则:对一个监视器锁的解锁,happens-before 于随后对这个监视器锁的加锁。
  • volatile变量规则:对一个volatile域的写,happens-before 于任意后续对这个volatile域的读。
  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
  • Thread.start()的调用会happens-before于启动线程里面的动作。
  • Thread中的所有动作都happens-before于其他线程从Thread.join中成功返回。

5. 参考文献

[1] Brian Goetz.Java并发编程实战.机械工业出版社.2012
[2] http://ifeve.com/easy-happens-before/
[3] http://www.infoq.com/cn/articles/java-memory-model-2/
[4] http://www.cnblogs.com/mengyan/archive/2012/08/22/2651575.html
[5] http://my.oschina.net/chihz/blog/58035
[6] http://www.cnblogs.com/aigongsi/archive/2012/04/01/2429166.html
[7] http://ifeve.com/jvm-reordering/
[8] …

以上仅为个人学习所记笔记,如有错误,欢迎指正

你可能感兴趣的:(程序开发)