文章>没搞懂,啥玩意是“借助同步”啊?>

没搞懂,啥玩意是“借助同步”啊?

why技术2周前

你好呀,我是why哥。

不是,这个照片不是我,标题说的老爷子就是这个哥们,这事得从前几天说起。

前几天,发现在一个大佬云集的技术群里面,大佬们就 Happens-Before 关系和 as-if-serial 语义进行了激烈的讨论。

而我一看时间,快到 23 点了,大佬们都这么卷,那我必须得跟着卷进去,于是看了一下他们的聊天记录。

而我,作为一只菜鸡,虽然没有什么参与感,但是感觉大佬们说的都挺有道理的,据理力争。

所以基本上,我全程就是这样的:

但是,当他们说着说着就聊到了《Java并发编程实战》,我一下就支棱了起来。

这书我看过啊,而且这书就在我手边呀,终于可以插上话了。

仔细一看,他们说的是书中的 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.<br>
在本节中,我们将更详细地定义一些我们提出的非正式概念。

其中对论文中的七个概念进行了详细描述,分别是:

  • Shared variables/Heap memory<br>
  • Inter-thread Actions<br>
  • Program Order<br>
  • Intra-thread semantics<br>
  • Synchronization Actions<br>
  • Synchronization Order<br>
  • Happens-Before and Synchronizes-With Edges<br>

其中,我个人理解,happens-before 中的 action 主要是说下面这个三个概念:

线程间(inter-thread)动作、线程内(intra-thread)动作、同步动作(Synchronization Actions)。

加锁、解锁、对 volatile 变量的读写、启动一个线程以及检测线程是否结束这样的操作,均为同步动作。

而线程间(inter-thread)动作与线程内(intra-thread)动作是相对而言的。比如一个线程对于本地变量的读写,也就是栈上分配的变量的读写,是其他线程无法感知的,这是线程内动作。而线程间动作比如对于全局变量的读写,也就是堆里面分配的变量,其他线程是可以感知的。

另外,你看 Inter-thread Actions 里面我画下划线的地方,描述其实和同步动作相差无几。我理解,其实线程间动作大多也就是同步动作。

所以你去看一本书,叫做《深入理解Java虚拟机HotSpot》,这本书里面对于happens-before 的描述就稍微有点不一样了,开篇加的限定条件就是“所有同步动作…”:

1)所有同步动作(加锁、解锁、读写volatile变量、线程启动、线程完成)的代码顺序与执行顺序一致,同步动作的代码顺序也叫作同步顺序。
<br>1.1)同步动作中对于同一个monitor,解锁发生在加锁前面。
<br>1.2)同一个volatile变量写操作发生在读操作前面。
<br>1.3)线程启动操作是该线程的第一个操作,不能有先于它的操作发生。
<br>1.4)当T2线程发现T1线程已经完成或者连接到T1,T1的最后一个操作要先于T2
所有操作。
<br>1.5)如果线程T1中断线程T2,那么T1中断点要先于任何确定T2被中断的线程的
操作。
<br>对变量写入默认值的操作要先于线程的第一个操作;对象初始化完成操作要先
于finalize()方法的第一个操作。
<br>2)如果a先于b发生,b先于c发生,那么可以确定a先于c发生。
<br>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 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 方法就变成了这个样子:

经过简化之后,这就是最终 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又被网友发现了。

在这篇文章里面提到了:

老爷子说他“故意这样写的”,这背后是不是还包含着“借助同步”的这个背景呢?

不得而知,但是我仿佛有了一丝“梦幻联动”的感觉。

好了,本次的文章就分享到这里了。

恭喜你,又学到了一个这辈子基本上不会用到的知识点。

再见。

最后说一句

才疏学浅,难免会有纰漏,如果你发现了错误的地方,可以在留言区提出来,我对其加以修改。

感谢您的阅读,我坚持原创,十分欢迎并感谢您的关注。

image.png

1303 阅读
请先登录,再评论

评论列表

美丽七2周前

haha,感谢让我学到了“这辈子基本上不会用到”的知识点😁

2