synchronized 和 ReentrantLock 的区别,多数人能背出"一个自动、一个手动,一个非公平、一个可选公平"。但真正决定线上表现的,是锁在竞争激化时做了什么:是自旋、是排队、是陷入内核态,还是干脆放弃。这些行为分别由 monitor、AQS 队列和 CAS 自旋承担,理解它们才能解释"为什么加锁后 RT 变高""为什么 CPU 打满但 QPS 不涨"。
1. synchronized 的三层演进
| 状态 | 触发条件 | 实现 | 代价 |
|---|---|---|---|
| 偏向锁 | 只有一个线程反复进入 | Mark Word 记录线程 ID | 近乎零开销(JDK 15 起默认关闭) |
| 轻量级锁 | 有竞争但很短 | CAS 把 Mark Word 指向栈上 Lock Record | 自旋消耗 CPU |
| 重量级锁 | 自旋失败或竞争激烈 | 对象关联 ObjectMonitor,进入等待队列 |
用户态/内核态切换 |
-XX:-UseBiasedLocking 曾经是默认开启的优化,从 JDK 15 起被标记废弃并默认关闭(JEP 374)。原因是偏向锁的撤销(revocation)在大量并发容器场景下开销反而更高,维护成本不划算。按 JDK 17 核对:新版本上不必再讨论偏向锁,直接分析轻量级自旋与重量级等待即可。
重量级锁的核心是 ObjectMonitor 的几个字段:_owner(持有者)、_cxq(竞争队列)、_EntryList(候选者)、_WaitSet(调用 wait() 的线程)。wait() 把线程从 _EntryList 移到 _WaitSet,notify() 再移回去——这也是为什么 wait/notify 必须持有锁:没有锁就没有队列归属。
2. AQS 的三个核心
AQS(AbstractQueuedSynchronizer)本身不定义"锁"是什么,它只提供三件东西:
- 一个
volatile int state:语义完全由子类定义; - 一个 CLH 变体的双向等待队列:拿不到就排队,且只允许队首尝试;
acquire/release模板方法:把"尝试 → 失败 → 入队 → 阻塞 → 唤醒"的骨架固化,子类只实现tryAcquire/tryRelease。
// 骨架,见 JDK 17 AbstractQueuedSynchronizer#acquire
public final void acquire(int arg) {
if (!tryAcquire(arg) && // ① 子类决定能不能拿到
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) // ② 入队 + 自旋/阻塞
selfInterrupt(); // ③ 补上被吞掉的中断标志
}
| AQS 组件 | 作用 | 子类如何参与 |
|---|---|---|
state |
资源计数,volatile 保证可见 |
ReentrantLock 用它存重入次数;Semaphore 存许可数 |
exclusiveOwnerThread |
记录独占持有者 | 只有独占模式用 |
head/tail 队列 |
排队与唤醒顺序 | 由 AQS 独占管理,子类不碰 |
Node.waitStatus |
SIGNAL/CANCELLED/CONDITION |
子类不碰,但影响唤醒正确性 |
第三个要点常被忽略:AQS 的队列是"懒创建"的。没有竞争时 head/tail 都是 null,一次 CAS 就完成了加锁;只有竞争出现才 addWaiter 建队列。这是"无竞争快路径"的来源。
3. 独占与共享:同一个 AQS,两种实现
| 维度 | 独占(ReentrantLock) |
共享(Semaphore/CountDownLatch) |
|---|---|---|
| 实现方法 | tryAcquire/tryRelease |
tryAcquireShared/tryReleaseShared |
| 返回值语义 | boolean |
int:负数失败、0 成功但后续会失败、正数成功且可继续 |
| 队首唤醒 | 唤醒一个 | 链式传播,唤醒后继续唤醒后继 |
| 典型场景 | 互斥临界区 | 限流、信号、一次性门闩 |
共享模式的"传播"是关键差异:
// 共享模式释放后,会沿队列连续唤醒直到遇到失败(简化)
for (;;) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h); // 唤醒后继;后继再尝试,成功则继续传播
else break;
}
CountDownLatch 用 state 存计数,countDown 就是 releaseShared(1),await 是 acquireSharedInterruptibly(1) 直到 state == 0。它一次性,计数归零后不可重置;需要重置用 CyclicBarrier。
4. 公平与非公平:差别只在"要不要先看队列"
// 非公平:直接抢一次,插队成功就赢
final void lock() {
if (compareAndSetState(0, 1)) setExclusiveOwnerThread(current); // 插队点
else acquire(1);
}
// 公平:先检查有没有人在排队
protected final boolean tryAcquire(int acquires) {
if (getState() == 0 && !hasQueuedPredecessors() && // 关键差异
compareAndSetState(0, acquires)) { ... }
}
非公平的吞吐优势来自减少"唤醒等待"窗口:线程 A 释放锁时,队首线程 B 被 unpark 到真正运行有一段延迟,此时到达的线程 C 直接拿走锁,避免了 CPU 空转。代价是 B 可能长期饥饿。
| 维度 | 非公平 | 公平 |
|---|---|---|
| 加锁路径 | 先 CAS 抢,失败再排队 | 先 hasQueuedPredecessors() |
| 吞吐 | 更高 | 更低 |
| 饥饿风险 | 有 | 无 |
| 默认 | 是 | 需显式 new ReentrantLock(true) |
hasQueuedPredecessors() 不只看 head != tail,还要处理"队首就是自己"的情况,所以它比表面复杂——这也是公平锁更慢的原因之一。
5. Condition:两个队列之间的转移
ConditionObject 是 AQS 的内部类,它维护一条条件队列(单向 nextWaiter),与同步队列(双向)分离。
| 操作 | 线程位置变化 | 要求 |
|---|---|---|
await() |
同步队列 → 条件队列,释放全部锁 | 必须持有锁 |
signal() |
条件队列 → 同步队列(插到队尾) | 必须持有锁 |
signalAll() |
全部转移 | 同上 |
| 被唤醒后 | 重新竞争锁,成功才从 await 返回 |
— |
ReentrantLock lock = new ReentrantLock();
Condition notFull = lock.newCondition();
lock.lock();
try {
while (queue.size() == capacity) notFull.await(); // 必须 while,不能 if
} finally {
lock.unlock();
}
await() 内部会完全释放重入锁(保存 state 再恢复),这是它比 wait() 强的地方。while 而不是 if 的原因是:从条件队列转回同步队列后,线程仍可能被其他线程插队,醒来时条件已不成立。
6. CAS 的底层与三个经典问题
CAS 对应 CPU 的 cmpxchg 指令(x86),配合 lock 前缀形成完整内存屏障:写刷新到主存、读从主存重读,因此它同时具备原子性与可见性。
// JDK 9+ 推荐 VarHandle,Unsafe 的 compareAndSwapInt 不再是首选
private static final VarHandle VH;
static {
try { VH = MethodHandles.lookup().findVarHandle(Counter.class, "value", long.class); }
catch (ReflectiveOperationException e) { throw new ExceptionInInitializerError(e); }
}
private volatile long value;
boolean ok = VH.compareAndSet(this, 0L, 1L); // 内存序默认全屏障
| 问题 | 表现 | 解法 |
|---|---|---|
| ABA | 值改了又改回来,CAS 误判未变 | AtomicStampedReference 带版本号 |
| 自旋开销 | 高竞争下大量失败重试,CPU 空转 | 退避、LongAdder、直接加锁 |
| 单变量限制 | 无法原子更新多个字段 | 封装成对象 + AtomicReference |
Unsafe 正在被 VarHandle 替代:后者是标准 API,编译期可达、支持 getAcquire/setRelease 等细粒度内存序,不再需要 --add-opens 去撬 java.base 的私有字段。
7. AtomicInteger 与 LongAdder
| 维度 | AtomicInteger/Long |
LongAdder |
|---|---|---|
| 结构 | 单个 volatile 值 |
base + Cell[] 数组 |
| 低竞争 | 一次 CAS 成功,最快 | 多一层间接,略慢 |
| 高竞争 | 所有线程抢同一个变量,反复失败 | 分散到不同 Cell,减少冲突 |
sum() |
精确 | 非快照,并发下可能不准 |
| 适用 | 计数器、状态标志 | 统计量(QPS、请求数) |
LongAdder adder = new LongAdder();
adder.increment(); // 优先 CAS base,失败则落到当前线程的 Cell
long total = adder.sum(); // 遍历 base + cells 累加,期间可能仍在变化
sum() 不是快照:它逐个读 Cell,读完可能已经过期。做监控可以接受,做"必须精确的账"不行——那类场景应该直接查数据库或用 AtomicLong。
8. volatile 的两个语义,与它保证不了的事
volatile 提供两件事:可见性(写立即对其他线程可见)和禁止重排(屏障约束编译器和 CPU 的顺序)。它不提供原子性。
private volatile int count;
count++; // 三步:读 → 加 → 写。三步之间照样可以被打断
多线程下的丢失更新就来自这里:两个线程各自读到 5,各自写回 6,一次自增消失。
| 坑 | 错误认知 | 后果 | 正确做法 |
|---|---|---|---|
volatile 当锁 |
以为它能互斥 | 丢失更新 | AtomicInteger 或加锁 |
i++ 无保护 |
以为单行语句原子 | 计数偏小 | incrementAndGet() |
LongAdder.sum() |
以为返回一致快照 | 统计抖动 | 明确精度要求 |
| 锁粒度过粗 | 整方法 synchronized |
吞吐塌陷 | 缩小临界区 |
| 伪共享 | 两个热点计数挨着放 | 缓存行反复失效 | @Contended + -XX:-RestrictContended |
伪共享值得展开一句:Cell[] 里相邻元素若落在同一条缓存行(通常 64 字节),LongAdder 的分段优势会被抵消,所以它的 Cell 带 @Contended 填充。自己写高并发计数器时同理。
9. 边界:这些情况不该在锁上做文章
| 场景 | 首选 | 理由 |
|---|---|---|
| 短临界区、无竞争 | synchronized |
JVM 已优化,代码更少 |
| 需要可中断/超时/公平 | ReentrantLock |
synchronized 做不到 |
| 读多写少 | StampedLock / ReentrantReadWriteLock |
读写分离 |
| 纯计数统计 | LongAdder |
高竞争下冲突最少 |
| 生产者消费解耦 | 阻塞队列 / Disruptor | 比手写条件变量可靠 |
| 跨进程互斥 | 分布式锁 | 本地锁管不到别的 JVM |
一句判断:锁的正确性靠"谁持有、谁释放、何时可见"三问,性能靠"竞争强度"一个变量。 先把三问回答清楚,再谈优化——绝大多数锁问题不是慢,而是漏了解锁或可见性没保证。
10. 自测
- 偏向锁从哪个 JDK 版本起默认关闭?关闭后短竞争走哪条路径?
- AQS 的
state在ReentrantLock和Semaphore中分别表示什么? - 共享模式释放后为什么要"链式唤醒",独占模式为什么不用?
- 非公平锁的吞吐优势具体来自哪个时间窗口?
Condition.await()前后线程在两条队列之间如何移动?- 为什么
LongAdder.sum()在并发下不能当作一致性快照使用?