说到happen before,很多人都知道。但因为其理论的抽象,以及在语义上的微妙,使得对happen before的理解,往往陷入“隔靴搔痒“的境地。本文试图宏观性、多角度的来分析围绕happen before的诸多问题,从而搞清楚我们为什么需要happen before?
-可见性
-重排序
-happen before
-共享存储模型 vs. 消息模型
可见性
说到可见性,很多人立马会想到volatile,知道它是解决单个变量、多线程之间可见性的方法,但这只是可见性的1个方面。
本文,试图从一个更宏观的角度来探讨此问题。我们都知道,Java的多线程通信模型是“共享存储模型“,与之相对应的是“消息模型“。
在笔者看来,所有的“共享存储模型“,无论是内存的存储,还是DB的存储,从抽象角度,都要解决2个问题:
问题1:屏蔽可见性 -- A(线程/客户端)把数据写到共享存储,不想让B看见,但B却看见了。为什么不想让B看见呢?
因为此时A数据才写了1半,B去读,会读到脏数据。比如前面提到的64位long/double的原子性问题,比如Mysql中一个事务写了一半,另一个事务去读。。
我们通常的Lock,无论是db的lock,还是线程的lock,都是为了解决此问题。当然,它也解决了问题2。
问题2:敞开可见性 -- A把数据写到共享存储,想让后续B读的时候,是可见的,但B却看不见!为什么B看不见呢?
因为A/B各自有自己的缓存。从物理实现角度讲,就是现代cpu都有自己的缓存;从抽象角度讲,JMM中,每个线程都有自己的工作内存。
之前所说的volatile,就是为了解决此问题。
重排序
我们都知道,为了尽可能的提高指令执行的并发度和效率,编译器和cpu都会做重排序。从底层实现原理来讲,重排序的规则非常多。但对程序员来讲,到底什么时候指令会被重排,什么时候不被重排呢?
在此,笔者认为可以用“负“的方法来看待这个问题:除了那些不会被重排的,剩下的,都可能被重排!
那哪些不会被重排呢?
(1)as - if - serial语义 -- 从程序员角度来看,单线程的程序都是不会被重排的。也就是说,在底层实现上,不管如何重排,会保证上层看起来,是按代码顺序执行的。
(2)具有happen before语义的,也就是有同步机制的: volatile, final, synchronized, lock等,不会被重排。
除此之外,都可能被重排!
换句话说:编译器和cpu只保证单个线程内部的重排序,符合as-if-serial语义。至于这个重排序,对其它线程是否有影响,编译器和cpu是不管的。
因为重排序导致的线程之间不同步问题,需要程序员自己用各种线程同步机制来解决。
关于重排序导致的线程之间不同步问题,前面已有很多例子:
比如线程安全的单例模式DCL问题,
比如ConcurrentHashMap里面,get的时候,value = null,加锁重新读取的问题。。
happen before
那究竟什么是happen before呢?happen before是JVM定义的1套规则,这套规则就是为了解决上述的可见性问题和重排序问题。
规则本身的定义很简单:A happen before B,则A的操作结果对B可见。
A happen before B,并不是说A一定要在B之前执行。而是说:如果A在B之前执行,则A的操作结果必须对B可见。
以下是一些常用的happen before规则:
(1)单线程中,前面的指令 happen before 后面的指令(这句话暗含的意思是:即使单线程中,前面的指令可能会后执行,后面的指令可能先执行。因为重排序)
(2)对volatile的写,happen before 后续所有对volatile的读(这句话暗含的意思是:对应普通变量,没有happen before关系,1个线程先写了,另1个线程去读,可能读不到)
(3)对final域的初始化,happen before 后续所有对final域的读(这句话暗含的意思是:对于普通变量,其初始化过程,可能被重排序到构造函数外面!!也即意味着,另外一个线程拿到了对象的引起,但构造函数里面的代码,可能还未执行完毕!)
(4)对 lock的解锁,happen before后续对该lock的加锁。(这句话暗含的意思是:因为happen before的偏序关系,可以推导出,第1次加锁,在锁里面执行的结果,对于第2次加锁里面的读,一定是可见的)