JMM、Volatile、重排序、happen-before原则

这篇文章要梳理的概念会比较多,我在第一次接触这些概念时理解了很久,反反复复,用了很长时间才弄明白个大概。我想在这篇文章把概念说清楚,每个概念本身都有很多外延的内容,外延的内容我会不断学习后逐渐到文中。

JMM(Java Memory Model:java内存模型)

这是一个java技术规范,java的强大之一是它的多线程支持。java多线程执行期间是如何使用内存的呢?JMM就是这样一个规范,它描述了多线程执行时的内存使用方式。

硬件层面,CPU的指令执行速度远快于内存存取,为了缓解这种速度差,在CPU和内存之间,往往会有很多级速度比内存快的寄存器,存储一些CPU频繁访问的变量,这比直接去频繁访问内存要快很多。

Java在多线程方面,也会充分利用以上物理特性,为每个线程构建私有的工作内存。JMM规范要求,线程对变量的读写,需要从主存拷贝变量副本到工作内存中,以提高执行性能,再在合适的时机同步回主存,以使其他线程可见。

盗用一张网络图片

上面提到,线程会在工作内存操作线程副本,合适时机同步回内存。这里就会有一个同步问题,这是外延内容,以后补充。

这里我们只需要了解:线程有自己的工作内存,对变量的读写是需要从主存拷贝进工作内存的。

如果是一个共享变量,多线程都在并发访问,这时候会有一个问题:就是线程T1时序上先改了共享变量a,把值变成了a=1,原始值比如a=100

线程T2时序上在此后读取a,T2读取到的a值不一定等于1。注意这里是不一定,不是必然。就是说T1虽然改了共享变量的值,其它线程比如T2不一定能看到这个值。因为T2读取也是工作在自己的工作内存上,T1虽然做了修改,而这个也是发生在T1的工作内存上,只有T1把变量a的值同步回主存,T2再从主存拷贝a的值到自己的工作内存,才能保证T2能看到T1的修改。

这就是JMM规范下的一个变量在多线程情况下的可见性问题,下面要讲解的Volatile关键字是一个解决可见性问题的一个关键字。


Volatile

volatile 是java语言当中的一个关键字。它用于声明变量,声明变量后,起到的作用是:保障变量在多线程环境下的可见性。

什么意思呢?保障变量在多线程环境下的可见性。

在讲述JMM时,我们最后说了一个问题,JMM规范下,一个线程修改了某个共享变量的值,另外一个线程即使在它之后执行,也不一定能看到这个变量被修改过后的值。我用以下代码说明:

共享变量   boolean  ready = false;  //初始值false

线程T1先执行以下代码

while(!ready){

// wait

}

// ready, do something


线程T2后执行以下代码

ready = true;

//do other thing

看完以上代码,我们可能以为线程T1启动后,一直检测ready的值,如果是false,就一直while循环,直到线程T2启动,把ready的值改为true,线程T1才能退出循环。

其实真正执行以上代码,你会发现,T1始终不能发现ready的值变更,会一直在while中死循环。这就是可见性问题,T1没有发现这个变量变更了。

即使把以上共享变量换成数组,如


共享变量   boolean [] ready = new boolean[3];  //初始值false

线程T1先执行以下代码

while(!ready[1]){

// wait

}

// ready, do something


线程T2后执行以下代码

ready[1] = true;

//do other thing

它的效果也是一样的。这时候,你给ready变量,加上volatile后,再试试

volatile  boolean  ready = false; 或

volatile boolean [] ready = new boolean[3];

你会发现,T2一执行,T1马上跳出了循环。这就是volatile的作用,它保障了被修饰变量的可见性,T2一旦做过变更,T1就能看到。

那么究竟volatile底层又是如何保障的呢,这属于外延内容,我后续补充。


重排序

先说说什么是重排序,看下面这段代码。

int a = 1;

char b = ‘b’;

byte c = 2;

这三行代码很简单,就是简单的声明了3个变量。你可能会认为,这三行代码的执行顺序就如写代码的顺序一样,按a b c的顺序进行,实际上是不一定的。java语言是允许重排序的,也就是不按照abc的顺序执行,可能是cba,bac都有可能。前提是不改变执行结果,数据之间不存在依赖。

如果

int a = 1;

int b = a;

像上面这种b对a有数据依赖的,是不会被重排序的,执行顺序必然是a在b之前。

重排序发生在以下几个阶段:

编译器优化的重排序。编译器在不改变单线程程序语义放入前提下,可以重新安排语句的执行顺序。

指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

内存系统的重排序。由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

为什么会重排序,上面这几个阶段里大概提到了,提高并行效率,编译器认为重排序后执行更优,指令级重排序并行效率更好等等。在单个线程的视角看来,重排序不存在任何问题,重排序不改变执行结果,如下例:

int a = 1;

int b = 2;

int c = a + b;

c因为对a和b有数据依赖,因此c不会被重排序,但是a 、b的执行可能被重排序。但在单个线程下,这种重排序不存在任何问题,不论先初始化a、还是先初始化b,c的值都是3。但是在多线程情况下,重排序就可能带来问题,如下例:

线程T1执行:

a = 1; //共享变量 int a

b = true; //共享变量  boolean b


线程T2执行:

if (b){

int c = a;

System.out.println(c);

}

假如某个并发时刻,T2检测到b变量已经是true值了,并且变量都对T2可见。c 赋值得到的一定是 1 吗?

答案是不一定,原因就是重排序问题的存在,在多线程环境下,会造成问题。T1线程如果 a 和 b变量的赋值被重排序了,b先于 a发生,这个重排序对T1线程本身不存在什么问题,之前我们已经讨论过。但是在T2这个线程看来,这个执行就有问题了,因为在T2看来,如果没有重排序,b值变为true之前,a已经被赋值1了。而重排序使得这个推断变得不确定,b有可能先执行,a还没来的及执行,此时线程T2已经看到b变更,然后去获取a的值,自然不等于1。


happen-before原则

因为有以上重排序问题,会导致并发执行的问题,那么有没有方法解决呢?

happen-before原则,就是用来解决这个问题的一个原则说明,它告诉我们的开发者,你放心的写并发代码,但是你要遵循我告诉你的原则,你就能避免以上重排序导致的问题。

这个原则是什么呢?

    1、程序次序规则:在一个单独的线程中,按照程序代码的执行流顺序,(时间上)先执行的操作happen—before(时间上)后执行的操作。

2、管理锁定规则:一个unlock操作happen—before后面(时间上的先后顺序,下同)对同一个锁的lock操作。

3、volatile变量规则:对一个volatile变量的写操作happen—before后面对该变量的读操作。

4、线程启动规则:Thread对象的start()方法happen—before此线程的每一个动作。

5、线程终止规则:线程的所有操作都happen—before对此线程的终止检测,可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。

6、线程中断规则:对线程interrupt()方法的调用happen—before发生于被中断线程的代码检测到中断时事件的发生。

7、对象终结规则:一个对象的初始化完成(构造函数执行结束)happen—before它的finalize()方法的开始。

8、传递性:如果操作A happen—before操作B,操作B happen—before操作C,那么可以得出A happen—before操作C。

我们一条条看,

第一条

单线程情况下,happen-before原则告诉你,你放心的认为代码写在前面的就是先执行就ok了,不会有任何问题的。(当然实际并非如此,因为有指令重排序嘛,有的虽然写在前面,但是未必先执行,但是单线程情况下,这并不会给实际造成任何问题,写在前面的代码造成的影响一定对后面的代码可见)

happen-before 有一种记法,hb(a,b) ,表示a比b先行发生。

单线程情况下,写在前面的代码都比写在后面的代码先行发生

int a = 1;

int b= 2;

hb(a,b)


第二条

看如下代码

线程T1:

a = 1;

lock.unlock();

b = ture;

线程T2:

if (b){

lock.lock();

int c = a;

System.out.println(c);

lock.unlock();

}

此前在讲重排序的时候说过这个问题,说c有可能读取到的a值不一定是1。因为重排序,导致a的赋值语句可能没执行。但是现在在

b赋值之前加了解锁操作,线程T2在读取到b值变更后,做了加锁操作。这时候就是第二条原则生效的时候,它告诉我们,假如在时间上T1的lock.unlock()先执行了,T2 的lock.lock()后执行,那么T1 unlock之前的所有变更,a = 1这个变更,T2是一定可见的,即T2 在 lock后,c拿到的值一定是 a 被赋值1的值。

因为 a = 1 和  lock.unlock() 有  hb 关系  hb(a=1 ,   lock.unlock() )

第二条原则  hb(unlock, lock),  而  hb(lock , c = a ),因此c在被赋值a时,a=1一定会先行发生。

第三条

volatile关键字修饰的变量的写先行发生与对这个变量的读,如下


线程T1:

a = 1;

vv = 33;//volatile

b = ture;

线程T2:

if (b){

int  ff = vv;//  vv is  volatile

int c = a;

System.out.println(c);

}

与前面的锁原则一样,这次是volatile变量   写happen-before读。线程T2在读取a变量前先读取以下vv这个volatile变量。因为第三条原则的存在,只要T1在时间上执行了vv写操作,T2在执行vv读操作后,a=1的赋值一定可以被T2读到。

第四条、第五条、第六条

线程T1 start方法,先行发生于T1即将做的所有操作。

如,在某个线程中启动thread1

a = 1;

thread1.start();

如上,a =1 先行发生 thread.start(),而第四条规则又说,start方法先行发生该线程即将做的所有操作,那么a =1 ,也必将先行发生于 thread1 的任何操作。所以thread1启动后,是一定可以读取到a的值为1的

五、六条类似,线程终止前的所有操作先行发生于终止方法的返回。这就保障了一个线程结束后,其他线程一定能感知到线程所做的所有变更。

第七条

对象被垃圾回收调用finalize时,对象的构造一定已经先行发生。

第八条

传递性


至此,概念基本梳理完了。后续增加的外延有,分析java并发集合在实现时,为了符合happen-before的一些处理。

你可能感兴趣的:(JMM、Volatile、重排序、happen-before原则)