这是why的第 99 篇原创文章java
你好呀,我是why哥。程序员
不是,这个照片不是我,标题说的老爷子就是这个哥们,这事得从前几天提及。编程
前几天,发如今一个大佬云集的技术群里面,大佬们就 Happens-Before 关系和 as-if-serial 语义进行了激烈的讨论。安全
而我一看时间,快到 23 点了,大佬们都这么卷,那我必须得跟着卷进去,因而看了一下他们的聊天记录。markdown
而我,做为一只菜鸡,虽然没有什么参与感,可是感受大佬们说的都挺有道理的,力排众议。并发
因此基本上,我全程就是这样的:app
可是,当他们说着说着就聊到了《Java并发编程实战》,我一下就支棱了起来。函数
这书我看过啊,并且这书就在我手边呀,终于能够插上话了。oop
仔细一看,他们说的是书中的 16.1.4 小节:优化
没啥映像了,甚至连“借助同步”这个词都没有搞明白啥意思。
因而我翻到这一小节,读了起来。
因为这小节篇幅不长,且除了 Happens-Before 关系这个基础知识铺垫外,没有其余的背景,因此我把这一小节截图出来,给你们看看:
怎么样,你们看完以后什么感受?
是否是甚至都没有耐心看完,一种云里雾里的感受?
说实话,我看的时候就是这个感受,每一个字都看得懂,可是连在一块儿就不知道啥意思了。
因此,读完以后的感受就是:
可是不慌,文章里面举的例子是 FutureTask ,这玩意并发编程基础之一,我熟啊。
因而决定去源码里面看看,可是并没找到书中举的 innerSet 或者 innerGet 的方法:
因为我这里是 JDK 8 的源码了,而这本书的发布时间是 2012 年 2 月:
因为是译本,原书写做时间可能就更早了。
对比这 JDK 版本发布时间线来看,若是是源码,也是 JDK 8 以前的源码了:
果真,一个大佬告诉我,JDK 6 里面的源码就是这样写的:
可是我以为去研究 JDK 6 的收益不是很大呀。(主要仍是我懒得去下载)
因而,我仍是在 JDK 8 的源码里面,发现了一点点蛛丝马迹。
终于搞懂了,什么是“借助同步”了。
并且不得不赞叹 Doug Lea 老爷子的代码,真的是:妙啊。
到底什么是“借助同步”呢?且听我细细道来。
为了文章的顺利进行,必须得进行一个基础知识的铺垫,那就是 Happens-Before 关系。
而 Happens-Before 关系的正式提出,就是 jsr 133 规范:
http://www.cs.umd.edu/~pugh/java/memoryModel/jsr133.pdf
若是你不知道 jsr133 是啥,那么能够去这个连接里面看看。
http://ifeve.com/jsr133/
在这里面就有你们耳熟能详的 Happens-Before 关系的正式描述,你们看到的全部的中文版翻译的原文,就是这里:
因为这段话,特别是那六个小黑点后面的话过重要了,失之毫厘谬以千里,因此我不敢轻易按照以前的轻松风格大体翻译。
因而我决定站在大佬的肩膀上,分别把《深刻理解Java虚拟机(第三版)》、《Java并发编程实战》、《Java并发编程的艺术》这三本书中关于这部分的定义和描述搬运一下,你们对比着看。
若是对于该规则了然于心,能够跳过本小节。
走起。
首先是《深刻理解Java虚拟机(第三版)》:
- 程序次序规则(Program Order Rule):在一个线程内,按照控制流顺序,书写在前面的操做先行发生于书写在后面的操做。注意,这里说的是控制流顺序而不是程序代码顺序,由于要考虑分支、循环等结构。
- 管程锁定规则(Monitor Lock Rule):一个unlock操做先行发生于后面对同一个锁的 lock操做。这里必须强调的是“同一个锁”,而“后面”是指时间上的前后。
- volatile 变量规则(Volatile Variable Rule):对一个 volatile 变量的写操做先行发生于后面对这个变量的读操做,这里的“后面”一样是指时间上的前后。
- 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每个动做。
- 线程终止规则(Thread Termination Rule):线程中的全部操做都先行发生于对此线程的终止检测,咱们能够经过Thread::join()方法是否结束、Thread::isAlive()的返回值等手段检测线程是否已经终止执行。
- 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,能够经过Thread:interrupted()方法检测到是否有中断发生。
- 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
- 传递性(Transitivity):若是操做A先行发生于操做B,操做B先行发生于操做C,那就能够得出操做A先行发生于操做C的结论。
接着是《Java并发编程实战》:
- 程序顺序规则:若是程序中操做A在操做B以前,那么在线程中A操做将在B操做以前执行。
- 监视器锁规则:在监视器锁上的解锁操做必须在同一个监视器锁上的加锁操做以前执行。
- volatile 变量规则:对volatile 变量的写入操做必须在对该变量的读操做以前执行。
- 线程启动规则:在线程上对Thread.Start的调用必须在该线程中执行任何操做以前执行。
- 线程结束规则:线程中的任何操做都必须在其余线程检测到该线程已经结束以前执行,或者从Thread.join中成功返回,或者在调用Thread.isAlive时返回 false.
- 中断规则:当一个线程在另外一个线程上调用interrupt时,必须在被中断线程检测到interrupt调用以前执行(经过抛出InterruptedException,或者调用isInterrupted和interrupted).
- 终结器规则:对象的构造函数必须在启动该对象的终结器以前执行完成。
- 传递性:若是操做A在操做B以前执行,而且操做B在操做C以前执行,那么操做A必须在操做C以前执行。
《Java并发编程的艺术》,在这本书里面做者加了一个限定词“与程序员密切相关的 happens-before规则以下”:
- 程序顺序规则:一个线程中的每一个操做,happens-before 于该线程中的任意后续操做。
- 监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁。
- volatile 变量规则:对一个volatile域的写,happens-before 于任意后续对这个 volatile 域的读。
- 传递性:若是 A happens-before B,且B happens-before C,那么 A happens-before C。
也就是说:线程启动规则、线程结束规则、中断规则、对象终结规则其实对于开发来讲是无感的,在这几个规则里面,咱们没有什么能够搞事的空间。
当你把这三本书中对于同一件事情的描述对比着来看的时候,也许会稍微的印象深入一点吧。
本质上说的是一回事,只是描述略有不一样而已。
另外,我以为我须要补充一个我以为很是重要的点,那就是在原文论文中多处出现的一个很是重要的单词 action:
那么啥是 action?
对于这个略显模糊的定义,论文开篇的第五点提到了具体含义:
In this section we define in more detail some of the informal concepts we have presented.
在本节中,咱们将更详细地定义一些咱们提出的非正式概念。
其中对论文中的七个概念进行了详细描述,分别是:
- Shared variables/Heap memory
- Inter-thread Actions
- Program Order
- Intra-thread semantics
- Synchronization Actions
- Synchronization Order
- Happens-Before and Synchronizes-With Edges
其中,我我的理解,happens-before 中的 action 主要是说下面这个三个概念:
线程间(inter-thread)动做、线程内(intra-thread)动做、同步动做(Synchronization Actions)。
加锁、解锁、对 volatile 变量的读写、启动一个线程以及检测线程是否结束这样的操做,均为同步动做。
而线程间(inter-thread)动做与线程内(intra-thread)动做是相对而言的。好比一个线程对于本地变量的读写,也就是栈上分配的变量的读写,是其余线程没法感知的,这是线程内动做。而线程间动做好比对于全局变量的读写,也就是堆里面分配的变量,其余线程是能够感知的。
另外,你看 Inter-thread Actions 里面我画下划线的地方,描述其实和同步动做相差无几。我理解,其实线程间动做大多也就是同步动做。
因此你去看一本书,叫作《深刻理解Java虚拟机HotSpot》,这本书里面对于happens-before 的描述就稍微有点不同了,开篇加的限定条件就是“全部同步动做...”:
1)全部同步动做(加锁、解锁、读写volatile变量、线程启动、线程完成)的代码顺序与执行顺序一致,同步动做的代码顺序也叫做同步顺序。
1.1)同步动做中对于同一个monitor,解锁发生在加锁前面。
1.2)同一个volatile变量写操做发生在读操做前面。
1.3)线程启动操做是该线程的第一个操做,不能有先于它的操做发生。
1.4)当T2线程发现T1线程已经完成或者链接到T1,T1的最后一个操做要先于T2 全部操做。
1.5)若是线程T1中断线程T2,那么T1中断点要先于任何肯定T2被中断的线程的 操做。
对变量写入默认值的操做要先于线程的第一个操做;对象初始化完成操做要先 于finalize()方法的第一个操做。
2)若是a先于b发生,b先于c发生,那么能够肯定a先于c发生。
3)volatile的写操做先于volatile的读操做。
原本,我还想举出《Java编程思想》里面关于 happens-before 的描述的。
结果,我翻完了书中关于并发的部分,结果它:
没,有,写!
好吧,我想有可能这本神书写于 2004 年 jsr133 发布以前?
结果,它的英文版发布时间是在 2006 年,也就是做者故意没写的,他只是在 21.11.1 章节里面提到了《Java Concurreny in Practice》:
而《Java Concurreny in Practice》就是咱们前面说的《Java并发编程实战》。
做为在 Java 界享有如此盛誉的一本书,竟然没有提到 happens-before,略微有点遗憾。
可是转念一想,这书的江湖地位虽然很高,可是定位实际上是入门级的,没提到这块的知识也算是比较正常。
另外,一个有意思的地方是这样的:
在《深刻理解Java虚拟机(第三版)》里面把 Monitor 翻译为了“管程”,另外两本翻译过来都是“监视器”。
那么“管程”究竟是个什么东西呢?
害,原来是一回事啊。
在 Java 里面的 synchronized 就是管程的一种实现。
前面铺垫了这么多,你们应该还没忘记我这篇主要想要分享的东西吧?
那就是“借助同步”这个东西在 FutureTask 里面的应用。
这是 JDK 8 里面的 FutureTask 源码截图,重点关注我框起来的两个部分。
- state 是有 volatile 修饰的。
- outcome 变量后面跟的注释。
着重关注这句注释:
non-volatile, protected by state reads/writes
你想,outcome 里面封装的是一个 FutureTask 的返回,这个返回多是一个正常的返回,也多是任务里面的一个异常。
举一个最简单,也是最多见的应用场景:主线经过 submit 方式把任务提交到线程池里面去了,而这个返回值就是 FutureTask:
接下来你会怎么操做?
是否是在主线程里面调用 FutureTask 的 get 方法获取这个任务的返回值?
如今的状况就是:线程池里面的线程对 outcome 进行写入,主线程调用 get 方法对 outcome 进行读取?
这个场景下,咱们的常规操做是否是得在 outcome 上加一个 volatile,保证可见性?
那么为何这里没有加 volatile 呢?
你先本身咂摸咂摸。
接下来,要描述的全部东西都是围绕着这个话题展开的。
来,走起。
首先,纵观全局,outcome 变量的写入操做,只有这两个地方:
set 和 setException,而这两个地方的逻辑和原理实际上是一致的。因此我就只分析 set 方法了。
接下来看看 outcome 变量的读取操做,只有这个地方,也就是 get 方法:
须要说明的是 java.util.concurrent.FutureTask#get(long, java.util.concurrent.TimeUnit)
方法和 get 方法原理一致,也就不作过多解读了。
因而咱们把目光汇集到了这三个方法上:
get 方法不是调用了 report 方法嘛,咱们把这两个方法合并一下:
这里没毛病吧?
接着,咱们其实只关心 outcome 何时返回,其余的对于我来讲都是干扰项,因此咱们把上面的 get 变成伪代码:
当 s 为 NORMAL 的时候,返回 outcome,这伪代码也没毛病吧?
下面,咱们再看一下 set 方法:
其中第二行的含义是利用 CAS 操做把状态从 NEW 修改成 COMPLETING 状态,CAS 成功以后在进入 if 代码段里面。
而后在通过第三行代码,即 outcome=v
以后,状态就修改成了 NORMAL。
其实你看,从 NEW 到 NORMAL,中间这个的 COMPLETING 状态,其实咱们能够说是转瞬即逝。
甚至,好像没啥用似的?
那么为了推理的顺利进行,我决定使用反证法,假设咱们不须要这个 COMPLETING 状态,那么咱们的 set 方法就变成了这个样子:
通过简化以后,这就是最终 set 的伪代码:
因而咱们把 get/set 的伪代码放在一块儿:
到这里,终于终于全部的铺垫都完成了。
欢迎你们来到解密环节。
首先,若是标号为 ④ 的地方,读到的值是 NORMAL,那么说明标号为 ③ 的地方必定已经执行过了。
为何?
由于 s 是被 volatile 修饰的,根据 happens-before 关系:
volatile 变量规则:对volatile 变量的写入操做必须在对该变量的读操做以前执行。
因此,咱们能够得出标号为 ③ 的代码先于标号为 ④ 的代码执行。
而又根据程序次序规则,即:
在一个线程内,按照控制流顺序,书写在前面的操做先行发生于书写在后面的操做。注意,这里说的是控制流顺序而不是程序代码顺序,由于要考虑分支、循环等结构。
能够得出 ② happens-before ③ happens-before ④ happens-before ⑤
又根据传递性规则,即:
若是操做A先行发生于操做B,操做B先行发生于操做C,那就能够得出操做A先行发生于操做C的结论。
能够得出 ② happens-before ⑤。
而 ② 就是对 outcome 变量的写入,⑤ 是对 outcome 变量的读取。
虽然被写入,被读取的变量没有加 volatile,可是它经过被 volatile 修饰的 s 变量,借助了 s 变量的 happens-before 关系,完成了同步的操做。
即:写入,先于读取。
这就是“借助同步”。
有没有品到一点点味道了?
别急,我这反证法呢,还没聊到 COMPLETING 状态呢,咱们继续分析。
回过头去看 set 方法的伪代码,标号为 ① 的地方我还没说呢。
虽然标号为 ① 的地方和标号为 ③ 的地方都是对 volatile 变量的操做,可是它们之间不是线程安全的,这个点咱们能达成一致吧?
因此,这个地方咱们得用 CAS 来保证线程安全。
因而程序变成了这样:
这样,线程安全的问题被解决了。可是其余的问题也就随之而来了。
第一个问题是程序的含义发生了变化:
从“outcome 赋值完成后,s 才变为 NORMAL”,变成了“s 变成 NORMAL 后,才开始赋值”。
可是,这个问题不在我本文的讨论范围内,并且最后这个问题也会被解决,因此咱们看另一个问题,才是我想要讨论的问题。
什么问题呢?
那就是 outcome 的“借助同步”策略失败了。
由于若是咱们经过这样的方式去解决线程安全的问题,把 CAS 操做拆开看,程序就有点像是这样的:
根据 happens-before 关系,咱们只能推断出:
② happens-before ④ happens-before ⑤,和 ③ 没有扯上关系。
因此,咱们不能得出 ③ happens-before ⑤,因此借助不了同步了。
这种时候,若是是咱们碰到了怎么办呢?
很简单嘛,给 outcome 加上 volatile 就好了,哪里还须要这么多奇奇怪怪的推理。
可是 Doug Lea 毕竟是 Doug Lea,加 volatile 多 low 啊,老爷子准备“借助同步”。
前面咱们分析了,这样是能够借助同步的,可是不能保证线程安全:
protected void set(V v) {
if (s==NEW) {
outcome = v;
s=NORMAL;
}
}
复制代码
那么,咱们是否是能够搞成这样:
protected void set(V v) {
if (s==NEW) {
s=COMPLETING;
outcome = v;
s=NORMAL;
}
}
复制代码
COMPLETING 也是对 s 变量的写入呀,这样 outcome 又能“借助同步”了。
用 CAS 优化一下就是这样:
protected void set(V v) {
if (compareAndSet(s, NEW, COMPLETING)){
outcome = v;
s=NORMAL;
}
}
复制代码
引入一个转瞬即逝的 COMPLETING 状态,就可让 outcome 变量不加 volatile,也能创建起 happens-before 关系,就能达到“借助同步”的目的。
看起来其貌不扬、无关紧要的 COMPLETING 状态,居然是一个基于代码优化得出的一个深思熟虑的产物。
不得不说,老爷子这代码:
真的是“骚”啊,学不来,学不来。
另外,关于 FutureTask 以前我也写过一篇文章,描述的是其另一个 BUG:
Doug Lea在J.U.C包里面写的BUG又被网友发现了。
在这篇文章里面提到了:
老爷子说他“故意这样写的”,这背后是否是还包含着“借助同步”的这个背景呢?
不得而知,可是我仿佛有了一丝“梦幻联动”的感受。
好了,本次的文章就分享到这里了。
恭喜你,又学到了一个这辈子基本上不会用到的知识点。
再见。
才疏学浅,不免会有纰漏,若是你发现了错误的地方,能够在留言区提出来,我对其加以修改。
感谢您的阅读,我坚持原创,十分欢迎并感谢您的关注。