侧边栏壁纸
博主头像
why

一个主要敲代码,经常怼文章,偶尔拍视频的成都人。

  • 累计撰写 197 篇文章
  • 累计创建 11 个标签
  • 累计收到 101 条评论

一个困扰我122天的技术问题,我好像知道答案了。

why
why
2020-03-22 / 0 评论 / 1 点赞 / 239 阅读 / 7,039 字
温馨提示:
关注公众号why技术,第一时间接收最新文章。

先出个题

这个程序的意思就是定义一个 boolean 型的 flag 并设置为 false。主线程一直循环,直到 flag 变为 true。

而 flag 什么时候变为 true 呢?

从程序里看起来是在子线程休眠 100ms 后,把 flag 修改为 true。

来,你说这个程序会不会正常结束?

但凡是对 Java 并发编程有一定基础的朋友都能看出来,这个程序是一个死循环。导致死循环的原因是 flag 变量不是被 volatile 修饰的,所以子线程对 flag 的修改不一定能被主线程看到。

而这个地方,如果是在 HotSpot jvm 中用 Server 模式跑的程序,是一定不会被主线程看到,原因后面会讲。

如果你对于 Java 内存模型和 volatile 关键字的作用不清楚的话,我建议你先赶紧去搜一下相关的知识点,补充一下后再来看这篇文章。

由于 Java 内存模型和 volatile 关键字是面试常见考题,出现的几率非常之高,所以已经有很多的文章写过了,本文不会对这些基本概念进行解释。

我默认你是了解 Java 内存模型和 volatile 关键字的作用的。

我第一次遇到这个问题,是在 2019 年 11 月 19 日,距今天已经122天了。我常常在夜里想起这个题以及这个题的变种问题,为什么呢?到底是为什么呢?

我再给你提供一个可以直接复制粘贴运行的版本,我建议文中的代码你都去执行一遍,你就会知道:MD,这事儿真是绝了!

public class VolatileExample {

    private static boolean flag = false;
    private static int i = 0;
    public static void main(String[] args) {
        new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(100);
                flag = true;
                System.out.println("flag 被修改成 true");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
        while (!flag) {
            i++;
        }
        System.out.println("程序结束,i=" + i);
    }
}

还有,需要事先说明的是:要让程序按照预期结束的正常操作是用 volatile 修饰 flag 变量。但是这题要是加上 volatile 就没有意思了,也就失去了探索的意义。

所以下面的这些骚操作,仅做研究,真实场景中不能这样去做。

另外,需要说明的是,根据不同的机器、不同的JVM、不同的CPU可能会产生不一样的效果。

遇事不决,量子力学

我会在这一小节基于上面展示的程序进行三次非常小的变化。

相信我,绝对让你懵逼。甚至让你觉得:不可能吧?我得亲自操作一下。

操作之后你就会说:卧槽,还真是这样?这是量子力学吗?

第一次程序改造

那我把上面这题变一下,改变成下面这样:

仅仅在程序的第 24 行加入了一个输出语句,用于输出每次循环时 flag 的值。其他地方没有任何变化。

可以看到 idea 在 24 行还给了我们一个友情提示:

它说:flag is always false。

来,你再猜一下。这个程序还是不是死循环呢?

执行之后你会发现,这个程序居然正常结束了,但是你不知道为什么,你只能大喊一声:卧槽,绝了!

或者你说你知道,因为输出语句里面有 synchronized 关键字。

很好,别着急,接着往下看。看看会不会被打脸。

第二次程序改造

先接着看下面的程序:

这次的变动点是在 while 循环里面加了一个 100ms 的睡眠。

来,你再猜一下。这个程序还是不是死循环呢?

执行之后你会发现,这个程序居然正常结束了,但是你也不知道为什么,你只能再次大喊一声:卧槽,这TM绝了!

sleep 语句里面没有 synchronized 了吧,你再给我解释一波?

也许你会说,这我也知道,sleep 会导致内存的刷新操作。

来,等会把你的另外一半脸伸过来挨打。

第三次程序改造

再看这一个改造程序:

这次的改动点是在第 9 行,用 volatile 修饰了变量 i。注意啊,flag 变量还是没有用 volatile 修饰的。

在 23 行,idea 又给了一个友情提示:

对于 volatile 修饰的字段 i 进行了非原子性的操作。

但是,没有关系,朋友们,这个题的考点不在于此,好吗?

你只需要知道对于 volatile 修饰的变量 i,进行 i++ 操作是不对的,因为 volatile 只保证可见性,不保证原子性,而 i++ 操作就不是原子操作的。

来,你再猜一下。上面这个程序还是不是死循环呢?

执行之后你会发现,这个程序居然正常结束了,但是你还是不知道为什么,你只能再次大喊一声:卧槽,真TM绝了!

第四次程序改造

再看最后一次的改造,也是致命一击的改造:

这次的改动点还是在第 9 行,把变量 i 从 基本类型 int 变成了包装类型 Integer。

来,你再猜一下...

算了,别猜了,直接喊吧:

这个程序也会正常结束。

上面的四种情况,你来品一品,你怎么解释。

Effective Java

其实在《Effective Java》这本 Java 圣典里面也提到过一嘴这个问题。

在第 66 条(同步访问共享的可变数据)这一小节中,有这么一个程序:

你觉得这个程序会怎么执行呢?

书里面说:也许你可能期望这个程序运行大概一秒钟左右,之后主线程将 stopRequested 设置为 true,致使后台线程的循环停止。但是在我的机器上,这个程序永远不会终止:因为后台线程永远在循环!

问题在于,由于没有同步,就不能保证后台线程何时“看到”主线程对 stopRequested 的值所做的改变。

没有同步,所以虚拟机会将这个代码变成下面这个样子:

书里面是这样说的:

书里提到了一个活性失败的概念:多线性并发时,如果 A 线程修改了共享变量,此时 B 线程感知不到此共享变量的变化,叫做活性失败。

如何解决活性失败呢?

让两个线程之间对共享变量有 happens-before 关系,最常用的操作就是volatile 或 加锁。

活性失败的知识点记下来就行,不是这里的重点,重点是下面。

书里说:这是可以接受的,这种优化称作提升(hoisting)。

说起提升这两字,我联想不出来啥,但是看到 hoisting 这个单词,有点意思了。

电光火石之间,我想到了《深入理解Java虚拟机》描述即时编译(Just In Time,JIT)里说到的一些东西了。

《深入理解Java虚拟机》和《Effective Java》,呼应上了!

虽然《Effective Java》里面没有详细描述这个提升是什么,但是我们有理由相信,它指的就是《深入理解Java虚拟机》里面描述的循环表达式外提(Loop Expression Hoisting)。

而这个提升是 JIT 帮我们做的。

我们还能怎么验证一下这个结论呢?

运行的时候配置下面的参数,其含义是禁止 JIT 编译器的加载:

-Djava.compiler=NONE

还是一样的代码,禁用了 JIT 的优化。程序正常运行结束了。

结合上面的描述,再加上这个“循环表达式外提”。现在,你应该就能品出点味道来了。

而且,这里还有一个非常非常重要的信息我可以品出来。

一个没有被 volatile 修饰的变量 stopRequested ,在子线程和主线程中都有用到的时候,Java 内存模型只是不能保证后台线程何时“看到”主线程对 stopRequested 的值所做的改变,而不是永远看不见。

加了 volatile,jvm 一定会保证 stopRequested 的可见性。

不加 volatile,jvm 会尽量保证 stopRequested 的可见性。

也许你会问了,从左边到右边的提升到底是怎么回事,能细致一点,底层一点吗?

当然可以啊。可以深入到汇编语言去。具体怎么操作,你看R大的这两个链接,非常之硬核,虽然可能看不懂,但是看着看着就是想磕头,不读三遍以上,你可能根本不知道他在说什么:

https://hllvm-group.iteye.com/group/topic/34932
https://www.iteye.com/blog/rednaxelafx-644038

我直接说个R大的结论:

所以,这里再次回到文章开始的时候说的点:根据不同的机器、不同的JVM、不同的CPU可能会产生不一样的效果。

但是由于我们绝大部分同学都使用的是 HotSpot 的 Server 模式,所以,运行结果都一样。

在这一小节的最后,我们回到本文[先出个题]环节抛出的那个程序:

这个地方的 while 循环和上面的如出一辙。所以你知道为什么这个程序为什么不会正常结束了吗?

你不仅知道了,而且你还可以回答的比 volatile 更深入一点。

由于变量 flag 没有被 volatile 修饰,而且在子线程休眠的 100ms 中, while 循环的 flag 一直为 false,循环到一定次数后,触发了 jvm 的即时编译功能,进行循环表达式外提(Loop Expression Hoisting),导致形成死循环。而如果加了 volatile 去修饰 flag 变量,保证了 flag 的可见性,则不会进行提升。

比如下面的程序,注释了 14 行和 16 行,while 循环,循环了3359次(该次数视机器情况而定)后,就读到了 flag 为 true,还没有触发即时编译,所以程序正常结束。

输出语句

接下来,我们看输出语句对这个程序的影响:

首先,我们知道了,在第 24 行加入输出语句后,这个程序是会正常结束的。

经过我们上面的分析,我们也可以推导出。加了输出语句后 JVM 并没有做 JIT。

点进 println 方法,可以看到该方法内部是调用了 synchronized 的。

关于这个问题,我需要分三个角度去讨论:

角度一 - stack overflow

在 stack overflow 中找到了这个地址:

https://stackoverflow.com/questions/25425130/loop-doesnt-see-value-changed-by-other-thread-without-a-print-statement?noredirect=1&lq=1

和我们这里的问题,如出一辙。该问题下面有一个回答,非常的好,得到了大家的一致好评:

该回答从现象到原理,再到解决方案都说的头头是道。建议你去阅读一下。

我这里只解析和本文相关的输出语句部分的回答:

我结合自己的理解和这个回答来解释一下:

同步方法可以防止在循环期间缓存 pizzaArrived(就是我们的stop)。

严格的说,为了保证变量的可见性,两个线程必须在同一个对象上进行同步。如果某个对象上只有一个线程同步操作,通过 JIT 技术,JVM 可以忽略它(逃逸分析、锁消除)。

但是,JVM 不够聪明,它无法证明其他线程在设置 pizzaArrived 之后不会调用 println,因此它只能假定其他线程可能会调用 println。(所以有同步操作)

因此,如果使用 System.out.println, JVM 将无法在循环期间缓存变量。

这就是为什么,当有 print 语句时,循环可以正常结束,尽管这不是一个正确的操作。

角度二 - Doug Lea

这个角度其实和角度一基本上一致。但是由于有了 Doug Lea 的加持,所以得单独的再提一下,大佬,必须值得这样的待遇。

在 Doug Lea 写的这本书里:

有一小节专门讲可见性的:

他先说了一句:写线程释放同步锁,读线程随后获取相同的同步锁。

这是我们常规的认知。但是他紧接着说了个 In essence(本质上)。

从本质上来说,线程释放锁的操作,会强制性的将工作内存中涉及的,在释放锁之前的,所有写操作都刷新到主内存中去。

而获取锁的操作,则会强制新的重新加载可访问的值到该线程的工作内存中去。

角度三 - IO操作

第三个角度,和前面说的 synchronized 关系就不大了。

在这个角度里面,解释是这样的:前面我们已经知道了,即使一个变量没有加 volatile 关键字,JVM 会尽力保证内存的可见性。但是如果 CPU 一直处于繁忙状态,JVM 不能强制要求它去刷新内存,所以 CPU 有没办法去保证内存的可见性了。

而加了 System.out.println 之后,由于 synchronized 的存在,导致 CPU 并不是那么的繁忙(相对于之前的死循环而言)。这时候 CPU 就可能有时间去保证内存的可见性,于是 while 循环可以被终止。

(别说锁粗化了,我觉得这个回答肯定是不对的。)

通过上面三个角度的分析,我们能得到两个结论

  • 输出语句的 synchronized 的影响。
  • 输出语句让 CPU 有时间去做内存刷新的事儿。比如在我的示例中,把输出语句换成new File()的操作也是可以正常结束的。

但是说真的,我也不知道哪个结论是对的,诸君判断吧。

sleep语句

sleep 语句对程序的影响,我给出的例子是这样的:

同样,我在 stack overflow 上也找到了相关问题:

https://stackoverflow.com/questions/42676751/thread-sleep-makes-compiler-read-value-every-time

下面有个回答是这样的:

根据这个回答,我解释一下为什么我们的测试程序没有死循环。

关于 sleep 我们可以看官方文档:

https://docs.oracle.com/javase/specs/jls/se8/html/jls-17.html#jls-17.3

文档中的 while 循环中的 done 也是没有被 volatile 修饰的。

里面有两句话特别重要(上面红框圈起来的部分):

  • Thread.sleep 没有任何同步语义(Thread.yield也是)。编译器不必在调用 Thread.sleep 之前将缓存在寄存器中的写刷新到共享内存,也不必在调用 Thread.sleep 之后重新加载缓存在寄存器中的值。
  • 编译器可以自由(free)读取 done 这个字段仅一次。

特别是第二点,注意文档中的这个 free。简直用的是一发入魂。

自由,意味着编译器可以选择只读取一次,也可以选择每次都去读取,这才是自由的含义。这是编译器自己的选择。

volatile -- 巧合

接着我们看第三个改造点:

改动点是在第 9 行,用 volatile 修饰了变量 i。

如果我们用下面的 jvm 参数运行:

-XX:+UnlockDiagnosticVMOptions
-XX:+PrintAssembly
-XX:CompileCommand=dontinline,*VolatileExample.main
-XX:CompileCommand=compileonly,*VolatileExample.main

可以看到如下输出:

在操作程序的第 23 行,有个 lock 前缀。而这个 lock 指令,就相当于一个内存屏障。会触发 Java 内存模式中的“store”和“write”操作。

这里属于 volatile 的知识点,就不详细说明了。

有的人可能会往 happens-before 的方面去想。很不幸,这个想法是不对的。

为什么呢?

主线程读的是非 volatile 类型的 flag,写的是 volatile类型的 i。但是子线程中只有对非 volatile 类型的 flag 的写入。

来,你怎么去建立起子线程对 flag 的写入 happens-before 于主线程对 flag 的读的关系?

我个人理解这个地方导致程序正常结束的原因是:巧合!

巧合在于,可能由于某个时刻变量 i 和 flag 处于同一 CPU 的 cacheline 中。因为 lock 操作保证变量 i 的可见性的同时把 flag 也刷出去了。

需要特别说明的是:这个地方纯属个人理解,我没有找到相应的资料进行结论的支撑。不具备权威性和引用性。

PS:后续证明了,这个地方的观点是错误的。

Integer -- 玄学

再看最后一次的改造,也是致命一击的改造:

改动点还是在第 9 行,把变量 i 从 基本类型 int 变成了包装类型 Integer。

这个程序在我的机器上正常结束了。我真不知道为什么,写出来的目的是万一有读者朋友知道的原因的话,请多多指教。

如果要让我强行给个解释的话,我想会不会是 i++ 操作涉及到的拆箱装箱操作,导致 CPU 有时间去刷了工作内存。

这个程序我再稍稍一变:

注释掉了第九行,在第21行加入 Integer i=0。

是的,它也运行结束了。只是需要一点时间。在i = -2147483648 的时候。

而 -2147483648 就是 Integer.MIN_VALUE:

别问,问就是玄学。

留个坑在这里,希望以后自己能把它填上。也希望知道原因的朋友能给我指点一二,不胜感谢。

最后说一句(求关注)

回到文章最开始说的,其实要让程序按照预期结束的正确操作是用 volatile 修饰 flag 变量。但是这题要是加上 volatile 就没有意思了,也就失去了探索的意义。

再次申明:上面的这些骚操作,仅做研究,真实场景中不能这样去做。

上面的问题关于输出语句和 sleep 对线程安全的影响,其实困扰我很长时间了,从第一次遇见到现在有122天了,这两个问题我现在是比较清楚了。

但是,我在写这篇文章的时候又遇到了上面说的最后一个关于 Integer 的问题。实在是不知道怎么回事。

也许,我可以把这个坑填上吧。

也许,编程的尽头,是玄学吧。

好了,看到了这里了,关注一下我的公众号[why技术]吧,文章写好后第一时间会先发布在公众号里面。

写文章很累的,需要一点正反馈。你的关注,就是强有力的正反馈!

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

抱拳了,铁子!

0

评论区