【编程玄学】一个困扰我122天的技术问题,我好像知道答案了。

众所周知,编程是一门玄学。html

本文主要是描述输出语句、sleep以及Integer对线程安全的影响。第一次碰到这个问题是122天以前,当时就以为很奇怪。java

至于为何还有Integer?我也不知道,多是玄学吧! 这也是本文最后留下的一个问题,若是有知道的朋友还请指点一二。面试

荒腔走板聊生活

首先,仍是本号特点,先荒腔走板的聊聊生活。编程

上面这张图是我 2017 年 12 月 9 日在北京西山国家森林公园拍的。缓存

拍照的地方有个颇有意思的名字:鬼笑石。安全

我在北京待了三年,这个地方一共只去了两次,这是第一次去的时候拍的,我一我的从香山走到了西山,那个时候仍是一个充满斗志的北漂。并发

第二次去是由于我感受本身可能要离开北京了,若是说在离开以前还能去一个地方留恋一下,“鬼笑石”算得上其中之一。因而约了好几个朋友一块儿再爬了一次。oracle

在这个地方一眼望去,你能站在五环边上,看到大半个北京,从夕阳西下,倦鸟归林看到华灯初上,万家灯火。app

你能够感觉到在偌大的北京中本身的眇小,也能感觉到在这么大的北京,必定要好好拼命努力才能不负北漂的时光。jvm

两次我都在听同一首歌赵雷的《理想》:

公车上我睡过了车站
一路上我望着霓虹的北京
个人理想把我丢在这个拥挤的人潮
车窗外已是一片白雪茫茫
......
理想今年你几岁
你老是诱惑着年轻的朋友
你老是谢了又开 给我惊喜
又让我沉入失望的生活里
......
理想永远都年轻
你让我倔强地反抗着命运
你让我变得苍白
却依然天真的相信花儿会再次的怒放

歌词写的真好,赵雷唱的真好,以致于我日后的每一次听到这首歌的时候,我都会想起北漂的那些日子。

每次有读者私聊我说,他要开始北漂啦。我都会说:必定要好好珍惜、把握、不虚度北漂的每一天。

此次,我再分享两首歌给你吧。赵雷的《理想》和李志的《热河》。

好了,说回文章。

本文主要是描述输出语句、sleep 以及 Integer 对线程安全的影响。

为何还有 Integer ?我也不知道,多是玄学吧!

先出个题

这个程序的意思就是定义一个 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 循环里面加了一个 10ms 的睡眠。

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

执行以后你会发现,这个程序竟然正常结束了,可是你也不知道为何,你只能再次大喊一声:卧槽,这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 循环能够被终止。

(别说锁粗化了,我以为这个回答确定是不对的。)

经过上面三个角度的分析,咱们能获得两个结论

1.输出语句的 synchronized 的影响。

2.输出语句让 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 修饰的。

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

1.Thread.sleep 没有任何同步语义(Thread.yield也是)。编译器没必要在调用 Thread.sleep 以前将缓存在寄存器中的写刷新到共享内存,也没必要在调用 Thread.sleep 以后从新加载缓存在寄存器中的值。

2.编译器能够**自由(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 也刷出去了。

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

Integer -- 玄学

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

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

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

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

这个程序我再稍稍一变:

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

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

而 -2147483648 就是 Integer.MIN_VALUE:

也许是溢出操做带来的影响。我也不知道。

别问,问就是玄学。

留个坑在这里,但愿之后本身能把它填上。也但愿知道缘由的朋友能给我指点一二,不胜感谢。

最后说一句(求关注)

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

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

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

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

也许,我能够把这个坑填上吧。

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

才疏学浅,不免会有纰漏,若是你发现了错误的地方,还请你留言给我指出来,我对其加以修改。(我每篇技术文章都有这句话,我是认真的说的。)

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

我是why技术,一个不是大佬,可是喜欢分享,又暖又有料的四川好男人。

欢迎关注公众号【why技术】,坚持输出原创。分享技术、品味生活,愿你我共同进步。

相关文章
相关标签/搜索