跳到正文

并发编程:AQS 与锁 ​

一句话:AQS 用一个 volatile int state 表示同步状态、一个 FIFO 等待队列保存抢不到锁的线程,把「怎么抢、怎么等、怎么醒」拆成模板;ReentrantLock 和条件队列只是它的一组具体实现。

适用 JDK 17 / JDK 21。本文只讲 AQS、ReentrantLock 和 Condition;synchronized 的监视器实现、JMM 的可见性规则分别见 08-JMM内存模型 和 JVM 相关文档。

为什么需要 ​

synchronized 能解决互斥,但有几个工程上的硬限制:等锁不能超时、不能响应中断、一个锁只有一个等待队列、不能尝试获取。业务里常见的「等 200ms 拿不到就降级」「可被取消的等待」「生产者/消费者分开唤醒」都做不到。

ReentrantLock 把这些能力显式化:tryLock(timeout)、lockInterruptibly()、多个 Condition。代价是必须手动 unlock,忘记释放就是永久死锁。

核心机制 ​

AQS 的三个部件 ​

部件作用
statevolatile int,0 表示未占用;ReentrantLock 里表示重入次数
head / tail等待队列的头尾,节点是 Node,按 FIFO 排队
Node.waitStatusSIGNAL 表示后继需要被唤醒,CANCELLED 表示已取消,CONDITION 表示在条件队列

AQS 本身不定义「什么算获取成功」,只提供模板方法:tryAcquire、tryRelease、tryAcquireShared、tryReleaseShared 由子类实现。独占模式对应 ReentrantLock,共享模式对应 Semaphore、CountDownLatch。

获取与释放 ​

独占获取的路径:

text
acquire(1)
  → tryAcquire(1) 成功则返回
  → addWaiter(EXCLUSIVE) 入队
  → acquireQueued(node, 1)
       → 前驱是 head 且 tryAcquire 成功 → 成为新 head
       → 否则 shouldParkAfterFailedAcquire 把前驱标成 SIGNAL
       → LockSupport.park(this) 休眠
  → 被唤醒后检查中断,必要时 selfInterrupt

释放时 tryRelease 把 state 减到 0,unparkSuccessor 唤醒队列里第一个未取消的后继。AQS 的队列是 CLH 的变体:原版 CLH 靠自旋检查前驱,AQS 增加了显式 prev/next 指针,支持取消节点和直接唤醒后继。

公平与非公平 ​

非公平锁的 tryAcquire 先直接 CAS state,不管队列里有没有人;失败才入队。公平锁在 CAS 前检查 hasQueuedPredecessors(),有人排队就让出。

非公平的吞吐更高,因为刚释放锁的线程很可能立刻再次拿到锁,减少了唤醒和上下文切换;代价是队列里的线程可能长期饥饿。ReentrantLock 默认非公平,构造参数 true 才公平。

重入与条件队列 ​

ReentrantLock 的 state 就是持有次数:同一线程再次 lock() 时 state+1,unlock() 时 -1,减到 0 才真正释放。这也意味着 lock() 和 unlock() 必须成对,且要在 finally 里释放。

每个 Condition 有一条独立的条件队列。await() 做三件事:把当前线程加入条件队列、完全释放锁(保存重入次数)、挂起;被 signal() 转移到 AQS 同步队列后,还要重新竞争锁才能从 await() 返回。signal() 本身不释放锁,所以调用方必须在 lock() 内调用,并在 finally 中解锁。

await() 可能被虚假唤醒,判断条件必须放在 while 里,不能写 if:

java
lock.lock();
try {
    while (!conditionMet) {
        condition.await();
    }
    // 条件满足后的业务
} finally {
    lock.unlock();
}

实战场景 ​

一、异常路径没走 unlock,锁被永久持有 ​

现象:一个库存扣减方法用 ReentrantLock,lock() 之后的业务代码抛异常,unlock() 写在方法最后一行没进 finally。第一次异常之后,该锁再也拿不到:后续线程全部 park 在 AQS 队列里,jstack 显示上百个线程 WAITING (parking),堆内存随着队列增长,服务表现为“越跑越慢直到无响应”,但 CPU 很低。

排查:独占获取失败会入队休眠,只有持锁线程把 state 减到 0 才会唤醒后继。

定位层取证据判定
谁持锁jstack 里 Locked <java.util.concurrent.locks.ReentrantLock$NonfairSync> 所在的线程直接定位持锁线程
持锁线程在干什么该线程栈是否已退出业务(在等别的锁/IO)说明 unlock 没被执行
state 值反射或 -XX:+PrintConcurrentLocks(jcmd Thread.print -l)非 0 且无持有活动即泄漏
队列规模AQS getLengthOfQueue(jcmd 输出里有 queue length)持续增长即堆积
代码形态unlock() 是否在 finally;重入时是否配平漏配平是同一类问题

重入锁的 state 表示重入次数:加两次锁必须解两次,finally 里只解一次同样会留下 1 的计数。

对症做法:

java
// 1. 唯一正确写法:lock 紧跟 try,unlock 在 finally
lock.lock();
try {
    return doDeduct(sku, qty);
} finally {
    lock.unlock();
}

// 2. 加超时的获取方式,让“拿不到锁”变成可见失败而不是无限排队
if (!lock.tryLock(200, TimeUnit.MILLISECONDS)) {
    metrics.lockTimeout("stock-deduct");
    throw new BusyException("stock lock timeout, sku=" + sku);   // 快速失败交给上游重试
}
try { doDeduct(sku, qty); } finally { lock.unlock(); }
text
3. 重入配平检查:封装成模板方法,不让调用方自己写 lock/unlock:
   <T> T withLock(Lock lock, Supplier<T> body)   // 内部统一 try/finally
4. 优先用更高层结构:
   能用 ConcurrentHashMap.compute / 原子更新表达的互斥,不要自己上锁;
   能靠数据库条件更新(WHERE qty >= ?)保证的正确性,不要用 JVM 锁假装跨实例互斥。

复盘要点:判据是“代码里不出现 lock() 与 unlock() 相隔多行的写法”——静态扫描 lock( 之后同一方法内没有紧跟 try 即报警。另外要给“锁等待时长”与“tryLock 超时次数”加指标:等待时长 P99 突增是死锁/泄漏的前兆,比线程堆积告警更早。

二、把锁改成公平锁,吞吐掉了八成 ​

现象:为“避免线程饥饿”,某高并发计数器服务的 ReentrantLock 构造参数从默认改成 true。上线后 QPS 从 12 万掉到 2.4 万,CPU 反而更高,GC 没变化。

排查:公平性来自“获取前先检查队列是否有前驱”,代价是每次都要排队与唤醒。

对比项非公平(默认)公平
获取方式可以直接抢:state 为 0 就 CAS 成功先看 AQS 队列有没有前驱,有就入队休眠
上下文切换少:刚醒的线程可能让后来者插队多:每个请求都要 park/unpark
吞吐高显著低(本例约 1/5)
饥饿理论上可能,实践中被随机调度缓解无饥饿

AQS 的获取路径里,非公平模式的“插队”正是吞吐来源:持锁线程释放时不保证唤醒最早等待者,正在运行的线程可能直接 CAS 到锁,省掉一次线程切换。

对症做法:

java
// 1. 默认非公平;确有饥饿证据时再用公平锁,并用压测确认代价
ReentrantLock lock = new ReentrantLock();                 // 保持默认

// 2. 真需要“按序处理”的场景不要在锁上做,改用队列串行化:
//    单线程 executor / 队列 + 消费者,语义清晰且可观测排队长度
ExecutorService serial = new ThreadPoolExecutor(1, 1, 0L, MILLISECONDS,
        new ArrayBlockingQueue<>(2000), new NamedThreadFactory("seq"), new AbortPolicy());

// 3. 竞争热点本身要拆:按 key 分段(Striped lock / CHM 的 bin 级锁)
Lock[] stripes = new Lock[64];
Lock lockFor(long id) { return stripes[(int) (Math.abs(id) & 63)]; }

复盘要点:判据是“上下文切换次数(vmstat 的 cs)与锁等待时长同步变化”,公平化带来的吞吐下降会先体现在切换上。更根本的解决顺序是:减少共享(分段/无锁结构)→ 缩短临界区 → 再谈锁语义。公平锁的正确使用场景是“等待时长必须可预期且业务量不大”,不是“看起来更公平”。

三、await() 没用 while 包住,条件队列被虚假唤醒 ​

现象:一个有界阻塞队列自研实现用 notFull.await(),压测时偶发“放入的元素被覆盖”和 NullPointerException;另一次是消费者拿到空列表继续处理。复现概率随机器负载上升。

排查:条件队列与等待/通知语义的三条硬规则。

陷阱现象机制
if 判断 + await虚假唤醒后条件已再次被别线程占用唤醒只代表“可以去争”,不代表条件仍成立
signal 而非 signalAll 用错需要多唤醒的场景只唤醒一个特定事件需要多个等待者响应时会漏
signal 在锁外调用状态与通知交错修改条件与通知应同处 lock 区内
await 无超时上游异常时永久挂起没有退出条件,故障被放大成线程堆积
中断被吞InterruptedException 只打日志线程继续跑,取消语义失效

对症做法:

java
// 1. 循环判断条件,await 带超时;被中断时恢复中断位并退出
lock.lock();
try {
    long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
    while (size == capacity) {
        long remain = deadline - System.nanoTime();
        if (remain <= 0) throw new TimeoutException("queue full");
        if (!notFull.await(remain, NANOSECONDS)) throw new TimeoutException("queue full");
    }
    insert(elem);
    notEmpty.signal();                 // 与状态修改同在锁内
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();          // 不要吞中断
    throw new AbortedException(e);
} finally {
    lock.unlock();
}
text
2. 优先用现成实现:ArrayBlockingQueue / LinkedBlockingQueue(有界)
   已经正确处理条件队列,自研只在需要特殊调度策略时才做。
3. CountDownLatch/Semaphore 也要设超时:
   await(timeout) / tryAcquire(timeout),避免上游异常导致永久等待。
4. 共享模式的等待线程数要有上限(Semaphore 的许可数),
   否则“无阻塞”会变成“无界排队”。

复盘要点:判据是“代码里不出现无超时的 await()”与“条件判断全部在 while 内”。自研同步器要跑三类测试:虚假唤醒(多消费者竞争)、超时退出、中断响应;这三条覆盖不到,就不要用在生产链路上——AQS 的细节(waitStatus 的 SIGNAL/CANCELLED/CONDITION、shouldParkAfterFailedAcquire 的标记过程)不是靠读一遍源码就能写对的。

最小上手 ​

用两个线程验证「await 释放锁、signal 不释放锁」:

java
import java.util.concurrent.locks.*;

public class LockDemo {
    static final ReentrantLock lock = new ReentrantLock();
    static final Condition ready = lock.newCondition();
    static boolean flag;

    public static void main(String[] args) throws Exception {
        Thread waiter = new Thread(() -> {
            lock.lock();
            try {
                while (!flag) ready.await();   // await 会释放 lock
                System.out.println("awake, holdCount=" + lock.getHoldCount());
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                lock.unlock();
            }
        });
        waiter.start();
        Thread.sleep(100);
        lock.lock();
        try {
            flag = true;
            ready.signal();                    // signal 不释放 lock
        } finally {
            lock.unlock();
        }
        waiter.join();
    }
}
bash
javac LockDemo.java && java LockDemo
# 输出 awake, holdCount=1

如果去掉 finally 里的 unlock(),主线程会一直持有锁,waiter 永远醒不来。这就是 ReentrantLock 最常见的生产事故。

生产动作 ​

  • 释放写在 finally 第一行:lock.lock(); try { ... } finally { lock.unlock(); }。不要在 lock() 和 try 之间放可能抛异常的代码。
  • 等锁要能退出:外部调用用 tryLock(200, TimeUnit.MILLISECONDS) 或 lockInterruptibly(),避免线程池被一个慢依赖拖死。
  • 锁对象要稳定:不要锁 String.intern()、不要锁可变字段、不要在锁对象上做赋值;锁对象变了,互斥就失效。
  • 排障:jcmd <pid> Thread.print -l 会打印 Locked ownable synchronizers,能看到 ReentrantLock 的持有者;jstack 里 WAITING (parking) 且栈顶是 LockSupport.park,说明在 AQS 队列里等锁。
  • 虚拟线程:JDK 21 中 synchronized 阻塞可能 pin 住载体线程,ReentrantLock 通常不会;虚拟线程场景优先用 ReentrantLock,升级到 JDK 24+ 后 synchronized 的 pinning 问题由 JEP 491 处理。
  • 不要用 AQS 自己造业务锁:先看 ReentrantLock、Semaphore、CountDownLatch 是否够用;自定义同步器要处理取消、中断和超时,成本远高于表面。

边界 ​

  • AQS 不是分布式锁:它只在单个 JVM 内有效,多实例互斥要用 Redis、ZooKeeper 或数据库锁。
  • 公平不等于正确:公平锁只保证先来先得,不保证业务顺序;吞吐通常更低,默认不要开。
  • 条件队列不是消息队列:signal() 只唤醒一个等待者,没有持久化、没有重试、没有跨进程能力。
  • 锁不能替代不可变设计:能通过局部变量、不可变对象、无共享状态解决的问题,不要引入锁。
  • synchronized 仍然有价值:JVM 内置监视器不需要手动释放,JDK 21 之前在某些场景更轻;不要因为 AQS 更灵活就全面替换。

讲清楚 ​

AQS 把同步器的公共部分抽出来:state 表示资源,FIFO 队列保存等不到资源的线程,LockSupport.park/unpark 负责挂起和唤醒。子类只实现 tryAcquire 和 tryRelease,ReentrantLock 用 state 记重入次数,非公平锁先抢再排队,公平锁先看队列。

Condition 是另一条条件队列,await 会释放锁并挂起,signal 只是把节点挪回同步队列,真正唤醒后还要重新抢锁。生产上最重要的不是背源码,而是 unlock 必须放 finally、等锁要带超时或可中断、锁对象不能变。AQS 只解决单 JVM 的互斥,跨实例要用分布式锁。

把一门技术讲透到既懂也会用