分享一篇朋友 y 哥的文章。
五年前,2020 年,我写文章的时候曾经遇到过一个技术问题,百思不得其解,当时把那个问题归类为玄学问题。
后来也会偶尔想起这个问题,但是我早就不纠结于这个问题了,没再去研究过。
前几天,骑着共享单车下班回家的路上,电光石火之间,这个问题突然又冒出来了。
然后,结合这段时间火出圈的 DeepSeek,我想着:
为什么不问问神奇的 DeepSeek 呢?
先说问题
问题其实是一个非常常见的、经典的问题。
我上个代码你就立马能明白怎么回事。
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);
}
}
这个程序的意思就是定义一个 boolean 型的 flag 并设置为 false。
主线程一直循环执行 i++,直到 flag 变为 true。
那么 flag 什么时候变为 true 呢?
从程序里看起来是在子线程休眠 100ms 后,会把 flag 修改为 true。
来,你说这个程序会不会正常结束?
但凡是对 Java 并发编程有一定基础的朋友都能看出来,这个程序是一个死循环。
导致死循环的原因是 flag 变量不是被 volatile 修饰的,所以子线程对 flag 的修改不一定能被主线程看到。
这也是一个非常经典的面试八股题。
Java 内存模型和 volatile 关键字是面试常见考题,出现的几率非常之高,所以我默认你是了解 Java 内存模型和 volatile 关键字的作用的。
如果你不知道或者不熟悉,赶紧去恶补一下,别往下看了,没有这些基础打底,后面你看不懂的。
另外,还需要事先说明的是:
要让程序按照预期结束的正确操作是用 volatile 修饰 flag 变量。不要试图去想其他骚操作。
但是这题要是按照上面的操作了,在 flag 上加上 volatile 就没有意思了,也就失去了探索的意义。
好了,铺垫完成了。
我准备开始微调一下,给你上“玄学”了。
第一次微调
我用 volatile 修饰了变量 i:
注意啊,我再说一次,我用 volatile 修饰的是变量 i。
flag 变量还是没有用 volatile 修饰的。
这个程序正常运行结束了。
怎么解释这个现象?
我解释不了。
如果非要让我解释,我五年前写的时候的解释是:
但是这只是个人猜测,没有资料支撑。
第二次微调
我仅仅是把变量 i 从 基本类型 int 变成了包装类型 Integer,其他啥也不动:
和五年前一样,程序也可以正常结束:
现象就是上面这个现象。
当年经验不足,我也只能去猜测到底是什么原因,我甚至不知道应该从那个方面去找什么资料去验证我的猜想。
但是问题我很清晰。
五年过去了,我已经不纠结于这个问题了,但是我还是想问问 DeepSeek。
DeepSeek 解惑
首先,我还是把最开始的代码扔给了它,让它进行解释:
它给的解释,完美符合我的预期:
然后,我先把第二处微调,也就是把“把变量 i 从基本类型 int 变成了包装类型 Integer”,给它,让它继续解释:
我们先一起看看它的回答。
首先它抓住了变量 i 类型变化之后,i++ 操作的含义也发生了变化:
当 i 是基本类型 int 时,i++ 是直接修改栈内存中的值。
而当 i 是包装类型时,每次 i++ 会创建一个新的 Integer 对象并更新引用。
在“思考”里面,它还专门提到了一个小小的注意点,显得更加严谨:超过缓存范围时会新建对象。
然后它从“可见性”的角度进行了进一步描述:
前面这两点结合起来看是什么意思呢?
就是说,由于 i 从基本类型变成了包装类型,导致每次 i++ 会创建一个新的 Integer 对象并更新引用。
而在部分 JVM 实现中,对象引用的赋值可能隐含内存同步。
所以 JVM 在写入对象引用时,可能(非强制)触发短暂的本地内存与主存同步。
主线程在 i++ 中更新 i 的引用时,可能顺带读取到新线程修改的 flag = true。
所以循环退出。
那问题就来了,你说可能就可能吗?
有没有什么资料支撑一下呢?
所以我追问了一下:
在 JMM 中,只是明确规定了当线程操作共享变量时需要遵循的规则:
但是对普通变量的操作无强制同步规则。
因此某些 JVM 在对普通变量执行某些操作(如对象引用赋值、方法调用、内存分配)时,可能顺带将工作内存中的变量刷新到主内存。
这种同步是 JVM 实现的细节,
非 JMM 规范要求
,因此结果不可靠。
也就是说,有的 JVM 可能是有这个隐藏的特性,有的却没有。
而我们常用的 HotSpot 就有这个特性,所以我们观察到了程序结束的现象:
到此,基本上能够解决我的一部分困惑,总结起来就是之前出现过的两个字:巧合。
但是,我还是进一步追问了一下:
jvm 限定为 HotSpot,请从字节码的层面解释一下,当我把“private static int i = 0;”修改为“private static Integer i = 0;”程序是否会运行结束?
DeepSeek 还是对比了两种情况时, i++ 操作的字节码:
关注点都在 putstatic 指令上。