相信如果对JMM底层有过了解或者接触过java并发编程的读者对以上的概念并不陌生,但是真正理解的可能并不多。这里我就对这些概念再做一次讲解。相信读者多读几遍应该就有自己的理解,实在不理解也没关系,说明知识的储备还不够,不妨以后再来读一遍,可能会瞬间突然明白。
参考内容:
- https://mp.weixin.qq.com/s/kQ498ifh4OUEDd829JIhnQ 作者:CodeSheep;他同时也是B站up主,ID就是CodeSheep,视频良心,有兴趣的读者可以自己查看。
- 《实战Java高并发程序设计》 作者:葛一鸣 郭超 ;如果是并发编程初学者我也推荐这本书,通俗易懂
JMM的关键技术点都是围绕着多线程的原子性、可见性和有序性来建立的。要理解JMM首先就需要了解这三个特性。而volatile关键字很好的贯彻了可见性和有序性,提到volatile关键字也是为了加深对这三个特性的理解,同时volatile也是非常重要的内容,也是难点。
1、原子性
原子性其实非常好理解,原子性操作就是指这些操作是不可中断的,要做一定做完,要么就没有执行,也就是不可被中断。
我们使用的int类型的数据如果只是是简单的读取和赋值的话就是原子操作。下面给出几个例子:
i = 2; 赋值给i -----------------------------操作步骤:1 原子操作
j = i; 读取i值 赋值给j -----------------------操作步骤:2 非原子操作
i++; 读取i值 i值加1 赋值给i ------------------操作步骤:3 非原子操作
i = i+1; 读取i值 i值加1 赋值给i --------------操作步骤:3 非原子操作
但是如果我们不使用int型而使用long型的话,对于32位系统来说,long型数据的读写不是原子性的(因为long有64位)。虚拟机规范中允许对 64位数据类型( long和 double),分为 2次 32为的操作来处理,也就是说,如果两个线程同时对long进行写入的话(或者读取),对线程之间的结果是有干扰的。可能高位是一个线程写的,低位又是另一个线程写的,如果这时候读的话,就会读到错误的值。不是线程1写的值,也不是线程2写的值。但是最新 JDK实现还是实现了原子操作的。JMM只实现了基本的原子性,像上面 i++那样的操作,必须借助于 synchronized和 Lock来保证整块代码的原子性了。
2、可见性
可见性是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道这个修改。
显然,对于串行程序来说,可见性问题是不存在的。因为你在任何一个操作步骤中修改了某个变量,那么在后续的步骤中,读取这个变量的值,一定是修改后的新值。但是这个问题在并行程序中就不见得了。如果一个线程修改了某一个全局变量,那么其他线程未必可以马上知道这个改动。这个问题可能由cache优化引起,比如下面这个例子:
如果在CPU1和CPU2上各运行了一个线程,它们共享变量t,由于编译器优化或者硬件优化的缘故,在CPU1上的线程将变量t进行了优化,将其缓存在cache中或者寄存器里。这种情况下,如果在CPU2上的某个线程修改了变量t的实际值,那么CPU1上的线程可能并无法意识到这个改动,依然会读取cache中或者寄存器里的数据。因此,就产生了可见性问题。外在表现为:变量t的值被修改,但是CPU1上的线程依然会读到一个旧值。可见性问题也是并行程序开发中需要重点关注的问题之一。
可见性问题是一个综合性问题。除了上述提到的缓存优化或者硬件优化(有些内存读写可能不会立即触发,而会先进入一个硬件队列等待)会导致可见性问题外,指令重排(这个问题将在下一节中更详细讨论)以及编辑器的优化,都有可能导致一个线程的修改不会立即被其他线程察觉。
3、有序性
JMM是允许编译器和处理器对指令重排序的,但是规定了 as-if-serial语义,即不管怎么重排序,程序的执行结果不能改变。比如下面的程序段:
double pi = 3.14; // A
double r = 1; // B
doubles = pi *r *r; // C
无论是 A->B->C 还是 B->A->C 都对结果没有影响。但是这是发生在单线程之中的。在多线程之中可能就不是这样了,多线程有序性引起的问题我们可以看一个典型的例子:
class OrderExample{
int a = 0;
boolean flag = false;
public void writer(){
a = 1;
flag = true;
}
public void reader(){
if(flag){
int i = a + 1;
}
}
}
如果这个类的writer()和reader()方法是在不同的线程中运行的。那么writer()中的方法可能会被重排序为flag= true先执行。这个时候如果被中断,换到执行reader()的线程执行,flag为true,进入if判断就会自然认为a = 1;但是这个时候a还是0。这里大概就能理解重排序带来的问题了。
JMM具备一些先天的有序性,即不需要通过任何手段就可以保证的有序性,也就是在下面这些情况中,是不能进行重排序的。通常称为 happens-before原则
- 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作,但是在 JMM里其实只要执行结果一样,是允许重排序的,这边的 happens-before强调的重点也是单线程执行结果的正确性,但是无法保证多线程也是如此。上面的例子已经很好的说明了这一点。
- 监视器锁规则:对一个线程的解锁,happens-before于随后对这个线程的加锁
- volatile变量规则:对一个volatile域的写,happens-before于后续对这个volatile域的读。也就是一个线程写了volatile域,其他线程如果执行这个域的读操作就会知道它改变了。注意这里必须是另一个线程执行读操作才能知道。具体可以看下面对volatile的讲解。
- 传递性:如果A happens-before B ,且 B happens-before C, 那么 A happens-before C
- start()规则:如果线程A执行操作ThreadBstart()(启动线程B) , 那么A线程的ThreadBstart()happens-before 于B中的任意操作
- join()原则:如果A执行ThreadB.join()并且成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
- interrupt()原则:对线程interrupt()方法的调用先行发生于被中断线程代码检测到中断事件的发生,可以通Thread.interrupted()方法检测是否有中断发生
- finalize()原则:一个对象的初始化完成先行发生于它的finalize()方法的开始
关于为什么需要重排序,这里再详细说明一下:
比如执行:
a = b + c;
d = e + f;
在cpu中执行的过程可能是这样:
左边是汇编指令,右边就是流水线的情况。注意,在ADD指令上,有一个大叉,表示一个中断。也就是说ADD在这里停顿了一下。为什么ADD会在这里停顿呢?原因很简单,R2中的数据还没有准备好!所以,ADD操作必须进行一次等待。由于ADD的延迟,导致其后面所有的指令都要慢一个节拍。
既然停顿是因为数据还没有准备好,那我们就在它等待数据准备好的时候做其他事情。也就是在ADD和前面的LW指令之间插入一个做其他事情的指令,SUB同理,具体来说我们可以这样移动指令:
变成:
可以看到一共节约了两步执行时间。
4、volatile关键字
被 volatile修饰的共享变量,具有以下两点特性:
- 保证了不同线程对该变量操作的内存可见性;
- 禁止指令重排序
JMM规定对一个 volatile域的写, happens-before于后续对这个 volatile域的读(也就是一个线程写了volatile域,其他线程如果执行读操作就会知道它改变了),其实就是如果一个变量声明成是 volatile的,那么当我读变量时,总是能读到它的最新值,这里最新值是指不管其它哪个线程对该变量做了写操作,都会立刻被更新到主存里,我也能从主存里读到这个刚写入的值。
从内存语义上来看
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存
当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。
关于禁止重排序也就是不会让volatile写之前执行的结果跑到后面去,再拿这个例子说明就是a=1;不会到flag = true之后执行。
class OrderExample{
int a = 0;
boolean flag = false;
public void writer(){
a = 1;
flag = true;
}
public void reader(){
if(flag){
int i = a + 1;
}
}
}
同时保证volatile读之后的操作不会到volatile读之前操作;
但是volatile是无法保证原子性的,但是和int类型变量一样,简单的读取赋值还是原子的,但是这和volatile关键字没什么关系,只是作为普通变量的特性。举个例子,比如线程A读取了volatile变量,阻塞了。换到线程B读取了volatile变量执行加1操作,写回。现在又切换到线程A,因为线程A已经执行了读操作,无法触发线程A感知volatile变量已经改变,只有在做读取操作时,发现自己缓存行无效,才会去读主存的值,所以该线程直接加1,写回。所以虽然执行了2次加1,但实际只加了一次加1。理解只有volatile变量的读操作才能触发线程感知变量已经改变是非常重要的。
关于volatile的底层实现机制:
如果把加入 volatile关键字的代码和未加入 volatile关键字的代码都生成汇编代码,会发现加入 volatile关键字的代码会多出一个 lock前缀指令。
lock前缀指令实际相当于一个内存屏障,内存屏障提供了以下功能:
1 . 重排序时不能把后面的指令重排序到内存屏障之前的位置
2 . 使得本CPU的Cache写入内存
3 . 写入动作也会引起别的CPU或者别的内核无效化其Cache,相当于让新写入的值对别的线程可见。
关于volatile的使用场景
- 状态量标记,就如上面对 flag的标记,还是这个例子:
class OrderExample{
int a = 0;
volatile boolean flag = false;
public void writer(){
a = 1;
flag = true;
}
public void reader(){
if(flag){
int i = a + 1;
}
}
}
使用volatile来标示flag,就能解决上面说到的可见性问题,这种对变量的读写操作,标记为 volatile可以保证修改对线程立刻可见。比 synchronized, Lock有一定的效率提升。
- 单例模式的实现,典型的双重检查锁定(DCL)
class Singleton{
private volatile static Singleton instance = null;
private Singleton(){}
public static Singleton getInstance(){
if(instance == null){
synchronized(Singleton.class){
if(instance == null){
instance = new Singleton();
}
}
}
return instance;
}
}
这是一种懒汉的单例模式,使用时才创建对象,为了避免初始化操作的指令重排序,给 instance加上了 volatile。