Skip to content

Condition 与 AQS 同步工具 ​

本页建立在 LockSupport 与 AQS 核心机制 之上,回答两个应用问题:多个等待条件如何精确唤醒,以及常见同步工具在 AQS 上如何分工与选型。

1. Condition:AQS 里的等待队列 ​

ReentrantLock.newCondition() 得到一个 Condition 对象。它替代 Object.wait/notify,能力上超过后者。

1.1 两条独立的队列 ​

一个 AQS 内部只有一条 CLH 同步队列(排队争锁的)。但可以挂多个条件队列——每个 Condition 一条:

txt
同步队列(一条,AQS 内建)
   head → Node → Node → Node ← tail
           争锁排队者

Condition_notEmpty 条件队列(挂在 lock 上)
   firstWaiter → Node → Node ← lastWaiter
                在 notEmpty 上 await 的线程

Condition_notFull 条件队列(挂在同一个 lock 上)
   firstWaiter → Node ← lastWaiter
                在 notFull 上 await 的线程

节点在两条队列之间迁移:

  • await():当前线程必须持有关联锁。它先进入对应的条件队列,再完全释放锁并等待;可能因信号、中断、超时或虚假唤醒而结束等待,收到信号后进入同步队列,重新获取锁成功后,await() 才返回
  • signal():当前线程必须持有关联锁。它将条件队列中最先等待的有效节点转移到同步队列;被转移的线程还要重新竞争锁,并不会立刻执行

关键:signal 不代表"立刻运行"。被 signal 的线程只是从"等条件"变成"等锁",还得排队争锁——和 wait/notify 是一样的。signal 表示等待条件发生变化后,可以让某个等待者重新竞争锁,不保证该线程立即运行,也不保证它一定能获得锁。

1.2 生产者-消费者用两条 Condition 精确唤醒 ​

java
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull  = lock.newCondition();
private final Condition notEmpty = lock.newCondition();

public void put(E e) throws InterruptedException {
    lock.lock();
    try {
        while (isFull()) notFull.await();     // 只挂在 notFull 上
        enqueue(e);
        notEmpty.signal();                    // 精确唤一个消费者
    } finally { lock.unlock(); }
}

public E take() throws InterruptedException {
    lock.lock();
    try {
        while (isEmpty()) notEmpty.await();   // 只挂在 notEmpty 上
        E e = dequeue();
        notFull.signal();                     // 精确唤一个生产者
    } finally { lock.unlock(); }
}

如果生产者和消费者都在同一个 Monitor 的等待集合中,notify() 可能唤醒条件不满足的线程,导致其他真正需要运行的线程继续等待;使用 notifyAll() 可以避免只唤醒错误类型线程的问题,但会让所有等待者醒来重新竞争锁。Condition 把两条队列拆开,每次 signal 只精准唤一个方向的线程,减少无关唤醒和锁竞争。无论使用哪种方案,醒来的线程都必须重新检查条件,因此示例中的 while 不能改成 if。

1.3 Condition vs wait/notify ​

维度wait/notifyCondition
队列数量一个(Monitor 的等待队列)多个(每次 newCondition 一条)
唤醒精度notifyAll 惊群,notify 任意选择一个等待者signal 只唤指定条件的线程
可中断等待wait() 支持await() 支持,另有 awaitUninterruptibly
超时等待wait(ms)await(t, unit) / awaitUntil(deadline)
前置要求必须在 synchronized 块内必须在 lock/unlock 内

await 的超时组合也比 wait 丰富——awaitNanos 返回剩余时间、awaitUntil 用绝对时间。前者适合"再等 500ms 就走",后者适合"11 点前必须返回"。

2. 基于 AQS 的工具矩阵 ​

java.util.concurrent.locks 与 java.util.concurrent 中的多个工具直接复用 AQS;StampedLock 和 CyclicBarrier 则使用自己的锁状态或 Lock/Condition 组合。对 AQS 工具来说,它们的差异主要落在 tryAcquire*、tryRelease*、tryAcquireShared* 和 tryReleaseShared* 的实现方式。

2.1 一张矩阵 ​

工具模式state 语义典型用途
ReentrantLock独占0 空闲;正数表示持有线程的获取次数(含首次获取)通用互斥
ReentrantReadWriteLock独占 + 共享高 16 位 = 读锁持有数;低 16 位 = 写锁重入读多写少
StampedLock独占 + 共享 + 乐观读版本戳(含锁状态)读极多、读操作极短
Semaphore共享剩余许可数限流、资源池
CountDownLatch共享(一次性)未完成计数等 N 件事都完成
CyclicBarrier用 ReentrantLock + Condition 组合,而非直接 AQS——一批线程互相等到齐再一起走

CyclicBarrier 和 StampedLock 不直接继承 AQS:前者用 ReentrantLock 与 Condition 组合,后者用内部版本戳和同步状态。列在这里是为了按协调语义一并选型,而不是把所有工具都归入同一套 AQS 源码。

2.2 ReentrantReadWriteLock:一个 int 同时管理读写 ​

用 32 位 state 的高低位分别记两种锁:

txt
state (32 bit)
┌───────────────────────┬───────────────────────┐
│    高 16 位             │    低 16 位            │
│    读锁持有数           │    写锁重入次数        │
│    (共享模式)          │    (独占模式)         │
└───────────────────────┴───────────────────────┘

state != 0 且低 16 位为 0 时,说明“有读锁在,无写锁”——读请求可以尝试 CAS 增加读计数。是否立即进入还受公平策略、队列中其他请求和并发竞争影响。位分割设计让读写状态共享一个 int,但不意味着所有读线程都无条件绕过排队。

代价是理论上限——读锁总持有计数和写锁重入计数的理论上限均为 65535。读锁计数统计的是当前读锁持有次数,不等同于不同读线程的数量;同一线程重入获取读锁也会占用计数。超过计数上限时,获取相应锁会失败并抛出 Error。是否接近上限取决于业务规模,排查计数异常时值得记住。

2.3 StampedLock:用版本戳验证乐观读 ​

ReentrantReadWriteLock 在非公平模式下有一个风险:写线程可能长时间等待。读远多于写时,持续进入的读请求可能让写请求迟迟得不到机会;非公平模式不提供严格的等待时间保证。

StampedLock 用三档模式解决:

java
StampedLock lock = new StampedLock();

// 1. 悲观写锁(独占)
long writeStamp = lock.writeLock();
try { /* 写 */ } finally { lock.unlockWrite(writeStamp); }

// 2. 悲观读锁(共享)
long readStamp = lock.readLock();
try { /* 读 */ } finally { lock.unlockRead(readStamp); }

// 3. 乐观读(不获取读锁,用版本戳验证)
long optimisticStamp = lock.tryOptimisticRead();
int x = point.x, y = point.y;
if (!lock.validate(optimisticStamp)) {   // 期间被写过?
    long rereadStamp = lock.readLock();  // 降级到悲观读
    try { x = point.x; y = point.y; }
    finally { lock.unlockRead(rereadStamp); }
}

乐观读期间读到的数据可能暂时不一致,应先把字段读到局部变量,再用 validate() 验证,验证通过后才使用;验证失败则丢弃本次结果,改用悲观读锁重新读取。读多、读操作短的场景下,它通常比悲观读锁更轻;实际成本仍取决于架构、内存序和实现细节。

限制:StampedLock 不可重入,也不支持 Condition。它是一个"高性能读写锁",不是 ReentrantLock 的替代品。

2.4 Lock vs synchronized:三维对比 ​

Lock 提供的新能力,付出的代价,与 synchronized 的对照:

维度synchronizedLock
可中断获取❌✅ lockInterruptibly()
超时获取❌✅ tryLock(t, unit)
非阻塞尝试❌✅ tryLock()
公平 / 非公平仅非公平构造时可选
条件队列数量一条多条(newCondition)
非块结构(跨方法加解锁)❌✅
出错自动释放编译器保证必须 try/finally 手写
JIT 优化常见有锁消除、锁粗化优化路径不同,需按具体调用点与 JVM 分析

选型规则很好记:

  • 没有 Lock 才有的能力诉求 → 优先考虑 synchronized,语法简单,异常路径也能自动释放监视器;具体性能由 JVM 和实际代码形态决定
  • 需要超时 / 中断 / 公平 / 多条件队列 / 跨方法持锁 → 用 Lock
  • 写少读多且读操作短 → StampedLock
  • 写少读多但需要重入或 Condition → ReentrantReadWriteLock
java
// ❌ Lock 忘了 try/finally,异常路径漏解锁
lock.lock();
doSomething();       // 抛异常,锁永远不释放
lock.unlock();

// ✅ 强制 try/finally
lock.lock();
try {
    doSomething();
} finally {
    lock.unlock();
}

这是 Lock 相比 synchronized 最容易踩的坑。synchronized 的 monitorexit 有异常处理路径保底,Lock 没有——unlock 必须写在 finally 里。

3. 常见协调工具的语义与边界 ​

Semaphore、CountDownLatch 和 CyclicBarrier 都解决“多个线程按某种计数协作”的问题,但计数代表的含义、能否复用和失败后的状态不同。

3.1 CountDownLatch:一次性的完成计数 ​

CountDownLatch 创建时固定计数。其他线程调用 await() 等待计数归零,负责方完成一项工作后调用 countDown():

java
CountDownLatch completed = new CountDownLatch(tasks.size());
for (Runnable task : tasks) {
    executor.execute(() -> {
        try {
            runTask(task);
        } finally {
            completed.countDown();
        }
    });
}

if (!completed.await(3, TimeUnit.SECONDS)) {
    throw new IllegalStateException("tasks did not complete in time");
}

计数不能重置。需要重复使用时创建新的 Latch,或者选择支持重置的协调结构。await() 可以被中断,也支持超时;调用方必须处理 InterruptedException。Latch 只表达“完成了多少”,不会收集工作线程抛出的异常;任务失败还要通过 Future、异常队列或统一错误处理传回。它适合“等 N 件一次性事件完成”,不适合“每轮都让 N 个线程重新集合”。

3.2 Semaphore:限制许可数量 ​

Semaphore 的状态表示可用许可数。线程先 acquire() 或 tryAcquire() 取得许可,完成资源使用后在 finally 中 release():

java
Semaphore permits = new Semaphore(maxConcurrentRequests);

if (permits.tryAcquire()) {
    try {
        callLimitedResource();
    } finally {
        permits.release();
    }
} else {
    throw new RejectedExecutionException("concurrency limit reached");
}

必须保证“成功取得的许可最终只释放一次”。没有先取得许可却调用 release(),会凭空增加许可,使限流失效。tryAcquire() 可以立即返回失败,tryAcquire(timeout) 可以给等待设置预算。Semaphore 默认不保证公平排队;在强公平需求下要显式构造公平模式,并接受吞吐与排队行为的变化。

3.3 CyclicBarrier:一批线程互相等待 ​

CyclicBarrier(parties) 要求指定数量的线程都调用 await() 后才一起继续。可以在创建时提供 barrier action,让所有线程释放前先执行一次公共步骤:

java
CyclicBarrier barrier = new CyclicBarrier(
        participantCount,
        () -> publishRoundResult()
);

// 每个参与线程在每一轮结束时调用
barrier.await(5, TimeUnit.SECONDS);

调用点需要声明或捕获 InterruptedException、TimeoutException 和 BrokenBarrierException。一轮全部到达后,Barrier 可以复用。一轮中如果等待线程被中断或等待超时,Barrier 会进入 broken 状态;如果配置的 barrier action 抛出异常,也会使 Barrier 进入 broken 状态。其他等待线程可能收到 BrokenBarrierException,后续 await() 也不会自动恢复。参与线程在调用 await() 之前发生业务异常,不会因此自动被 Barrier 感知。需要重新协作时,可以明确调用 reset(),或创建新的 Barrier。它由 ReentrantLock 和 Condition 组合实现,不直接继承 AQS。

3.4 按协作语义选型 ​

你想表达的关系首选工具不适合的场景
一次性等待 N 个事件完成CountDownLatch需要多轮复用
最多允许 N 个线程进入资源Semaphore只想等待固定事件数量
N 个线程每轮到齐后一起继续CyclicBarrier单向通知或单次启动门闩
精确唤醒生产者或消费者两个 Condition线程只等待一个一次性计数
多阶段、动态参与方需要评估 Phaser 等结构把上述工具强行拼接

这些工具都只协调线程,不解决共享数据的原子更新,也不决定任务在哪个线程池执行。选型后还要检查并发集合、线程池和并发性能优化是否形成完整方案。

4. 本页小结 ​

问题根源解决方案
需要超时/中断/公平的锁synchronized 把选择权关在 JVM 内部LockSupport 把挂起/唤醒暴露给 Java 层
唤醒可能早于挂起造成丢失notify 无许可证语义LockSupport.unpark 的许可证保留
每种同步器都要写一套等待队列队列与业务耦合AQS 抽出"state + CLH + park/unpark"骨架
独占与共享的唤醒规则不同一个持有者 vs 多个持有者JDK 21:SharedNode + signalNextIfShared
多种等待条件混在一个 Monitor 里_WaitSet 只有一个Condition 每个一条独立队列
读多写少下写线程等待非公平读锁可能被持续读请求延迟StampedLock 的乐观读
Lock 忘解锁导致永久阻塞无编译器保底unlock 强制放 finally

Released under the MIT License.