← 返回博客
2026-09-10 08:00:02

AQS 与 ReentrantLock学习笔记(2/2):ReentrantLock、Condition 与 synchronized 对比

AQS 与 ReentrantLock学习笔记(2/2):ReentrantLock、Condition 与 synchronized 对比

上一篇读完了 AQS 内核:state、CLH 变体队列、LockSupport 的许可机制。但 AQS 是个框架,日常你手上拿的是 ReentrantLock 这个壳——它的获取方式有四种,各自的行为差别很大,tryLock() 甚至有个跟公平性冲突的细节。

真正体现 ReentrantLock 比 synchronized 灵活的地方是 Condition一把锁可以挂多组等待队列,这是 Object.wait/notify 给不了的。最后把两者做一次彻底对比,顺带说清锁升级在 JDK 15 之后发生的事情。

版本说明:源码引用分两套,JDK 8 那套是面试主线;JDK 14 起 AQS 被整体重写Condition 的实现也跟着变了,jstack 栈里能直接看出来。实测输出跑在本机 OpenJDK 26.0.1

手把手实操:await 到底释不释放锁

先把最容易记错的一点焊死。await() 会释放锁——不是"看起来释放了",是真把 state 减到 0、让别的线程能拿到。跑一段验证:

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

/**
 * 一、await() 会释放锁:把锁交给子线程,等它进 await 之后,主线程(此刻不持锁)
 *     再 lock() 能立刻拿到,说明锁确实被 await 放开了。
 * 二、await 期间线程停在 LockSupport.park,留 6 秒窗口供 jstack 抓取。
 */
public class ConditionAwaitDemo {
    static final ReentrantLock LOCK = new ReentrantLock();
    static final Condition COND = LOCK.newCondition();

    static void log(String s) { System.out.println(s); System.out.flush(); }

    static volatile boolean inAwait = false;

    public static void main(String[] args) throws Exception {
        LOCK.lock();
        log("1. 主线程先持有锁");

        Thread t = new Thread(() -> {
            LOCK.lock();                            // 主线程释放后才能拿到
            try {
                log("2. 子线程拿到锁,准备 await");
                inAwait = true;
                long t0 = System.currentTimeMillis();
                COND.await();                       // 进 await:先释放锁,再挂起
                log("6. 子线程被 signal 唤醒并重新拿到锁,await 期间等待 "
                        + (System.currentTimeMillis() - t0) + " ms");
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                LOCK.unlock();
            }
        }, "await-demo-thread");
        t.start();

        Thread.sleep(300);
        LOCK.unlock();                              // 让子线程拿到锁
        while (!inAwait) Thread.onSpinWait();       // 等它真的进了 await
        Thread.sleep(300);

        long t0 = System.currentTimeMillis();
        LOCK.lock();                                // 主线程此刻不持锁;若 await 没释放锁,这里会一直等
        long cost = System.currentTimeMillis() - t0;
        log("3. 子线程正在 await,主线程重新 lock() 耗时 " + cost + " ms -> 锁确实被 await 释放了");
        log("4. jstack 窗口打开,子线程此刻停在 ConditionObject.await(留 6 秒)");

        Thread.sleep(6000);

        log("5. 主线程 signal()(必须持锁才能调用)");
        COND.signal();
        LOCK.unlock();

        t.join();
        log("7. 结束");
    }
}

实测输出:

1. 主线程先持有锁
2. 子线程拿到锁,准备 await
3. 子线程正在 await,主线程重新 lock() 耗时 0 ms -> 锁确实被 await 释放了
4. jstack 窗口打开,子线程此刻停在 ConditionObject.await(留 6 秒)
5. 主线程 signal()(必须持锁才能调用)
6. 子线程被 signal 唤醒并重新拿到锁,await 期间等待 6305 ms
7. 结束

第 3 行是关键。主线程在子线程已经进 await 之后调 LOCK.lock(),耗时 0 ms——如果 await 没释放锁,这里会一直等到第 5 步 signal() 之后。所以 await() 的语义完整说是三步:释放锁 → 挂起等待 → 被唤醒后重新抢锁

注意第三步,这个"重新抢锁"经常被漏掉,它直接决定了后面 if 还是 while 的写错后果。

第 6 行 6305 ms 也顺带说明:signal() 之后子线程不是立刻继续,而是要走完"从条件队列搬回同步队列 → 重新竞争锁 → 抢到"这条路。

ReentrantLock 的四种获取方式

synchronized 只有一种获取方式:抢不到就一直等,不响应中断、不能超时。ReentrantLock 有四种,是它最主要的实用性优势。

方法抢不到时的行为响应中断
lock()一直等(进 AQS 队列 park)
lockInterruptibly()一直等,但可被中断抛异常
tryLock()立即返回 false,不等待
tryLock(timeout, unit)等指定时长,超时返回 false

lockInterruptibly() 的价值在"可取消"。任务被提交到线程池,用户点了取消,如果任务卡在 synchronized 上,唯一的下场是把线程一起废掉;用 lockInterruptibly() 就能优雅退出:

try {
    if (!lock.tryLock(3, TimeUnit.SECONDS)) {
        // 3 秒都没抢到:记日志、降级、直接返回,绝不无限等
        return Result.timeout();
    }
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();   // 恢复中断标记,别吞掉
    return Result.cancelled();
}
try {
    // 临界区
} finally {
    lock.unlock();
}

这里有个很容易踩的坑tryLock(timeout)lockInterruptibly() 在超时或中断时返回/抛出,此时根本没拿到锁,所以后面的 unlock() 绝对不能执行。写 finally { lock.unlock(); } 把整个 try 包进去,会抛 IllegalMonitorStateException。规范写法就是上面这样,把 lock() 单独拿出来判。

还有一个公平性冲突的细节,查了 JDK 源码才注意到:tryLock() 定义在抽象的 Sync 上并且是 final 的,公平锁和非公平锁都不覆写它

// ReentrantLock$Sync(非公平锁和公平锁共用这一个实现)
final boolean tryLock() {
    Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (compareAndSetState(0, 1)) {          // 直接 CAS 抢,没检查队列
            setExclusiveOwnerThread(current);
            return true;
        }
    } else if (getExclusiveOwnerThread() == current) {
        if (++c < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(c);
        return true;
    }
    return false;
}

对比一下公平锁的 initialTryLock()——它是会检查 hasQueuedThreads() 的。tryLock() 绕过了这个检查,直接 CAS 插队。 所以即使你声明了 new ReentrantLock(true),调 tryLock() 依然会让队伍前面的线程被插队。

这个设计是不得已:tryLock() 的语义是"立即返回,绝不阻塞",如果它还要先排队,语义就不成立了。理解这一点,就不会在"公平锁为什么没按顺序"的问题上纠结。

查询类 API 顺手记一下:isFair()isLocked()isHeldByCurrentThread()getHoldCount()(重入了几次)、hasQueuedThreads()getQueueLength()(队列里排了多少个)。synchronized 一个都没有——这是"灵活"这个词最实在的落点。

Condition:一把锁配多组等待队列

这是 ReentrantLock 相比 synchronized 真正的结构性优势。

Object 上的 wait/notify/notifyAll 只有一个等待集。一个生产者-消费者里,"队列满了要等"和"队列空了要等"两类线程混在同一个等待集里,生产者 notify() 唤醒的可能是另一个生产者——白白唤醒,还可能造成死锁。所以用 synchronized 写这段,只能一律 notifyAll(),唤醒所有线程让它们自己抢,效率很低。

Condition 直接解决这个问题:一把 ReentrantLock 可以 newCondition() 出任意多个等待队列,每类线程等自己的那个:

ReentrantLock lock = new ReentrantLock();
Condition notFull  = lock.newCondition();   // 生产者等这个
Condition notEmpty = lock.newCondition();   // 消费者等这个

这也是本文后面那个生产者-消费者示例能同时写"满等、空等"两支逻辑的原因。

await 和 signal 在内部干了什么

用上一篇的说法对一下,就知道为什么说"条件队列"和"同步队列"是两条队列:

所以 signal() 唤醒的是队首那一个,且唤醒不等于拿到锁。这个"两步走"是理解后面所有问题的钥匙。

下面这张图把两条队列和一次搬家画在一起,顺带对比 await / signal / signalAll 三个动作各自做了什么:

Condition 的两条队列与搬家过程:await 把节点挂到条件队列并完全释放锁再 park;signal 把条件队列队首节点转移到同步队列尾部但不叫醒线程,被搬过去的线程必须重新竞争锁

盯住图里那条绿色虚线——它是"搬家",不是"交还锁"。线程 A 从条件队列挪到同步队列尾部之后,身份变成了一个普通的等锁线程,跟线程 X、线程 Y 排在一起重新竞争。这正是下面 if / while 实验要验证的机制。

jstack 里怎么认出它在等 Condition

上面那个程序的第 4 步窗口期内抓 jstack,子线程的栈是这样的(地址和线程号做了简化):

"await-demo-thread" #27 prio=5 os_prio=0 ... waiting on condition
   java.lang.Thread.State: WAITING (parking)
	at jdk.internal.misc.Unsafe.park(Native Method)
	- parking to wait for  <0x...b28> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
	at java.util.concurrent.locks.LockSupport.park(LockSupport.java:369)
	at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionNode.block(AbstractQueuedSynchronizer.java:520)
	at java.util.concurrent.ForkJoinPool.unmanagedBlock(ForkJoinPool.java:4367)
	at java.util.concurrent.ForkJoinPool.managedBlock(ForkJoinPool.java:4313)
	at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:1752)
	at ConditionAwaitDemo.lambda$main$0(ConditionAwaitDemo.java:27)

关键看 parking to wait for 后面跟的对象。上一篇等锁时这是一把 ReentrantLock$NonfairSync;这里是 AbstractQueuedSynchronizer$ConditionObject。线程状态都是 WAITING (parking),光看状态分不出来,只能靠这个对象区分"在等锁"还是"在等条件"

另外注意 ConditionNode.block 这一层。JDK 14 起把 ConditionNode 做成了 ForkJoinPool.ManagedBlocker 的实现,所以 await 挂起时走的是 managedBlockunmanagedBlockConditionNode.blockLockSupport.park。JDK 8 里这条链短得多,直接就是 ConditionObject.awaitLockSupport.park(this)。这个"绕了一圈 ForkJoinPool"的栈,是 JDK 14+ 的典型指纹。

手把手实操:if 和 while 的真实差距

await() 会释放锁、唤醒后要重新抢锁——这两件事合起来意味着:线程被唤醒的那一刻,它等待的条件未必还成立

signal() 只是"把节点搬回同步队列",搬回去之后这个线程要重新抢锁。在它抢到锁之前,完全可能有别的线程先抢到锁、把条件又改回去了。所以醒来之后必须重新检查条件,这就决定了等待条件是写在 while 里还是 if 里。

写个实验把差距量化。容量故意设成 1,3 个消费者,生产 20000 条,signalAll() 唤醒所有等待者放大破绽:

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

/**
 * 同一个生产者-消费者实现,只把等待条件的 if 换成 while,结果完全不同。
 * 用法:java ConditionIfBreakDemo if|while
 */
public class ConditionIfBreakDemo {
    static final int CAP = 1;                 // 容量故意设成 1,放大竞争
    static final int ITEMS = 20000;

    static final ReentrantLock LOCK = new ReentrantLock();
    static final Condition NOT_FULL = LOCK.newCondition();
    static final Condition NOT_EMPTY = LOCK.newCondition();
    static final Deque<String> QUEUE = new ArrayDeque<>();

    static final AtomicInteger CONSUMED = new AtomicInteger();
    static final AtomicInteger GOT_NULL = new AtomicInteger();
    static volatile boolean running = true;

    public static void main(String[] args) throws Exception {
        boolean useWhile = args.length > 0 && args[0].equals("while");

        Runnable consumer = () -> {
            while (true) {
                LOCK.lock();
                try {
                    // 只有"队列已空且收到关停"才退出,保证两个版本都会把队列排空
                    if (QUEUE.isEmpty()) {
                        if (useWhile) {
                            while (QUEUE.isEmpty()) {
                                if (!running) return;
                                NOT_EMPTY.await();         // 正确:醒来回到 while 再检查
                            }
                        } else {
                            if (!running) return;
                            NOT_EMPTY.await();             // 错误:醒来直接往下走
                        }
                    }
                    String msg = QUEUE.pollFirst();
                    if (msg == null) {
                        GOT_NULL.incrementAndGet();        // 被空取了
                    } else {
                        CONSUMED.incrementAndGet();
                    }
                    NOT_FULL.signal();
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return;
                } finally {
                    LOCK.unlock();
                }
            }
        };

        Thread c1 = new Thread(consumer, "consumer-1");
        Thread c2 = new Thread(consumer, "consumer-2");
        Thread c3 = new Thread(consumer, "consumer-3");
        c1.start(); c2.start(); c3.start();

        for (int i = 0; i < ITEMS; i++) {
            LOCK.lock();
            try {
                while (QUEUE.size() == CAP) NOT_FULL.await();
                QUEUE.addLast("msg-" + i);
                NOT_EMPTY.signalAll();          // 唤醒所有等待者,放大 if 的破绽
            } finally {
                LOCK.unlock();
            }
        }

        running = false;
        LOCK.lock();
        try { NOT_EMPTY.signalAll(); } finally { LOCK.unlock(); }
        c1.join(5000); c2.join(5000); c3.join(5000);

        System.out.println("等待条件用 " + (useWhile ? "while" : "if  ")
                + " -> 生产 " + ITEMS + " 条,成功消费 " + CONSUMED.get()
                + " 条,取到 null(被空取) " + GOT_NULL.get() + " 次"
                + ",线程是否都退出 " + (!c1.isAlive() && !c2.isAlive() && !c3.isAlive()));
    }
}

两个版本的退出条件写成对称的(都是"队列空且收到关停才退出"),保证它们都会把队列排空,唯一的差别就是 ifwhile。跑起来:

javac ConditionIfBreakDemo.java
java ConditionIfBreakDemo if
java ConditionIfBreakDemo while

实测(if 版连跑 3 轮,while 版 2 轮):

等待条件用 if    -> 生产 20000 条,成功消费 20000 条,取到 null(被空取) 39931 次,线程是否都退出 true
等待条件用 if    -> 生产 20000 条,成功消费 20000 条,取到 null(被空取) 39941 次,线程是否都退出 true
等待条件用 if    -> 生产 20000 条,成功消费 20000 条,取到 null(被空取) 39921 次,线程是否都退出 true
等待条件用 while -> 生产 20000 条,成功消费 20000 条,取到 null(被空取) 0 次,线程是否都退出 true

if 版空取了将近 4 万次,while 版一次都没有。

为什么是 4 万这个量级?容量是 1,3 个消费者总有两个在等。生产者每放一条就 signalAll(),两个等着的消费者被同时搬回同步队列,然后依次抢锁:第一个抢到的把唯一那条数据取走,第二个抢到锁时队列已经空了,但它的 if 判断早在 await 之前就做过了,醒来不会再检查,于是直接 pollFirst() 拿到 null——白醒一次,还可能把 null 当成数据往下传。

这里要澄清一个常见混淆:这不是"虚假唤醒(spurious wakeup)"。虚假唤醒指的是没有任何 signalpark 自己返回,那是操作系统层面的行为。上面的空取是实实在在的 signalAll 唤醒了它,只是唤醒时条件已经不成立——这是正常唤醒,问题出在代码没重新检查条件上。

所以规则很硬:await() 永远放在 while 里,不要放 if。顺带一提,即使没有别的线程来抢,await() 在 JDK 里也允许自己返回(规范明确写了调用方要放在循环里),所以这条规则没有例外。

ReentrantLock 与 synchronized 的全面对比

维度synchronizedReentrantLock
实现层JVM 内置(monitor,字节码 monitorenter/monitorexitJava 层(AQS),ReentrantLock$Sync
加解锁配对编译器保证,自动释放手动unlock() 必须放 finally
抢不到时一直等四选一:等 / 可中断等 / 立即返回 / 超时返回
响应中断不支持lockInterruptibly()tryLock(timeout)
超时不支持tryLock(timeout, unit)
公平性只有非公平可选公平(构造参数 true
等待队列一个等待集一把锁配 多个 Condition
状态查询isLocked / isHeldByCurrentThread / getQueueLength
是否可被优化编译期锁消除、锁粗化、逃逸分析无法,是普通对象方法调用
出错表现卡在 monitor 上 BLOCKEDWAITING (parking),且忘写 unlock 会永久泄漏

选型建议直接给结论:能用 synchronized 就用 synchronized(代码短、不会忘记释放、JVM 还能做优化);需要"超时/可中断/多等待条件/公平性"这四样里的任何一样,才用 ReentrantLock。反过来说,只因为"听说 ReentrantLock 性能好"就换过去,是没有依据的——JDK 6 之后 synchronized 的优化已经很充分,空跑场景下两者难分高下,真正的差距在功能而不在裸性能。

锁升级:JDK 15 之后这件事变了

背过八股的话,你现在脑子里大概是这条链:

无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁

放在今天,这条链的中间一环基本可以删掉了

偏向锁解决的是"同一个线程反复进入同一个临界区"的场景:第一个拿到锁的线程,JVM 直接把它的线程 ID 记在对象头 Mark Word 里,下次它再来,比对一下 ID 就进去了,连 CAS 都不用。看起来很美,代价是撤销偏向的成本很高——一旦有第二个线程来抢,要等一个安全点(safepoint)才能撤销,而这个等待会拖慢整个 JVM。JDK 15 的 JEP 374 直接把它废弃并默认禁用

到本机这个 JDK 26 上,连开关都不存在了:

$ java -XX:+UseBiasedLocking -version
Unrecognized VM option 'UseBiasedLocking'
Error: Could not create the Java Virtual Machine.

所以现在正确的说法是synchronized 的锁状态是 无锁 → 轻量级锁(CAS 自旋)→ 重量级锁(monitor) 三级,偏向锁是 JDK 15 之前的历史。

轻量级到重量级的升级路径还是值得记:轻量级锁靠 CAS 在 Mark Word 里换指针,如果 CAS 自旋失败到一定次数(或等待线程数变多),就升级成重量级锁,把竞争线程挂到 monitor 的 _EntryList/_WaitSet 上,走操作系统的互斥量。面试问"锁升级"时按三级答,再补一句"偏向锁在 JDK 15 被 JEP 374 废弃、在更新版本里连 VM 参数都移除了"——这一句就能把"看的还是老书"的印象翻过来。

面试速答

Q:await() 会释放锁吗?被唤醒之后呢?

A:会。完整语义是"释放锁 → 挂起 → 被唤醒后重新抢锁"。释放锁是立即生效的(实测中主线程在子线程 await 期间重新 lock() 耗时 0 ms)。但唤醒后必须重新竞争锁,且竞争到的时刻条件未必仍成立,所以等待条件要写在 while 里。

Q:ConditionObject.wait/notify 的区别?

A:一个 ReentrantLock 可以创建多个 Condition,就等于有多个独立的等待队列,"队列满"和"队列空"分开等,signal() 能精准唤醒对应那一类线程。Object 只有一个等待集,只能一律 notifyAll(),效率低还容易出问题。另外 Condition.await() 内部是 AQS 的条件队列,signal() 把节点从条件队列转移到同步队列,不是直接唤醒。

Q:signal()signalAll() 怎么选?

A:signal() 只把条件队列队首那个节点搬到同步队列。如果等待的条件对所有等待线程都一样(比如"队列非空"),且每次只可能有一个线程被满足,用 signal() 就够。判断不准就用 signalAll()——它只是多几次无效唤醒,比"该醒的没醒"导致死锁安全得多。

Q:公平锁下手动调用 tryLock() 会插队吗?

A:会。tryLock() 定义在 ReentrantLock$Sync 上且是 final,公平锁和非公平锁都不覆写,内部直接 compareAndSetState(0, 1)没有检查队列里有没有前驱。因为 tryLock() 的契约是"立即返回不阻塞",加排队就违背契约了。要按顺序就得用 lock()

Q:unlock() 写在 finally 里,有什么陷阱?

A:如果 tryLock(timeout) 返回 false 或 lockInterruptibly() 抛异常,说明根本没拿到锁,此时执行 unlock() 会抛 IllegalMonitorStateException。正确做法是把获取锁和临界区分成两段 try:获取失败就 return,只对成功获取的那段用 finally { unlock(); }

Q:synchronized 的锁升级过程?

A:现在只有三级:无锁 → 轻量级锁(Mark Word 里 CAS 换指针 + 自旋)→ 重量级锁(自旋失败,挂到 monitor 上走操作系统互斥量)。偏向锁在 JDK 15 被 JEP 374 废弃并默认禁用,实测在本机 JDK 26 上 -XX:+UseBiasedLocking 直接报 Unrecognized VM option,参数已经彻底移除。

Q:怎么选 synchronized 还是 ReentrantLock

A:默认 synchronized。只有需要"超时获取、可中断获取、一把锁多组等待条件、公平锁"这四样之一时,才用 ReentrantLock。它不是性能更好,而是功能更多。

核心收获与下一步

ReentrantLock 的价值集中在两处:获取方式有四种(等 / 可中断等 / 立即返回 / 超时返回),一把锁能挂多组 Condition。这两样 synchronized 都给不了。

Condition 的机制记成两条队列的搬家:await() 把节点挂到条件队列并释放锁,signal() 把节点从条件队列搬到同步队列,然后由 AQS 那套逻辑重新竞争锁。因为中间隔着"重新竞争"这一步,await() 必须写在 while 里。

最后一条版本相关的:synchronized 的锁升级现在是无锁 → 轻量级 → 重量级三级,偏向锁在 JDK 15 之后已经出局,别再背成四级。

下一步:把 ConditionIfBreakDemo 里的 while 改成 if,加个计数器跑一遍,亲眼看着空取次数从 0 变成几万次——这比背"要写 while"有效得多。然后把 getQueueLength() 打进日志,观察生产快消费慢的时候同步队列是怎么涨起来的。

本文关键词:ReentrantLock、Condition、await、signal、锁升级、synchronized