你好呀,我是why。
不知道大家还有没有印象,我曾经写了这样的一篇文章:《一个困扰我122天的技术问题,我好像知道答案了。》
文章我给出了这样的一个示例:
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); } }
上面这个程序是不会正常结束的,因为变量 flag 没有被 volatile 修饰。
而在子线程休眠的 100ms 中, while 循环的 flag 一直为 false,循环到一定次数后,触发了 jvm 的即时编译功能(JIT),进行循环表达式外提(Loop Expression Hoisting),导致形成死循环。
而如果加了 volatile 去修饰 flag 变量,保证了 flag 的可见性,则不会进行提升。
验证方案就是关闭 JIT 功能,对应的命令是 -Xint
或者 -Djava.compiler=NONE
。
这都不是重点,重点是我接下来有几处小改动,代码的运行结果也是各不相同。
文章中的最后一节我是这样说的:
而图片里面提到的“关于Integer”的问题,就是文章说提到的“玄学”:
是的,我回来填坑了。
再次探索
其实让我再次探索这个问题的起因是因为四月份的时候有人私信我,问我关于 Integer 的玄学问题是否有了结论。
我只能说:
但是,后来我想到了这篇文章里面的一个留言:
由于当时公众号没有留言功能,用的第三方小程序,所以我没有太注意到留言提醒。
这位大佬留言之后,我隔了很长时间才看到,我还在留言后面回复了一个:
谢谢大佬分析,有时间的时候我按照这个思路去分析分析。
但是后来我也搁置了,因为我感觉好像继续在这里面深究下去收益已经不大了。
没想到,时隔这么长时间,又有读者来问了。
于是在五一期间我按照留言的说法,修改了一下程序,并进行了一波基于搜索引擎的研究。
嘿,你猜怎么着?
我还真的研究出了一点有意思的东西。
先说结论:final 关键字影响了程序的结果。
在上面这个案例中,final 关键字在哪呢?
当我们把程序里面的 int 修改为 Integer 后,i++ 操作涉及到装箱、拆箱的操作,这个过程中对应的源码是这里:
程序能正常结束,确实是 final 关键字影响了程序的结果。
那么final 到底是怎么影响的呢?
这个地方我经过探索之后,发现和留言中说的有一定的偏差。
留言中说的是因为有 storestore 屏障加上 Happens-Before 关系得出 flag 会被刷到主内存中。
而我基于搜索引擎的帮助,探索出来的结论是加上 final 和不加 final,生成的是两套机器码,导致运行结果不一致。
但是我这里得加上一个前提:处理器是 x86 架构。
得出这个结论基于的测试案例如下,也是按照留言给的思路写出来的:
Class 里面包含一个 final 的属性,在构造方法里面给属性赋值。然后在 while 循环里面不断 new 该对象:
我的运行环境是:
- jdk1.8.0_271
- win10
- IntelliJ IDEA 2019.3.4
运行结果是:
- 如果 age 属性加上 final 修饰,程序则可以正常退出。
- 如果 age 属性去掉 final 修饰,程序则无限循环,不能退出。
动图如下:
你也可以把我上面给的代码粘出来,跑一跑,看看是否和我说的运行结果一致。
说说 final
当我把程序改造成上面这个样子之后,其实结论已经很明显了,final 关键字影响了程序的运行。
其实当时我得出这个结论的时候非常兴奋,一个困扰我长达一年多的问题终于要被我亲手解开神秘面纱了。
结论都有了,寻找推理过程还不是轻而易举的事情?
而且我知道去哪里找答案,答案就藏在我桌子上的一本书里面。
于是我翻开了《Java并发编程的艺术》,其中有一小节专门讲到了 final 域的内存语义:
这一小节我印象可是太深刻了,因为 3.6.5 小节的“溢出”应该是“逸出”才对,早年间还基于此,写了这篇文章:
所以我只要在这一个小节里面找到证据,来证明留言里面的“storestore 屏障加上 Happens-Before 关系得出 flag 会被刷到主内存中”这个论点就行了。
但是,事情远远没有我想的这么简单,因为我发现,我在书里面没有找到能证明论点的证据,反而找到了推翻论点的证据。
书里面的一大段内容我就不搬运过来了,仅仅关注 3.6.6 final语义在处理器中的实现这一小节的内容:
注意画了下划线这一句话:在 X86 处理器中,final 域的读/写不会插入任何内存屏障。
由于没有任何内存屏障的存在,即“storestore 屏障”也是省略掉了。因此在 X86 处理器的前提下,final 域的内存语义带来的 flag 刷新是不存在的。
所以前面的论点是不正确的。
那么这本书里面的“在 X86 处理器中,final 域的读/写不会插入任何内存屏障”这个结论又是从哪里来的呢?
这个说来就巧了,是我们的老朋友 Doug Lee 告诉作者的。
你看 3.6.7 小节提到了 JSR-133。而关于 JSR-133,老爷子写过这样的一篇文章:《The JSR-133 Cookbook for Compiler Writers》,直译过来就是《编译器编写者的JSR-133食谱》
在这篇食谱里面,有这样的一个表格:
可以看到,在 x86 处理器中,LoadStore、LoadLoad、StoreStore 都是 no-op,即无任何操作。
On x86, any lock-prefixed instruction can be used as a StoreLoad barrier. (The form used in linux kernels is the no-op lock; addl $0,0(%%esp).) Versions supporting the "SSE2" extensions (Pentium4 and later) support the mfence instruction which seems preferable unless a lock-prefixed instruction like CAS is needed anyway. The cpuid instruction also works but is slower.
翻译过来就是:在 x86 上,任何带 lock 前缀的指令都可以用作一个 StoreLoad 屏障。 (在 Linux 内核中使用的形式是 no-op lock; addl $0,0(%%esp)。) 支持 "SSE2" 扩展的版本(Pentium4 和更高版本)支持 mfence 指令, 该指令似乎是更好的,除非无论如何都需要像 CAS 这样的带 lock 前缀的指令。cpuid 指令也可以,但是速度较慢。
查到这里的时候我都快懵逼了,好不容易整理出来的一点点思路就这样再次被堵死了。
我给你捋一下啊。
我们是不是已经可以非常明确 final 带来的屏障(StoreStore)在 X86 处理器中是空操作,并不能对内存可见性产生任何影响。
那么为什么程序加上 final 之后,停下来了?
程序停下来了,说明主线程一定是观测到了 flag 的变化了?
那么为什么程序去掉 final 后,停不下来了?
程序没有停了,说明主线程一定沒有观测到 flag 的变化?
也就是说停不停下来,和有没有 final 有直接的关系。
但是 final 域带来的屏障在 X86 处理器中是空操作。
这特么是玄学吧?
绕了一圈,怎么又回去了啊。
这波,说真的,激怒我了,我花了这么多时间,绕了一圈又回来了?
干它。