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