并发是 Java 后端最硬的一块骨头,也是面试官最喜欢深挖的地方。很多人一上来就背 “volatile 保证可见性、synchronized 保证原子性”,却说不清背后为什么。 这一篇我想从两个最本质的底层问题讲起,把 volatile、synchronized 和 AQS 串成一条线。
两个绕不开的底层问题:可见性与原子性
要理解并发,先得接受一个反直觉的事实:你写的代码,不一定按你写的样子执行。 这背后是两个“罪魁祸首”:
- 可见性:每个线程有自己的工作内存(CPU 缓存的抽象)。线程 A 改了一个变量, 可能只是改了自己缓存里的副本,线程 B 根本看不到。
-
原子性:一句看起来不可分割的代码(比如
count++), 实际上是“读—改—写”三步,中途可能被打断。
还有个更隐蔽的有序性:编译器和 CPU 为了性能会重排指令,单线程下没影响, 多线程下可能让人崩溃。volatile 和 synchronized 都是围绕这三性问题展开的。
volatile:可见性保证,但不保证原子性
给一个变量加上 volatile,JMM 会保证:任何线程对它的写,立刻对其他线程可见;
任何线程对它的读,都拿到最新值,而不是缓存里的旧值。听起来很美,但有个经典陷阱:
// ⚠️ 即使加了 volatile,这段代码在多线程下依然是错的
private static volatile int count = 0;
void increment() {
count++; // 这是“读 + 加 1 + 写”,不是原子操作
}
为什么 volatile 救不了?因为 count++ 是三步:读到旧值、加一、写回。
volatile 只能保证单次读写的可见性,却保证不了“读—改—写”这个复合动作的原子性。
两个线程同时读到旧值,各自加一写回,最终就丢了一次自增。正确的做法是用
AtomicInteger(底层是 CAS)或者 synchronized:
private static final AtomicInteger count = new AtomicInteger(0);
void increment() {
count.incrementAndGet(); // CAS 保证原子自增
}
一句话记住:volatile 解决可见性,不解决原子性。 它适合那种“一个线程写、多个线程读”的状态标志位,而不是计数器。
volatile 防指令重排:双重检查单例的坑
volatile 还有一个常被忽略的作用:禁止指令重排序。最经典的例子是 双重检查锁(DCL)单例:
public class Singleton {
// 这里为什么必须 volatile?往下看
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 第一次检查,避免无谓加锁
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查,避免重复创建
instance = new Singleton(); // 问题就藏在这一行
}
}
}
return instance;
}
}
instance = new Singleton() 这行并不是原子的,它实际分三步:
- 分配一块内存;
- 在内存上初始化对象;
- 把
instance指向这块内存。
如果没有 volatile,编译器可能把顺序重排成 1 → 3 → 2:先指向内存,再初始化。
这时另一个线程在第一次检查时看到 instance != null,兴冲冲地拿去用——
可对象还没初始化完,直接 NPE。volatile 加了一道内存屏障,禁止这种重排,单例才真正安全。
volatile = 可见性 + 禁止重排,但不含原子性。三者别混。
synchronized:从字节码到锁升级
synchronized 是 JVM 级别的锁,基于监视器(Monitor)实现。
反编译后能看到,同步块前后是 monitorenter 和 monitorexit 两条字节码指令
(同步方法则是方法 flag 上的 ACC_SYNCHRONIZED)。
很多人以为 synchronized 就是“重量级锁,一上来就找操作系统”。其实从 JDK 6 开始, 它经过了一系列优化,会根据竞争激烈程度逐级升级:
- 无锁 / 偏向锁:假定大多数时候锁只有一个线程在用,对象头里记下这个线程, 下次它来不用任何同步操作。(注:偏向锁在 JDK 15 后已逐渐废弃,现代 JDK 默认关闭。)
- 轻量级锁:出现竞争但很轻微时,用 CAS + 自旋,避免真正挂起线程。
- 重量级锁:竞争激烈、自旋也抢不到时,升级为操作系统级别的互斥量, 抢不到的线程进入等待队列被挂起,开销最大。
这套“能轻就不重”的升级机制,让 synchronized 在低竞争场景下性能已经很好, 大多数时候你不需要为了性能特意避开它。
AQS:用一个 state 撑起半个 juc
ReentrantLock、Semaphore、CountDownLatch、
ReentrantReadWriteLock……这些 juc 里的同步工具,底层都建立在同一个骨架之上——
AQS(AbstractQueuedSynchronizer)。理解了 AQS,等于理解了半个 juc。
AQS 的核心就两样东西:
- 一个 volatile int state,不同的同步工具赋予它不同的含义。 在 ReentrantLock 里,0 表示没人加锁,>0 表示锁被持有(值就是重入次数); 在 Semaphore 里它表示剩余的许可数;在 CountDownLatch 里它表示还剩多少个计数。
- 一个 FIFO 双向等待队列(CLH 队列的变体),存放的是抢锁失败后被阻塞的线程。
以 ReentrantLock 的非公平获取为例,核心逻辑(简化)是这样:
// ReentrantLock 的非公平 tryAcquire,简化呈现
final boolean nonfairTryAcquire(int acquires) {
int c = getState();
if (c == 0) {
// 没人持锁,CAS 抢一下
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
} else if (getExclusiveOwnerThread() == Thread.currentThread()) {
// 已经是自己持有,重入:state + 1
setState(c + acquires);
return true;
}
return false; // 抢不到,AQS 会把这个线程包成节点丢进等待队列
}
抢到锁的线程继续跑;抢不到的,被包装成一个 Node 进队列、挂起(LockSupport.park)。
等持锁线程释放(state 归零),就唤醒队首线程(unpark)。
“state 表示状态,队列管排队”,就是 AQS 的全部精髓。
它还区分两种模式:独占式(Exclusive,同一时刻只有一个线程能获取资源,如
ReentrantLock)和共享式(Shared,允许多个线程同时获取,如 Semaphore、CountDownLatch)。
子类只要实现 tryAcquire/tryRelease(或共享版本),排队、阻塞、唤醒这些脏活累活
AQS 全包了。这就是它能撑起半个 juc 的原因。
synchronized 还是 ReentrantLock?怎么选
两者都是可重入锁,但取舍点不同:
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 实现层面 | JVM 关键字 | API 层,基于 AQS |
| 释放锁 | 自动(退出同步块即释放) | 必须在 finally 里手动 unlock() |
| 可中断 | 否 | 可以(lockInterruptibly()) |
| 公平性 | 仅非公平 | 可选公平 / 非公平 |
| 条件变量 | 1 个(wait/notify) | 多个 Condition |
| 能否超时 | 否 | 可以(tryLock(time)) |
我的经验是:默认用 synchronized(简单、不会忘记释放、JVM 优化后够快),
只有在需要“可中断、公平、多条件、超时尝试”这些高级能力时,才上 ReentrantLock。
别为了显得“高级”而滥用 API 级锁,它带来的忘记 unlock 的风险,是实打实的坑。
并发的本质,是用一套约定(内存屏障、锁、CAS)去对抗硬件层面的缓存与重排。 理解了可见性、原子性、有序性这三性,再看 volatile、synchronized、AQS, 就不再是零散的八股,而是一套自洽的设计。