【多线程】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,就是把本文手写的生产者-消费者缓冲区,封装成了开箱即用的标准组件。

相关推荐
Lightpwd2 小时前
Spring Boot 多数据源落地:AbstractRoutingDataSource + 注解切面(附源码)
数据库·后端
LabVIEW开发2 小时前
LabVIEW 64位安装的位深陷阱:工具包、内存与工程兼容
数据库·labview·labview知识·labview功能·labview程序
寺中人2 小时前
MySQL 8.0 Windows 完整安装教程:环境配置、密码重置与常见报错排查
数据库·windows·mysql·环境搭建·mysql 安装
这个DBA有点耶2 小时前
同样48核配置TPS差1倍?高性价比数据库一体机的“软硬协同”才是分水岭
服务器·数据库·架构
财迅通Ai2 小时前
光智科技全景透视:一家被“光学元件”标签遮蔽的稀散金属材料平台
大数据·人工智能·科技·光智科技
这个DBA有点耶2 小时前
MySQL 8.0执行计划分析利器:EXPLAIN ANALYZE到底比EXPLAIN强在哪?
数据库·mysql·代码规范
YHHLAI3 小时前
SQL 完全指南:从入门到精通
数据库·sql
oradh4 小时前
Oracle enq: TX - index contention 锁等待事件问题排查总结
数据库·oracle·oracle enq tx·enq tx index