JMM、volatile 与 CAS:并发安全三大问题
并发程序为什么会出问题?
很多人会先说“因为多线程同时执行”。这句话没错,但太粗了。真正落到 Java 面试里,通常要拆成三个词:原子性、可见性、有序性。
synchronized、Lock、volatile、CAS、Atomic 类,本质上都是围绕这三个问题在做不同取舍。
并发安全到底在防什么
PPT 里把 Java 并发编程三大特性列得很清楚:
| 特性 | 问题 | 常见解决方式 |
|---|---|---|
| 原子性 | 一组操作执行到一半被别的线程插进来 | synchronized、Lock、CAS、Atomic 类 |
| 可见性 | 一个线程改了共享变量,另一个线程看不到 | volatile、synchronized、Lock |
| 有序性 | 编译器或 CPU 为优化执行顺序,导致多线程结果异常 | volatile、锁、happens-before 规则 |
原子性:ticketNum-- 不是一步
下面这种扣库存逻辑,单线程下没问题,多线程下就危险:
intticketNum=10;publicvoidgetTicket(){if(ticketNum<=0){return;}System.out.println(Thread.currentThread().getName()+" 抢到一张票, 剩余:"+ticketNum);ticketNum--;}ticketNum--看起来是一行代码,实际不是一个不可分割的动作。它至少包含:
- 读取
ticketNum。 - 计算
ticketNum - 1。 - 写回
ticketNum。
两个线程可能同时读到1,然后都扣减成功。
解决原子性最直接的方式是加锁:
publicsynchronizedvoidgetTicket(){if(ticketNum<=0){return;}ticketNum--;}也可以用Lock,或者在适合的场景下用 Atomic 类底层的 CAS。
JMM 是什么
JMM,全称 Java Memory Model,Java 内存模型。
它不是 JVM 内存结构里的堆、栈、方法区那套东西。JMM 讨论的是:多线程读写共享变量时,Java 语言层面应该遵守什么规则。
JMM 把内存抽象成两块:
- 主内存,保存共享变量。
- 工作内存,每个线程自己的本地副本。
线程之间不能直接访问对方的工作内存。线程 A 要把修改告诉线程 B,必须通过主内存完成。
这就引出了可见性问题:线程 A 修改了共享变量,但线程 B 可能还在用自己的旧副本。
volatile 解决什么
volatile有两层核心语义:
- 保证线程间可见性。
- 禁止特定指令重排序。
先看可见性:
privatestaticvolatilebooleanstop=false;publicstaticvoidmain(String[]args){newThread(()->{while(!stop){// busy loop}System.out.println("stopped");},"t1").start();newThread(()->{stop=true;},"t2").start();}如果stop不加volatile,线程t1可能一直读不到t2写入的新值。JIT 编译器还可能把循环优化得更激进,让结果更难预测。
加了volatile后,写线程对stop的修改会对读线程可见。
但要注意,volatile不保证复合操作的原子性。
下面这样仍然不安全:
volatileintcount=0;publicvoidincrement(){count++;}count++还是读、改、写三步。volatile能保证每次读写的可见性,但不能把三步合成一个原子操作。
volatile 怎么禁止重排序
CPU 和编译器为了性能,可能会调整指令顺序。单线程下只要最终结果一致就行,但多线程下,其他线程可能观察到中间状态。
PPT 里用 jcstress 做了一个例子:
intx;inty;@Actorpublicvoidactor1(){x=1;y=1;}@Actorpublicvoidactor2(II_Resultr){r.r1=y;r.r2=x;}如果出现r1 = 1, r2 = 0,就说明线程 2 看到了y = 1,却没看到x = 1。这在直觉上很奇怪,因为代码里x = 1写在y = 1前面。
给关键变量加volatile,JVM 会在 volatile 读写附近插入内存屏障,限制重排序。
使用技巧可以简单记:
写变量时,让volatile变量尽量放在发布动作的最后。
读变量时,让volatile变量尽量放在读取动作的最前。
这不是死规矩,但有助于理解“用一个 volatile 变量作为状态发布点”的模式。
CAS 是什么
CAS,全称 Compare And Swap,比较并交换。
它体现的是乐观锁思想:先不加互斥锁,假设竞争不严重。更新时比较一下共享变量现在的值是不是自己当初看到的旧值,如果是,就更新;如果不是,说明被别人改过,那就重试。
CAS 有三个核心值:
| 名称 | 含义 |
|---|---|
| V | 当前内存值 |
| A | 旧的预期值 |
| B | 准备更新的新值 |
只有当V == A时,才把值改成B。
用伪代码表示就是:
while(true){intoldValue=value;intnewValue=oldValue+1;if(compareAndSwap(oldValue,newValue)){break;}}CAS 底层通常依赖 CPU 原子指令,Java 里很多并发工具都会用到,比如 Atomic 类、AQS 等。
CAS 和 synchronized 怎么选
PPT 里用了一个很口语化但很好记的对比:
synchronized是悲观锁,想的是“别人一定会来改,我先锁住”。
CAS 是乐观锁,想的是“别人不一定来改,就算改了我再重试”。
| 对比点 | synchronized / Lock | CAS |
|---|---|---|
| 思想 | 悲观锁 | 乐观锁 |
| 线程状态 | 竞争失败可能阻塞 | 竞争失败通常自旋重试 |
| 适合场景 | 临界区较大、竞争激烈、逻辑复杂 | 临界区很小、冲突不高 |
| 风险 | 阻塞和唤醒有成本 | 高竞争下自旋浪费 CPU |
所以不要把 CAS 神化。竞争很低时 CAS 很漂亮,竞争很高时大量线程一直自旋,也会把 CPU 打满。
面试怎么答
可以这么组织:
并发程序出问题的根本原因主要是原子性、可见性、有序性。
原子性指一组操作不能被中途打断,比如i++不是原子操作,可以用synchronized、Lock或 Atomic 类解决。可见性指一个线程对共享变量的修改,另一个线程能否及时看到,volatile、锁都能保证可见性。有序性指编译器和 CPU 可能重排指令,多线程下可能观察到异常结果,volatile可以通过内存屏障限制重排序。
JMM 定义了多线程读写共享变量的规则,线程有自己的工作内存,线程间通信必须经过主内存。
CAS 是比较并交换,是一种乐观锁思想。它比较当前内存值和旧预期值,如果一致就更新,否则自旋重试。Atomic 类和 AQS 都大量使用 CAS。CAS 适合冲突较少、操作很短的场景;竞争激烈时,自旋重试也会带来性能问题。
