而《Java Concurreny in Practice》就是我们前面说的《Java并发编程实战》。
作为在 Java 界享有如此盛誉的一本书,居然没有提到 happens-before,略微有点遗憾。
但是转念一想,这书的江湖地位虽然很高,但是定位其实是入门级的,没提到这块的知识也算是比较正常。
另外,一个有意思的地方是这样的:
在《深入理解Java虚拟机(第三版)》里面把 Monitor 翻译为了“管程”,另外两本翻译过来都是“监视器”。
那么“管程”到底是个什么东西呢?
害,原来是一回事啊。
在 Java 里面的 synchronized 就是管程的一种实现。
FutureTask in JDK 8
前面铺垫了这么多,大家应该还没忘记我这篇主要想要分享的东西吧?
那就是“借助同步”这个东西在 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 方法就变成了这个样子:
于是我们把 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又被网友发现了。
在这篇文章里面提到了:
老爷子说他“故意这样写的”,这背后是不是还包含着“借助同步”的这个背景呢?
不得而知,但是我仿佛有了一丝“梦幻联动”的感觉。
好了,本次的文章就分享到这里了。
恭喜你,又学到了一个这辈子基本上不会用到的知识点。
再见。
最后说一句
线程间(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》:才疏学浅,难免会有纰漏,如果你发现了错误的地方,可以在留言区提出来,我对其加以修改。
感谢您的阅读,我坚持原创,十分欢迎并感谢您的关注。