角度二 - 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技术】,坚持输出原创。分享技术、品味生活,愿你我共同进步。