生产者消费者模型实战:从wait/notify到BlockingQueue与线程池

1. 为什么说生产者消费者模型是并发编程的第一课

做后端开发这几年,我越来越确信一件事:生产者消费者模型的实现水平,基本就是你并发编程水平的缩影。面试里问它,不是为了让你背一段 wait/notify 模板代码,而是想看你有没有真正理解线程之间为什么要协作、怎么协作才安全、以及什么时候该用现成工具而不是自己造轮子。

先把这个模型讲明白。它描述的场景非常朴素:一类线程负责产生数据,叫生产者;另一类线程负责处理数据,叫消费者。两者之间不直接通信,而是通过一个共享的缓冲区中转。这个缓冲区可能是内存队列、磁盘队列、消息中间件里的 Topic,本质上都一样。

我用一个日常类比帮不少新人理解过这个模型:餐厅后厨和出餐口。厨师是生产者,做完菜往出餐口一放就不管了;服务员是消费者,从出餐口取菜送去给客人。如果没有出餐口这个缓冲区,厨师就得追着服务员跑,服务员忙的时候厨师只能干等——两边都被对方的速度绑架。有了出餐口,厨师做完就放,服务员有空就来取,各干各的,互不阻塞。这就是缓冲区存在的意义。

那这道题为什么值得单独写一篇文章?因为从出餐口这个类比到能跑通、能抗压、能优雅停机的 Java 代码之间,隔着好几道坎:线程安全怎么保证、阻塞和唤醒怎么做、多生产多消费时会不会互相踩踏、队列没边界会不会把内存打爆。这篇文章我就把自己实际写过的、上线跑过的代码逐个拆开,把每个关键选择背后的为什么也一并讲清楚。

适合谁看?刚开始学 Java 并发的人可以照着抄作业,写过几个 demo 但没在生产环境验证过的人可以对照查漏,准备面试的人可以把文里这些知识点串成自己的语言。不绕弯子,直接开始。

2. 缓冲区到底帮我们解决了什么:三个核心矛盾

2.1 速率不匹配:慢的那一方会把快的拖死

先看最简单的情况:没有缓冲区,生产者直接调用消费者的方法。生产速率是每秒 100 条,消费速率是每秒 10 条,会发生什么?消费者还没处理完上一条,生产者的调用就来了,要么阻塞等,要么拒绝丢数据。生产者的吞吐被强行压低到消费者的水平,整个系统的吞吐等于最慢环节的吞吐。

反过来说,如果生产速率远低于消费速率,消费者就得一直空转等待,CPU 白白烧掉。在真实业务里,生产速率和消费速率极少完全一致,而且会随时间波动——早上高峰消息产生快,凌晨就慢。没有缓冲区,系统就一直在“互相拖后腿”的状态里摇摆。

缓冲区把两边的速率解耦了。生产者只管往队列里放,放不进去时才阻塞;消费者只管从队列里取,取不到时才等待。两边各自维护自己的节奏,系统整体吞吐由缓冲区的吸纳能力做调节。这也是为什么消息队列能成为分布式系统的标配——本质上就是把生产者消费者模型从单机搬到了分布式环境。

2.2 耦合太紧:两边动一处,另一处就得跟着改

如果方法论上只是“速率不匹配”这一个问题,那写一个阻塞队列就完事了。但实际上第二个矛盾更隐蔽:生产者和消费者的实现被绑死了。

没有缓冲区时,生产者代码里得直接持有消费者的引用,知道调用它哪个方法。一旦消费者改接口、换实现类、调整处理逻辑,生产者代码就得跟着改。这在代码层面叫高耦合,在运维层面叫牵一发而动全身。

引入缓冲区后,生产者和消费者只依赖队列的接口。生产者的产出格式是队列里那条数据的格式,消费者的输入格式也是队列里那条数据的格式,双方对彼此一无所知。新增消费者不需要改生产者,新增生产者不需要改消费者,两边可以独立灰度、独立扩容。这就是解耦的实际意义,不是概念好听而已。

2.3 抗冲击能力:突发流量如何不冲垮下游

还有一种容易被忽略的场景:瞬时流量尖峰。比如某个营销活动整点开始,前 30 秒的订单量是平时的 50 倍。如果没有缓冲区,这波流量直接打到下游系统,数据库连接池瞬间被占满,慢查询把 CPU 打满,整个服务雪崩。

有了缓冲区,突发的数据先堆积在队列里,消费者按照自己能承受的速度慢慢消费。削峰填谷,这就是缓冲区在流量治理上的作用。当然,代价是数据会积压,消费延迟会增加,所以缓冲区的容量设计和消费能力的监控配套是必须的,后面我会专门讲。

理解这三个矛盾,再看 wait/notify、BlockingQueue、线程池这些技术手段,你就知道它们各自在解决哪个环节的问题了。

3. 从零手写一个基础版本:synchronized + wait/notify

3.1 最简代码骨架:一个共享队列加一把锁

先别急着上 BlockingQueue,手写一遍 wait/notify 版本是很有必要的。它能让“线程间是怎么协作的”这个黑盒打开来看。我见过一些人直接跳到 BlockingQueue,遇到问题连 ideal 的堆栈都不知道怎么分析,就是因为底层语义没建立起来。

下面是一个能跑的最小实现:

import java.util.LinkedList; import java.util.Queue; public class ProducerConsumerDemo { private static final int CAPACITY = 10; private final Queue<Integer> queue = new LinkedList<>(); public static void main(String[] args) { ProducerConsumerDemo demo = new ProducerConsumerDemo(); Thread producer = new Thread(() -> { int value = 0; while (true) { demo.produce(value++); } }, "producer"); Thread consumer = new Thread(() -> { while (true) { demo.consume(); } }, "consumer"); producer.start(); consumer.start(); } public synchronized void produce(int value) { while (queue.size() == CAPACITY) { try { wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } queue.offer(value); System.out.println("produced: " + value + ", queue size: " + queue.size()); notifyAll(); } public synchronized int consume() { while (queue.isEmpty()) { try { wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return -1; } } int value = queue.poll(); System.out.println("consumed: " + value + ", queue size: " + queue.size()); notifyAll(); return value; } }

这套代码的逻辑很简单:produce 和 consume 都加了 synchronized 锁,说明同一时刻只有一个线程能操作队列。队列满了,生产者就调 wait 把自己挂起;队列空了,消费者就调 wait 挂起。每次放入或取出数据之后用 notifyAll 叫醒对方。

3.2 为什么是 while 而不是 if:虚假唤醒的坑

我见过不少新人在这一步写错:把 while 改成了 if。看起来似乎没问题,反正被唤醒之后队列状态肯定变了,再判断一次不是多此一举吗?

问题出在 Java 官方的 wait 文档里。文档明确说明:线程可能在没有被 notify、被中断、被超时的情况下醒来,这叫做“虚假唤醒”。虽然实际环境里它不常发生,但这是规范允许的行为。如果你用的是 if,虚假唤醒时线程会直接往下走,不再检查条件,就可能从满队列里继续塞数据,或者从空队列里取数据。

用 while 的意义在于:每次醒过来,不管是被正常唤醒还是虚假唤醒,都重新检查一次条件,不满足就继续等。这是一个防御性写法,成本极低,收益是彻底规避一类隐蔽的并发 Bug。

3.3 notify 还是 notifyAll:这个选择并不简单

这段代码里我用的是 notifyAll。它会把所有等待这把锁的线程都唤醒,然后让它们重新抢占锁。理论上更保守、更安全,但有一点:如果有多个生产者和多个消费者,notifyAll 会带来无意义的竞争。所有线程都被唤醒,但最终只有一个能抢到锁,其他线程又得回去接着等。

notify 则只唤醒一个线程,开销更小。但它在单生产者单消费者场景下才是安全的。一旦有多个生产者和多个消费者,notify 可能造成“同类唤醒同类”的情况:比如缓冲区满了,生产者都在等,消费者 take 完唤醒了一个线程,但唤醒的恰好是另一个生产者,它醒来发现队列还是满的,继续等,而消费者没被唤醒,整个系统就卡住了。

所以基础版本里用 notifyAll 是稳妥的,即使带来一点竞争开销,也好过因为 notify 选错导致系统死锁。等到了后面讲 Condition,会有更精确的唤醒方式,那才是解决这个问题的正解。

3.4 手写版本的痛点在哪里

这段代码能跑,但它的问题也很明显:synchronized 的粒度是整个方法,所有生产者和消费者共享同一把锁。并发量一上来,锁竞争会非常激烈,吞吐上不去。而且整个通知机制必须小心处理,稍微改错一个条件就出死锁或活锁。

更麻烦的是,这套模板代码在业务代码里重复度极高——每个需要缓冲区的场景都要复制粘贴一套 wait/notify,改个变量名就用,出了 bug 还不好排查。这也是为什么 JDK 后来提供了 BlockingQueue,把线程安全的队列封装好了,把阻塞语义内建进去了。下一节就讲怎么用它。

4. 生产级首选:BlockingQueue 一行代码换掉整个通知机制

4.1 BlockingQueue 帮你管好了哪些脏活累活

BlockingQueue 是 java.util.concurrent 包下的并发队列接口,它把上一节手写的三件事全部内建了:

  • 线程安全的入队出队:内部通过 ReentrantLock 和 Condition 实现,不需要你再加锁
  • 阻塞语义:put 方法在队列满时阻塞,take 方法在队列空时阻塞
  • 条件通知:内部精确区分了“队列满”和“队列空”两个条件,生产者阻塞在 notFull 上,消费者阻塞在 notEmpty 上,唤醒时不会搞混

拿它替换手写版本,核心逻辑从几十行缩到两三行,出错概率断崖式下降。

4.2 完整实现代码

import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; public class BlockingQueueDemo { private static final int CAPACITY = 10; private final BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(CAPACITY); public static void main(String[] args) { BlockingQueueDemo demo = new BlockingQueueDemo(); Thread producer = new Thread(() -> { int value = 0; try { while (!Thread.currentThread().isInterrupted()) { demo.queue.put(value); System.out.println("produced: " + value + ", queue size: " + demo.queue.size()); value++; Thread.sleep(100); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); System.out.println("producer interrupted"); } }, "producer"); Thread consumer = new Thread(() -> { try { while (!Thread.currentThread().isInterrupted()) { Integer value = demo.queue.take(); System.out.println("consumed: " + value + ", queue size: " + demo.queue.size()); Thread.sleep(500); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); System.out.println("consumer interrupted"); } }, "consumer"); producer.start(); consumer.start(); } }

这个实现里,put 和 take 自带阻塞语义,我不需要写一行 wait、notify、synchronized。队列满时生产者线程停在 put 调用上,队列空时消费者线程停在 take 调用上,等到条件满足,内部机制会自动把它们唤醒。

4.3 四个常用实现类的选型差异

BlockingQueue 是一个接口,JDK 提供了多种实现,选错了在特定场景下会出大问题。我整理了一张对比表:

实现类是否支持有界底层结构锁机制适用场景
ArrayBlockingQueue必须指定容量,有界数组单锁需要严格背压控制,容量固定的场景
LinkedBlockingQueue可指定容量,默认无界单向链表两把锁(take 锁和 put 锁分离)生产消费速率差异明显,需要更高吞吐
SynchronousQueue不存储元素无存储内部 CAS + 转移每个 put 必须等待一个 take,直接交接
PriorityBlockingQueue无界堆单锁需要按优先级消费的场景

这里我想多说一句 LinkedBlockingQueue。它的 put 锁和 take 锁是分开的,所以生产者之间竞争锁不会影响消费者之间取数据,吞吐通常优于 ArrayBlockingQueue。但它有个大坑:如果构造时不指定容量,默认是 Integer.MAX_VALUE,也就是无界队列。无界意味着生产者可以无限往队列里塞,内存会慢慢被打满,直到 OOM。生产环境一定要显式指定容量。

4.4 有界和无界不是简单二选一的问题

有界队列的核心价值是背压。当消费速度跟不上生产速度,队列满了之后,生产者的 put 会阻塞,生产速度被强制降下来。这看起来像是“系统慢”了,但实际上是在保护整个链路:数据不会无限制堆积,内存不会被打爆,消费者可以按自己的节奏慢慢消化。

无界队列的方式是“先收下所有数据再说”,好处是生产者永远不会被阻塞,极端情况下的吞吐更高;坏处是内存风险由自己承担。如果你对生产者的峰值速率没有确切把握,或者消费链路抖动恢复时间无法预估,用一个无界队列就是拿内存去赌。

我个人在项目里的习惯是:默认有界,容量按照消费能力乘一个容忍系数来定。比如消费者每秒能处理 100 条,线上允许积压 30 秒的数据,那容量就设 3000 左右。太大了积压数据滞留过久,消费延迟飙升;太小了频繁触发阻塞,生产者吞吐受到明显压制。

5. 多生产者多消费者进阶:线程池、精确唤醒与优雅停机

5.1 必须掌握的进阶实现:线程池接管线程创建

实际项目里不太会像上面那样手动 new Thread,因为线程的创建和销毁成本很高,而且线程数量不可控。一般会用线程池来管理消费者(和生产者)。下面是从项目里抽出来的简化版本:

import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; public class MultiProducerConsumerDemo { private static final int PRODUCER_COUNT = 2; private static final int CONSUMER_COUNT = 4; private static final int CAPACITY = 1000; private final BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(CAPACITY); public static void main(String[] args) { MultiProducerConsumerDemo demo = new MultiProducerConsumerDemo(); ExecutorService producerPool = Executors.newFixedThreadPool(PRODUCER_COUNT); ExecutorService consumerPool = Executors.newFixedThreadPool(CONSUMER_COUNT); for (int i = 0; i < PRODUCER_COUNT; i++) { producerPool.submit(demo::produce); } for (int i = 0; i < CONSUMER_COUNT; i++) { consumerPool.submit(demo::consume); } Runtime.getRuntime().addShutdownHook(new Thread(() -> { producerPool.shutdownNow(); consumerPool.shutdownNow(); System.out.println("pools shut down"); })); } private void produce() { int value = 0; try { while (!Thread.currentThread().isInterrupted()) { queue.put(value); value++; } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } private void consume() { try { while (!Thread.currentThread().isInterrupted()) { Integer value = queue.take(); handle(value); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } private void handle(Integer value) { // 真实业务处理逻辑 } }

这段代码里有几个点值得展开说。

5.2 生产者和消费者的线程数配比怎么定

CONSUMER_COUNT 配多少,不是拍脑袋定的。核心逻辑是:消费者的总数乘以单消费者处理耗时,决定了消息在队列里的平均停留时间;而这个停留时间必须小于业务能容忍的延迟上限。

假设单个消费者处理一条消息耗时 50ms,消费者有 4 个,那么理论上每分钟能处理约 4800 条。如果生产速率为每秒 100 条,也就是每分钟 6000 条,积压就会持续增加,队列迟早被打满。这时候要么把消费者加到 6 个,要么优化单条处理耗时到 30ms 以下。消费者数量不是越多越好,因为线程切换和锁竞争的开销会在某个临界点后抵消掉并发收益。

生产者的线程数则相对简单,通常它的瓶颈不在线程数,而在上游数据源的速度。配 2 个通常就够,除非生产者本身也是多路数据源的聚合。

5.3 优雅停机:shutdownNow 和 interrupt 的配合逻辑

停机是很多人忽略的环节。直接 System.exit 或者 kill -9,会导致正在处理的半边消息丢失,或者消费了一半的数据状态错乱。上面代码里用了 shutdownNow,它的语义是:向线程池里所有线程发送 interrupt 信号,让正在执行的任务响应中断。

关键点在于:代码里必须响应中断。我的 produce 和 consume 方法里有个 while (!Thread.currentThread().isInterrupted()) 的判断,还有 catch (InterruptedException) 处理。如果这两处没写,shutdownNow 发过去的 interrupt 信号就被吞掉了,线程池可能无法真正退出。

这里有个容易被忽略的细节:如果任务里调用了 BlockingQueue 的 put/take,线程处于阻塞状态时,interrupt 会让它抛出 InterruptedException,线程退出阻塞并进入 catch 块。我在 catch 里重新设置了 interrupt 标志位(Thread.currentThread().interrupt()),是为了让下一次循环的 isInterrupted 判断能感知到中断状态。这个细节是很多线上应用停机失败的根源。

5.4 more 高级姿势:Condition 精确唤醒替代 notifyAll

如果你需要更细粒度的唤醒控制,可以放弃 synchronized,改用 ReentrantLock 配合 Condition。它和手写 wait/notify 的区别在于,可以创建多个条件队列:

  • notFull:队列不满时,通知生产者停止阻塞
  • notEmpty:队列不空时,通知消费者停止阻塞
import java.util.LinkedList; import java.util.Queue; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; public class ConditionDemo { private final Queue<Integer> queue = new LinkedList<>(); private final int capacity = 10; private final ReentrantLock lock = new ReentrantLock(); private final Condition notFull = lock.newCondition(); private final Condition notEmpty = lock.newCondition(); public void put(Integer value) throws InterruptedException { lock.lock(); try { while (queue.size() == capacity) { notFull.await(); } queue.offer(value); notEmpty.signal(); } finally { lock.unlock(); } } public Integer take() throws InterruptedException { lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); } Integer value = queue.poll(); notFull.signal(); return value; } finally { lock.unlock(); } } }

这个版本里,signal 唤醒的是精确的 condition 队列:生产者阻塞在 notFull 上,消费者阻塞在 notEmpty 上,唤醒生产者时不会误伤消费者,反之亦然。和 notifyAll 相比,它减少了无意义的锁竞争。

不过我要说句实在话:如果只是标准的生产者消费者模型,直接用 BlockingQueue 就好,别自己实现 Condition 版本。JDK 的 ArrayBlockingQueue 内部已经把 ReentrantLock 和 Condition 用得很成熟了,自己造轮子很容易在边界条件上出问题。如果你是想深入学习 AQS 和 Condition 机制,那自己写一遍确实有益。这个版本我给出来,当作学习材料,不建议直接搬到生产代码。

5.5 从 BlockingQueue 到消息队列:一套思想,两种规模

写到这里顺便展开一个很多人会问的点:单机版的 BlockingQueue 和分布式消息中间件,本质上是什么关系?

单机版里,“队列”是内存里的一个数据结构,生产者和消费者是同一个 JVM 里的线程。分布式场景下,“队列”变成了消息中间件里的一个 Topic,生产者是各种上游服务,消费者是各种下游任务。队列的操作从内存方法调用变成了网络 IO + 持久化 + 分布式协调。

理解单机的生产者消费者模型,会直接帮你理解消息中间件的设计动机:为什么要持久化(防止消费端崩溃丢数据)、为什么要分区(并行消费的天然分片)、为什么要消费位点(记录消费者进度,崩溃后可以从断点继续)。从 BlockingQueue 到消息队列,是同一个模型的规模放大和可靠性加固。

6. 我在实际项目中踩过的坑与排查实录

6.1 虚假唤醒、死锁、惊群效应:最经典的三个并发坑

前面讲了 while 解决虚假唤醒,Condition 解决 notify 误唤醒,这里我再把实际项目里遇到过的并发坑汇总一下:

死锁:最典型的是加锁顺序不一致。比如两个线程都持有了一把锁,还想获取对方的锁,互相等待就死锁了。排查这个,用 jstack 看线程栈,死锁会直接打印 found one Java-level deadlock 的提示。我见过最隐蔽的一种是:嵌套锁的时候,外层锁用 synchronized,内层锁也用了 synchronized,但两个锁的获取顺序在两条代码路径上是相反的。这种只在特定时序下触发,平时测试根本测不出来。

惊群效应:notifyAll 会唤醒所有等待线程,但大多数线程会发现条件仍然不满足,又回去睡。高并发下这一来一回会放大很多倍的无效竞争。解决方式就是用 Condition 的 signal 或者 BlockingQueue 内部已经做好的条件区分。生产环境里如果发现 CPU 的 sys 占比异常高,同时线程大量处于 BLOCKED 或 WAITING 状态,就值得怀疑是不是惊群效应。

虚假唤醒:while 循环是唯一正解。虽然在 x86 平台上很多 JVM 实现里它很难被触发,但它属于 Java 语言规范层面的行为,必须当作会发生的来写。

6.2 队列容量设置不合理导致的连环事故

有一个真实案例让我记到现在。某个数据同步服务,开发同学很自然地写了 new LinkedBlockingQueue<>(),没给容量。测试环境一切正常,因为数据量小,队列几乎不可能积压。上线后某个上游系统做全量数据推送,几十分钟内数据量到了千万级,队列无限膨胀,内存被打到接近上限,GC 频繁,整台实例响应超时。等运维紧急扩容,上游数据又源源不断进来,新实例内存也一样被打满。

这个问题的根子就是无界队列。我当时给的修复方案是:显式设置容量。容量按消费者处理能力估算——单消费者每秒处理 200 条,用 5 个消费者,允许积压 5 分钟,容量大约是 200 * 5 * 300 = 30 万。同时配合丢数据策略或者限流策略,让业务方决定在积压到一定程度时选择阻塞生产还是丢弃边缘数据。

这里我也养成了一个习惯:所有涉及阻塞队列的代码,一律显式传容量参数,哪怕就是几百。因为无界队列的默认值是一个随时会爆的隐患,不显式设置等于在代码里埋了一颗定时炸弹。

6.3 jstack 排查线程状态:实操记录

有一次线上服务出现“卡顿”,接口偶发超时。我加了排查日志,发现某几个请求阻塞在一个队列的 put 方法上,而且队列永远处于满状态。当时的第一反应是:消费者是不是挂了?于是把当时线程池的 dump 打出来,用 jstack 看各线程状态。

排查流程我整理了一下:

# 找到服务进程号 jps -l # 打印线程快照 jstack <pid> > thread_dump.txt # 在快照里搜索关键状态 grep -n "BLOCKED\|WAITING\|parking" thread_dump.txt

jstack 输出里,如果消费者线程大量处于 WAITING (parking) 状态,正在等队列元素,说明队列是空的;如果消费者线程处于 RUNNABLE 但长期不结束,可能是它的处理逻辑里有外部调用没设置超时;如果生产者线程大量 BLOCKED 在 ArrayBlockingQueue 的 put 上,说明队列满了消费者消化不完。

那次排查看下来,真正的原因是一个下游的数据库连接池满了,消费者在等连接时触发了阻塞,导致整个消费链路停摆,队列迅速积压到满。后来给连接池设置了合理的超时,并且消费者里加上了处理失败重试的终止条件,问题才算根治。

这一整套排查经验,比背十遍并发理论都值。要把生产者消费者模型吃透,必须能在现场快速定位是“生产太快”“消费太慢”还是“锁竞争失控”,这三个方向的排查思路完全不同。

6.4 一个容易被忽略的细节:队列监控要前置

最后聊一个意识层面的东西。代码逻辑再严谨,如果没有数据支撑,出问题的时候你就是睁眼瞎。我在所有用了阻塞队列的服务里,都会暴露至少三个监控指标:

  • 队列当前积压数量
  • 生产者的 put 阻塞次数和阻塞时长
  • 消费者的 take 阻塞次数和阻塞时长

这三个指标直接告诉你系统瓶颈在哪边。积压量持续上升,消费者阻塞时间拉长,说明消费能力不足;put 阻塞次数飙升,说明队列容量对生产速率来说太小。没有这些指标,等到 OOM 或者超时才去临时看日志,排查成本会翻好几倍。

什么技术方案都不如一个能暴露问题的指标面板来得实在。

7. 最后分享一点实际经验的收尾

这套模型我在不同项目里反复用,从最早的 notify/wait 手写队列,到后来的 BlockingQueue 搭配线程池,再到接分布式消息中间件,架构在变,核心思路没有变过。如果你能理解缓冲区解耦的本质、阻塞语义的执行细节、容量设计的取舍逻辑,那么无论是写一个本地的任务队列,还是去理解消息队列的消费位点机制,都能很快上手。

一点个人体会:不要在生产环境追求'炫技'的写法。Condition 版本可以作为学习材料,但实际项目里用现成的 ArrayBlockingQueue 或 LinkedBlockingQueue,是性价比最高的选择——JDK 维护者已经帮你处理了绝大多数边界条件。你真正要精心设计的是容量、线程数、监控和停机逻辑,这些才是线上稳定性的分水岭。

还有一个我反复踩过的建议:所有阻塞操作一定要设置合理的超时。比如 BlockingQueue 的 offer(timeout, unit) 和 poll(timeout, unit),不要裸用 put 和 take,除非你确实需要无限期等待。一个漫长的阻塞往往会被下一个上游的超时连锁放大,最后变成整个链路的雪崩。用带超时的方法,至少能在异常时快速失败,给上层一个降级的机会。

生产者消费者模型看起来是教科书里最基础的一道题,但它连接着并发编程里最核心的一大片知识。把这一个小模型的每个细节吃透,你再看线程池、锁、队列、消息中间件,都会有一种打通了的感觉。写代码的乐趣,很多时候就在于这种“越基础越深邃”的体会。

相关新闻

MPU6050卡尔曼滤波姿态解算:陀螺仪零偏估计与加速度计动态补偿

MPU6050卡尔曼滤波姿态解算:陀螺仪零偏估计与加速度计动态补偿

简介&#xff1a;本资源是一份面向嵌入式开发者与硬件爱好者的MPU6050姿态解算实战代码包&#xff0c;聚焦于使用卡尔曼滤波融合陀螺仪与加速度计数据&#xff0c;精准解算俯仰角、翻滚角和偏航角&#xff0c;适用于无人机、平衡小车、智能机器人等运动控制场景。压缩包为RAR格…

2026/10/20 5:12:00 阅读更多 →
FFmpeg解封装收尾:avformat_close_input()实现原理与工程避坑指南

FFmpeg解封装收尾:avformat_close_input()实现原理与工程避坑指南

如果你维护过一个播放器&#xff0c;或者做过流媒体转码服务&#xff0c;大概率对avformat_close_input()这个函数不陌生。它是 FFmpeg 中负责关闭解封装上下文的入口&#xff0c;和avformat_open_input()正好是一对。打开输入好说&#xff0c;真正容易出问题的其实是关闭&…

2026/10/20 5:12:00 阅读更多 →
AIGC检测原理与降AI痕迹实测:从选型到参数设置一次讲透

AIGC检测原理与降AI痕迹实测:从选型到参数设置一次讲透

现在很多平台和学校对论文、作业、自媒体稿件里的AIGC痕迹查得越来越严。不少专科生朋友跟我吐槽&#xff0c;明明是自己熬夜写的初稿&#xff0c;只是用了AI工具辅助润色和扩写&#xff0c;一查“AI概率”竟然飙到百分之七八十&#xff0c;直接被要求重写。我也踩过这个坑&…

2026/10/20 5:12:00 阅读更多 →

最新新闻

网络扫描新手入门指南:用Dracnmap掌握Nmap端口探测核心技能

网络扫描新手入门指南:用Dracnmap掌握Nmap端口探测核心技能

1. 项目环境解析&#xff1a;为什么新手需要Dracnmap这类工具网络安全圈子有个常见的误区&#xff1a;很多人觉得“渗透测试”是高不可攀的黑客技术&#xff0c;一上来就想学各种复杂攻击手法。但实际上&#xff0c;真正入门的起点应该是理解网络扫描和测绘——你要先知道目标网…

2026/10/20 5:46:17 阅读更多 →
MySQL数据库脚本工程化:初始化、备份与迁移实践

MySQL数据库脚本工程化:初始化、备份与迁移实践

接手过不少别人留下的 MySQL 项目之后&#xff0c;我越来越觉得&#xff0c;真正决定一个系统能走多远的&#xff0c;往往不是业务代码写得有多花哨&#xff0c;而是那堆看起来不起眼的数据库脚本。最近刚好在重构一套旧系统的初始化脚本和备份逻辑&#xff0c;踩了几个坑&…

2026/10/20 5:46:16 阅读更多 →
VSCode搭建ROS开发环境全攻略:从智能提示到调试远程开发

VSCode搭建ROS开发环境全攻略:从智能提示到调试远程开发

1. 为什么我坚持用VSCode做ROS开发做机器人开发这些年&#xff0c;我先后用过各种各样的编辑器组合&#xff0c;从最初在虚拟机里用Vim硬啃&#xff0c;到后来用Qt Creator写ROS节点&#xff0c;再到尝试过CLion、Eclipse&#xff0c;最后稳定在VSCode上&#xff0c;这个过程其…

2026/10/20 5:46:16 阅读更多 →
diagram-design:从草图到系统,图表设计工程化实战指南

diagram-design:从草图到系统,图表设计工程化实战指南

1. 从一张草图到一套系统&#xff1a;diagram-design 到底在解决什么问题第一次听到 diagram-design 这个词&#xff0c;很多人会以为它只是“画图工具”的另一个说法。但真正在项目里被图表折磨过的人知道&#xff0c;问题从来不是“能不能画出来”&#xff0c;而是“画出来的…

2026/10/20 5:46:16 阅读更多 →
一张图吃透OpenClaw四层架构:感知认知行动记忆的智能体设计

一张图吃透OpenClaw四层架构:感知认知行动记忆的智能体设计

懂不懂一套系统&#xff0c;其实用不着一上来就去翻源码。你只要先把它的层次边界吃透&#xff0c;后面看任何文档、追任何报错&#xff0c;都会轻松很多。这次我继续写“OpenClaw”的认知启蒙系列第2篇&#xff0c;重点就是把四层架构彻底讲清楚。上一篇我们聊了为什么需要一套…

2026/10/20 5:46:16 阅读更多 →
FMCW MIMO雷达多目标分辨与DBSCAN聚类MATLAB仿真详解

FMCW MIMO雷达多目标分辨与DBSCAN聚类MATLAB仿真详解

“雷达点云看着漂亮&#xff0c;真正落地的时候就知道里面全是坑。”我最近在 MATLAB 里重新整理了一套基于 FMCW MIMO 雷达的多目标分辨与聚类仿真链路&#xff0c;用 128 个 Chirp 做慢时间积累&#xff0c;从回波生成一路走到 DBSCAN 点云聚类&#xff0c;把两个位置很接近的…

2026/10/20 5:45:16 阅读更多 →

日新闻

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
ESP32舵机控制指南:原理、多路驱动与避坑实战

ESP32舵机控制指南:原理、多路驱动与避坑实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
位移法实现一阶低通滤波:单片机ADC采样降噪实战

位移法实现一阶低通滤波:单片机ADC采样降噪实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/19 10:02:04 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/19 17:32:41 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/19 2:54:50 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/19 9:10:22 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/18 12:16:16 阅读更多 →