跳到主要内容
约 7 分钟3 张卡

ReentrantLock

ReentrantLock 是通过方法调用来加锁和解锁的可重入互斥锁。它提供与 synchronized 相近的互斥和可见性保证。本文从获取与释放锁入手,再说明公平策略,最后讲 Condition 的等待、通知和超时预算。

1. 面试核心​

先记住这几条,再读正文
  • 锁的能力。 ReentrantLock 在提供互斥和可见性的同时,支持可中断获取、超时获取和多个条件队列。
  • 配对释放。 线程每次成功获取 ReentrantLock,都要在对应的 finally 中调用一次 unlock()。
  • 公平性。 公平锁优先让等待最久的线程获取锁,但无超时 tryLock() 仍允许插队。卡 · 可中断获取与超时获取
  • 条件等待。 Condition.await() 会完全释放关联锁,返回前重新获取并恢复持有次数,线程随后需重查条件。卡 · 条件重查与通知时机
  • 超时预算。 带时限的条件等待每轮都使用剩余预算,避免反复唤醒重新计时。卡 · 条件等待的剩余预算

2. 加锁与释放​

显式锁由代码负责释放,所以正常返回和异常退出都要经过 unlock()。把获取锁放在 try 之前,再在 finally 中释放,可以让两者正确配对。

private final ReentrantLock lock = new ReentrantLock();

void update() {
lock.lock();
try {
updateState();
} finally {
lock.unlock();
}
}

示例只有获取成功后才进入 try,因此 finally 释放的是当前线程已持有的锁。如果改用可中断获取,获取过程抛出异常时会直接跳过 try,避免错误地解锁。

同一线程可以重复获取同一把锁,这称为重入。每次获取都增加持有次数,每次 unlock() 减少一次。只有次数降到零,其他线程才能取得锁。

3. 可中断获取与超时获取​

请求有截止时间时,等锁也需要计入预算。带超时的 tryLock 可以限制等待时间,并在超时后让调用方拒绝请求或执行降级逻辑。

if (lock.tryLock(200, TimeUnit.MILLISECONDS)) {
try {
updateState();
} finally {
lock.unlock();
}
} else {
rejectBusyRequest();
}

示例最多等待 200 毫秒。期限内获取成功就更新数据;超时返回 false,调用方据此拒绝繁忙请求。该方法还会通过 InterruptedException 响应中断,所在方法需要处理或声明此异常。

选择获取方式时,需要区分以下四种行为:

  • lock() 持续等待获取锁,被中断后保留中断状态。
  • lockInterruptibly() 等待时可以响应中断,让调用方取消等锁。
  • tryLock() 立即返回获取结果,适合只尝试一次的操作。
  • tryLock(timeout, unit) 同时支持超时和中断,适合有等待期限的请求。

需要避免无限等待,或退出潜在的锁循环等待时,可以使用后两类可取消的等待方式。退出后仍要释放此前已取得的资源。

4. 公平锁与非公平锁​

公平策略决定锁空闲时,排队线程和新到线程怎样竞争。new ReentrantLock(true) 启用公平策略,竞争时优先让等待最久的线程获取锁。当前持有者重入时,仍可直接成功。

默认的非公平锁允许运行中的线程插队。它可以直接利用刚释放的锁,减少等待唤醒和线程切换的空档。公平锁需要把机会留给排队者,而排队者还要等待调度。所以,持续竞争时,公平锁通常吞吐更低,但各线程等待时间的差异更小。

无超时 tryLock() 会忽略公平策略,只要锁可用就能成功。需要遵守公平顺序的立即尝试,可以用 tryLock(0, unit)。后者还会响应中断。

公平性约束的是获取锁的顺序,操作系统仍独立决定何时调度线程。业务需要控制长期等待时,再考虑启用公平策略,并比较吞吐量和等待时间分布。

5. Condition 等待​

Condition 是与锁关联的条件变量,让线程等待某个业务条件成立。比如,消费者发现队列为空时,可以释放锁并等待,给生产者加入数据的机会。

private final Condition notEmpty = lock.newCondition();

Item take() throws InterruptedException {
lock.lockInterruptibly();
try {
while (queue.isEmpty()) {
notEmpty.await();
}
return queue.removeFirst();
} finally {
lock.unlock();
}
}

示例先持锁检查队列,再调用 await()。它把释放关联锁和进入等待作为一个协调好的动作,避免生产者的通知落在检查与等待的空档中。返回前,消费者会重新获取锁,并恢复调用前的持有次数。

恢复持锁后,要用 while 再检查队列。线程可以在没有收到有效通知时唤醒,这称为虚假唤醒。此外,其他消费者也能在它重新获取锁之前取走数据。所以,只有确认队列非空,才执行删除。

await() 只释放关联锁,当前线程持有的其他锁仍然保留。如果生产者需要这些锁才能加入数据,双方就会互相等待。因此,设计时还要检查等待者持有的其他锁。

6. signal 与 signalAll​

生产者改变共享状态后,需要通知等待该状态的线程。更新和通知都在同一把锁内完成,消费者才能在重新获取锁后看到更新。

lock.lock();
try {
queue.addLast(item);
notEmpty.signal();
} finally {
lock.unlock();
}

示例先加入数据,再通知等待者。收到通知的消费者进入获取锁的队列,生产者仍持有锁,直到执行 unlock()。所以,消费者要重新获取锁后,才能从 await() 返回。

能确认唤醒一个等待者就足够时,使用 signal()。多个等待者依赖的条件较复杂,或者容易遗漏应被唤醒的线程时,使用 signalAll()。它会通知全部等待者,各线程取得锁后仍要重新检查条件。

也可以用不同的 Condition 区分不同业务条件。比如,生产者等待“队列未满”,消费者等待“队列非空”,分别通知能减少无关唤醒。

7. 条件重查与通知时机​

收到通知后,线程仍要重新判断业务条件,因为通知到恢复执行之间,数据可以再次变化。下面用两个消费者说明 while 的作用:

  1. 两个消费者都发现队列为空,于是进入等待。
  2. 生产者加入一条数据,并通知两个消费者。
  3. 第一个消费者取得锁,取走唯一的数据。
  4. 第二个消费者取得锁时,队列已经再次变空,需要继续等待。

如果只用 if 在等待前检查一次,第二个消费者就会直接操作空队列。while 则在每次恢复持锁后重新判断,也能处理虚假唤醒。

8. 条件等待的剩余预算​

条件一直未满足时,反复唤醒也应共用同一份等待预算。awaitNanos 会返回估算的剩余纳秒数,下一轮继续使用这个值:

long remaining = timeoutNanos;
while (queue.isEmpty()) {
if (remaining <= 0L) {
return false;
}
remaining = notEmpty.awaitNanos(remaining);
}
return true;

本片段在持锁的 try 内执行,外围由 finally 解锁。若每轮都传入原始超时,等待预算就会重新开始,反复唤醒会让总等待时间越拖越长。

如果请求期限还包含获取锁的时间,就要从调用入口计算统一截止时间。每次加锁或等待前,都用截止时间减去当前时间,得到剩余预算。

9. 常见问题​

9.1 ReentrantLock 一定比 synchronized 快吗?​

两者的快慢取决于 JVM、竞争强度和临界区耗时,现代 JVM 会优化它们的常见路径。只需要互斥和可见性时,可以优先选语义简单的 synchronized。需要超时、可中断获取、公平策略或多个条件队列时,再选 ReentrantLock,并用基准测试判断性能。

9.2 Condition 可以脱离关联锁使用吗?​

10 · 面试题卡片​

10.1 公平锁为什么通常吞吐更低​

出现公司:蚂蚁集团、美团

考察重点

  • 解释插队为什么能减少锁交接时的调度等待。
  • 区分锁的排队顺序与操作系统调度线程的顺序。
  • 说明无超时 tryLock() 为什么仍可在公平锁上插队。

相关内容:可中断获取与超时获取和公平锁与非公平锁。

参考回答

公平锁要把空闲锁留给排队者,因此会增加等待唤醒和调度的交接时间。

非公平锁允许运行中的线程直接取得刚释放的锁,减少这段空档。持续竞争时,公平锁通常吞吐更低,但线程之间的等待时间差异更小。

锁的公平策略仍受操作系统调度影响,无超时 tryLock() 也允许插队。因此,应在需要限制长期等待时使用公平锁,并同时测量吞吐量和等待时间分布。

10.2 Condition.await 为什么必须放在 while 循环中?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:解释虚假唤醒和其他线程抢先操作,为什么都要求重新检查条件。

相关内容:条件重查与通知时机。

参考回答

await() 返回时,业务条件仍可能为假,所以必须用 while 再次检查。

线程可以虚假唤醒,也可以在重新获取锁前被其他消费者抢先取走数据。循环让线程只在条件成立时继续,否则再次释放锁并等待。

10.3 条件被反复唤醒时,怎样避免等待超出原定预算?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:说明怎样使用 awaitNanos 的剩余时间,以及何时需要统一截止时间。

相关内容:条件等待的剩余预算。

参考回答

每轮等待都使用剩余预算,预算耗尽后返回失败。

awaitNanos 返回估算的剩余时间,可以直接传给下一轮等待。如果每轮都重新传入原始时长,反复唤醒就会延长总等待。

请求还要限制加锁时间时,应从入口计算统一截止时间,再为每次等待计算剩余预算。

11. 参考资料​