volatile 可见性与内存屏障知识点
本篇解决什么
一个经典面试连环问:为什么"空的 run 方法"线程停不下来?加 volatile 能停,加一句 System.out.println 也能停,而 JDK 17+ 的 println 又没锁了? 七连问把 JMM、CPU 缓存、内存屏障、缓存一致性协议、synchronized 内存语义、PrintStream 源码、JDK 版本差异全部串起来------答完这一串,可见性就通了。
一、问题现场:为什么空 run 方法线程停不下来
场景 :子线程 while (!flag) 空转,主线程 2 秒后 flag = true,但线程停不下来:
ini
// 完整可运行代码(类名自拟),以下为类内逻辑
private static boolean flag = false; // ★ 普通变量(不加 volatile)
Thread worker = new Thread(() -> {
while (!flag) { // 空转,什么都不做
// System.out.println("想你" + atomicInteger.getAndIncrement() + "遍...");
}
System.out.println("线程中断...");
});
worker.start();
Thread.sleep(2000);
flag = true; // 主线程改了,子线程却看不到
worker.interrupt();
现象 :主线程明明把 flag 改成 true 了,子线程却永远空转。直觉上 bug,实际上符合 JMM------问题出在"可见性",不是"逻辑"。
二、可见性问题的根源(JMM + CPU 缓存)
JMM(Java 内存模型) 规定线程操作的是工作内存(寄存器/CPU 缓存) ,不直接操作主内存:
ini
主内存(flag = false)
↕ 拷贝
线程 A 工作内存 ── 读到自己缓存里的 flag = false ── 永远 false
↕ 拷贝
主线程 B 工作内存 ── 修改 flag = true ── 写入主内存(但 A 不知道)
- 线程读变量:先查自己的 CPU 缓存(L1/L2),命中就不去主内存;
- 主线程
flag = true:写入主内存,但不会主动通知其他线程的缓存失效; - 结果:子线程一直读缓存里的旧值 false------这不是 bug,是 JMM 允许的行为(可见性不保证)。
一句话 :多线程下共享变量不保证"改了立刻能被别人看到" ------这就是可见性问题(对应面试点:JMM、CPU 缓存、happens-before)。
三、volatile 为什么能停(内存屏障 + 缓存一致性)
arduino
private static volatile boolean flag = false; // 加 volatile 就能停
volatile 的三个特性:
| 特性 | 说明 |
|---|---|
| 可见性 | 写 volatile 会强制刷新到主内存 ,读 volatile 会强制从主内存读(缓存失效) |
| 有序性 | 禁止指令重排(读写前后插内存屏障,见下) |
| 不保证原子性 | 复合操作(如 count++)仍需锁/原子类 |
底层两件套:
- 内存屏障(Memory Barrier) :volatile 写前插入
StoreStore+ 写后插StoreLoad屏障;volatile 读后插LoadLoad/LoadStore屏障------禁止屏障两侧指令重排 ,并保证写操作先刷主内存 、读操作先失效缓存。 - 缓存一致性协议(MESI) :CPU 缓存行有 Modified/Exclusive/Shared/Invalid 四状态------写共享变量时广播失效,其他核的缓存行置 Invalid,读时重新从主内存拉最新值。
流程 :主线程 flag = true → 写屏障 → 刷主内存 + 广播失效 → 子线程读 volatile → 缓存失效 → 重新读主内存 → 看到 true → 循环退出。✅
四、为什么加一句 System.out.println 也能停(synchronized 内存语义)
csharp
while (!flag) {
System.out.println("想你" + atomicInteger.getAndIncrement() + "遍..."); // 打印也能让循环停
}
JDK 8 的 PrintStream.println 是 synchronized 方法:
arduino
// JDK 8:方法级 synchronized
public synchronized void println(String x) { ... }
synchronized 的内存语义 :进入锁读主内存(刷新工作内存 )、退出锁写回主内存(flush 工作内存)------锁的释放/获取形成 happens-before 关系,天然带内存屏障效果。
所以 :子线程每次 println(进出锁)都会刷新自己缓存 → 读到主内存最新的 flag = true → 循环停。但这是"副作用",不是语言保证 ------依赖 println 来同步是玄学,生产代码绝不能这么写。
关键结论
println 能停是"碰巧",volatile 能停是"保证"。面试要说清:synchronized 的内存语义顺带刷新了缓存,但正确做法是 volatile/锁/原子类,不是打印。
五、JDK 17+ 的 println 为什么没有同步锁了(版本差异)
JDK 17 对 PrintStream 做了性能优化 :write/println 不再无条件 synchronized ,而是用 getClass() 区分调用者:
arduino
// JDK 17+(简化源码):只有"子类"才加锁
public void write(byte[] buf, int off, int len) {
if (getClass() == PrintStream.class) {
implWrite(buf, off, len); // 本类:不加锁(内部有细粒度锁)
} else {
synchronized (this) { // 子类:加锁兜底
implWrite(buf, off, len);
}
}
}
为什么版本差异影响"println 能不能停线程" :JDK 8 的 println 有 synchronized → 打印顺带刷新缓存 → 死循环能停;JDK 17 的 println 对本类实例不加锁 → 打印不再有同步副作用 → 又停不下来了 。所以这道题的答案取决于 JDK 版本------面试答"JDK 8 能停、JDK 17 停不下来(本类无锁)"才完整。
动机 :synchronized 方法有获取/释放锁的开销,而 System.out 高频打印(日志)不该为"子类可能不安全"买单------把锁留给真正需要的子类。
六、getClass() vs instanceof:"仅对子类才加锁"是什么意思
两个判断的本质区别:
| 判断 | 语义 | 能否区分"是不是本类" |
|---|---|---|
getClass() == PrintStream.class |
运行时实际类型精确等于 | ✅ 只有"就是本类"才 true,子类必然 false |
x instanceof PrintStream |
类型兼容(本类或任意子类) | ❌ 子类也 true,无法区分 |
所以源码用 getClass() 是故意的 :instanceof 会把子类也算进去,无法实现"只对本类优化、只给子类加锁"。
"仅对子类加锁"= 只有 getClass() != PrintStream.class(即用户自定义的 PrintStream 子类实例)才走 synchronized(this) 兜底路径;本类实例走无锁快速路径。
七、"子类"是谁的子类?为什么本类不加锁?
PrintStream 的继承体系:
arduino
OutputStream(抽象基类:write(int))
└── FilterOutputStream(包装流基类)
└── PrintStream(打印流,本类)
└── 用户自定义子类(可能覆盖 write/println)
为什么只给子类加锁:
- 本类内部已有细粒度锁 :PrintStream 内部用
BufferedWriter(自带lock对象)+ 输出流的同步------字节级/缓冲级已经同步了,方法级 synchronized 是"重复加锁"(双重锁),性能白损失; - 信任自己的实现 vs 不信任子类 :JDK 自己写的
implWrite内部处理好了线程安全,锁粒度比方法级更细、更高效 ;但子类可以覆盖write()/println(),覆盖后的实现 JDK 无法保证线程安全 → 给子类加方法级锁兜底,牺牲一点性能换正确性; - getClass() 判断让优化精准落地 :自己(本类)走无锁快路径,别人(子类)走锁兜底------优化与安全兼得。
一句话: "信任自己、防着子类" ------本类实现已细粒度同步,方法级锁是双保险(去掉省性能);子类实现未知,只能方法级加锁兜底。
八、volatile vs synchronized(可见性手段对比)
| 维度 | volatile | synchronized |
|---|---|---|
| 可见性 | ✅ 保证 | ✅ 保证(进出锁刷缓存) |
| 原子性 | ❌ 不保证 | ✅ 保证(互斥) |
| 有序性 | ✅ 禁重排(部分) | ✅ 完全(临界区串行) |
| 开销 | 低(一条屏障指令) | 高(锁获取/释放/竞争) |
| 适用 | 单个标志位/发布不可变对象 | 复合操作/临界区 |
选型 :标志位(flag、shutdown)→ volatile;读-改-写(count++)→ 原子类/锁;临界区多步操作 → synchronized/ReentrantLock。
九、陷阱与坑
- volatile 不解决原子性 :
volatile int count; count++仍会丢计数(三步非原子)------用AtomicInteger。 - 依赖 println 同步是玄学:打印能停线程是 JDK 版本相关的副作用,生产代码必须用 volatile/锁。
- volatile 不能替代锁做复合操作 :如"检查再修改"(
if (!map.containsKey(k)) put(k,v))。 - 64 位变量:long/double 在 32 位 JVM 上非 volatile 可能读到"半个值"(低 32 位/高 32 位拼接)------volatile 保证 64 位读写原子(JDK 5+)。
- JDK 版本差异:同一段代码(println 停线程)在 JDK 8 与 JDK 17 行为不同------面试要带版本意识。
十、自测题
- 为什么空的 run 方法停不下来?(JMM 工作内存/CPU 缓存,可见性)
- volatile 靠什么保证可见性?(内存屏障 + MESI 缓存一致性)
- 为什么 JDK 8 里 println 能停线程?(synchronized 内存语义顺带刷缓存)
- JDK 17 的 println 为什么没锁了?(getClass()==PrintStream.class 只给子类加锁)
- getClass() 和 instanceof 的区别?(运行时精确类型 vs 类型兼容)
- 为什么本类(PrintStream)不加锁?(内部细粒度锁 + 信任自己 vs 防子类覆盖)
- volatile 和 synchronized 各解决什么问题?(可见性 vs 原子性)