老爷子这代码,看跪了! (下)

简介: 老爷子这代码,看跪了! (下)

而《Java Concurreny in Practice》就是我们前面说的《Java并发编程实战》。

作为在 Java 界享有如此盛誉的一本书,居然没有提到 happens-before,略微有点遗憾。

但是转念一想,这书的江湖地位虽然很高,但是定位其实是入门级的,没提到这块的知识也算是比较正常。

另外,一个有意思的地方是这样的:

image.png

在《深入理解Java虚拟机(第三版)》里面把 Monitor 翻译为了“管程”,另外两本翻译过来都是“监视器”。

那么“管程”到底是个什么东西呢?

image.png

害,原来是一回事啊。

在 Java 里面的 synchronized 就是管程的一种实现。


FutureTask in JDK 8


前面铺垫了这么多,大家应该还没忘记我这篇主要想要分享的东西吧?

那就是“借助同步”这个东西在 FutureTask 里面的应用。

image.png


这是 JDK 8 里面的 FutureTask 源码截图,重点关注我框起来的两个部分。

  • state 是有 volatile 修饰的。
  • outcome 变量后面跟的注释。

着重关注这句注释:

non-volatile, protected by state reads/writes

你想,outcome 里面封装的是一个 FutureTask 的返回,这个返回可能是一个正常的返回,也可能是任务里面的一个异常。

举一个最简单,也是最常见的应用场景:主线通过 submit 方式把任务提交到线程池里面去了,而这个返回值就是 FutureTask:

image.png

接下来你会怎么操作?

是不是在主线程里面调用 FutureTask 的 get 方法获取这个任务的返回值?

现在的情况就是:线程池里面的线程对 outcome 进行写入,主线程调用 get 方法对 outcome 进行读取?

这个场景下,我们的常规操作是不是得在 outcome 上加一个 volatile,保证可见性?

那么为什么这里没有加 volatile 呢?

你先自己咂摸咂摸。

接下来,要描述的所有东西都是围绕着这个话题展开的。

来,走起。

首先,纵观全局,outcome 变量的写入操作,只有这两个地方:

image.png

set 和 setException,而这两个地方的逻辑和原理其实是一致的。所以我就只分析 set 方法了。

接下来看看 outcome 变量的读取操作,只有这个地方,也就是 get 方法:

image.png

需要说明的是 java.util.concurrent.FutureTask#get(long, java.util.concurrent.TimeUnit) 方法和 get 方法原理一致,也就不做过多解读了。

于是我们把目光聚集到了这三个方法上:

image.png

get 方法不是调用了 report 方法嘛,我们把这两个方法合并一下:


image.png

这里没毛病吧?

接着,我们其实只关心 outcome 什么时候返回,其他的对于我来说都是干扰项,所以我们把上面的 get 变成伪代码:

image.png

当 s 为 NORMAL 的时候,返回 outcome,这伪代码也没毛病吧?

下面,我们再看一下 set 方法:

image.png

其中第二行的含义是利用 CAS 操作把状态从 NEW 修改为 COMPLETING 状态,CAS 成功之后在进入 if 代码段里面。

然后在经过第三行代码,即 outcome=v 之后,状态就修改为了 NORMAL。

其实你看,从 NEW 到 NORMAL,中间这个的 COMPLETING 状态,其实我们可以说是转瞬即逝。

甚至,好像没啥用似的?

那么为了推理的顺利进行,我决定使用反证法,假设我们不需要这个 COMPLETING 状态,那么我们的 set 方法就变成了这个样子:

image.png

于是我们把 get/set 的伪代码放在一起:

image.png

到这里,终于终于所有的铺垫都完成了。

欢迎大家来到解密环节。

首先,如果标号为 ④ 的地方,读到的值是 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 关系,完成了同步的操作。

即:写入,先于读取。

这就是“借助同步”。

有没有品到一点点味道了?


image.png

别急,我这反证法呢,还没聊到 COMPLETING 状态呢,我们继续分析。

回过头去看 set 方法的伪代码,标号为 ① 的地方我还没说呢。

虽然标号为 ① 的地方和标号为 ③ 的地方都是对 volatile 变量的操作,但是它们之间不是线程安全的,这个点我们能达成一致吧?

所以,这个地方我们得用 CAS 来保证线程安全。

于是程序变成了这样:

image.png

这样,线程安全的问题被解决了。但是其他的问题也就随之而来了。

第一个问题是程序的含义发生了变化:

从“outcome 赋值完成后,s 才变为 NORMAL”,变成了“s 变成 NORMAL 后,才开始赋值”。

但是,这个问题不在我本文的讨论范围内,而且最后这个问题也会被解决,所以我们看另外一个问题,才是我想要讨论的问题。

什么问题呢?

那就是 outcome 的“借助同步”策略失败了。

因为如果我们通过这样的方式去解决线程安全的问题,把 CAS 操作拆开看,程序就有点像是这样的:

image.png

根据 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 状态,竟然是一个基于代码优化得出的一个深思熟虑的产物。

不得不说,老爷子这代码:

真的是“骚”啊,学不来,学不来。



image.png

另外,关于 FutureTask 之前我也写过一篇文章,描述的是其另外一个 BUG:

Doug Lea在J.U.C包里面写的BUG又被网友发现了。

在这篇文章里面提到了:

image.png

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

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

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

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

再见。


最后说一句


线程间(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》:才疏学浅,难免会有纰漏,如果你发现了错误的地方,可以在留言区提出来,我对其加以修改。

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

目录
相关文章
|
3月前
|
Web App开发 自然语言处理
一盏茶的功夫带你掌握烦人的 this 指向问题( 一 )
一盏茶的功夫带你掌握烦人的 this 指向问题( 一 )
|
3月前
|
Web App开发 自然语言处理
一盏茶的功夫带你掌握烦人的 this 指向问题( 二 )
一盏茶的功夫带你掌握烦人的 this 指向问题( 二 )
|
Go
腥风血雨中,这招救了我的代码!
腥风血雨中,这招救了我的代码!
55 0
|
存储 安全 Python
python多线程------>这个玩意很哇塞,你不来看看吗
python多线程------>这个玩意很哇塞,你不来看看吗
|
前端开发 程序员 开发工具
你疯了吧,竟然在代码里面“下毒”?
除了有点味道以外,这回是不记住了,我们编程写代码的过程和我们日常生活的例子,往往都是这样可以对应上,有了真实可以触及的实物,再去了解编程就会更加容易,也很难忘记。但可能会写着写着代码,就傻笑起来!
|
Java 关系型数据库 MySQL
【浅尝高并发编程】接私活差点翻车
作为一名本本分分的练习时长两年半的Java练习生,一直深耕在业务逻辑里,对并发编程的了解仅仅停留在八股文里。一次偶然的机会,接到一个私活,核心逻辑是写一个 定时访问api把数据持久化到数据库的小服务。
174 0
|
前端开发 JavaScript IDE
YourBatman用趣味代码雨祝你:端午安康
使用Java的AWT给你写了个祝福
235 0
YourBatman用趣味代码雨祝你:端午安康
|
Java 程序员
老爷子这代码,看跪了! (中)
老爷子这代码,看跪了! (中)
141 0
老爷子这代码,看跪了! (中)
|
Java
老爷子这代码,看跪了! (上)
老爷子这代码,看跪了! (上)
147 0
老爷子这代码,看跪了! (上)
|
设计模式 移动开发 安全
与其硬啃“屎山”代码,不如用这六步有条不紊实现代码重构 李慧文
对大规模系统进行重构,如果一个人对着又臭又长的代码硬刚,即使花了大量的时间进行手工验证,最后仍然会有很多问题,特别是一些深路径及特殊场景下的问题。其实,大规模的系统级别重构时是有方法的。我们采访了 Thoughtworks 数字化转型与运营 资深咨询师黄俊彬(QCon+案例研习社讲师),请他来分享 MV*模式重构演进的方法和经验。
569 0
与其硬啃“屎山”代码,不如用这六步有条不紊实现代码重构 李慧文

相关实验场景

更多