2026/10/17 14:57:14

Lock与Condition实现生产者消费者:从synchronized到精准并发控制

Lock与Condition实现生产者消费者:从synchronized到精准并发控制 简介一份面向Java多线程开发者的示例资料聚焦如何用Lock与Condition实现生产者消费者模式解决多线程协作中缓冲区同步、等待唤醒、锁竞争等核心问题。示例以ProducerConsumerDemo类为核心使用ReentrantLock创建可重入锁并通过lock.newCondition()获取条件变量produce()在缓冲区满时调用condition.await()释放锁并等待未满则生产商品后signalAll()通知消费者consume()在缓冲区空时同样await()非空则取出商品并signalAll()唤醒生产者整个流程在finally块中确保unlock释放锁避免死锁。包体为1个PDF文件约27KB内含完整可运行源码与关键行注释。目前已有318人学习适合初中级Java程序员阅读相比synchronized方案该示例可帮助理解显式锁的灵活控制、条件队列的精细等待与虚假唤醒的规避并提供了向BlockingQueue演进的扩展思路。文件虽小但内容聚焦适合并发编程入门、课堂演示或面试前快速回顾。1. 从 synchronized 到 Lock这套生产者消费者示例到底解决了什么很多人在初学 Java 多线程时第一次接触生产者消费者模式都是用synchronized加wait/notify完成。这一版「Lock、Condition实现简单的生产者消费者模式示例」走的是另一条路用ReentrantLock配合Condition来替代传统管程模型。你看到这个资源的第一反应可能是——既然synchronized能写为什么还要学Lock答案在于精准控制Lock能控制公平性、能尝试非阻塞获取锁、能创建多个等待队列而Condition就是把这个能力落实到「缓冲区满时生产者等、缓冲区空时消费者等」这一具体场景里的关键工具。这份示例代码约 120 行逻辑清晰适合正在补并发编程基础、准备面试或者想把手头 synchronized 老代码升级到 JUC 体系的从业者。我把它拆开之后发现它不只是演示了一个模式还把「锁的获取释放姿势」「条件等待的正确写法」「多线程启动顺序」这些问题全暴露出来了。跟着复现一遍你踩过的坑大概率都能在这篇文章里找到对应解释。2. 核心机制拆解Lock 与 Condition 如何把「等待」和「唤醒」拆成两个精确动作2.1 可重入锁与公平策略为什么构造参数里的 true 不是可有可无代码里定义锁的一行是private final Lock lock new ReentrantLock(true);这个true表示公平锁。所谓公平就是多个线程在等待锁时按请求顺序获得锁先来后到不插队。ReentrantLock构造时传false或空参就得到非公平锁默认情况下非公平锁的性能通常更高因为它减少了线程切换的开销但代价是可能造成某些线程长时间得不到锁也就是「饥饿」。在这个生产者消费者场景里选择公平锁是合理的生产者和消费者线程数量多示例中是 10 个生产者、15 个消费者如果非公平竞争激烈某类线程可能频繁抢到锁而另一类被饿死缓冲区状态就难以稳定。公平锁虽然吞吐量略低但能避免这类「忙的忙死、闲的闲死」的极端情况。可重入性是ReentrantLock的另一个关键特性同一个线程可以多次获取同一把锁而不会自己把自己锁死。这在生产者消费者场景中意味着如果produce()内部又调用了其他需要同一把锁的方法比如日志输出不会出现死锁。代码中lock.lock()和lock.unlock()严格配对配合finally块确保解锁执行这就是比synchronized更精细的锁控制要点之一。private final Lock lock new ReentrantLock(true); // 公平锁避免线程饥饿 private final Condition condition lock.newCondition(); // 一个Condition对应一个等待队列参数说明true公平策略按等待时间长短分配锁适合多消费者多生产者均衡场景condition从lock.newCondition()创建和synchronized里的wait/notify不同Condition 可以创建多个且必须和锁绑定使用2.2 await 与 signalAll 的配对逻辑条件等待队列如何让出 CPU 又不丢信号用Condition时核心动作是condition.await()和condition.signalAll()。await()的作用是让当前线程释放锁并进入该 Condition 的等待队列这跟Object.wait()类似但语义更清晰——条件对象本身就是「等待什么」的声明。在produce()方法里缓冲区满时执行condition.await()生产者线程被挂起并释放锁。消费者取走一个元素后调用signalAll()把等待在该条件上的所有线程唤醒。注意这里用的是signalAll()而不是signal()区别在于前者唤醒所有等待线程后者只唤醒一个。在多生产者多消费者场景下signal()可能唤醒一个同类线程造成「通知失效」的假象而signalAll()虽然会有少量无谓竞争但逻辑正确性更高示例中的选择是稳妥的。同样consume()中缓冲区空时await()生产者放入商品后signalAll()唤醒消费者。这样形成闭环生产者塞满时停下消费者取空时停下双方都通过同一个 Condition 通信虽然可以用两个 Condition 分别管理但单条件实现简单、易于验证。public void produce() { lock.lock(); try { while (bufferList.size() bufferSize) { System.out.println(Producer wait, thread: Thread.currentThread().getName()); condition.await(); } bufferList.add(new Object()); System.out.println(Producer produce one, now buffer size: bufferList.size() , and thread: Thread.currentThread().getName()); condition.signalAll(); } catch (InterruptedException e) { e.printStackTrace(); } finally { lock.unlock(); } }逻辑说明这里最值得关注的是while而不是if来判断缓冲区状态。如果使用if线程被唤醒后会直接向下执行不做二次检查。在多线程环境下可能出现「消费者 A 和 B 同时被唤醒A 抢先消费最后一个商品B 继续消费时缓冲区已空」的越界问题。while循环让被唤醒的线程重新检查条件不满足就再次await()这是应对「虚假唤醒」的标准写法。参数与行为说明bufferList.size() bufferSize判断满注意是等号因为 add 之前插不进去new Object()示例中的「商品」是空对象实际工程中这里往往是任务对象、消息体signalAll()放在add()之后确保唤醒消费者时缓冲区里有数据可读consume()的逻辑是produce()的镜像空则等非空则从bufferList.remove(0)取出元素。这里用remove(0)模拟 FIFO 队列每次从头部移除意味着先生产的先被消费。如果用ArrayList做缓冲区remove(0) 会触发数组拷贝效率不高但作为学习示例这种写法没问题。public void consume() { lock.lock(); try { while (bufferList.isEmpty()) { System.out.println(Consumer wait, thread: Thread.currentThread().getName()); condition.await(); } bufferList.remove(0); System.out.println(Consumer consumer one, now buffer size: bufferList.size() , and thread: Thread.currentThread().getName()); condition.signalAll(); } catch (InterruptedException e) { e.printStackTrace(); } finally { lock.unlock(); } }参数说明remove(0)从链表头移除FIFO 顺序signalAll()放在 remove 之后唤醒生产者「有空间了」每次操作都打印缓冲区大小方便运行时观察状态变化3. 可动手调试的完整示例线程数量、缓冲区大小与运行观察3.1 从示例跑到动态调参那些日志输出验证了什么经验运行示例前先看主函数的设计10 个生产者线程、15 个消费者线程缓冲区容量 5。这个数量关系不是随便选的生产者比消费者少缓冲区容量不大意味着消费者要频繁等待生产者生产日志里会出现大量Consumer wait。反过来如果是消费者少于生产者则生产者会频繁等待缓冲区有空间。这个参数设计能在日志中清晰展现「谁在等、为什么等」。我在实操中拿到这份代码第一件事是把producerCount改成 20、consumerCount改成 5、缓冲区改成 3运行后日志里Producer wait的比例大幅上升。这是因为缓冲区很快被填满生产者被迫排队。这种调参观察是理解生产者消费者模型最好的方式比看十遍理论都有用。int producerCount 10; int consumerCount 15; final ProducerConsumerDemo pcd new ProducerConsumerDemo(5); Thread[] producerThreads new Thread[producerCount]; for (int i 0; i producerCount; i) { producerThreads[i] new Thread(producer (i 1)) { Override public void run() { pcd.produce(); } }; } Thread[] consumerThreads new Thread[consumerCount]; // 消费者线程创建同理略 for (int i 0; i producerCount; i) { producerThreads[i].start(); } for (int j 0; j consumerCount; j) { consumerThreads[j].start(); }参数与行为说明生产者线程名producer1..producer10消费者线程名consumer1..consumer15打印日志时能直接区分来源每个生产者/消费者线程只执行一次 produce/consume所以程序不会无限运行最终所有线程执行完毕JVM 正常退出每个线程执行一次操作10 个生产者和 15 个消费者共 25 次操作总生产量 10、总消费量 15最终缓冲区应该为空这也是验证逻辑正确性的方法真正动手跑一下你会看到日志出现如Consumer wait, thread: consumer5和Producer produce one, now buffer size: 3交替出现。这说明线程之间真的在「排队协作」而不是各跑各的。3.2 缓冲区容量校验构造器里的防御式编程构造函数里有一行容易被忽略的代码this.bufferSize bufferSize 0 ? bufferSize : DEFAULT_BUFFER_SIZE;。它的意思是如果传入的缓冲区大小不是正数就用默认值 10。这样避免了两类问题缓冲区为 0 时生产者和消费者会死锁在等待状态——生产者永远等不到空间消费者永远等不到商品缓冲区为负数时List初始化直接抛异常。我一般建议把这行校验改成更清晰的写法if (bufferSize 0) { this.bufferSize DEFAULT_BUFFER_SIZE; }效果相同但可读性更好。这个构造函数体现了「写库代码的人要为调用方兜底」的思路实际项目里还应该加上参数合法性日志否则使用者不知道自己的参数被静默替换了。public ProducerConsumerDemo(int bufferSize) { this.bufferSize bufferSize 0 ? bufferSize : DEFAULT_BUFFER_SIZE; bufferList new ArrayListObject(bufferSize); }参数说明bufferSize 0 ? bufferSize : DEFAULT_BUFFER_SIZE非法值回退到默认值保证后续逻辑安全的最后防线ArrayListObject(bufferSize)初始容量设定减少 add 时的数组扩容次数这是 ArrayList 构造器带参数的优化点4. 避坑手册四个常见问题的现象、原因与解决4.1 现象程序运行后日志停止所有线程都打印了 wait出现这种情况通常是某个线程持锁后没有释放或者所有生产者消费者都把锁互相锁死了。原因有两个常见来源一是await()的线程被唤醒后没有重新竞争到锁一直被挂起二是消费方阻塞在空缓冲区时如果所有生产者线程也都在等待锁而不是生产就会全员等待。解决方法是回看两处lock.lock()与lock.unlock()是否成对await()是否在try块里保证异常时能走finally释放锁。此外把生产者和消费者线程数量调成趋于平衡比如 10 和 10可以减少这类现象的干扰方便定位。提示遇到卡死优先查两类位置——lock 是否成对出现Condition 的 await 是否有对应的 signal/signalAll 路径。4.2 现象用 if 判断是否满/空运行偶尔抛出越界异常或消费到 null这是最常见的条件判断陷阱。把while(bufferList.size() bufferSize)改成if(bufferList.size() bufferSize)之后多线程竞争激烈时会出现IndexOutOfBoundsException或者消费到空。原因是 Java 的唤醒机制不保证「唤醒即条件满足」多个消费者可能同时从等待队列中被唤醒一个消费者消费了唯一的商品另一个消费者再执行 remove(0) 时数组已经空了。解决方法是无条件使用while做条件判断这也是《Java 并发编程实战》中明确推荐的写法与await()配合可防御spurious wakeup虚假唤醒。如果你是从synchronized迁移过来的老手记住这不是「一个 while 而已」而是并发安全的关键防线。4.3 现象程序运行一段时间后生产者或消费者线程毫无输出可能原因是signalAll()被放在了异常分支里没执行或者某个生产者线程在add()之前抛了业务异常。在这个示例里bufferList.add(new Object())不会抛业务异常但换成真实项目里的队列写入就可能出问题。另一个常见原因是await()方法抛出InterruptedException后代码只做了printStackTrace()就继续走了线程被中断后没有重新处理业务。解决方法InterruptedException要么恢复中断状态Thread.currentThread().interrupt()要么退出当前操作重新选择。在实际多线程组件的封装中不要只打印异常堆栈应该把中断状态传出去让上层决定是重试还是终止。4.4 现象日志正常打印但缓冲区大小始终为 0 或 5无中间状态这看起来奇怪但其实是正常现象——竞争激烈时线程拿到锁就立刻操作、立刻释放其他线程再拿到锁继续操作中间的缓冲区状态可能快速从 0 跳变到 1 再到 2而日志打印只反映了操作完成瞬间的大小。如果你在真实并发场景想观察缓冲区变化需要把add操作和size输出放进同一个锁段内否则打印的 size 可能已经被下个线程改了。这个「现象」的解决方式不是改代码而是改测试期望多线程日志不适合推理精确时序更合理的做法是统计总生产和总消费次数最终一致即可。这也是我从这个示例中学到的观察方法并发日志看的是趋势和最终状态不是具体顺序。5. 从单条件到多条件的升级路径拆开两个 Condition 的收益与代价5.1 单一 Condition 的实现缺陷为什么生产者和消费者会被「无意义唤醒」当前示例里生产者和消费者共享同一个condition。这意味着生产者signalAll()时唤醒的不只是等待中的消费者也包括等待中的其他生产者。同理消费者signalAll()也会唤醒生产者和其他消费者。被唤醒的那些「错类」线程会重新检查 while 条件发现自己仍然无法继续只能再次await()白白消耗一次锁竞争和上下文切换的开销。在 10 生产者 15 消费者的规模下这种开销可以忽略但如果是几百个线程或者缓冲区极小无意义唤醒的比例会显著升高。改进做法是创建两个 ConditionnotFull和notEmpty生产者等待notFull消费者等待notEmpty生产后notEmpty.signal()只唤醒消费者消费后notFull.signal()只唤醒生产者唤醒精度更高。private final Lock lock new ReentrantLock(true); private final Condition notFull lock.newCondition(); private final Condition notEmpty lock.newCondition(); public void produce() { lock.lock(); try { while (bufferList.size() bufferSize) { notFull.await(); } bufferList.add(new Object()); notEmpty.signal(); // 只唤醒一个消费者 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { lock.unlock(); } } public void consume() { lock.lock(); try { while (bufferList.isEmpty()) { notEmpty.await(); } bufferList.remove(0); notFull.signal(); // 只唤醒一个生产者 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { lock.unlock(); } }参数说明notFull.await()缓冲区满时生产者挂起在 notFull 队列notEmpty.signal()生产一个商品后告诉等待消费的线程有货了signal 唤醒一个即可因为一个商品对应一个消费者双 Condition 下signal()替代signalAll()是安全的因为唤醒的一定是同类等待者这段代码是这份资源最重要的升级方向。很多人在简历里写「熟悉生产者消费者模式」但真正面试时被问「为什么用两个 Condition」就露馅。学会这一手你能把示例代码从「能跑」提升到「设计合理」。5.2 定位实践把这份代码改造成多条件版本需要动哪几行改造路径很清晰新增一个notFull的 Conditionproduce()里的await和signalAll分别替换为notFull.await()与notEmpty.signal()consume()对应替换。注意signalAll改成signal后要确认每次生产数量与唤醒消费者数量匹配。就这个示例而言每次add一个对象唤醒一个消费者是精确匹配的。我实际改造后跑了几轮日志里Producer wait和Consumer wait交替出现更规律了「被错误唤醒后再次等待」的日志几乎消失。这个改动带来的性能提升在宏观吞吐量上很难量化但线程调度的确定性确实增强了。另一个可选升级是给缓冲区换成LinkedList或ArrayBlockingQueue。用ArrayBlockingQueue的话代码可以缩短一半它内部就用ReentrantLock和两个 Condition 实现了同样的逻辑。但这就绕回了「为什么不用BlockingQueue」的问题——直接用它你就不需要自己写锁了但也就失去了这个示例展示 Lock 和 Condition 用法的作用。6. 验证技巧与工程化收尾让这段示例代码从「能跑」变成「可信」调试多线程代码最有价值的不是看输出满屏的日志而是建立一个「状态可验证」的观察框架。针对这份资源我通常加一个计数器来验证最终一致性统计生产者总共生产了多少、消费者总共消费了多少以及程序结束时缓冲区剩余数量。三者满足生产总数 - 消费总数 剩余数量才算逻辑正确。private int totalProduced 0; private int totalConsumed 0; // produce() 内 add 之后 totalProduced; // consume() 内 remove 之后 totalConsumed;这个计数变量加在锁内避免多线程竞争下数值错乱。跑完所有线程后把三个数字打出来人工核对等式。如果对不上基本可以断定锁范围或条件判断有问题。这是个土办法但比看一百行日志更快锁定问题。验证框架搭好后我习惯把这份代码做成一个可配置的小工具通过命令行参数传入生产者数量、消费者数量、缓冲区大小脚本里循环跑多组参数组合比较不同配置下「哪类等待更多」「哪类线程总执行时间更长」。从这份示例学到的最有价值的一点是Lock和Condition的关系就像遥控器和空调——lock控制谁能进来操作condition控制什么时候该停下等待。把这两个概念分开理解之后再复杂的并发协作场景都能用同一套思路拆解。从那以后我每次写多线程协作代码都会强制走一遍「锁保护什么、条件等待什么、唤醒谁」这三问等号条件用while、锁释放放finally这套习惯让我的并发代码翻车率明显下降。希望帮到你。本文还有配套的精品资源点击获取