大家好,我是陪你系统复习的老周。
synchronized 咱们上周讲完了,今天讲它的"轻量版兄弟"——volatile。很多同学对 volatile 的印象是"保证可见性",但一被追问"怎么保证的"“为什么不保证原子性”“DCL 单例为什么必须加”,就卡住了。
今天咱们从 JMM 内存模型到内存屏障,把 volatile 一次讲透。
一、volatile 是什么?
你可以把 volatile 理解成一个**“透明玻璃箱”**:变量放在里面,一个线程改了,其他线程透过玻璃立刻就能看到最新值,不会被各自的"小抽屉"(CPU 缓存)挡住。
它是 Java 的轻量级同步机制,修饰变量,保证两大特性:
- 可见性:一个线程修改 volatile 变量,其他线程立即看到最新值
- 有序性:禁止指令重排序,volatile 前后的指令不能越过它重排
- 原子性:❌ 不保证(这是最容易踩的坑)
一句话:volatile 管"看得见"和"不重排",不管"一次做完"。
二、可见性问题的根源:JMM 内存模型
为什么会有"看不见"的问题?因为 Java 内存模型(JMM)规定:所有变量存在主内存,每个线程有自己的工作内存(CPU 缓存/寄存器)。
线程操作变量时,先从主内存拷贝到工作内存,改完再写回。这就导致:线程 A 改了变量但还没写回,线程 B 读到的还是旧值。
volatile 的作用就是强制:改完立即写回主内存,读时直接从主内存读。
运行验证可见性:
staticvolatilebooleanrunning=true;publicstaticvoidmain(String[]args)throwsException{Threadt=newThread(()->{while(running){}System.out.println("退出循环");});t.start();Thread.sleep(500);running=false;}子线程看到running=false,退出循环 子线程状态: 已退出(可见性成功)如果去掉 volatile,子线程可能永远看不到running=false,导致死循环——JIT 甚至可能把while(running)优化成while(true)。
三、可见性的底层:内存屏障 + MESI
volatile 的可见性靠**内存屏障(Memory Barrier)**实现:
- volatile 写:写操作后插入 StoreLoad 屏障,强制把值刷回主内存,并使其他 CPU 缓存中该变量的副本失效
- volatile 读:读操作前插入屏障,失效本地缓存,直接从主内存读最新值
这背后是 CPU 的MESI 缓存一致性协议:一个 CPU 修改变量后,通过总线"广播"通知其他 CPU"你的缓存过期了",其他 CPU 下次读时重新从主内存拉取。
四、有序性:禁止指令重排序
CPU 和编译器为了性能,会对指令做重排序(只要单线程下结果不变)。但多线程下重排序可能出大问题。
volatile 变量就像一堵墙:
- volatile 写之前的普通读写,不能重排到 volatile 写之后
- volatile 读之后的普通读写,不能重排到 volatile 读之前
这就是著名的happens-before 原则:对一个 volatile 变量的写,happens-before 于后续对这个变量的读。
五、为什么 volatile 不保证原子性?
这是面试最高频的坑。volatile int count; count++看起来是一行,编译后是四条字节码指令:
getstatic // 从主内存读 count 到操作数栈 iconst_1 // 把常量 1 压栈 iadd // 栈顶两个数相加(改) putstatic // 把结果写回主内存volatile 只保证getstatic(读)和putstatic(写)的可见性,但中间的 iadd 可能被其他线程打断:
线程A 读到 count=10 线程B 也读到 count=10 线程A 加1,写回 11 线程B 加1,写回 11(覆盖了A的结果,丢失一次递增)结论:volatile 不保证复合操作的原子性。需要原子性用synchronized、Lock或AtomicInteger(CAS)。
六、经典应用:DCL 单例为什么必须加 volatile?
双重检查锁定(Double-Checked Locking)单例:
publicclassSingleton{privatestaticvolatileSingletoninstance;publicstaticSingletongetInstance(){if(instance==null){synchronized(Singleton.class){if(instance==null){instance=newSingleton();}}}returninstance;}}关键在new Singleton(),它不是原子操作,分三步:
1. 分配内存空间 2. 初始化对象(调用构造方法) 3. 将 instance 指向分配的内存地址如果没有 volatile,编译器可能把步骤 2 和 3重排序为 1→3→2:
- 线程 A 执行到 1→3,instance 已经非 null,但对象还没初始化
- 线程 B 第一次检查发现 instance != null,直接返回
- 线程 B 拿到一个未初始化的半成对象,调用时 NPE
加了 volatile 后,禁止 2 和 3 的重排序,保证对象完全初始化后才把引用赋值给 instance。
七、volatile vs synchronized
| 维度 | volatile | synchronized |
|---|---|---|
| 修饰对象 | 变量 | 方法/代码块 |
| 原子性 | 不保证 | 保证 |
| 可见性 | 保证 | 保证 |
| 有序性 | 禁止重排序 | 串行化保证 |
| 性能 | 轻量,无线程切换 | 较重,可能阻塞 |
| 典型场景 | 状态标志位、DCL单例 | 复合操作、临界区 |
口诀总结
volatile 两特性:可见、有序,不原子; 写完刷主存,读时主存取,屏障挡重排; i++ 要原子,还得 synchronized 或 Atomic; DCL 单例加 volatile,防止半成对象。volatile 这块,面试核心就是三条线:可见性原理(内存屏障+MESI)→ 为什么不保证原子性(i++四步字节码)→ DCL 单例为什么必须加(防止重排序)。把这三条讲清楚,volatile 就稳了。
下周五我们讲线程池,看看核心参数、拒绝策略和 Executors 的坑,敬请关注。
如果这篇对你有帮助,欢迎点赞、收藏、关注,咱们每天一个知识点,系统重建并发知识体系。
#Java面试 #volatile #并发编程 #多线程 #JMM #内存屏障 #后端开发 #程序员