2640 字
约 8 分钟
15
S05-X22 · AQS 与 CAS:锁的底层实现
2024-12-31
2026-11-08

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)本身不定义"锁"是什么,它只提供三件东西:

  1. 一个 volatile int state:语义完全由子类定义;
  2. 一个 CLH 变体的双向等待队列:拿不到就排队,且只允许队首尝试;
  3. 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. 自测

  1. 偏向锁从哪个 JDK 版本起默认关闭?关闭后短竞争走哪条路径?
  2. AQS 的 state 在 ReentrantLock 和 Semaphore 中分别表示什么?
  3. 共享模式释放后为什么要"链式唤醒",独占模式为什么不用?
  4. 非公平锁的吞吐优势具体来自哪个时间窗口?
  5. Condition.await() 前后线程在两条队列之间如何移动?
  6. 为什么 LongAdder.sum() 在并发下不能当作一致性快照使用?
S05-X22 · AQS 与 CAS:锁的底层实现
https://www.clxhxhhr.top/posts/5927/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。