管程解决的两个问题:互斥进入与条件等待
多个线程共同操作一份状态时,仅仅保证“一次只有一个线程修改”还不够。消费者拿到锁以后可能发现队列为空,生产者拿到锁以后也可能发现队列已满;它们需要在条件不成立时暂停,并在状态变化后继续竞争执行机会。
管程把共享状态、访问状态的操作、互斥规则和条件等待放进同一个抽象里。Java 的 synchronized 与 wait/notify 组合正体现了这套思路:前者保护状态,后者协调线程什么时候可以继续。
只有一把锁,还不能写好生产者—消费者
假设有一个容量固定的队列。生产者向队列添加元素,消费者从队列取走元素。为了避免两个线程同时修改队列,可以在操作前加锁:
1 | 生产者 ─┐ |
锁解决了互斥,但没有回答条件不满足时怎么办。消费者发现队列为空,如果一直持锁循环检查,生产者永远无法进入并放入数据;如果释放锁后不断轮询,又会浪费 CPU,还要自行处理检查条件和再次加锁之间的竞态。
这里其实有两个独立问题:
- 互斥:同一时刻只允许一个线程检查或修改队列。
- 同步:队列为空时消费者等待,队列不满时生产者才能继续。
管程将这两部分放在同一个边界内。共享变量不能被外部线程随意修改,线程只能通过受保护的操作访问它;条件不满足时,线程进入与该条件相关的等待集合,并暂时让出进入管程的资格。
管程不只是锁,而是一段受保护的状态与操作
从编程模型看,一个管程通常包含三部分:共享状态、操作这些状态的过程,以及一个或多个条件变量。互斥规则保证同一时刻至多有一个线程在管程内执行;条件变量则让已经进入的线程表达“当前状态还不能继续”。
有界队列可以用两个条件描述:
1 | notEmpty:队列中至少有一个元素,消费者可以取 |
等待条件和竞争入口并不是同一件事。一个线程可能还没获得锁,正在等待进入临界区;另一个线程已经获得过锁,只是发现业务条件不成立,主动进入条件等待。前者在争夺访问资格,后者在等待共享状态发生特定变化。
封装在这里很重要。如果外部代码可以绕过管程直接改队列,那么状态变化可能没有配套通知,等待线程也无法依靠管程维护的不变式。把变量和操作放在同一边界里,才有条件在每次修改后判断应该唤醒谁。
三种管程语义的差别,在于唤醒后谁先执行
管程发展过程中常提到 Brinch Hansen、Hoare 和 Mesa 三类语义。它们都提供互斥与条件等待,主要区别是一个线程发出 signal 后,等待线程何时获得管程。
Hoare 语义采用立即交接。发出 signal 的线程把管程执行权直接交给被唤醒线程,自己暂时等待。被唤醒线程开始运行时,刚刚成立的条件仍由交接动作保证,因此推理很直接,但运行时需要更强的调度与切换配合。
Brinch Hansen 语义把 signal 限制在离开管程时执行。通知之后当前过程立即退出,被唤醒线程才有机会继续。这减少了“通知后信号线程又修改状态”的空间,但也限制了程序结构。
Mesa 语义把通知看作“状态可能已经变得有利”的提示。通知线程仍会继续执行,直到退出管程;等待线程只是变为可运行,之后还要重新竞争锁。等它真正进入时,其他线程可能已经再次改变共享状态,所以必须重新检查条件。
Java 的内置监视器表现为 Mesa 风格:notify() 不会立刻释放锁,被选中的等待线程也不会立刻从 wait() 返回。只有通知线程离开同步区域后,被唤醒线程重新获得同一把锁,它才能继续执行。
Java 中为什么要用 while 包住 wait
下面是一个精简的 Java 有界队列。代码的重点不是队列实现,而是条件检查、等待和状态修改都在同一个对象监视器保护下完成:
1 | import java.util.ArrayDeque; |
消费者调用 wait() 时必须已经持有当前对象的监视器。这个调用会把线程加入该对象的等待集合,并释放它对该监视器的所有重入占用,使生产者能够进入 put()。线程被通知、被中断、等待超时或发生允许的虚假唤醒后,仍要重新获得监视器;只有锁状态恢复以后,wait() 才会返回或抛出 InterruptedException。
这里使用 while,不能改成 if。原因不只是假唤醒。Mesa 语义下,收到通知的线程在重新获得锁之前,另一个消费者可能已经取走刚放入的元素。唤醒只表示条件曾经可能成立,线程实际继续执行时必须再次确认。
可以把 wait() 前后的过程理解为:
1 | 持有锁并检查条件 |
notify 只发通知,不负责移交锁
notify() 从当前对象的等待集合中任意选择一个线程唤醒,具体选择没有顺序保证。notifyAll() 会唤醒等待集合中的所有线程,但这些线程同样不能立即运行受保护代码;它们要等当前线程释放锁,再各自竞争进入。
在上面的有界队列里,生产者和消费者都等待在同一个对象的等待集合中,但它们关心的条件不同。使用 notify() 可能唤醒一个条件仍不成立的同类线程:例如队列仍满时唤醒另一个生产者,而真正可以继续的消费者还在等待。被错误唤醒的线程检查条件后会再次等待,特定调度下可能留下无人推进的局面。
notifyAll() 让所有等待者重新检查各自条件,写法更稳妥,代价是线程较多时会产生额外竞争。如果业务需要多个独立条件队列,可以使用 ReentrantLock 配合多个 Condition,将 notEmpty 和 notFull 分开通知,而不是让所有线程共享 Object 的一个等待集合。
还有一个容易忽略的边界:wait() 只释放调用对象的监视器。如果线程同时持有其他对象的锁,那些锁不会随之释放。嵌套锁中随意等待,仍可能让其他线程卡在没有释放的锁上。
Java 语义与 HotSpot 实现要分开理解
Java 语言层面保证每个对象都关联一个监视器和一个等待集合。同步实例方法锁定当前对象,静态同步方法锁定对应的 Class 对象,synchronized(lock) 则锁定表达式引用的对象。同步块通常由 JVM 的 monitorenter、monitorexit 指令支持,同步方法则通过方法标志隐式进入和退出监视器。
HotSpot 为了降低无竞争加锁的成本,不会在每次使用 synchronized 时都立即创建完整的重量级监视器。现代 HotSpot 通常先走轻量级锁路径;发生竞争、调用 wait() 或轻量级机制不足时,才可能为对象关联 ObjectMonitor,这一过程通常称为 monitor inflation。
在当前 OpenJDK 的 ObjectMonitor 实现中,可以看到 owner、等待集合、入口竞争队列以及支持重入的计数等状态。等待线程由 wait() 放入 wait set,通知操作把相应线程转移到之后重新竞争监视器的路径。这里的 _entry_list、owner 表示方式和对象头布局属于 HotSpot 实现细节,并不是 Java 语言规范要求的固定内存结构,也可能随 JDK 版本和对象头配置变化。
因此,理解并发代码时应先依靠稳定语义:谁保护共享状态、条件在哪里检查、等待释放哪把锁、醒来后是否重检。对象头中的标记位和具体队列结构适合用来分析某个 HotSpot 版本的性能与诊断行为,不能反过来当作所有 JVM 都必须遵守的编程契约。
管程把并发约束留在共享状态旁边
管程适合表达一组状态明确、操作边界稳定的并发约束。对有界队列来说,不变式很简单:元素数量始终在 0 到容量之间;put() 只在未满时修改状态,take() 只在非空时修改状态。锁保护检查与修改的原子边界,条件等待避免线程在暂时不能继续时占着锁。
实际代码中,还要决定中断如何传播、等待是否需要超时,以及一次状态变化应该通知一个还是多个条件队列。无论使用内置监视器还是 Lock/Condition,检查条件的循环都应与共享状态使用同一套同步规则;否则看似完整的等待通知代码,仍然可能丢失状态变化。