Lock和Condition怎么实现线程协作
Lock 不只是 synchronized 的另一种写法,它把加锁、尝试获取、可中断等待和公平策略都暴露出来;Condition 则把 wait/notify 的单一等待队列拆成多个有名字的条件队列。这篇从 ReentrantLock 和 AQS 讲起,结合生产者消费者示例说明 Lock、Condition 怎么用,以及它和 synchronized 的边界区别。
上一篇我们把 synchronized 关键字到底锁住了什么拆开讲了。
它简单、可靠,很多场景下直接用就够。
但遇到下面这些需求时,事情会开始变复杂:
- 获取锁时希望设置超时时间
- 线程等待锁时希望能够响应中断
- 希望指定公平锁策略
- 一个锁上需要维护多个不同的等待条件
- 想把加锁和解锁动作放到更明确的代码流程里
这时候就轮到 Lock 和 Condition 出场了。
它们不是为了把 synchronized 衬托得落后,而是把原来由 JVM 隐藏管理的能力,拿出了一部分交给开发者控制。
能力更多,责任也更多。
Lock 解决什么问题
Lock 是 Java 并发包提供的显式锁接口。
最常见的实现是 ReentrantLock。
private final Lock lock = new ReentrantLock();
public void updateStock() {
lock.lock();
try {
stock = stock - 1;
} finally {
lock.unlock();
}
}
这段代码和 synchronized 一样,都能保护临界区。
但 Lock 的锁生命周期需要我们自己维护:
lock.lock()
│
▼
执行临界区
│
▼
finally 中 unlock()
unlock 放在 finally 里不是格式要求,而是保命要求。
如果中间抛异常没有释放锁,后面的线程可能全部卡住。
ReentrantLock 的可重入
ReentrantLock 和 synchronized 一样支持可重入。
同一个线程已经拿到锁后,可以再次获得同一把锁,不会把自己阻塞。
private final ReentrantLock lock = new ReentrantLock();
public void outer() {
lock.lock();
try {
inner();
} finally {
lock.unlock();
}
}
public void inner() {
lock.lock();
try {
// 同一线程可以再次进入
doWork();
} finally {
lock.unlock();
}
}
它内部会记录重入次数,进入和退出必须成对出现。
少一次 unlock,重入计数就无法归零;多一次 unlock,则会抛出状态异常。
Lock 背后的 AQS
很多 Java 并发工具的底层都绕不开 AQS,也就是 AbstractQueuedSynchronizer。
可以把 AQS 理解成一个同步器骨架:
线程尝试获取资源
│
├── 获取成功:直接进入临界区
│
└── 获取失败:进入等待队列
│
▼
被唤醒后重新竞争
ReentrantLock 会基于 AQS 维护锁状态和等待队列。
线程抢锁失败后,不是简单地在死循环里疯狂占用 CPU,而是根据同步器状态排队、阻塞和唤醒。
这就是 Java 并发包中许多工具能共享一套排队逻辑的原因。
我们不必为了使用 ReentrantLock 去手写 AQS。
知道它是一个可扩展的队列同步框架,就足够理解 Lock 为什么能提供比 synchronized 更丰富的操作。
Lock 比 synchronized 多了什么
tryLock
tryLock 可以尝试获取锁,拿不到时立即返回,不必一直等待。
if (lock.tryLock()) {
try {
doWork();
} finally {
lock.unlock();
}
} else {
// 当前拿不到锁,走降级或稍后重试
fallback();
}
还可以设置等待时间:
if (lock.tryLock(200, TimeUnit.MILLISECONDS)) {
try {
doWork();
} finally {
lock.unlock();
}
}
这适合对等待时间敏感的场景。
例如请求不能无限卡住,超过 200 毫秒就返回降级结果。
lockInterruptibly
普通 lock 获取过程中,如果线程一直等不到锁,通常不会因为中断自动退出。
lockInterruptibly 允许等待锁的线程响应中断:
try {
lock.lockInterruptibly();
try {
doWork();
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
cancelTask();
}
线程池关闭、任务取消和超时控制场景里,这个能力很有价值。
公平锁
ReentrantLock 可以选择公平模式:
private final ReentrantLock fairLock =
new ReentrantLock(true);
公平锁会尽量按照等待顺序分配锁,减少某些线程长期抢不到锁的情况。
但公平不等于更快。
排队规则更严格,吞吐量可能下降,是否使用要看业务目标。
大多数场景默认的非公平锁已经够用,不要为了追求“看起来公平”就随意打开公平模式。
Condition 是什么
Condition 可以理解成和 Lock 配套的条件等待队列。
它解决的是:线程不仅要等待锁,还要等待某个业务条件成立。
Lock
├── Condition notEmpty:等待队列非空
└── Condition notFull:等待队列未满
一个 Lock 可以创建多个 Condition。
这和 synchronized 只有一个对象等待队列相比,表达能力更清楚。
创建 Condition:
private final ReentrantLock lock = new ReentrantLock();
private final Condition notEmpty = lock.newCondition();
private final Condition notFull = lock.newCondition();
await 对应等待,signal 和 signalAll 对应唤醒。
但它们有一个硬要求:调用前必须持有对应的 Lock。
await 和 signal 怎么配合
一个标准的 Condition 使用流程是:
lock.lock()
│
▼
检查条件
├── 不满足:condition.await(),释放 Lock 并等待
└── 满足:继续处理
│
▼
condition.signal()
│
▼
唤醒等待线程
│
▼
finally unlock()
await 不是简单地让线程睡眠。
它会原子地释放当前 Lock,把线程放入对应 Condition 的等待队列;被唤醒后,还需要重新竞争 Lock,拿到后才能从 await 后面继续执行。
所以代码必须放在锁里:
lock.lock();
try {
while (!ready) {
condition.await();
}
consume();
} finally {
lock.unlock();
}
唤醒也一样:
lock.lock();
try {
ready = true;
condition.signalAll();
} finally {
lock.unlock();
}
为什么仍然要用 while
和 wait 一样,Condition 判断条件也应该使用 while。
while (!notEmpty()) {
notEmpty.await();
}
线程被唤醒只代表“可以重新检查条件”,不代表条件一定已经成立。
可能是多个线程同时被唤醒,也可能是其他线程先拿到锁后又改变了状态。
被唤醒 ≠ 条件成立
被唤醒 = 重新竞争锁并检查条件
这条规则很重要,别用一个 if 把并发问题重新请回来。
用 Condition 实现有界队列
生产者消费者是最适合展示 Condition 的例子。
假设队列有容量上限:
- 队列满了,生产者等待
- 队列空了,消费者等待
- 放入数据后,唤醒消费者
- 取出数据后,唤醒生产者
public class BoundedQueue<T> {
private final ReentrantLock lock = new ReentrantLock();
private final Condition notEmpty = lock.newCondition();
private final Condition notFull = lock.newCondition();
private final Queue<T> queue = new ArrayDeque<>();
private final int capacity;
public BoundedQueue(int capacity) {
this.capacity = capacity;
}
public void put(T value) throws InterruptedException {
lock.lockInterruptibly();
try {
while (queue.size() == capacity) {
notFull.await();
}
queue.add(value);
notEmpty.signal();
} finally {
lock.unlock();
}
}
public T take() throws InterruptedException {
lock.lockInterruptibly();
try {
while (queue.isEmpty()) {
notEmpty.await();
}
T value = queue.remove();
notFull.signal();
return value;
} finally {
lock.unlock();
}
}
}
这段代码里,生产者只等待 notFull,消费者只等待 notEmpty。
数据放入后唤醒消费者,数据取出后唤醒生产者,线程不会被无差别地全部叫醒。
这就是多个 Condition 带来的表达能力。
Condition 和 synchronized 怎么比较
synchronized 的优势
synchronized 的优点很直接:
- 语法简单
- 异常时自动释放锁
- 不需要显式维护锁对象
- JVM 和工具链支持成熟
- 简单互斥场景可读性更好
如果只是保护一小段共享状态,synchronized 往往更稳。
Lock 的优势
Lock 适合需要精细控制的场景:
- 可以尝试获取锁
- 可以设置等待超时
- 可以响应中断
- 可以配置公平策略
- 可以创建多个 Condition 等待队列
但它要求我们自己保证 lock 和 unlock 成对出现。
synchronized:规则少,自动管理多
Lock:控制多,手动管理多
旧文 ID 137 讲过两者的选型,这里再补一句实际建议:
不要因为 Lock 更灵活,就把所有 synchronized 代码都改掉。
没有明确需求时,简单方案通常更不容易出错。
Condition 和 wait/notify 怎么选
可以这样理解:
synchronized + wait/notify
≈
Lock + Condition
但 Condition 可以创建多个等待队列,await、signal 的语义也更明确。
如果只有一个简单等待条件,wait/notify 足够;如果有生产者、消费者、读写状态等多个条件,Condition 通常更清晰。
使用 Lock 最容易踩的坑
忘记 finally 解锁
错误示例:
lock.lock();
doWork();
lock.unlock();
doWork 一旦抛异常,unlock 就执行不到。
正确写法必须是:
lock.lock();
try {
doWork();
} finally {
lock.unlock();
}
用错 Condition
一个 Condition 必须和创建它的 Lock 配套。
不能拿 A 锁创建的 Condition,跑到 B 锁的临界区里调用。
否则会抛出状态异常,或者让代码逻辑完全失控。
signal 时机不对
修改条件状态和发出 signal,应该放在同一把 Lock 保护下。
lock.lock();
try {
ready = true;
condition.signalAll();
} finally {
lock.unlock();
}
只 signal 不修改条件,等待线程被唤醒后还是会继续等待。
signal 还是 signalAll
signal 只唤醒一个等待线程,吞吐量更好,但调用方要确定唤醒一个就足够。
signalAll 会唤醒全部等待线程,更稳妥,但会产生一轮重新竞争。
如果多个线程等待的条件不同,盲目 signalAll 可能造成大量无效唤醒。
小结
Lock 把同步能力从 synchronized 的语法糖里拆出来,让我们可以控制获取锁的方式、等待时间、中断响应和公平策略。
Condition 则在 Lock 之上提供了多个条件等待队列:
await:等待条件并释放锁signal:唤醒一个等待线程signalAll:唤醒所有等待线程- 被唤醒后仍要重新竞争锁,并重新检查条件
使用时记住四件事:
- Lock 一定在 finally 中 unlock
- await、signal、signalAll 必须在对应 Lock 保护下调用
- 条件判断使用 while,不要只用 if
- 只有确实需要显式控制时才选择 Lock,简单互斥优先保持简单
一句话总结:synchronized 适合简单可靠地保护临界区,Lock 和 Condition 适合把锁竞争与线程协作拆成更精细的流程。
