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:唤醒所有等待线程
  • 被唤醒后仍要重新竞争锁,并重新检查条件

使用时记住四件事:

  1. Lock 一定在 finally 中 unlock
  2. await、signal、signalAll 必须在对应 Lock 保护下调用
  3. 条件判断使用 while,不要只用 if
  4. 只有确实需要显式控制时才选择 Lock,简单互斥优先保持简单

一句话总结:synchronized 适合简单可靠地保护临界区,Lock 和 Condition 适合把锁竞争与线程协作拆成更精细的流程。

更多推荐

章节目录