【多线程】Lock与Condition

【多线程】Lock 与 Condition

Lock 与 Condition:比 synchronized 多给了你什么?

文章目录

  • [【多线程】Lock 与 Condition](#【多线程】Lock 与 Condition)
    • 引言
    • 一、场景故事:它在解决什么问题?
    • 二、核心机制:从代码看原理
      • [2.1 ReentrantLock 标准骨架与三大增强](#2.1 ReentrantLock 标准骨架与三大增强)
      • [2.2 多 Condition:精准唤醒的交替打印](#2.2 多 Condition:精准唤醒的交替打印)
      • [2.3 生产者-消费者:手写有界缓冲区](#2.3 生产者-消费者:手写有界缓冲区)
    • 三、理解偏差:这些坑别踩
      • [偏差 1:判断条件用 if 就够了](#偏差 1:判断条件用 if 就够了)
      • [偏差 2:不持有锁也能调用 await / signal](#偏差 2:不持有锁也能调用 await / signal)
      • [偏差 3:unlock 不放 finally 也行,反正没抛异常](#偏差 3:unlock 不放 finally 也行,反正没抛异常)
      • [偏差 4:公平锁更公平,所以应该都用公平锁](#偏差 4:公平锁更公平,所以应该都用公平锁)
      • [易混淆点:Lock + Condition vs synchronized + wait/notify](#易混淆点:Lock + Condition vs synchronized + wait/notify)
    • [四、记忆卡片:核心 QA 对](#四、记忆卡片:核心 QA 对)
    • 五、知识网络:相似、互斥与学习路径
      • [🔗 相似知识(同类不同派):Object.wait / notify](#🔗 相似知识(同类不同派):Object.wait / notify)
      • [⚔️ 互斥/替代知识(通常二选一):synchronized vs ReentrantLock](#⚔️ 互斥/替代知识(通常二选一):synchronized vs ReentrantLock)
      • [📚 前置与后续(学习路径)](#📚 前置与后续(学习路径))
    • 六、配套代码运行指南
    • 结语

引言

上一篇讲到,synchronized 是互斥的默认选择,但它有四个先天限制:

  1. 拿不到锁只能死等,不能超时放弃;
  2. 等锁的过程不可中断,死锁了只能杀进程;
  3. 全局只有一个等待队列 ,notifyAll() 会把不相干的线程全部吵醒;
  4. 无法选择公平策略,总有线程被"饿死"。

ReentrantLock + Condition 就是 JDK 对这四个限制的逐一回应。本文通过三个递进的 Demo 讲透:标准用法与三大增强 → 多 Condition 精准唤醒 → 手写生产者-消费者 。第三个 Demo 写出来的缓冲区,就是下一篇 BlockingQueue 的内部原理。


一、场景故事:它在解决什么问题?

📖 故事一:医院的分诊叫号

某小诊所只有一位坐诊医生,候诊大厅里挤着看各种病的病人。轮到下一位时,前台直接用大厅广播喊:"下一位!"------结果所有人都涌向诊室门口重新排队抢,只有一个人能进去,其余人骂骂咧咧回到座位,白跑一趟,大厅嘈杂又低效。

后来诊所改造:按科室分成三个诊室,各自门口有等候区 。挂骨科的去骨科门口坐着,挂眼科的去眼科门口坐着。医生看完一个骨科病人,只喊"下一位骨科"------只有骨科门口的病人会被叫到,眼科病人安心坐着刷手机,一次都不用白跑。

💡 对应关系:

故事中 技术中
唯一的医生(诊室) ReentrantLock(同一时刻只放一个线程进入)
大厅广播"下一位" notifyAll():唤醒整个等待集合
各科室门口的等候区 一个个 Condition(独立等待队列)
只叫"下一位骨科" condition.signal():精准唤醒指定队列
病人坐下等叫号 condition.await():释放锁并进入等待队列

📖 故事二:面包店的烤盘

面包店后厨有一个 5 格的烤盘 。烤面包的师傅发现烤盘满了 ,只能停下来歇着,等有人买走一个腾出空位;来买面包的顾客发现烤盘空了,也只能等着,等师傅烤好一炉。空位和面包,就是师傅和顾客之间无声的"暗号"------谁也不催谁,条件不满足就各自等着,条件一满足就自动继续。

💡 对应关系:

故事中 技术中
5 格烤盘 容量为 5 的有界缓冲区
烤盘满 → 师傅歇着 生产者在 notFull 条件上 await()
烤盘空 → 顾客等着 消费者在 notEmpty 条件上 await()
买走一个 → 叫醒师傅 消费后 notFull.signal()

基于这两个直觉,我们来看正式机制。


二、核心机制:从代码看原理

2.1 ReentrantLock 标准骨架与三大增强

java 复制代码
// Demo01_Basic.java
static final ReentrantLock lock = new ReentrantLock();
static int count = 0;

// 标准骨架(必须背下来):lock 一定配 try/finally
Runnable task = () -> {
    for (int i = 0; i < 10000; i++) {
        lock.lock();                    // 注意:lock() 放在 try 外面
        try {
            count++;                    // 业务代码(临界区)
        } finally {
            lock.unlock();              // 放 finally:业务抛异常,锁也必须释放
        }
    }
};

与 synchronized 相比,ReentrantLock 的三大增强在 Demo01 中逐一演示:

java 复制代码
// 增强 1:tryLock ------ 拿不到锁立刻返回 false,去干别的,不傻等
if (lock.tryLock()) {
    try { /* 拿到了,处理 */ } finally { lock.unlock(); }
} else {
    System.out.println("没拿到锁,先去做别的事了(没有阻塞)");
}

// 增强 2:lockInterruptibly ------ 等锁期间可以被 interrupt 打断
try {
    lock.lockInterruptibly();   // synchronized 等锁时永远不可打断,这里可以
    try { /* ... */ } finally { lock.unlock(); }
} catch (InterruptedException e) {
    System.out.println("等锁时被打断,放弃获取锁");   // 死锁的解药之一
}

// 增强 3:公平锁 ------ new ReentrantLock(true),先来先得
ReentrantLock fairLock = new ReentrantLock(true);   // 默认 false = 非公平(吞吐更高)

💡 代码要点:

  1. unlock() 必须放 finally:synchronized 由 JVM 保证异常时自动释放,而 Lock 是普通 API,忘写 unlock 就意味着锁永远不会释放------这是从 synchronized 迁移到 Lock 时最常见的致命错误。
  2. lock.lock() 写在 try 外面 :如果写在 try 内且 lock() 本身失败,finally 里的 unlock() 会释放一把从未获得的锁,抛出 IllegalMonitorStateException。
  3. "Reentrant(可重入)"与 synchronized 一样:同一线程可重复 lock(),unlock() 次数必须与之配对。
  4. 公平锁保证先来先得、无线程饿死,但需要维护队列顺序,吞吐量低于默认的非公平锁。绝大多数场景用默认非公平即可。

2.2 多 Condition:精准唤醒的交替打印

synchronized 的所有等待线程挤在一个 等待集合里,notifyAll() 只能全员唤醒再重新抢锁;而一个 ReentrantLock 可以 newCondition() 出多个 Condition,每个 Condition 就是一条独立的等待队列------这就是故事一里的"分科室叫号"。

Demo02 用它实现了 3 个线程交替打印 A B C A B C ...(各 5 轮):

java 复制代码
// Demo02_ConditionAlternate.java
static final ReentrantLock lock = new ReentrantLock();
static final Condition condA = lock.newCondition();   // 线程A 的专属等候区
static final Condition condB = lock.newCondition();   // 线程B 的
static final Condition condC = lock.newCondition();   // 线程C 的
static int state = 1;                                 // 1=该A,2=该B,3=该C

static void print(String content, int myState, Condition self, int nextState, Condition next) {
    for (int i = 0; i < 5; i++) {
        lock.lock();
        try {
            // ① 不是轮到自己 → 在自己的队列里睡觉(释放锁)
            while (state != myState) {
                self.await();
            }
            // ② 轮到自己:干活,然后切换状态
            System.out.print(content + " ");
            state = nextState;
            // ③ 精准叫醒"下一个",而不是全员广播
            next.signal();
        } finally {
            lock.unlock();
        }
    }
}
// 线程A:print("A", 1, condA, 2, condB) ------ 打完 A 叫醒 B
// 线程B:print("B", 2, condB, 3, condC) ------ 打完 B 叫醒 C
// 线程C:print("C", 3, condC, 1, condA) ------ 打完 C 叫回 A,形成闭环

整个协调过程是一张状态环:

复制代码
        ┌────────────────────────────────────────────┐
        │                                            │
        ▼                                            │
   [state=1] 打印A ── state=2 ──▶ 打印B ── state=3 ──▶ 打印C ── state=1
   condA 队列              condB 队列                condC 队列
   (谁不满足条件,谁就在自己的队列里 await 睡觉)

💡 代码要点:

  1. 每个线程"睡在自己的队列、只被自己的接棒人叫醒",等待关系一目了然------用 wait/notifyAll 实现同样的逻辑,需要每个线程醒来后反复判断、反复抢锁,代码难写且低效。
  2. await() 做了两件事:释放锁 (否则别人永远进不了临界区,状态永远变不了)+ 进入自己的等待队列 ;被 signal 后还要重新竞争锁 ,拿到锁才从 await() 返回。
  3. while 而不是 if:被唤醒后条件可能又被别人改了,必须重新检查(详见误区一)。

2.3 生产者-消费者:手写有界缓冲区

这是 Condition 最重要的应用模型。Demo03 用"一把锁 + 两个 Condition + 环形数组"实现了经典的有界缓冲区------两个 Condition 把"队列空"和"队列满"两种等待分开管理:

java 复制代码
// Demo03_ProducerConsumer.java
static final int CAPACITY = 5;
static final ReentrantLock lock = new ReentrantLock();
static final Condition notFull  = lock.newCondition(); // 生产者的等候区:"没满"才轮到我
static final Condition notEmpty = lock.newCondition(); // 消费者的等候区:"不空"才轮到我
static final String[] buffer = new String[CAPACITY];
static int putIdx, takeIdx, size;

static void produce(String item) throws InterruptedException {
    lock.lock();
    try {
        while (size == CAPACITY) {          // 烤盘满了 → 去生产者等候区睡觉
            notFull.await();
        }
        buffer[putIdx] = item;
        putIdx = (putIdx + 1) % CAPACITY;   // 环形下标:走到头绕回 0
        size++;
        notEmpty.signal();                  // 库存变了 → 叫醒一个等取货的消费者
    } finally {
        lock.unlock();
    }
}

static String consume() throws InterruptedException {
    lock.lock();
    try {
        while (size == 0) {                 // 烤盘空了 → 去消费者等候区睡觉
            notEmpty.await();
        }
        String item = buffer[takeIdx];
        takeIdx = (takeIdx + 1) % CAPACITY;
        size--;
        notFull.signal();                   // 空位变了 → 叫醒一个等空位的生产者
        return item;
    } finally {
        lock.unlock();
    }
}

两边的等待与唤醒互不打扰:

复制代码
 生产者                         共享缓冲区                        消费者
    │                        [ ][ ][ ][ ][ ] 空了                   │
    │── produce() ──▶ 放入商品 ── notEmpty.signal() ──▶ 叫醒消费者   │
    │                        [✔][✔][ ][ ][ ]                        │
    │                        [✔][✔][✔][✔][✔] 满了                   │
    │  notFull.await() ◀── 在生产者队列睡觉 ◀──┐                     │
    │                                        └── 消费者取走并 signal │

💡 代码要点:

  1. 一把锁保护缓冲区的全部状态(buffer、下标、size),保证"检查条件 + 修改状态 + 唤醒对方"是一个原子过程。
  2. 两个 Condition 的分工是按条件分组,而不是按角色站队:生产者等的条件是"有空位",消费者等的条件是"有货",各睡各的队列。
  3. 放入成功 signal 消费者、取走成功 signal 生产者------每一次唤醒都"叫对了人"。这正是 Java 官方 ArrayBlockingQueue 的内部实现(下一篇直接用它)。

三、理解偏差:这些坑别踩

偏差 1:判断条件用 if 就够了

❌ 常见误解 :if (size == CAPACITY) notFull.await();------检查一次,不满足就等,满足就干活,逻辑上没毛病。

✅ 正确理解 :必须用 while。原因有二:其一 ,await() 返回后到重新拿到锁之间存在时间窗,条件可能已被其他线程改回去(生产者 A 和 B 同时被唤醒,只有一个能放下货,另一个醒来时又满了);其二 ,存在虚假唤醒(spurious wakeup)------API 允许线程无理由地被唤醒,while 保证醒来先复查条件,两种情况一起兜底。

🧠 为什么容易错:单线程思维里"检查过一次的条件在几微秒内不会变",而并发环境下"检查"和"行动"之间的每一纳秒都可能被插入其他线程的操作。

🧪 自测方法 :把 Demo03 的两处 while 改成 if 再跑,多来几轮大概率出现库存越界(ArrayIndexOutOfBoundsException)或 size 变成负数。


偏差 2:不持有锁也能调用 await / signal

❌ 常见误解 :Condition 是个独立对象,随时可以调用 condition.await()。

✅ 正确理解 :await()/signal() 必须在持有对应 Lock 的前提下调用(即 lock() 与 unlock() 之间),否则抛 IllegalMonitorStateException。原因很本质:await 的第一步是"释放锁",你手里根本没有锁,释放什么?

🧠 为什么容易错 :wait()/notify() 也有同样的约束(必须在 synchronized 内),但报错发生在运行期而非编译期,很多人是踩了异常才记住。

🧪 自测方法 :记住固定结构------await/signal 永远出现在 lock.lock(); try { ... } finally { lock.unlock(); } 内部。看到锁外调用直接判错。


偏差 3:unlock 不放 finally 也行,反正没抛异常

❌ 常见误解 :临界区就几行代码,不会抛异常,直接顺序写 lock(); 业务; unlock(); 更简洁。

✅ 正确理解 :业务代码抛出任何 RuntimeException,unlock() 将被执行不到,锁永远不会释放,之后所有线程在这把锁上永久阻塞------一个异常演变成整个系统的停摆。

🧠 为什么容易错:"现在不会抛异常"是对当前代码的判断,而代码会被修改、被复用。synchronized 用户从来没有这个心智负担(JVM 自动释放),切到 Lock 时容易把这个责任弄丢。

🧪 自测方法 :review 时看到 lock() 与 try 之间、或 finally 缺失 unlock(),一律按 bug 处理。


偏差 4:公平锁更公平,所以应该都用公平锁

❌ 常见误解 :非公平锁会"插队",不公平就是不好,生产环境应该 new ReentrantLock(true)。

✅ 正确理解 :非公平是刻意的设计------刚释放/刚到达的线程大概率 CPU 缓存还热着、线程也无需挂起唤醒,直接抢锁省去一次"挂起-唤醒"的上下文切换,吞吐量显著更高。公平锁只在确实需要"杜绝线程饿死"的场景(如对延迟公平性敏感的任务调度)才有价值,代价是整体吞吐下降。

🧠 为什么容易错:把生活中的"公平是美德"直接平移到技术选型,忽略了两种模式各自服务的目标(吞吐 vs 公平延迟)。

🧪 自测方法:问自己------这个场景里"某个线程偶尔多等几毫秒"有实际危害吗?没有,就用非公平。


易混淆点:Lock + Condition vs synchronized + wait/notify

维度 synchronized + wait/notify Lock + Condition
等待队列 一个对象只有一个等待集合 一个 Lock 可派生多个 Condition 队列
唤醒粒度 notifyAll() 全员唤醒、重新抢锁 signal() 只唤醒指定队列,精准唤醒
唤醒方 notify() 随机唤醒一个(可能叫错人) 明确唤醒某个条件的等待者
等锁可中断 ❌ ✅ lockInterruptibly()
等锁超时 ❌ ✅ tryLock(timeout)
公平性 不可选 可选
释放锁 自动 必须手动(finally)
一句话区分 够用但只有"一室一厅" 复杂协作的"多室多厅"

四、记忆卡片:核心 QA 对

# 问题 答案
1 定义:Condition 是什么? 与 Lock 配套的等待/唤醒机制 :一个 Lock 可 newCondition() 多个 Condition,每个 Condition 是一条独立的等待队列。
2 机制 :await() 和 signal() 分别做了什么? await():释放锁 + 当前线程进入对应 Condition 队列;signal():把该队列中的线程移回锁的竞争队列,等它重新拿到锁后才从 await() 返回。
3 场景:什么时候必须用多个 Condition? 线程需要按不同条件分组等待:生产者-消费者(notFull/notEmpty)、多线程交替执行、读写分离等待等。单一条件用 wait/notify 即可。
4 权衡:使用 Lock 的风险是什么? 锁要手动释放(忘 finally = 永久锁死);不持锁调用 await/signal 抛异常;公平锁吞吐更低------换来的是可中断、可超时、多队列的灵活性。
5 区分 :signal() vs notifyAll()? notifyAll 只能唤醒同一个等待集合的全部线程(惊群);signal 精准唤醒指定 Condition 队列,无关线程毫无感知。
6 边界 :为什么等待必须用 while 而不是 if? 唤醒后条件可能又被其他线程改掉,且存在虚假唤醒;while 保证醒来后重新检查,是等待-唤醒范式的标准写法。
7 增强:ReentrantLock 相比 synchronized 的三大能力? tryLock()(不傻等/限时等)、lockInterruptibly()(等锁可中断,死锁解药)、公平锁可选(先来先得)。

五、知识网络:相似、互斥与学习路径

🔗 相似知识(同类不同派):Object.wait / notify

对比维度 Condition(await/signal) Object(wait/notify)
核心思想 把"等待"抽象为可多实例的对象 把"等待"挂在对象监视器上,一对象一套
队列数量 一个 Lock 多个 Condition 一把监视器锁只有一个等待集合
唤醒控制 精准到队列 只能全员或随机一个
依赖的锁 ReentrantLock synchronized
一句话区分 wait/notify 是 Condition 的"单队列特例" Condition 是 wait/notify 的"多队列升级"

⚔️ 互斥/替代知识(通常二选一):synchronized vs ReentrantLock

对比维度 synchronized ReentrantLock
核心思想 JVM 语法级互斥,自动管理生命周期 API 级互斥,手动管理、能力更全
最佳场景 简单互斥、临界区短、无复杂协作 可中断/可超时/多条件/公平性需求
关键差异 自动释放锁,写不出"忘解锁"的 bug 灵活,但把释放责任交给了程序员
选择依据 默认选它;只有用到 Lock 独有能力时才换 明确需要三大增强之一时选它

📚 前置与后续(学习路径)

  • 前置知识 :
    • synchronized 与竞态条件(系列第 1 篇):理解"互斥"和"锁对象"是本文的地基
    • 线程中断(interrupt()):lockInterruptibly() 的前提是理解中断只是"打个招呼",由线程自己决定如何响应
  • 后续知识 :
    • BlockingQueue(系列第 3 篇):本文 Demo03 的官方封装版,业务代码只剩 put/take
    • AQS(AbstractQueuedSynchronizer):ReentrantLock 和 Condition 的底层框架,"同步队列 + 条件队列"双队列结构的源码级实现
    • Semaphore / CountDownLatch:基于同一套 AQS 的其他协作工具
  • 最佳搭档 :
    • 环形数组 :putIdx = (putIdx + 1) % CAPACITY 的环形缓冲区与 Lock+Condition 组合,就是 ArrayBlockingQueue 的完整蓝本
    • 线程池 :ThreadPoolExecutor 的 worker 线程正是在条件队列上等待任务

六、配套代码运行指南

复制代码
a02LockCondition/
├── Demo01_Basic.java              标准骨架 + tryLock + lockInterruptibly
├── Demo02_ConditionAlternate.java 多 Condition 精准唤醒(ABC 交替打印)
└── Demo03_ProducerConsumer.java   手写有界缓冲区(生产者-消费者)

每个类都含 main 方法,直接运行即可(JDK 17)。建议重点观察:

  • Demo01:tryLocker 没拿到锁时没有阻塞 ,waiter 等锁时被 interrupt() 干净地退出;
  • Demo02:输出恒为 A B C A B C ...,把 next.signal() 换成 lock.newCondition().signal()(叫错人)就能看到程序卡死,体会"精准"的含义;
  • Demo03:消费者较慢(300ms),观察库存到 5 后生产者阻塞、等待消费者腾位的完整过程。

结语

Lock 与 Condition 的价值不是"比 synchronized 更好的锁",而是把锁从语法关键字升级成了可编程的对象 :等待可以放弃(tryLock)、可以被打断(lockInterruptibly)、可以分组排队(多 Condition)。其中"一把锁 + 多个条件队列"的组合,是理解 Java 几乎所有高级同步工具的钥匙------下一篇的 BlockingQueue,就是把本文手写的生产者-消费者缓冲区,封装成了开箱即用的标准组件。

相关推荐
小羊没烦恼!5 天前
微服务化的基石——持续集成
java·大数据·word·powerpoint·.net
一隅论数智5 天前
给AI一张“业务概念地图“:本体如何从哲学走向企业智能
大数据·人工智能·经验分享·笔记·学习·学习方法·政务
尧炎科技5 天前
防潮抗变形,就选纯品梅花全桉多层板
大数据
这个DBA有点耶5 天前
MVCC深入:Read View、版本链与快照读——InnoDB并发控制的内核
数据库·mysql·架构
DBA_G5 天前
从地面到云霄:GBase数据库在民航三大场景的落地实践
数据库
程序员大阳5 天前
副队长大数据教程(5)--集群情况下虚拟机网络配置
大数据·集群·nat·网路
自由能燃气设备5 天前
商用全预混低氮冷凝锅炉免费方案vs付费方案对比+选型避坑指南
大数据·数据库·人工智能
科创致远5 天前
科创致远 ESOP 系统核心效能与实战价值展示
大数据·数据库·人工智能·精益工程
嘉立创FPC苗工5 天前
FPC与机器人的双向赋能,解锁智能装备进化新势能
大数据·人工智能·制造·fpc·电路板
AI职业加油站5 天前
AI智能体应用工程师证书:政策红利下的职业新风口
大数据·运维·人工智能·学习·职场发展