JVM并发机制的探讨——内存模型、内存可见性和指令重排序

ImportNew  •  扫码分享
我是创始人李岩:很抱歉!给自己产品做个广告,点击进来看看。  
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修饰。
 

随意打赏

提交建议
微信扫一扫,分享给好友吧。