Monitor 与 synchronized:从临界区追到等待队列
线程栈里出现几十个 BLOCKED (on object monitor),真正有用的问题不是“synchronized 性能是不是很差”,而是这些线程在争哪一个 monitor、谁持有它、持有者在临界区里做什么、等待是否有退出路径。synchronized 同时提供互斥和内存可见性;用对了,它是最容易审查的锁,用错了,同样会把一次慢 SQL 扩散成整个进程的排队。
一个被远程调用拖住的临界区,可以沿字节码、monitor、wait set 和线程 dump 还原完整证据链。AQS、ReentrantLock 与多个 Condition 使用不同队列和失败控制能力,不能直接套用内置锁的等待模型。
先找到真正被锁住的对象
下面代码锁住的不是“方法”这个抽象概念,而是当前 InventoryService 实例的 monitor:
public synchronized Result reserve(String sku) {
Inventory current = repository.load(sku);
Result result = remoteRiskCheck(current); // 锁内远程调用
repository.save(current.reserve());
return result;
}一旦 remoteRiskCheck 变慢,所有调用同一实例同步方法的线程都要排队。若对象是 Spring 单例,这个锁可能覆盖整个应用实例;若误锁在字符串常量、Class 对象或外部可见对象上,还可能与完全无关的代码共享 monitor。
先把外部 IO 移出临界区,再只保护必须一致更新的内存状态:
RiskDecision decision = remoteRiskCheck(request);
synchronized (stateLock) {
return applyDecision(decision);
}这不是说所有 IO 都能机械移出去。读取、校验、写入若必须在同一一致性边界完成,JVM 锁也未必是正确工具:数据库事务、唯一约束或状态机可能更合适。锁的范围要由不变量决定,不由代码缩进决定。
方法、代码块和静态锁分别锁谁
三种写法对应不同 monitor:
public synchronized void instanceMethod() { }
public void block() {
synchronized (stateLock) { }
}
public static synchronized void staticMethod() { }实例同步方法锁 this。同步代码块锁括号中的对象。静态同步方法锁声明类对应的 Class 对象。
锁对象必须稳定且由当前组件私有持有。下面写法每次都创建新对象,根本没有形成共同互斥:
synchronized (new Object()) {
update();
}锁可变引用也危险:线程 A 锁住旧对象,线程 B 把字段替换后锁住新对象,两者同时进入所谓“临界区”。常见写法是 private final Object stateLock = new Object();,不把锁对象暴露给调用方。
字节码把进入和退出责任写得很清楚
同步代码块通常编译为 monitorenter 与 monitorexit,异常路径也必须释放 monitor:
synchronized (lock) {
state++;
}可以用 javap 查看:
javac --release 17 MonitorProbe.java
javap -c -v MonitorProbe | grep -n -A30 -E 'monitorenter|monitorexit'同步方法则在方法访问标志上使用 ACC_SYNCHRONIZED,由调用与返回过程管理 monitor。应用开发不需要手写退出逻辑,但要理解异常不会天然造成“忘记 unlock”;真正导致长时间持锁的通常是临界区没有结束、进入 native/IO 阻塞,或代码发生死循环。
同一个线程可以重复获得同一 monitor,这就是可重入。递归方法或一个同步方法调用同对象另一个同步方法不会把自己锁死;运行时会记录持有者与进入次数,最外层退出后才真正释放。
从字节码异常表追到两个等待集合
同步代码块反编译后通常会看到不止一个 monitorexit:正常路径释放一次,异常处理路径再释放一次并重新抛出。异常表覆盖的是临界区指令范围,因此即使临界区抛出异常,控制流也会先执行释放。用 javap -c -v 时不能只搜索指令名,还要核对异常表的 from/to/target;否则只能证明“出现过退出指令”,不能证明异常路径被保护。
monitor 现场里至少有两种性质不同的等待。尚未进入 synchronized 的竞争者等待所有权,在 Java 线程状态里是 BLOCKED;已经持有 monitor 并调用 wait 的线程进入 wait set、释放所有权,通常是 WAITING 或 TIMED_WAITING。通知只让等待者有资格重新竞争,不会把锁直接交给它。路径是 owner → wait set → 被通知 → 重新竞争 → owner,而不是 wait set → 继续执行业务。
MonitorStateProbe 用门闩把三个角色固定在同一时刻:一个条件等待者已经释放锁,一个持有者拿锁休眠,一个竞争者卡在入口。
cd examples/backend-development/concurrency/monitor-synchronized
javac --release 17 -Xlint:all -Werror MonitorStateProbe.java
java MonitorStateProbe
# waiter=WAITING, holder=TIMED_WAITING, contender=BLOCKED这行输出反驳两个常见误判。持有者显示 TIMED_WAITING 不表示它没有阻塞别人;线程状态描述它自己在做什么,不描述它仍持有哪些 monitor。等待者显示 WAITING 则说明它已经不再持有调用 wait 的那把锁。释放持有者后,竞争者取得锁并执行 notifyAll,等待者还要再次取得锁才能返回。
HotSpot 资料常把膨胀 monitor 的竞争入口与条件等待描述为 EntryList 和 WaitSet。它们适合解释 dump 中 BLOCKED 与 WAITING 为什么不能混为一谈,但具体字段、入队策略和对象头编码属于所检查 JDK 构建的实现。应用层稳定的推理轴是:线程是否已经拥有 monitor、是否因 wait 主动释放、唤醒后是否还要重新竞争。
wait 不是 sleep,它会释放当前 monitor
生产者发现队列为空时,如果拿着锁循环睡眠,生产者永远进不来:
synchronized (lock) {
while (queue.isEmpty()) {
Thread.sleep(100); // 睡眠期间仍持有 lock
}
}Object.wait() 的语义不同:调用线程必须先拥有该对象 monitor;进入等待时,它加入该对象的 wait set,并释放这个对象的同步所有权。被通知、被中断、超时或虚假唤醒后,它还要重新竞争 monitor,成功取回锁后才能从 wait 返回。
synchronized (lock) {
while (queue.isEmpty()) {
lock.wait(500);
}
return queue.removeFirst();
}条件必须放在 while 而不是 if 中。线程可能虚假唤醒,也可能在被唤醒后还没拿到锁时,被另一个消费者抢先取空队列。重新得到锁只说明可以检查状态,不说明条件一定成立。
wait 只释放调用它的那个对象 monitor,不会释放线程同时持有的其他锁。嵌套锁中等待很容易形成隐藏依赖:线程释放内层锁,却仍拿着外层锁,负责通知的一方可能正需要外层锁才能推进。
notify 只移动一个等待者,而且不承诺是谁
notify() 从 wait set 中任意选择一个线程唤醒;notifyAll() 唤醒所有等待者,让它们重新竞争 monitor。调用者必须持有同一个对象的 monitor,否则抛 IllegalMonitorStateException。
synchronized (lock) {
queue.addLast(task);
lock.notifyAll();
}如果一个 wait set 混合“队列不空”“队列不满”“系统未暂停”等多种条件,notify() 可能叫醒不匹配的等待者,随后它又睡回去,而真正能推进的线程仍未醒。简单场景可以配合 notifyAll 和 while 保证正确性;条件复杂时,ReentrantLock 的多个 Condition 更容易表达。
通知也不会让对方立刻执行。当前线程退出同步区之前仍持有 monitor;被唤醒者只是从条件等待转为锁竞争。把耗时工作放在 notifyAll() 之后但仍在同步块里,会制造“已经通知却迟迟不运行”的假象。
内置锁同时建立可见性边
一个线程退出同步块,之后另一个线程进入同一个 monitor,前者在临界区中的写入对后者可见。这也是 synchronized 与“某段代码一次只让一个人进”之间的关键差别:它同时解决互斥与发布。
如果写入用锁保护,读取却绕开锁,读取方并不会自动获得同样保证:
synchronized (stateLock) {
status = READY;
}
// 另一个线程无锁读取 status,协议被破坏
if (status == READY) { ... }所有访问遵守同一守护规则,才叫 guarded-by。锁对象、被保护字段和访问入口应该在类内部收口;只给写方法加锁、读方法裸奔,是常见审查缺口。
对象头和锁优化属于 HotSpot 证据
在 HotSpot 中,轻量竞争、对象头 mark word、锁记录和膨胀后的 ObjectMonitor 用来实现内置锁。具体布局、状态编码和优化会随 JDK 版本改变。应用层可以用这些实现事实解释性能与诊断,但不能把某一版 mark word 位图当成 Java 规范承诺。
现代 HotSpot 会尽量让无竞争或短竞争路径保持低成本;竞争持续、需要 wait,或其他条件出现时,可能使用更重量的 monitor 结构。由此得到的工程结论不是“永远避免 synchronized”,而是:先缩短临界区、消除共享热点,用 JFR 和线程栈证明竞争,再决定是否换锁或改状态模型。
微基准尤其容易误导。锁对象没有真正逃逸、临界区被 JIT 消除、线程没有同时竞争时,测到的不是线上锁成本。并发性能测试至少要有多线程争用、结果校验、预热和真实临界区工作量。
从线程 dump 反推持有者
典型等待者可能显示:
"http-worker-42" #83 BLOCKED (on object monitor)
at example.InventoryService.apply(InventoryService.java:88)
- waiting to lock <0x000000071234abcd> (a java.lang.Object)另一个线程会显示同一地址已经锁定:
"http-worker-7" #48 RUNNABLE
at example.RiskClient.call(RiskClient.java:131)
at example.InventoryService.apply(InventoryService.java:91)
- locked <0x000000071234abcd> (a java.lang.Object)排查时先用 monitor 地址把等待者和持有者连起来,再看持有者栈顶。几十个等待线程往往只是受害者,真正根因是一个持锁线程卡在远程调用、慢 SQL、文件 IO 或无限循环。
jcmd "$PID" Thread.print -l > monitor.txt
grep -n -E 'BLOCKED|waiting to lock|locked <|Found one Java-level deadlock' monitor.txt连续三次栈若持有者一直停在同一外部调用,优先止血下游与超时;如果持有者变化很快但等待时间仍高,才更像高频短临界区竞争。JFR 的 monitor enter/wait 事件可以补充等待时长和热点类。
死锁来自等待环,不来自“锁多”三个字
两个线程以相反顺序获取两把锁,就能形成最小死锁:
Thread a = new Thread(() -> {
synchronized (left) {
pause();
synchronized (right) { useBoth(); }
}
});
Thread b = new Thread(() -> {
synchronized (right) {
pause();
synchronized (left) { useBoth(); }
}
});固定全局获取顺序可以消除这类环。若资源来自动态集合,就按稳定 ID 排序后再加锁;更好的方案可能是用单一状态所有者、消息串行化或数据库原子条件更新,避免同时持有多把 JVM 锁。
内置锁进入时不能像 tryLock(timeout) 那样给竞争设置时限,所以需要可中断、可超时获取的场景更适合 ReentrantLock。这不是性能高低判断,而是失败控制能力不同。
上线前真正要检查的是临界区
锁对象是否私有、稳定,是否可能和外部代码共享。被保护的不变量是什么,所有读写是否遵守同一把锁。临界区内有没有网络、数据库、磁盘、日志阻塞或回调。
多把锁是否有全局顺序,是否存在 JVM 锁与数据库锁交叉等待。条件等待是否使用 while,通知是否针对同一 monitor,退出是否响应中断。能否从线程名、monitor 地址和 JFR 事件定位持有者。
扩容前是否先证明锁是瓶颈;多实例只会绕开 JVM 锁,并不会自动守住共享数据库状态。
把条件等待与锁竞争放进同一个可运行现场
完整源码位于 examples/backend-development/concurrency/monitor-synchronized/。MonitorWaitDemo.java 验证 wait 释放 monitor、条件成立后重新获取;MonitorStateProbe.java 同时制造 WAITING、持锁休眠与 BLOCKED。进入目录执行:
mkdir -p out
javac --release 17 -Xlint:all -Werror -d out MonitorWaitDemo.java MonitorStateProbe.java
java -cp out MonitorWaitDemo
java -cp out MonitorStateProbe
javap -classpath out -c -v MonitorStateProbe前两次运行稳定得到:
received=task-42
waiter=WAITING, holder=TIMED_WAITING, contender=BLOCKEDholder=TIMED_WAITING 说明线程状态不能单独判断是否持锁;contender=BLOCKED 必须沿 monitor 地址找到 holder;waiter=WAITING 则要回到条件谓词和通知者。生产门禁至少同时采集 monitor 竞争等待分位数、持有时长分位数、超长持有者栈样本和条件等待者数量。优化后只有等待下降且业务吞吐、错误率与下游耗时没有恶化,才能认定临界区缩短有效;BLOCKED 数下降但任务被移到无界队列,不是修复。
当这些边界清楚时,synchronized 往往是最朴素也最可靠的选择;边界不清时,换成更复杂的锁只会把同一个问题藏进更深的队列。
规范语义可继续查看 JLS 第 17 章 和 Object.wait/notify API;字节码指令定义见 JVMS monitorenter。
