" />
文章

ReentrantLock 公平锁/非公平锁的实现,以及 signal 如何通知线程

ReentrantLock 公平锁/非公平锁的实现,以及 signal 如何通知线程

面试指北 · 去哪儿

题意

这道题表面上是两个问题:ReentrantLock 的公平锁和非公平锁怎么实现,以及 Condition.signal() 怎么通知一个线程。实际考察的是对 AQS 的理解:独占锁的获取与释放、state 的含义、同步队列与条件队列的区别、公平性策略,以及 await/signal 的线程协作流程。

回答时不能只背“公平锁排队、非公平锁插队”,还要说清楚“在哪里排队”“signal 把线程移动到了哪个队列”“被通知后是不是立刻执行”。

结论

  1. ReentrantLock 基于 AQS 实现,内部通过 Sync、FairSync、NonfairSync 区分公平与非公平模式,默认是非公平锁。
  2. 公平锁在 tryAcquire 中会调用 hasQueuedPredecessors(),如果同步队列里存在比当前线程更早等待的线程,当前线程就不能抢锁,只能入队排队。
  3. 非公平锁在 lock() 时先直接 CAS 抢一次锁,tryAcquire 里也不检查队列,所以新来的线程可能插队;吞吐量通常更高,但可能造成线程饥饿。
  4. Condition 依赖 AQS 的条件队列。await() 会释放锁并把当前线程加入条件队列;signal() 会把条件队列中的一个节点转移到同步队列,让它在后续竞争锁。
  5. signal() 不是“直接让线程继续执行”,它不会释放锁。被通知的线程只是从条件队列转到同步队列,仍然要等锁释放后重新竞争到锁,才能从 await() 返回。

要点展开

1. ReentrantLock 与 AQS 的基本关系

ReentrantLock 是可重入的互斥锁。可重入的意思是:同一个线程已经持有锁时,再次调用 lock() 仍然可以成功,AQS 的 state 会递增;每 unlock() 一次递减,减到 0 才真正释放锁。

创建锁时可以指定模式:

ReentrantLock fairLock = new ReentrantLock(true);   // 公平锁
ReentrantLock nonFairLock = new ReentrantLock(false); // 非公平锁
ReentrantLock defaultLock = new ReentrantLock();    // 默认非公平锁

AQS 内部维护一个 state 和一个同步队列。state == 0 表示锁空闲,大于 0 表示被某个线程持有,数值代表重入次数。获取锁失败的线程会被包装成 Node 放入同步队列,并通过 LockSupport.park() 挂起,等待前驱节点释放锁后唤醒。

公平锁和非公平锁的差异,核心就在“尝试获取锁时要不要看同步队列”。

2. 公平锁的实现

公平锁强调先来后到。线程调用 lock() 后,会走 AQS 的 acquire(1),进而调用 FairSync.tryAcquire()。简化后的核心代码如下:

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();

    if (c == 0) {
        // 只有同步队列中没有前驱等待线程时,才允许 CAS 抢锁
        if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    } else if (current == getExclusiveOwnerThread()) {
        // 当前线程已经持有锁,允许重入
        int nextc = c + acquires;
        if (nextc < 0) {
            throw new Error("Maximum lock count exceeded");
        }
        setState(nextc);
        return true;
    }
    return false;
}

这里最关键的是 hasQueuedPredecessors()。它判断的是:同步队列中是否存在比当前线程更早等待的线程。如果存在,当前线程就不能“截胡”,tryAcquire 返回 false,随后 AQS 会把它加入同步队列排队。

公平锁的优点是顺序清晰,不容易饥饿;缺点是严格排队会带来更多线程切换和唤醒开销,性能通常略低于非公平锁。

3. 非公平锁的实现

非公平锁允许新来的线程先尝试抢锁,即使同步队列里已经有线程在等。NonfairSync.lock() 的典型逻辑是:

final void lock() {
    // 先直接抢一次,不管队列里有没有人等
    if (compareAndSetState(0, 1)) {
        setExclusiveOwnerThread(Thread.currentThread());
    } else {
        acquire(1);
    }
}

进入 tryAcquire 后,非公平锁也不会调用 hasQueuedPredecessors() 检查队列。简化后的核心逻辑是:

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();

    if (c == 0) {
        // 非公平锁直接 CAS 抢锁,不检查队列
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    } else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0) {
            throw new Error("Maximum lock count exceeded");
        }
        setState(nextc);
        return true;
    }
    return false;
}

非公平锁的核心特点是:锁释放的一瞬间,新来的线程可能比同步队列里被唤醒的线程更快抢到锁。这样减少了“唤醒线程后还要等它被调度”的空档,吞吐量通常更好。但它不是完全无序,更不是永远不排队;只是允许在锁空闲的窗口期插队。代价是极端情况下某些线程可能长时间抢不到锁。

可以用一张表对比:

对比项 公平锁 非公平锁
是否检查同步队列 tryAcquire 中检查 hasQueuedPredecessors() 不检查队列
是否允许插队 不允许 允许
吞吐量 通常略低 通常更高
饥饿风险 较低 相对更高
默认模式 否 是

4. Condition 与 signal 的通知流程

ReentrantLock 通过 newCondition() 创建 Condition,用来替代 synchronized 配合 wait/notify 的线程通信。一个锁可以创建多个 Condition,每个 Condition 维护自己的条件队列,因此可以实现更精准的唤醒。

Condition 的关键是两套队列:

  • 同步队列:等待获取锁的线程队列,AQS 的双向链表。
  • 条件队列:调用 await() 后等待条件的线程队列,ConditionObject 内部的单向链表。

await 做了什么

线程调用 condition.await() 时,大致流程是:

  1. 检查当前线程是否持有锁,没有持有就抛 IllegalMonitorStateException。
  2. 把当前线程包装成 Node,加入当前 Condition 的条件队列,节点状态为 CONDITION。
  3. 完全释放锁,包括重入次数。也就是说,如果线程重入了 3 次,这里会一次性释放到 state == 0,并唤醒同步队列中的后继节点。
  4. 通过 LockSupport.park() 挂起当前线程。
  5. 当其他线程调用 signal() 把它转移到同步队列后,它需要重新竞争锁;竞争成功后,从 await() 返回,并恢复之前的重入计数。

signal 做了什么

signal() 的核心逻辑是:检查当前线程是否持有锁,然后把条件队列中的第一个有效节点转移到同步队列。简化源码如下:

public final void signal() {
    if (!isHeldExclusively()) {
        throw new IllegalMonitorStateException();
    }
    Node first = firstWaiter;
    if (first != null) {
        doSignal(first);
    }
}

private void doSignal(Node first) {
    do {
        if ((firstWaiter = first.nextWaiter) == null) {
            lastWaiter = null;
        }
        first.nextWaiter = null;
    } while (!transferForSignal(first) &&
             (first = firstWaiter) != null);
}

final boolean transferForSignal(Node node) {
    // 把节点从 CONDITION 状态改为普通状态
    if (!compareAndSetWaitStatus(node, Node.CONDITION, 0)) {
        return false;
    }

    // 加入同步队列,返回前驱节点
    Node p = enq(node);
    int ws = p.waitStatus;

    // 如果前驱已取消,或者设置 SIGNAL 失败,就直接唤醒该线程
    if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL)) {
        LockSupport.unpark(node.thread);
    }
    return true;
}

这段代码可以拆成几个关键点:

  1. signal() 必须在持有锁时调用,否则抛 IllegalMonitorStateException。
  2. doSignal() 从 firstWaiter 开始,把节点从条件队列摘下。
  3. transferForSignal() 把节点的等待状态从 CONDITION 改为 0,然后通过 enq() 加入同步队列。
  4. 如果前驱节点状态正常,就把前驱标记为 SIGNAL,表示前驱释放锁时负责唤醒它;如果前驱已经取消或设置失败,就直接 unpark。
  5. 被通知的线程并没有立刻拿到锁,它只是进入了同步队列。等 signal() 的调用方执行 unlock() 后,它才有机会被唤醒并重新竞争锁。

这也是面试里最容易答错的地方:signal() 不是“让线程马上运行”,而是“把线程从条件队列转移到同步队列,给它一个重新竞争锁的资格”。

下面保留一个使用示例:

ReentrantLock lock = new ReentrantLock();
Condition condition = lock.newCondition();

new Thread(() -> {
    lock.lock();
    try {
        System.out.println("Thread 1: Waiting...");
        condition.await(); // 进入条件队列并释放锁
        System.out.println("Thread 1: Woken up!");
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        lock.unlock();
    }
}).start();

new Thread(() -> {
    lock.lock();
    try {
        System.out.println("Thread 2: Signaling...");
        condition.signal(); // 唤醒条件队列中的一个线程
    } finally {
        lock.unlock();
    }
}).start();

这个例子只用于演示 API。真实业务里,等待条件通常要放在 while 循环中判断,避免虚假唤醒或信号丢失;同时要确保等待线程已经进入 await(),否则先 signal() 后 await() 可能导致等待线程错过通知。

易错点与对比

  1. signal() 不会释放锁。
    调用 signal() 后通常要在 finally 中 unlock()。如果只 signal() 不释放锁,被转移到同步队列的线程仍然拿不到锁。

  2. signal() 不是直接唤醒执行。
    它只是把节点从条件队列转移到同步队列。被通知线程还要重新竞争锁,竞争成功后 await() 才返回。

  3. await() 和 signal() 都必须在锁内调用。
    否则会抛 IllegalMonitorStateException。这一点和 Object.wait()/notify() 必须持有 monitor 类似。

  4. 条件队列不是同步队列。
    条件队列存放的是调用 await() 等待条件的线程;同步队列存放的是等待获取锁的线程。signal() 的本质是完成一次“条件队列到同步队列”的转移。

  5. 公平锁不是绝对按操作系统调度顺序执行。
    它保证的是:在锁的获取逻辑上,不允许新线程越过同步队列中的前驱线程。线程真正谁先被 CPU 调度,仍受操作系统影响。

  6. 非公平锁不是完全不排队。
    它只是允许在锁空闲时插队;获取失败后仍然会进入同步队列。非公平锁吞吐高,但极端情况下可能让某些线程等待较久。

  7. hasQueuedPredecessors() 不是简单判断队列是否为空。
    它判断的是同步队列中是否存在比当前线程更早等待的前驱节点。公平锁正是靠这个判断来避免插队。

  8. Condition 比 wait/notify 更灵活。
    一个锁可以创建多个 Condition,实现分组等待和精准唤醒;Object.wait/notify 只有一个等待集,notify() 唤醒哪个线程并不由业务指定。

  9. 等待条件要用 while 而不是 if。
    await() 可能被虚假唤醒,也可能出现通知先于等待的情况。标准写法是:
    java lock.lock(); try { while (!conditionSatisfied) { condition.await(); } // 条件满足后的业务逻辑 } finally { lock.unlock(); }

小结

面试回答可以按这个顺序组织:

先一句话定性:ReentrantLock 基于 AQS,公平锁和非公平锁的差异集中在 tryAcquire 是否检查同步队列;默认是非公平锁。然后展开公平锁的 hasQueuedPredecessors() 和非公平锁的“先 CAS 抢一次、不检查队列”。最后讲 Condition:await() 释放锁并进入条件队列,signal() 在持锁状态下把条件队列节点转移到同步队列,被通知线程仍然要重新竞争锁才能继续执行。

把这几个点讲清楚,基本就能覆盖“公平/非公平锁实现”和“signal 怎么通知线程”这两个核心问题。

License:  CC BY 4.0