Lock与Condition实现生产者消费者:精准唤醒与并发协作实践 简介这份PDF面向Java多线程初学者与需要深入理解并发协作的中级开发者以Lock与Condition为切入点讲解生产者消费者模式如何通过显式锁与条件队列实现线程间的安全协作解决了传统synchronized和wait/notify在锁粒度控制、多条件等待方面不够灵活的问题。文档从ProducerConsumerDemo类的共享缓冲区设计讲起完整展示了ReentrantLock保证互斥、condition.await()释放锁并进入等待、condition.signalAll()唤醒全部等待线程的完整流程并特别解释了为什么缓冲区满/空时要用while循环判断而不是if以避免虚假唤醒造成的越界生产或消费。除了基础流程文档还补充了InterruptedException异常处理、tryLock()非阻塞获取锁以及ArrayBlockingQueue等内置阻塞队列的替代方案帮助读者在实际项目中根据场景选择合适的同步机制。资源包仅含1个PDF文件大小27KB内容紧凑方便随时查阅目前已有318人学习下载无论是备考Java并发编程还是复习生产者消费者模式这份示例都能提供清晰的代码级参考。通过学习读者可以掌握使用Lock与Condition编写可靠生产者消费者模型的能力并灵活迁移到任务队列、数据缓存、连接池等业务场景。1. 用 Lock 和 Condition 手写生产者消费者到底比 BlockingQueue 多得到了什么生产环境里一个 ArrayBlockingQueue 就能解决的生产者消费者场景为什么还要用 Lock 和 Condition 自己写因为有些管道需要精细控制流量、需要多路唤醒、需要异常恢复而 BlockingQueue 的黑匣子行为在高压下并不总是透明的。这篇文章用一个 100 行不到的仓库模型从 wait/notify 讲到多 Condition 的并发姿势适合读得懂 synchronized 但还没把 Condition 用熟的开发者也适合想把线程协作从「碰运气」变成「看得清」的人。读完你能拿到一份可以直接跑的最小示例代码以及 5 个实操里真实踩过的坑最后还有一个多生产多消费下的验证方法避免上线前心里没底。2. 先弄懂 Lock 和 Condition它和 wait/notify 到底差在哪2.1 有了 synchronized为什么还要引入 Lock很多项目里的线程协作一开始都是从 synchronized 加 wait/notify 写起的。synchronized 的问题不是不能跑而是太粗糙一个监视器锁只有一个等待集合所有没满足条件的线程都睡在同一张床上。生产者想等「仓库有空位」消费者想等「仓库有数据」这两个等待原因完全不一样却被强制塞在同一个等待队列里。notify 只能随机叫醒一个线程你没法保证叫醒的是消费者还是另一个生产者notifyAll 虽然能把所有人都叫醒但代价是所有人重新抢锁、重新判断条件大量线程做了无用功。Lock 接口解决的就是这个粒度问题。ReentrantLock 是显式锁支持可中断获取、超时获取、公平与非公平策略这些 synchronized 都没有。但真正让 Lock 在生产者消费者模式里脱颖而出的是它配套的 Condition。一个 ReentrantLock 可以 new 出多个 Condition每个 Condition 维护一条独立的条件等待队列。生产者等 notFull消费者等 notEmpty两条队列互不干扰唤醒时也能精准投递。这是我愿意在项目里从 synchronized 迁移到 Lock 的主要理由不是为了炫技是为了减少无效唤醒和排队竞争。2.2 Condition 与 wait/notify 的映射await、signal、signalAll如果你熟悉 wait 和 notifyCondition 的 API 几乎可以一一对应await 对应 waitsignal 对应 notifysignalAll 对应 notifyAll。但这里有三个关键差异直接影响你写代码的方式。第一synchronized 的 wait 只能在 synchronized 块里调用而 Condition 的 await 只能在持有对应 Lock 时调用它同样会释放当前持有的锁但释放的是 Lock 而不是内置监视器。第二await 支持超时版本 await(long time, TimeUnit unit)await 期间还响应中断这意味着线程不会因为某个条件永远不满足而无限期挂死。第三signal 只是把等待线程从条件队列移到锁队列中被移出的线程需要重新参与锁竞争而不是立刻继续执行。很多人第一次写 Condition 时以为 signal 之后线程马上就往下走实际不是这样。这里有一个最容易被忽略的点await 返回后线程并不代表条件已经满足。线程醒来只是说明「它有了重新检查条件的机会」条件可能仍然不成立。比如两个消费者同时被 signal 唤醒但仓库里只有一份数据抢到锁的消费者取走了数据另一个消费者拿到锁时仓库已经空了它必须再次等待。这就是为什么标准写法里判断条件必须用 while 而不是 if。我一般会把这个规则记成一句话await 永远被放在 while 循环里醒来的第一件事是重新检查而不是直接消费。2.3 为什么两个 Condition 比一个 Condition 更精准用一个 Condition 实现生产者消费者是完全可行的put 和 take 都操作同一个条件变量但效率上会有明显的缺陷。假设生产者完成一次生产后调用 signal此时等待队列里既有生产者也有消费者signal 唤醒谁是不确定的如果唤醒的是另一个生产者它醒来后发现自己等的是「有空间」而仓库又满了只能再次睡回去这就是一次无效唤醒。改用 signalAll 也一样所有线程同时醒来互相竞争锁最终只有少数线程能真正推进其余全部白醒一场。两个 Condition 的写法是典型的生产者消费者标准答案。notEmpty 队列专门睡等待数据的消费者notFull 队列专门睡等待空间的生产者。put 操作完成之后调用 notEmpty.signal只可能唤醒消费者take 操作完成之后调用 notFull.signal只可能唤醒生产者。唤醒精准了线程切换少了锁竞争也小。在仓库容量小、生产消费频率高的场景里这个差距会被放大得非常明显。所以我做某跨平台系统的消息管道时只要需要手动控制线程协作第一选择就是双 Condition。如果只是简单的任务分发BlockingQueue 仍然更合适这点到最后一章会细说。3. 最小可运行示例仓库模型与三处必调参数3.1 完整示例代码一个仓库类加上一生产一消费代码是最有说服力的。下面这个示例不依赖任何框架纯 JDK 并发包实现。我用 LinkedList 模拟仓库容量由构造参数决定生产者往尾部放数据消费者从头部取数据一放一取天然符合 FIFO。你可以直接复制运行。import java.util.LinkedList; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; public class SimpleStorage { private final LinkedListInteger items new LinkedList(); private final int capacity; private final ReentrantLock lock new ReentrantLock(); private final Condition notEmpty lock.newCondition(); private final Condition notFull lock.newCondition(); public SimpleStorage(int capacity) { this.capacity capacity; } public void put(int value) throws InterruptedException { lock.lock(); try { // 必须用 while 而不是 if防止虚假唤醒后越过容量检查 while (items.size() capacity) { notFull.await(); } items.addLast(value); notEmpty.signal(); } finally { lock.unlock(); } } public int take() throws InterruptedException { lock.lock(); try { while (items.isEmpty()) { notEmpty.await(); } int value items.removeFirst(); notFull.signal(); return value; } finally { lock.unlock(); } } }启动代码负责创建仓库、启动生产者和消费者线程。public class ProducerConsumerDemo { public static void main(String[] args) throws Exception { SimpleStorage storage new SimpleStorage(3); Thread producer new Thread(() - { for (int i 1; i 10; i) { try { storage.put(i); System.out.println(produced: i); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } }, producer); Thread consumer new Thread(() - { for (int i 0; i 10; i) { try { int value storage.take(); System.out.println(consumed: value); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } }, consumer); producer.start(); consumer.start(); producer.join(); consumer.join(); System.out.println(all done); } }运行结果里 produced 和 consumed 会交替出现顺序不固定但最终能配成 10 对。如果生产量变成 1000、消费量变成 1000这个程序依然能稳定跑完不会死锁也不会丢数据这正是 Condition 协作的核心价值。3.2 逐段拆解await 释放锁、signal 不释放锁、while 循环先把 put 方法的执行流程走一遍。生产者调用 put第一件事是 lock.lock() 拿锁。拿到锁之后检查 items.size() capacity仓库满就进入 notFull.await()。await 会做三件事释放当前持有的锁、把当前线程挂起、加入 notFull 条件队列。线程在这里睡住直到某个消费者 take 之后调用 notFull.signal()线程才会从条件队列移到锁队列重新参与锁竞争。它获得锁后从 await 返回但此时不能直接 addLast必须回到 while 循环再检查一次容量。这就是 while 的价值避免虚假唤醒和竞争带来的越界写入。signal 的调用时机也值得说。put 方法里 notEmpty.signal() 调用时当前线程仍然持有锁signal 只是把等在 notEmpty 队列里的某个消费者移动到锁队列并不会释放锁。真正释放锁是走到 finally 里的 lock.unlock()。所以消费者被唤醒后并不会马上执行它要等生产者完全退出临界区并释放锁然后去抢锁。这个顺序是 Condition 机制设计的核心也是新手最容易误解的地方。如果有人在 unlock 之后再调用 signalReentrantLock 会直接要求当前必须持有锁运行时就会抛出 IllegalMonitorStateException这一点非常容易踩。take 方法完全对称。消费者拿锁后检查仓库是否为空空就 await被生产者唤醒后重新检查不为空才 removeFirst。这里要注意的是removeFirst 操作本身在锁保护内对 LinkedList 的修改是线程安全的。我之前在某图像处理 Demo 里偷懒直接把锁去掉结果生产环境偶发报了 NoSuchElementException排查了大半天才发现是消费者在空列表上取数据本质上就是条件的判断被绕过了。3.3 三个必调参数容量、公平性、等待超时第一个必调参数是仓库容量 capacity。容量为 1 时生产者和消费者基本是严格交替执行你一步我一步吞吐量最低但内存占用最小容量调大后生产者可以连续生产多条再休息消费者也能攒批消费吞吐量会明显上升代价是数据在管道里的停留时间变长。我的习惯是先按单条消息大小和允许的最大延迟估算出一个粗值再通过压测微调而不是拍脑袋定一个 128 或 1024。第二个必调参数是 ReentrantLock 的公平性。构造函数 new ReentrantLock() 默认非公平线程抢锁凭运气吞吐量高但极端情况下可能出现线程饥饿new ReentrantLock(true) 会按等待时间排队分配锁线程更公平但锁切换开销更大。生产者消费者场景里我一般保持默认非公平除非有明确的服务质量要求比如某个任务必须在限定时间内被处理否则没必要为公平性付出吞吐代价。第三个必调参数是 await 的超时时间。如果线程在 await 里无限等待一旦对应的 signal 因为 bug 没发出程序就永久挂死。我一般会改成 notFull.await(3, TimeUnit.SECONDS) 这种写法超时后返回 false再根据返回值和当前状态决定是继续重试还是放弃。注意 await 超时不等同于锁超时lock.tryLock(3, TimeUnit.SECONDS) 是拿锁超时await 超时是等条件超时两者语义完全不同第 4 章会专门讲这个坑。4. Lock/Condition 落地避坑5 个真实会踩的并发问题4.1 把 while 写成 if空仓库上取数据直接翻车现象消费者线程偶尔抛出 NoSuchElementException但程序并不是必现跑几十次才出现一次线上日志无法稳定复现。 原因判断条件用 if 而不是 while。消费者被 signal 唤醒后直接走到 removeFirst但此时仓库可能是空的。为什么会空因为可能有多个消费者同时等在 notEmpty 队列上生产者只放了一条数据signal 唤醒了其中一个消费者它抢到锁取走数据另一个消费者也被唤醒了它抢到锁后没有重新检查仓库状态直接在空链表上执行 removeFirst异常就发生了。 解决把 if 换成 while。这不是风格问题是 Condition 使用的强制要求只要条件判断是和 await 配对使用的一律用 while 循环。我也被这个问题坑过一次那个「玄学」bug 最后查出来就是一行 if 的锅。4.2 signal 写错了队列生产者等了半天也没被唤醒现象仓库满了生产者线程阻塞在 notFull.await()消费者不断取走数据日志显示生产者和消费者都活着但生产就是永远不恢复。 原因take 方法里把 notFull.signal() 写成了 notEmpty.signal()。消费者完成消费后只唤醒等待数据的消费者而等待空间的生产者还睡在 notFull 队列里没有收到任何通知。这属于复制代码时改漏了对象名但排查时很容易忽略因为代码能编译、能运行只是逻辑死锁。 解决写完后把 put 和 take 的 signal 对象对照检查一遍生产完成唤醒 notEmpty消费完成唤醒 notFull。我的习惯是把这两个 Condition 的命名和用途直接写在变量声明旁边即使这样也拦不住手滑。遇到线程挂死不报错的场景先看唤醒用的是哪个 Condition再看唤醒的方向对不对。4.3 吞掉 InterruptedException线程中断状态丢失现象程序停止时调用线程的 interrupt() 想让它退出但线程没有任何反应继续卡在 await 里进程无法优雅关闭。 原因catch 块里直接写了注释或者空语句没有处理中断状态。await 抛出 InterruptedException 后会把线程的中断状态清除如果 catch 里不做任何事线程就丢失了被中断的信号程序只能强制杀掉。 解决在 catch 里调用 Thread.currentThread().interrupt() 恢复中断状态然后 return 退出循环。这既保留了中断信息也让上层逻辑能感知到线程是被中断而非正常结束。我踩过这个坑之后凡是有 await 或 sleep 的代码catch 块里第一行必然是 interrupt() 恢复已经成了肌肉记忆。4.4 await 超时当成锁超时等条件的时间全耗在抢锁上现象设置了 await(5, TimeUnit.SECONDS)但线程还是经常超过 5 秒才恢复有时候甚至等了 10 多秒。 原因await 超时等待的是「条件被通知」的时间不包含等待获取锁的时间。线程从 await 返回后它还在锁队列里排队其他线程持有锁的时间越长它恢复执行就越晚。如果还有多个等待者竞争同一把锁总耗时可能远大于 5 秒。这是把 await 超时和 lock.tryLock 超时混为一谈造成的误判。 解决先明确自己到底想限制哪一步。拿锁阶段用 tryLock(timeout)条件等待阶段用 Condition.await(timeout)两个超时可以配合使用。如果业务要求整体响应时间可控可以在两个阶段都设超时并在超时后走降级分支而不是赌某一边一定能在时间内完成。4.5 多消费者场景下信号丢失部分线程永远睡下去现象改成两个消费者线程后生产者放了很多数据但某些消费者线程始终不消费日志里没有任何输出线程状态显示 WAITING。 原因put 方法用了 notEmpty.signal() 而不是 signalAll()。双 Condition 下 signal 确实只投递给消费者但如果多个消费者同时在等生产者每生产一条只叫醒一个消费者而消费者可能取完一条又继续睡整体上每个消费者都有机会不存在永久丢失。真正的问题出在单 Condition 版本或者混合唤醒逻辑里signal 可能叫醒的是生产者消费者队列没人叫醒等待的消费者就永久睡眠。 解决如果真正用了两个 Conditionsignal 是够用的因为每个 Condition 只睡同一种角色如果项目里为了省事用了单 Condition必须改成 signalAll 并用 while 兜底。最稳妥的排查方式是把所有线程 dump 出来看 WAITING 线程的 stack trace停在哪个 await 上就能反推出是谁忘了发信号。5. 从能跑到跑稳多生产多消费下的验证方法与压测技巧手动写的 Lock、Condition 代码最怕一件事单测能过多线程一跑就出问题。我的验证方法是固定总量配合计数器让多个生产者和多个消费者同时跑最后断言消费总数必须等于生产总数。下面是一个可复用的骨架。int total 1000; SimpleStorage storage new SimpleStorage(4); AtomicInteger consumed new AtomicInteger(); int producerThreads 2; int consumerThreads 2; Thread[] producers new Thread[producerThreads]; for (int t 0; t producerThreads; t) { producers[t] new Thread(() - { for (int i 0; i total / producerThreads; i) { try { storage.put(1); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } }); } Thread[] consumers new Thread[consumerThreads]; for (int t 0; t consumerThreads; t) { consumers[t] new Thread(() - { while (consumed.get() total) { try { storage.take(); consumed.incrementAndGet(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } }); } // 启动所有线程并 join最后断言 consumed.get() total这个骨架里有几个细节。消费者的 while 条件用 consumed.get() total保证消费线程不会提前退出AtomicInteger 替代 int 避免多线程下的可见性问题最终断言用 。跑过几百次如果断言都通过说明生产消费基本配对正确。这个验证方式不算严格证明但作为上线前的回归手段足够用。压测方面容量是最值得调的参数。下面这组观察关系可以帮你快速定位瓶颈容量行为特征主要风险1生产消费严格交替线程切换频繁吞吐量低4-16缓冲充分吞吐平稳需要压测确定最优值过大生产者几乎不阻塞内存占用上升数据延迟变高压测时先固定线程数只调容量看总耗时和内存曲线再固定容量调线程数观察锁竞争情况。如果压测中消费者一直追赶不上生产者优先看消费端逻辑耗时而不是盲目调大容量。容量只是缓冲不是解决消费慢的方案。我现在的习惯是先判断能不能直接上 BlockingQueue能就绝不手写只有需要多路精确唤醒、需要超时控制、需要异常恢复时才用 Lock 和 Condition。一旦上手while 循环、超时兜底、中断恢复这三件套必须写齐缺一个都不敢上线。排查线程不结束的问题时永远按「等待谁、通知谁、谁来唤醒它」的顺序找而不是到处打印日志碰运气。这套方法帮我避过好几次生产事故希望帮到你。本文还有配套的精品资源点击获取