Monitor 与 synchronized
synchronized 把一组操作放进临界区:线程先取得指定对象的 monitor,执行代码,退出时释放。其他线程若要取得同一个 monitor,需要等待当前持有者释放;释放与后续获取还为共享数据建立可见性关系。
一个锁对象
├─ 当前持有者:执行受保护代码,可重入
├─ 进入竞争者:等待取得同一个 monitor
└─ wait set:已经调用 wait、暂时释放该 monitor 的线程这三个位置决定了线程能做什么。进入竞争者尚不能读取受保护状态;条件等待者要等状态可能变化后重新取锁、重新检查。
选择共同的锁对象
把更新与读取放在同一保护下
下面的 Counter 用一个私有、固定的对象保护 value。两个线程分别自增 1000 次,主线程等待结束后读取结果:
public final class Counter {
private final Object lock = new Object();
private int value;
public void increment() { synchronized (lock) { value++; } }
public int value() { synchronized (lock) { return value; } }
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread a = new Thread(() -> { for (int i = 0; i < 1000; i++) counter.increment(); });
Thread b = new Thread(() -> { for (int i = 0; i < 1000; i++) counter.increment(); });
a.start();
b.start();
a.join(5000);
b.join(5000);
if (a.isAlive() || b.isAlive()) throw new IllegalStateException("threads not finished");
if (counter.value() != 2000) throw new IllegalStateException("lost update");
System.out.println("count=" + counter.value());
}
}在 Linux 普通用户的可写目录保存为 Counter.java。需要完整 JDK,JAVA_HOME 改成实际安装位置;JDK 的选择与安装见Java 版本基线。实验使用 Temurin 25.0.4+7,也兼容 Java 17。
export JAVA_HOME=/opt/jdk-25
unset JAVA_TOOL_OPTIONS JDK_JAVA_OPTIONS _JAVA_OPTIONS
LAB_OUT=$(mktemp -d /tmp/monitor-learning.XXXXXX)
"$JAVA_HOME/bin/java" -version
"$JAVA_HOME/bin/javac" --release 17 -Xlint:all -Werror -d "$LAB_OUT" Counter.java
"$JAVA_HOME/bin/java" -cp "$LAB_OUT" Counter预期输出 count=2000。两个 increment 调用无法在同一临界区内交错执行,因此读取、加一、写回作为一组受到保护。value 方法也使用相同锁,使日常运行中的并发读取遵守同一规则;主线程的 join 则负责等待实验线程终止。
--release 17 限制编译目标与 API,运行时仍是实际启动的 JDK。若出现编译器不存在或 class 版本不支持,先核对 JAVA_HOME 和实际 java/javac;结果断言失败时,检查是否改动了锁对象或绕开了同步访问。
三种写法分别取得哪个 monitor
| 写法 | 被锁定的对象 |
|---|---|
| 实例 synchronized 方法 | 调用接收者 this |
| static synchronized 方法 | 声明该方法的类对应的 Class 对象 |
| synchronized(expression) | expression 求值得到的那个对象 |
规则见 JLS 17.1。两个不同实例上的同步方法可以同时执行;静态方法与实例方法也不会因为同属一个类就互斥。expression 为 null 时会抛 NullPointerException,无法进入临界区。
保护同一字段的锁引用应保持稳定。synchronized(new Object()) 每次创建新对象,各线程拿到不同的 monitor;用可替换字段作锁也有类似风险。String 常量、装箱数值、外部公开对象还可能与不相关代码共享,组件内部通常使用私有 final 锁。
下载 Monitor 实验包,解压并进入 monitor-synchronized 目录,在同一个终端沿用 LAB_OUT:
"$JAVA_HOME/bin/javac" --release 17 -Xlint:all -Werror -d "$LAB_OUT" *.java
"$JAVA_HOME/bin/java" -cp "$LAB_OUT" MonitorLab wrong-lock预期为 different-locks result=1。该模式让两个线程分别取得新对象锁,在都读到旧值 0 后才写回 1。屏障固定了这组交错;丢失更新说明锁对象没有形成共同保护,不能据此认为 synchronized 本身失效。
互斥和可见性一起生效
一个线程解锁,另一个线程随后取得同一 monitor,形成 happens-before。写者在释放前完成的更新,可以交接给后续获得锁的读者。锁外读取普通字段则需要其他有效的同步方式;仅给写方法加锁,不能让所有无锁读取自动安全。完整读写规则见JMM、happens-before 与 volatile。
锁保护的范围由业务关系决定。例如 available 与 reserved 的和必须保持固定,就要在同一临界区完成两次修改,并用相同保护方式读取这组值。只把每个字段各自改成 volatile,仍可能在修改一半时被读取。
获取、重入与异常退出
字节码中的正常和异常路径
对刚编译的 Counter 执行:
"$JAVA_HOME/bin/javap" -p -c -v -classpath "$LAB_OUT" Counterincrement 中可以看到 monitorenter,以及正常返回和异常路径上的 monitorexit。Exception table 的 from、to、target 指出受保护指令范围与异常处理入口;异常路径先释放锁,再重新抛出异常。指令定义见 JVMS monitorenter。
同步方法则使用 ACC_SYNCHRONIZED 标志,由调用和退出机制处理锁,不要求方法体里出现同样的指令对。可继续对实验包中的 SingleSlot 执行:
"$JAVA_HOME/bin/javap" -p -c -v -classpath "$LAB_OUT" SingleSlotput、take 和 close 带有 ACC_SYNCHRONIZED。标志定义见 JVMS 方法信息。不要只搜索有没有 monitorexit 就判断一个同步方法会不会释放锁。
同一线程可以再次进入
同一线程取得同一 monitor 后,可以在嵌套调用中再次获取。每次退出抵消一次进入,最外层退出才让其他线程取得锁。可重入解决了“同步方法调用同对象的另一个同步方法”这一常用组合;它并不改变两个不同线程之间的互斥。
运行:
"$JAVA_HOME/bin/java" -cp "$LAB_OUT" MonitorLab release结果为 reentrant=true exception-released=true。实验在嵌套同步块退出后检查仍持有外层锁,再从外层抛出预期异常,最后由另一个线程成功取得相同锁。
自动释放发生在控制流退出同步块或方法时。死循环、无限等待和迟迟不返回的 I/O 会让控制流留在内部;用 interrupt 也不能强行抢走一个线程持有的 monitor。入口获取本身没有可中断或超时版本,需要这些能力时使用显式锁与 Condition。
条件等待怎样释放并重新取得锁
谓词描述状态,通知提示重新检查
消费者等待“有元素可取”,需要同时完成检查状态、进入等待与释放锁。若在锁内 sleep,生产者就可能无法取得锁放入元素。wait 会释放调用对象的 monitor,使负责改变状态的线程有机会进入。
synchronized (lock) {
while (queue.isEmpty()) {
lock.wait();
}
return queue.removeFirst();
}这是一段依赖现有 lock 和 queue 的条件等待结构;完整可运行实现使用下方 SingleSlot。queue 的检查、修改和通知必须遵守同一个锁协议。
wait 的调用者必须已经持有该对象 monitor,否则抛 IllegalMonitorStateException。它释放该 monitor 的全部重入层数,返回前再恢复;线程持有的其他锁保持不变。嵌套锁中等待因此可能让通知者被外层锁阻挡。规则见 Object.wait/notify API。
持有 monitor
↓ 条件不成立
进入 wait set,释放该 monitor
↓ 通知 / 超时 / 中断 / 伪唤醒
重新获取 monitor
├─ 正常返回 → 再检查谓词 → 满足后执行
└─ 中断异常 → 交给调用方处理重新获取存在竞争时,线程可以处于 BLOCKED。即使等待的超时时间已到,仍可能迟迟拿不到锁;所以 wait 的超时参数不能当成方法返回的硬截止时间。中断异常也在重新取得 monitor 后抛出,并清除中断状态。通知与中断同时发生时,还存在正常返回且保留待处理中断的合法情况,详见 JLS 17.2。
用错误通知观察 if 与 while 的差别
运行两种模式:
"$JAVA_HOME/bin/java" -cp "$LAB_OUT" MonitorLab while
"$JAVA_HOME/bin/java" -cp "$LAB_OUT" MonitorLab if输出分别为:
guard=while result=item
guard=if result=empty-access控制线程先通知读者,但故意不改变“有元素”的谓词。while 模式重新检查后继续等待,直到真正放入数据;if 模式跳过第二次检查,在空状态下继续访问。
这不是在强迫 JVM 产生伪唤醒,而是稳定复现“收到通知时条件仍不满足”的情形。真实程序还可能被其他消费者抢先取走元素,因此即使每次通知都伴随一次放入,读取方也要重新判断。
notify 从当前 wait set 选一个等待者,不保证顺序;notifyAll 让所有等待者重新参与获取。二者都需要持有同一个 monitor,调用结束后通知者仍持锁,必须等它释放,等待者才可能继续。通知不会累积成许可证:没有线程等待时发出的 notify,不会留给未来的一次 wait。
谓词能跨越这个时间差。若生产者先更新状态,消费者随后拿锁检查时便可直接消费,无需依赖那次通知是否曾被接收。多个条件混用同一个 wait set 时,notify 可能选中不满足条件的等待者;可以使用 notifyAll 配合各自的 while,或用多个 Condition 分离条件队列。
单槽邮箱:等待、超时与关闭
SingleSlot 用 item 表示槽位,用 closed 表示不再接受新元素。put 等待空位,take 等待元素;关闭后允许取走剩余元素,排空后返回 null。完整源码如下:
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public final class SingleSlot<T> {
private T item;
private boolean closed;
public synchronized void put(T value, long timeoutMillis)
throws InterruptedException, TimeoutException {
Objects.requireNonNull(value);
if (timeoutMillis <= 0 || timeoutMillis > 60_000) throw new IllegalArgumentException("timeout");
long budget = TimeUnit.MILLISECONDS.toNanos(timeoutMillis);
long start = System.nanoTime();
while (item != null && !closed) {
long remaining = budget - (System.nanoTime() - start);
if (remaining <= 0) throw new TimeoutException("slot full");
TimeUnit.NANOSECONDS.timedWait(this, remaining);
}
if (closed) throw new IllegalStateException("slot closed");
item = value;
notifyAll();
}
public synchronized T take(long timeoutMillis)
throws InterruptedException, TimeoutException {
if (timeoutMillis <= 0 || timeoutMillis > 60_000) throw new IllegalArgumentException("timeout");
long budget = TimeUnit.MILLISECONDS.toNanos(timeoutMillis);
long start = System.nanoTime();
while (item == null && !closed) {
long remaining = budget - (System.nanoTime() - start);
if (remaining <= 0) throw new TimeoutException("slot empty");
TimeUnit.NANOSECONDS.timedWait(this, remaining);
}
if (item == null) return null; // Closed and drained.
T result = item;
item = null;
notifyAll();
return result;
}
public synchronized void close() {
closed = true;
notifyAll();
}
}这里拒绝 null,以便把 take 的 null 返回值留给“已关闭且排空”。每次唤醒后从原始预算减去已耗时,避免循环重置超时。1—60000 毫秒是示例输入限制,不是业务系统的通用阈值。
计时从获得 monitor 后开始,且无法限制重新争锁的时间,因此这是条件等待预算。需要限制入口竞争的总等待,应使用可超时获取的显式锁。InterruptedException 向上传递,由任务拥有者决定取消、重试或结束,不能吞掉后继续等待。
执行 java -cp "$LAB_OUT" MonitorLab mailbox,预期输出 received=task-42 closed-and-drained=true。实验先放入一个元素,再关闭;消费者读完后观察关闭终态,之后的 put 被拒绝。close 更新状态并 notifyAll,空等的消费者和等待空位的生产者都能醒来重新判断。
项目接入时,应用组件持有邮箱,停止入口后调用 close,再等待消费者退出。业务处理放在 take 返回之后,避免消费者一边持锁一边调用外部服务。生产中已有 BlockingQueue 时通常直接采用它;队列本身没有统一 close 协议,仍需通过应用状态、结束消息或中断明确关闭行为。
HotSpot 的 monitor 实现与版本差异
所有者、竞争链表与等待集合
HotSpot 可以用快速锁路径处理简单获取,必要时使用 ObjectMonitor 管理较复杂的竞争与等待。Java 的每个对象在语义上都有 monitor,不意味着每个对象一创建就分配一个完整的重量级 ObjectMonitor。
在固定 OpenJDK jdk-25+36 的 ObjectMonitor中,关键字段有:
| 字段 | 实际职责 |
|---|---|
| _owner | 所有者标识或内部特殊状态;不是一律存放平台线程指针 |
| _recursions | 额外重入次数,首次进入为 0 |
| _entry_list | 等待进入或重新进入的节点链表 |
| _wait_set | 调用 wait 的条件等待节点 |
| _succ | 选出的候选后继,用于减少无效唤醒 |
首次获取把空闲状态变为当前所有者;重入增加计数,退出逐层减少。最外层释放后,竞争者仍要争取所有权。wait 暂存重入信息、释放所有权并进入等待集合;被移出等待集合后,还要重新获取并恢复进入深度。
这是运行关系的概括,具体快速路径和链表操作依实现而变。较旧资料中的 _EntryList、_WaitSet、_cxq 或偏向锁状态图,不应直接标成 JDK 25 的字段布局。尤其 _recursions 的 0 表示首次持有,不等同于当前锁无人持有。
锁优化与虚拟线程
JIT 可能消除没有跨线程共享必要的锁,也可能合并相邻锁区。对象头、快速锁与膨胀结构会随版本和选项变化。只有一个线程、锁对象没有逃逸或循环工作被优化掉的微基准,难以反映共享热点的代价。
JDK 24 的 JEP 491 改变了虚拟线程与 monitor 的配合:持有 synchronized 时执行可卸载的阻塞操作,不再仅因持锁而固定载体线程;进入竞争和 Object.wait 也得到相应支持。JDK 25 的行为说明见虚拟线程官方指南。native 或外部调用仍需按具体阻塞路径判断,不能推广成所有阻塞均能卸载。
载体可以去执行别的虚拟线程,同一个 monitor 的互斥仍然存在。一个任务持锁调用远端服务时,其他需要这把锁的任务仍要等它。因此,升级后应分别观察载体利用和临界区排队,不用“消除 pinning”替代锁粒度设计。
从活跃线程找到阻塞源头
启动一个持续存在的目标
在同一个 Linux 用户、PID 命名空间中运行下列命令。使用前面已经编译的 LAB_OUT,probe 30 会让一个线程持有 monitor 等待约 30 秒,另一个线程竞争入口:
"$JAVA_HOME/bin/java" -cp "$LAB_OUT" MonitorLab probe 30 > "$LAB_OUT/probe.log" 2>&1 &
PID=$!
for attempt in $(seq 1 50); do
grep -q 'contender=BLOCKED' "$LAB_OUT/probe.log" && break
sleep 0.1
done
cat "$LAB_OUT/probe.log"
kill -0 "$PID"
"$JAVA_HOME/bin/jcmd" "$PID" Thread.print -l > "$LAB_OUT/monitor.txt"
grep -n -E 'monitor-owner|monitor-contender|waiting to lock|locked <' "$LAB_OUT/monitor.txt"启动日志应包含 owner=TIMED_WAITING contender=BLOCKED,PID 以本次输出为准。jcmd 的使用和权限说明见官方命令文档。容器内诊断尽量在同一容器执行,确认相同用户及可用的 /tmp;目标已退出、Attach 被禁用或命名空间不同都会影响连接。
打开完整 monitor.txt,找到 monitor-contender 的 waiting to lock <...>,再用相同对象标识找 monitor-owner 的 locked <...>。地址每次运行可能变化,只用于同一份快照内匹配。owner 正在 CountDownLatch 的带时限等待中,因此自己显示 TIMED_WAITING;它仍持有外层 synchronized,造成 contender 的 BLOCKED。
等待目标自然结束后再清理:
wait "$PID"
unset PID若启动日志没有进入预期状态,先查看异常,再确认编译目标、资源与等待时间;不要把已退出进程的旧 PID 交给后续采集。Thread.print 输出可能包含业务类名、参数或上下文,生产采集需要按敏感诊断材料保存。
区分入口竞争、条件等待与死锁
| 观察 | 优先查找 | 对应处理 |
|---|---|---|
| 多个 BLOCKED 指向相同对象 | 当前锁拥有者及其栈顶 | 缩短持锁工作,处理被卡住的调用 |
| WAITING / TIMED_WAITING 在 Object.wait | 等待谓词与负责改变它的线程 | 修复状态更新、通知、关闭或中断协议 |
| 通知后仍 BLOCKED | 通知者是否仍在同步区 | 把无需持锁的后续工作移出 |
| 两条持锁依赖形成环 | A 持有 L1 等 L2,B 持有 L2 等 L1 | 固定获取顺序或减少嵌套持锁 |
| 只有状态数变化,没有耗时变化 | 任务是否转移到别处排队 | 同时观察请求延迟、任务年龄和下游负载 |
Thread.print 的死锁提示可以帮助发现可识别的 Java 锁环,但没有提示不能排除线程池饥饿、外部数据库锁或条件协议错误。分别采集几份间隔快照,判断持有者是否长期不变;对象标识跨快照可能受对象移动影响,应结合线程、类和调用位置核对。综合等待图与 JFR 操作见并发诊断。
死锁修复要处理形成环的顺序。例如多个账户转移余额,可以按稳定且唯一的账户 ID 顺序加锁;ID 相同或存在别名时先合并为同一个资源,不能只排序显示名称。数据库行锁与 JVM 锁混用时也要审查跨层顺序。interrupt 不会让 BLOCKED 的线程绕过 monitor 获取,临时止损不能依赖它打破内置锁死锁。
临界区的长度与项目取舍
一把全局锁会把本可独立处理的工作串行化。若锁内有网络请求、慢 SQL、磁盘或用户回调,耗时变化就会传给所有竞争者。先确定必须保持一致的内存操作,把无关序列化、日志格式化或远程读取移出去,再测等待和业务结果。
有些操作依赖读取时的状态。把远程检查移到锁外后,结果回来时状态可能已经变化。可以在锁内保存版本与必要输入,锁外执行检查,重新加锁后比较版本再应用;版本变化就重算或拒绝。若远程动作本身已经产生副作用,还要配合幂等和业务补偿,不能在持锁前后简单重复发送。
按业务键拆锁能够减少互不相关请求的竞争,但锁对象的生命周期必须稳定。若线程仍等待旧锁时缓存把该键的锁淘汰,新请求可能得到新锁,同时修改相同状态。固定分段锁可以简化生命周期,代价是不同键可能落在同一段;需要动态逐键锁时,应使用经过验证的实现或显式引用管理。
多实例部署下,每个 JVM 的 monitor 各自独立。跨实例共享库存、唯一性或扣减需要数据库约束、原子条件更新或明确的分布式协调,JVM 锁只保护本进程内存。
运行全部实验与回收目录
在已解压目录设置好 JAVA_HOME 后执行 bash run.sh。它严格编译三份 Java 源码,运行 Counter 与六种 MonitorLab 模式,每个子进程有 20 秒外部上限,最后打印 PASS monitor-lab。probe 的默认模式只用于状态验证;手动诊断才传入 30。
也可以由已获 Docker 权限的 Linux 主机用户运行。提前从可信仓库拉取镜像;离线环境由联网端导出并校验归档,再导入本地。运行阶段无需网络:
LAB_SOURCE="$(pwd -P)"
JDK_IMAGE=eclipse-temurin:25.0.4_7-jdk
docker pull "$JDK_IMAGE"
docker run --pull never --rm --network none --read-only \
--user 10001:10001 --cap-drop ALL --security-opt no-new-privileges \
--mount "type=bind,src=$LAB_SOURCE,dst=/lab,readonly" \
--tmpfs /tmp:rw,nosuid,nodev,size=128m,mode=1777 \
"$JDK_IMAGE" bash /lab/run.sh源目录需要让 UID 10001 可读和可遍历,编译输出只写 tmpfs。Java 17 对照使用 eclipse-temurin:17.0.20_8-jdk;完整 JDK 与运行镜像不是同一概念,仅 JRE 无法执行 javac/jcmd。
完成手动命令且目标已经退出后,只删除本次编译和实验记录目录:
case "$LAB_OUT" in
/tmp/monitor-learning.*) rm -r -- "$LAB_OUT" ;;
*) printf '%s\n' '保留未知目录' ;;
esac
unset LAB_OUT当等待仍然很长时,比较优化前后的相同负载:入口等待、临界区工作量、请求尾延迟和下游错误率应一起改善。若只是把持锁等待搬到无界队列,延迟和资源积压会在新的位置继续增长。
权威资料与规范地址
同步规则查语言规范,字节码与具体字段查虚拟机规范和固定实现,诊断命令按实际 JDK 查阅。
| 资料 | 完整地址 |
|---|---|
| JLS 17.1 | https://docs.oracle.com/javase/specs/jls/se25/html/jls-17.html#jls-17.1 |
| JVMS monitorenter | https://docs.oracle.com/javase/specs/jvms/se25/html/jvms-6.html#jvms-6.5.monitorenter |
| JVMS 方法信息 | https://docs.oracle.com/javase/specs/jvms/se25/html/jvms-4.html#jvms-4.6 |
| Object.wait/notify API | https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/Object.html |
| JLS 17.2 | https://docs.oracle.com/javase/specs/jls/se25/html/jls-17.html#jls-17.2 |
| OpenJDK jdk-25+36 的 ObjectMonitor | https://github.com/openjdk/jdk/blob/jdk-25%2B36/src/hotspot/share/runtime/objectMonitor.hpp |
| 虚拟线程官方指南 | https://docs.oracle.com/en/java/javase/25/core/virtual-threads.html |
| 官方命令文档 | https://docs.oracle.com/en/java/javase/25/docs/specs/man/jcmd.html |
