JVM并发机制探讨—内存模型、内存可见性和指令重排序
并发本来就是个有意思的问题,尤其是现在又流行这么一句话:“高帅富加机器,穷矮搓搞优化”。从这句话可以看到,无论是高帅富还是穷矮搓都需要深入 理解并发编程,高帅富加多了机器,需要协调多台机器或者多个CPU对共享资源的访问,因此需要了解并发,穷矮搓搞优化需要编写各种多线程的代码来压榨 CPU的计算资源,让它在同一时刻做更多的事情,这个更需要了解并发。
在我前一篇关于并发的文章http://my.oschina.net/chihz/blog/54731中提到过管程,管程的特色是在编程语言中 对并发的细节进行封装,使程序员可以直接在语言中就得到并发的支持,而不必自己去处理一些像是控制信号量之类容易出错且繁琐的细节问题。一些语言是通过在 编译时解开语法糖的方式去实现管程,但Java在编译后生成的字节码层面上对并发仍然是一层封装,比如syncrhonized块在编译之后只是对应了两 条指令:monitorenter和monitorexit。更多的并发细节是在JVM运行时去处理的,而不是编译。这篇文章主要是针对JVM处理并发的 一些细节的探讨。
JAVA内存模型
对于我们平时开发的业务应用来说,内存应该是访问速度最快的存储设备,对于频繁访问的数据,我们总是习惯把它们放到内存缓存中,有句话不是说么,缓 存就像是清凉油,哪里有问题就抹一抹。但是CPU的运算速度比起内存的访问速度还要快几个量级,为了平衡这个差距,于是就专门为CPU引入了高速缓存,频 繁使用的数据放到高速缓存当中,CPU在使用这些数据进行运算的时候就不必再去访问内存。但是在多CPU时代却有一个问题,每个CPU都拥有自己的高速缓 存,内存又是所有CPU共享的公共资源,于是内存此时就成了一个临界区,如果控制不好各个CPU对内存的并发访问,那么就会产生错误,出现数据不一致的情 况。为了避免这种情况,需要采取缓存一致性协议来保证,这类协议有很多,各个硬件平台和操作系统的实现不尽相同。
JVM需要实现跨平台的支持,它需要有一套自己的同步协议来屏蔽掉各种底层硬件和操作系统的不同,因此就引入了Java内存模型。对于Java来说 开发者并不需要关心任何硬件细节,因此没有多核CPU和高速缓存的概念,多核CPU和高速缓存在JVM中对应的是Java语言内置的线程和每个线程所拥有 的独立内存空间,Java内存模型所规范的也就是数据在线程自己的独立内存空间和JVM共享内存之间同步的问题。下面这两张图说明了硬件平台和JVM内存 模型的相似和差异之处。
Java内存模型规定,对于多个线程共享的变量,存储在主内存当中,每个线程都有自己独立的工作内存,线程只能访问自己的工作内存,不可以访问其它 线程的工作内存。工作内存中保存了主内存共享变量的副本,线程要操作这些共享变量,只能通过操作工作内存中的副本来实现,操作完毕之后再同步回到主内存当 中。如何保证多个线程操作主内存的数据完整性是一个难题,Java内存模型也规定了工作内存与主内存之间交互的协议,首先是定义了8种原子操作:
(1) lock:将主内存中的变量锁定,为一个线程所独占
(2) unclock:将lock加的锁定解除,此时其它的线程可以有机会访问此变量
(3) read:将主内存中的变量值读到工作内存当中
(4) load:将read读取的值保存到工作内存中的变量副本中。
(5) use:将值传递给线程的代码执行引擎
(6) assign:将执行引擎处理返回的值重新赋值给变量副本
(7) store:将变量副本的值存储到主内存中。
(8) write:将store存储的值写入到主内存的共享变量当中。
我们可以看到,要保证数据的同步,lock和unlock定义了一个线程访问一次共享内存的界限,有lock操作也必须有unlock操作,另外一 些操作也必须要成对出现才可以,像是read和load、store和write需要成对出现,如果单一指令出现,那么就会造成数据不一致的问题。 Java内存模型也针对这些操作指定了必须满足的规则:
(1) read和load、store和write必须要成对出现,不允许单一的操作,否则会造成从主内存读取的值,工作内存不接受或者工作内存发起的写入操作而主内存无法接受的现象。
(2) 在线程中使用了assign操作改变了变量副本,那么就必须把这个副本通过store-write同步回主内存中。如果线程中没有发生assign操作,那么也不允许使用store-write同步到主内存。
(3) 在对一个变量实行use和store操作之前,必须实行过load和assign操作。
(4) 变量在同一时刻只允许一个线程对其进行lock,有多少次lock操作,就必须有多少次unlock操作。在lock操作之后会清空此变量在工作内存中原 先的副本,需要再次从主内存read-load新的值。在执行unlock操作前,需要把改变的副本同步回主存。
内存可见性
通过上面Java内存模型的概述,我们会注意到这么一个问题,每个线程在获取锁之后会在自己的工作内存来操作共享变量,在工作内存中的副本回写到主 内存,并且其它线程从主内存将变量同步回自己的工作内存之前,共享变量的改变对其它线程是不可见的。那么很多时候我们需要一个线程对共享变量的改动,其它 线程也需要立即得知这个改动该怎么办呢?比如以下的情景,有一个全局的状态变量open:
boolean open= true;
这个变量用来描述对一个资源的打开关闭状态,true表示打开,false表示关闭,假设有一个线程A,在执行一些操作后将open修改为false:
//线程A
resource.close(); open = false;
线程B随时关注open的状态,当open为true的时候通过访问资源来进行一些操作:
//线程B
while(open) { doSomethingWithResource(resource); }
当A把资源关闭的时候,open变量对线程B不可见,如果此时open变量的改动尚未同步到线程B的工作内存中,那么线程B就会用一个已经关闭了的资源去做一些操作,因此产生错误。
所以对于上面的情景,要求一个线程对open的改变,其他的线程能够立即可见,Java为此提供了volatile关键字,在声明open变量的时 候加入volatile关键字就可以保证open的内存可见性,即open的改变对所有的线程都是立即可见的。volatile保证可见性的原理是在每次 访问变量时都会进行一次刷新,因此每次访问都是主内存中最新的版本。
指令重排序
很多介绍JVM并发的书或文章都会谈到JVM为了优化性能,采用了指令重排序,但是对于什么是指令重排序,为什么重排序会优化性能却很少有提及,其实道理很简单,假设有这么两个共享变量a和b:
private int a; private int b;
在线程A中有两条语句对这两个共享变量进行赋值操作:
a = 1; b = 2;
假设当线程A对a进行复制操作的时候发现这个变量在主内存已经被其它的线程加了访问锁,那么此时线程A怎么办?等待释放锁?不,等待太浪费时间了,它会去尝试进行b的赋值操作,b这时候没被人占用,因此就会先为b赋值,再去为a赋值,那么执行的顺序就变成了:
b = 2; a = 1;
对于在同一个线程内,这样的改变是不会对逻辑产生影响的,但是在多线程的情况下指令重排序会带来问题,看下面这个情景:
在线程A中:
context = loadContext(); inited = true;
在线程B中:
while(!inited ){ sleep } doSomethingwithconfig(context);
假设A中发生了重排序:
inited = true; context = loadContext();
那么B中很可能就会拿到一个尚未初始化或尚未初始化完成的context,从而引发程序错误。
想到有一条古老的原则很适合用在这个地方,那就是先要保证程序的正确然后再去优化性能。此处由于重排序产生的错误显然要比重排序带来的性能优化要重 要的多。要解决重排序问题还是通过volatile关键字,volatile关键字能确保变量在线程中的操作不会被重排序而是按照代码中规定的顺序进行访 问。
最后的总结
这篇文章简单的介绍了Java内存模型、内存可见性和指令重排序。不过最后看来其实主要是在解释volatile这个关键字,个人感觉 volatile关键字是Java当中最令人困惑和最难理解的关键字。相对于synchronized块的代码锁,volatile应该是提供了一个轻量 级的针对共享变量的锁,当我们在多个线程间使用共享变量进行通信的时候需要考虑将共享变量用volatile来修饰,对于需要使用volatile的各种 情景,看到IBM Developer Works上有一篇文章总结的很不错,推荐一下: http://www.ibm.com/developerworks/cn/java/j-jtp06197.html
补充说明:64位long和double
在JVM规范中Java内存模型要求lock、unlock、read、load、assign、use、store、write这8个操作必须是 原子的,但是对于64位的long和double来说,如果没有被volatile修饰符修饰,那么可以不是原子的,注意是可以,即虚拟机在实现的时候可 以选择是否是原子操作。目前几乎所有的商用虚拟机都将此实现为原子操作,因此不必每次用到它们都去加volatile修饰。
来源:SamChi的博客