《Java 高级编程》4.3 锁与条件队列:synchronized、ReentrantLock 与 AQS

libraryd 需要一条借阅等待队列:没有可借副本时让请求有序等待,有人归还时唤醒其中一个。本节用超时、中断、条件队列三组实验对比 synchronized 与 ReentrantLock,手写一个基于 AQS 的 BooleanLatch,并给出 LoanWaitQueue 的完整实现与边界。

本节目标:在「必须等待某个条件成立」的场景里,写出可超时、可中断、不会丢唤醒的等待队列。
适用版本:JDK 25(Temurin 25.0.5);只使用 JDK 标准库。

承接的问题

4.1 解决了单个变量的可见性,4.2 解决了单个变量的原子性。但 libraryd 的借阅流程里有一个更复杂的判断:这本书当前有没有可借副本,没有的话这个会员要不要排队。这是一个「检查—等待—再检查」的复合动作,volatile 和 AtomicLong 都给不了。

卷二的实现是「查数据库,没有就返回 BOOK_ALREADY_BORROWED」。这对图书馆场景不够:会员点了借阅,书正在归还台上,两分钟后就能借,直接拒绝体验很差。产品希望加一条内存等待队列:请求最多等 3 秒,期间如果有人归还,就唤醒最久的等待者。

同时还有一个运维诉求:等待中的请求必须能在停机时被叫醒,而不是傻等到超时。这就要求锁支持超时和中断——synchronized 两样都不给。

synchronized 的边界

synchronized 简单、由 JVM 管理、不会忘记释放,这些都是优点。但它有两个硬边界:拿不到锁时会一直等,且不响应中断。

用一个实验把差异摆出来。monitorHolder 持有 MONITOR 500ms,lockHolder 持有 LOCK 500ms;主线程先 tryLock(200ms),再启动一个 lockInterruptibly 的线程并在 100ms 后中断它,最后让一个线程去抢 MONITOR 并记录拿到锁用了多久:

Thread blocked = new Thread(() -> {
    long start = System.nanoTime();
    synchronized (MONITOR) {
        System.out.printf("synchronized acquired after %dms (no timeout possible)%n",
                (System.nanoTime() - start) / 1_000_000);
    }
});
blocked.start();

long start = System.nanoTime();
boolean got = LOCK.tryLock(200, TimeUnit.MILLISECONDS);
System.out.printf("tryLock(200ms) got=%s after=%dms%n",
        got, (System.nanoTime() - start) / 1_000_000);

本机输出(三次输出顺序随调度变化,数值为同一次运行):

tryLock(200ms) got=false after=201ms
lockInterruptibly interrupted after waiting
synchronized acquired after 452ms (no timeout possible)

两点结论:

  • tryLock(200ms) 在 200ms 后放弃,返回 false;lockInterruptibly 在等待中被中断,抛出 InterruptedException。前者让调用方可以返回 429 或降级,后者让停机时能叫醒等待者。
  • synchronized 只能等。它最终在 452ms 拿到锁(holder 还剩约 450ms),期间没有任何办法让它提前退出。

synchronized 在 JDK 25 里仍然值得用:它没有额外对象、JIT 可以做锁消除和偏向优化、代码短。当你不需要超时、中断、多个条件队列时,它就是更好的选择。反过来,需要这些能力时才上 ReentrantLock。

ReentrantLock 与 AQS

ReentrantLock 的内部是一个 Sync,继承 AbstractQueuedSynchronizer(AQS)。AQS 用一个 volatile int state 表示同步状态,加一个双向链表(CLH 变体)存放等待线程:

  • state == 0 表示没人持有;lock() 用 CAS 把 0 改成 1。
  • 重入时 state 递增,unlock() 递减到 0 才真正释放。
  • 抢不到锁的线程被包装成节点挂到队列尾部,然后 park。
  • 释放时唤醒队首,队首重新竞争。

公平与非公平的差别就在「新来的线程要不要排队」:非公平锁允许刚到的线程直接 CAS 抢,抢不到再排队;公平锁检查队列里有没有前驱,有就直接排队。非公平吞吐更高(减少唤醒开销),公平锁更可预测。

ReentrantLock 暴露的几个方法值得记住:

方法行为典型用途
lock()阻塞直到拿到锁临界区短、确定不会死锁
tryLock()立即返回,不等待避免死锁、可跳过的工作
tryLock(time, unit)最多等指定时间有 SLA 的请求
lockInterruptibly()等待时可被中断停机、取消
newCondition()创建条件队列生产者消费者、等待队列

state 只是一个 int,AQS 不关心它代表什么。子类通过 tryAcquire/tryRelease(独占)或 tryAcquireShared/tryReleaseShared(共享)定义语义。ReentrantLock 的语义是「重入计数」,Semaphore 是「许可数」,CountDownLatch 是「还剩几个」,BooleanLatch 是「0 或 1」。

Condition:等待某个条件成立

有了锁只能保证互斥,还不能表达「等可借副本出现」。Condition 提供三个动作:

  • await():释放锁,把当前线程挂到条件队列,被唤醒后重新拿锁再返回。
  • signal():把条件队列里等待最久的线程转移到锁的等待队列,等当前线程释放锁后它才有机会运行。
  • signalAll():转移全部。

关键点是 await() 会释放锁。这看起来理所当然,但它是整个机制能工作的前提:如果等待者不释放锁,归还线程永远拿不到锁,也就永远没人能改变条件。

用一个三等待者的实验验证。队列初始 0 个副本,三个会员分别等 1000ms、400ms、400ms;100ms 后有一个副本归还,且只 signal() 一个:

boolean borrow(Duration timeout, String member) throws InterruptedException {
    long nanos = timeout.toNanos();
    lock.lock();
    try {
        while (copies == 0) {
            if (nanos <= 0) {
                return false;
            }
            nanos = copyReturned.awaitNanos(nanos);
        }
        copies--;
        return true;
    } finally {
        lock.unlock();
    }
}

本机输出:

member-0 awaits, lock held by=waiter-0
member-1 awaits, lock held by=waiter-1
member-2 awaits, lock held by=waiter-2
return 978000000001 acquired lock in 0ms, copies=1, waiters=3
member-0 got a copy, left=0
member-2 timed out, copies=0
member-1 timed out, copies=0

每一行都对应一个设计点:

  • return ... acquired lock in 0ms:三个线程都在 await 里,锁是释放的,归还线程瞬间拿到锁。如果 await 不释放锁,这里会一直等到第一个等待者超时。
  • waiters=3 且 member-0 got a copy:signal() 在归还时调用,此时 3 个线程都还挂在条件队列上(被转移的那个要等锁释放后才摘除);只唤醒了一个,被选中的是等待最久的 member-0。这里用了公平锁,条件队列按 FIFO 转移;非公平锁下不保证。
  • 另外两个 timed out:没有被 signal 的线程只能等自己的 awaitNanos 到期。

三个必须遵守的写法:

  1. 条件必须用 while 重查,不能用 if。 await 返回不代表条件成立:可能被 signalAll 误唤醒,可能 awaitNanos 超时返回,也可能在重新拿锁的间隙被别人抢先消费掉副本。
  2. awaitNanos 的返回值是剩余纳秒,要接住。 每次被唤醒后重新计算剩余时间,否则循环一次就把超时重置了,等待时间会无限延长。
  3. signal/signalAll 必须在持有锁时调用,否则抛 IllegalMonitorStateException。这不是形式要求:只有持锁时调用,才能保证「条件变更」和「唤醒」之间不被插入。

AQS 的最小实现:BooleanLatch

CountDownLatch 是一次性的,不能重置;有时只需要一个「准备好了」的开关。直接用 AQS 写一个 BooleanLatch,能看清共享模式下的四个钩子:

public final class IndexWarmupLatch {

    private final Sync sync = new Sync();

    private static final class Sync extends AbstractQueuedSynchronizer {

        boolean isDone() {
            return getState() != 0;
        }

        @Override
        protected int tryAcquireShared(int ignored) {
            return isDone() ? 1 : -1;
        }

        @Override
        protected boolean tryReleaseShared(int ignored) {
            setState(1);
            return true;
        }
    }

    public void await() throws InterruptedException {
        sync.acquireSharedInterruptibly(1);
    }

    public boolean await(Duration timeout) throws InterruptedException {
        return sync.tryAcquireSharedNanos(1, timeout.toNanos());
    }

    public void complete() {
        sync.releaseShared(1);
    }

    public boolean isDone() {
        return sync.isDone();
    }
}

tryAcquireShared 返回负数表示失败,0 表示成功但后续的共享获取也会成功,正数表示成功且还有余量。这里返回 1:一旦完成,所有等待者都能通过。等待者由 AQS 自动排队、park、被唤醒后重试,不需要自己写循环。

本机运行:

index warmed, signal()
request proceeded after 302ms, state=true
timeout await=false after=204ms

第一个请求在预热完成后 302ms 通过(预热本身睡了 300ms),第二个 latch 从未完成,await(200ms) 在 204ms 返回 false。这就是 CountDownLatch 的底层形态,理解了它,Semaphore(state 是许可数)和 ReentrantReadWriteLock(state 高低位分别记读锁写锁)都是同一套骨架。

在 libraryd 里落地

第一处是借阅等待队列。它是「按 ISBN 一条」的:每本书一个队列,互不干扰。

package com.plume.library.persistence;

import java.time.Duration;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

public final class LoanWaitQueue {

    private final String isbn;
    private final ReentrantLock lock = new ReentrantLock(true);
    private final Condition copyReturned = lock.newCondition();
    private int copies;
    private int waiting;

    public LoanWaitQueue(String isbn, int copies) {
        if (copies < 0) {
            throw new IllegalArgumentException("copies 不能为负: " + copies);
        }
        this.isbn = isbn;
        this.copies = copies;
    }

    public boolean borrow(Duration timeout, String memberId) throws InterruptedException {
        long nanos = timeout.toNanos();
        lock.lock();
        try {
            while (copies == 0) {
                if (nanos <= 0) {
                    return false;
                }
                waiting++;
                try {
                    nanos = copyReturned.awaitNanos(nanos);
                } finally {
                    waiting--;
                }
            }
            copies--;
            return true;
        } finally {
            lock.unlock();
        }
    }

    public void registerReturn(String memberId) {
        lock.lock();
        try {
            copies++;
            copyReturned.signal();
        } finally {
            lock.unlock();
        }
    }

    public int copies() {
        lock.lock();
        try {
            return copies;
        } finally {
            lock.unlock();
        }
    }

    public int waiting() {
        lock.lock();
        try {
            return waiting;
        } finally {
            lock.unlock();
        }
    }
}

几个实现决定:

  • new ReentrantLock(true) 用公平锁。等待队列是有业务含义的:先来的会员应该先拿到书。非公平锁吞吐更高,但在「有人排了 2 秒」的场景里可能被新请求插队。
  • waiting 的加减也在锁内,保证 waiting() 读到的是一个一致的值,可以直接进指标。
  • registerReturn 只 signal() 一个,因为只多了一个副本。如果一次归还多本,应该循环 signal() 或直接 signalAll()。
  • memberId 参数目前只用于日志和后续的公平性审计;真正的「谁先来」由条件队列的 FIFO 顺序保证。

第二处是启动预热。libraryd 启动时要加载 ISBN 索引、罚金规则、馆藏分布三份数据,全部就绪才开放流量:

package com.plume.library.app;

public final class Warmup {

    public static void awaitAll(StartupGate gate, ExecutorService executor, List<Runnable> tasks)
            throws InterruptedException {
        for (Runnable task : tasks) {
            executor.submit(() -> {
                try {
                    task.run();
                } finally {
                    gate.taskDone();
                }
            });
        }
        if (!gate.await(Duration.ofSeconds(30))) {
            throw new LibraryException(ErrorCode.DB_UNAVAILABLE,
                    "预热超时,剩余 " + gate.remaining() + " 个任务未完成");
        }
    }
}

StartupGate 内部就是一个 CountDownLatch。用 await(Duration) 而不是无参 await(),是为了让启动失败时能带着「还剩几个任务」的信息退出,而不是卡在启动脚本里。

第三处要说清边界:JVM 锁只保护单进程内的状态。LoanWaitQueue 的 copies 是内存值,多实例部署时每个实例各有一份,A 实例归还不会唤醒 B 实例的等待者。真正的并发控制仍然靠数据库的条件更新:

update loans
set returned_on = ?
where isbn = ? and returned_on is null

内存等待队列只负责「让等的人不要空转」,不负责正确性。这两个层次不能混。

常见错误与边界

错误一:用 if 代替 while。

// 反例:只检查一次
if (copies == 0) {
    copyReturned.await();
}
copies--;

await 返回时条件可能仍然不成立(超时唤醒、被别的等待者抢走),此时 copies-- 会把计数减成负数。所有条件等待都必须是 while (条件不成立) await();。

错误二:unlock 没放在 finally。 临界区里抛异常(比如 memberId 为空触发 IllegalArgumentException),锁就永久泄漏。写法固定为 lock(); try { ... } finally { unlock(); },中间不要 return 在 try 外面。

错误三:以为 signal 会立刻切换线程。 signal 只是把等待者从条件队列转到锁队列,当前线程继续执行到 unlock 才可能让对方运行。所以「先 signal 再改状态」是错的:

// 反例:先唤醒,后改状态
copyReturned.signal();
copies++;

被唤醒的线程拿到锁后看到 copies 还是 0,又回去等,而这次 signal 已经用掉了。正确顺序永远是「改状态 → signal」。

边界:公平锁不等于公平调度。 new ReentrantLock(true) 只保证锁的获取按排队顺序,不保证线程被 CPU 调度的顺序。上面实验里 member-0 先拿到,是因为条件队列 FIFO 加上公平锁,不是 JVM 承诺的。

边界:synchronized 在 JDK 25 不再 pin 虚拟线程,但仍然是互斥锁。 6.1 会用实验说明这一点。不 pin 只意味着等待时不占住载体线程,不意味着两个线程可以同时进入临界区。把全局 synchronized 保留在借阅主链路上,虚拟线程带来的并发度会被这把锁吃掉。

小结

  • synchronized 无法超时、无法中断;需要这两种能力时用 ReentrantLock。
  • 本机实验中 tryLock(200ms) 在 201ms 返回 false,lockInterruptibly 被中断退出,synchronized 只能等满 452ms。
  • AQS 用 state 加等待队列实现同步器;ReentrantLock 的 state 是重入计数,Semaphore 是许可数。
  • Condition.await 会释放锁,这是「等待者不阻塞条件变更者」的前提;条件必须 while 重查,超时用 awaitNanos 的返回值续算。
  • signal 只唤醒一个、按等待时间选择;signalAll 唤醒全部,代价是惊群。
  • libraryd 的 LoanWaitQueue 只做进程内排队,正确性仍由数据库条件更新保证。

单进程内的锁讲完了,但把所有读都串行化并不划算。下一节看 ConcurrentHashMap 与 CopyOnWriteArrayList 如何在不加全局锁的情况下支撑读多写少的索引。

阅读导航:上一节:4.2 原子类与 CAS:AtomicLong 与 LongAdder 的取舍 · 下一节:5.1 并发容器:ConcurrentHashMap 与 CopyOnWriteArrayList 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发