简介:这份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 和 notify,Condition 的 API 几乎可以一一对应:await 对应 wait,signal 对应 notify,signalAll 对应 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 LinkedList<Integer> 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 之后再调用 signal,ReentrantLock 会直接要求当前必须持有锁,运行时就会抛出 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 可能叫醒的是生产者,消费者队列没人叫醒,等待的消费者就永久睡眠。 解决:如果真正用了两个 Condition,signal 是够用的,因为每个 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 循环、超时兜底、中断恢复这三件套必须写齐,缺一个都不敢上线。排查线程不结束的问题时,永远按「等待谁、通知谁、谁来唤醒它」的顺序找,而不是到处打印日志碰运气。这套方法帮我避过好几次生产事故,希望帮到你。
本文还有配套的精品资源,点击获取