本节目标:从对象头与 Monitor 讲清
synchronized在运行时发生了什么,并用 JFR 证明竞争发生在哪。
适用版本:JDK 25(Temurin 25.0.5+8);偏向锁已移除;JFRjdk.JavaMonitorEnter/JavaMonitorWait/JavaMonitorInflate。
承接的问题
libraryd 的借阅计数用一个 synchronized 方法保护,归还队列的消费者也会更新计数。压测时 P99 升高,CPU 却只有 30%,线程转储里几个 libraryd-worker 处于 BLOCKED (on object monitor)。直觉是「锁竞争」,但还需要回答三个更具体的问题:
- 锁状态到底存在哪里,为什么一个对象能同时当锁和存哈希值。
- 什么叫「膨胀」,什么时候从轻量级锁变成
ObjectMonitor。 - 用 JFR 看锁竞争时,为什么
synchronized有事件,ReentrantLock却没有JavaMonitorEnter。
这三个问题里还夹着一个 JDK 25 的事实:偏向锁已经不存在了。很多旧资料仍然写「偏向锁默认开启,适合单线程访问」,照抄会得出错误结论。先把这一点确认掉,再往下讲。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,默认 G1。
对象头与 Mark Word:锁状态存在哪里
每个 Java 对象在堆里都有一块对象头。64 位、开启压缩类指针时,典型布局是 8 字节的 Mark Word 加 4 字节的类指针(klass),再按 8 字节对齐。数组还会多 4 字节长度。字段布局在第 3.2 节用 JOL 验证过,这里只关注 Mark Word。
Mark Word 是一个复用字段,同一组比特在不同状态下含义不同:
- 未锁定:存对象的身份哈希(identity hash code)、GC 分代年龄、锁标志位。
- 轻量级锁定:存指向线程栈上锁记录(Lock Record)的指针。
- 重量级锁定:存指向
ObjectMonitor的指针。
identity hash 一旦写进 Mark Word,锁状态位就要让位,HotSpot 会把锁升级到能同时容纳哈希的形态;JDK 25 的具体位布局随版本和压缩指针配置变化,不要背固定位图。
偏向锁是 Mark Word 里的另一种状态:它把线程 ID 记在 Mark Word 里,下一次同一线程进入时不用做原子操作。JDK 15(JEP 374)默认禁用,后来彻底移除。本机确认:
java -XX:+PrintFlagsFinal -version 2>&1 | grep -i biased
本机输出为空,退出码 1。整份 PrintFlagsFinal 共 509 行,没有 UseBiasedLocking。加上诊断和实验参数再查:
java -XX:+UnlockDiagnosticVMOptions -XX:+UnlockExperimentalVMOptions \
-XX:+PrintFlagsFinal -version 2>&1 | grep -iE 'biased|MonitorDeflation'
本机输出:
intx MonitorDeflationMax = 1000000 {diagnostic} {default}
仍然没有偏向锁参数。能查到的只有 MonitorDeflationMax,它控制空闲监视器的回收上限,和偏向锁不是一回事。JDK 25 里没有「偏向锁默认开启」这回事,也没有「关闭偏向锁」的参数。
Monitor 与膨胀:从轻量级到 ObjectMonitor
javap 能看到 synchronized 在字节码层的两种写法。LoanCounter 同时提供了同步方法和同步块:
package com.plume.library.web;
public final class LoanCounter {
private long borrowed;
private long returned;
public synchronized void recordBorrow() {
borrowed++;
}
public synchronized void recordReturn() {
returned++;
}
public synchronized long active() {
return borrowed - returned;
}
public void recordBorrowBlock() {
synchronized (this) {
borrowed++;
}
}
}
编译后看字节码:
javac --release 25 -d out LoanCounter.java
javap -p -c -classpath out com.plume.library.web.LoanCounter
本机输出(节选):
public synchronized void recordBorrow();
Code:
0: aload_0
...
10: return
public void recordBorrowBlock();
Code:
0: aload_0
1: dup
2: astore_1
3: monitorenter
...
14: aload_1
15: monitorexit
16: goto 24
19: astore_2
20: aload_1
21: monitorexit
22: aload_2
两个差异:
synchronized方法没有monitorenter/monitorexit,javap -v显示flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED。JVM 在方法调用和返回时隐式加锁/解锁。synchronized块显式生成monitorenter和两条monitorexit:一条正常路径,一条异常路径。异常表保证抛异常时也会解锁。
HotSpot 的锁状态在运行时决定,策略是:
- 没有竞争时,走轻量级锁路径(thin lock),在 Mark Word 和线程栈的 Lock Record 之间做一次 CAS。
- 竞争加剧、或者需要
wait/notify、或者锁被长时间持有时,膨胀为重量级锁,分配一个ObjectMonitor,Mark Word 指向它。 - 竞争消失后,监视器可以被回收(deflate),
MonitorDeflationMax=1000000控制缓存上限。
JFR 里的锁竞争:Enter、Wait 与 Park
写一个四种模式的竞争实验:4 个线程,每个 25 次,每次持锁 10 ms。
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.ReentrantLock;
public class LockDemo {
static final Object MONITOR = new Object();
static final ReentrantLock LOCK = new ReentrantLock();
static int counter;
static void body(String mode) throws InterruptedException {
switch (mode) {
case "sync" -> { synchronized (MONITOR) { counter++; Thread.sleep(10); } }
case "reentrant" -> {
LOCK.lock();
try { counter++; Thread.sleep(10); } finally { LOCK.unlock(); }
}
case "wait" -> { synchronized (MONITOR) { MONITOR.wait(50); } }
default -> throw new IllegalArgumentException(mode);
}
}
public static void main(String[] args) throws Exception {
String mode = args.length > 0 ? args[0] : "sync";
CountDownLatch start = new CountDownLatch(1), done = new CountDownLatch(4);
long begin = System.nanoTime();
for (int i = 0; i < 4; i++) {
new Thread(() -> {
try {
start.await();
for (int k = 0; k < 25; k++) body(mode);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally { done.countDown(); }
}, "loan-counter-" + i).start();
}
start.countDown();
done.await();
System.out.println("mode=" + mode + " counter=" + counter
+ " elapsedMs=" + (System.nanoTime() - begin) / 1_000_000);
}
}
用 profile 配置录一轮:
java -XX:StartFlightRecording=filename=/tmp/vol4-d/locks/sync.jfr,settings=profile,dumponexit=true \
-cp out LockDemo sync
jfr summary /tmp/vol4-d/locks/sync.jfr | grep -E 'JavaMonitor|ThreadPark'
本机输出:
mode=sync counter=100 elapsedMs=1205
jdk.JavaMonitorEnter 9 199
jdk.JavaMonitorStatistics 2 15
jdk.ThreadPark 1 38
jdk.JavaMonitorDeflate 0 0
jdk.JavaMonitorInflate 0 0
jdk.JavaMonitorNotify 0 0
jdk.JavaMonitorWait 0 0
JavaMonitorEnter 只有 9 次,是因为 profile 配置给这个事件设了 20 ms 阈值:只有等待超过 20 ms 的进入才记录。100 次 synchronized 进入里,大部分等待很短,超过阈值的是少数。看一条具体事件:
jfr print --events jdk.JavaMonitorEnter --stack-depth 4 /tmp/vol4-d/locks/sync.jfr
本机输出(节选):
jdk.JavaMonitorEnter {
duration = 276 ms
monitorClass = java.lang.Object (classLoader = bootstrap)
previousOwner = "loan-counter-1" (javaThreadId = 30)
address = 0x8C8868460
eventThread = "loan-counter-3" (javaThreadId = 32)
}
duration 是等锁的时间,previousOwner 是上一个持有者。另一个事件等了 298 ms,说明线程在队列里排了很久。这类事件能直接回答「谁在等谁」;栈行号随代码排版变化,这里只保留事件字段。
wait/notify 走的是另一条路:
java -XX:StartFlightRecording=filename=/tmp/vol4-d/locks/wait.jfr,settings=profile,dumponexit=true \
-cp out LockDemo wait
jfr summary /tmp/vol4-d/locks/wait.jfr | grep -E 'JavaMonitorWait|JavaMonitorNotify'
本机输出:
mode=wait counter=0 elapsedMs=1343
jdk.JavaMonitorWait 100 2396
jdk.JavaMonitorNotify 0 0
100 次 wait(50) 全部超时,事件里的 timeout = 50.0 ms、timedOut = true,duration 约 52.9 ms(多出来的 2.9 ms 是唤醒和调度开销)。JavaMonitorNotify 为 0,因为没有线程调用 notify。
ReentrantLock 的对比最能说明「事件类型不能混」:
java -XX:StartFlightRecording=filename=/tmp/vol4-d/locks/reentrant.jfr,settings=profile,dumponexit=true \
-cp out LockDemo reentrant
jfr summary /tmp/vol4-d/locks/reentrant.jfr | grep -E 'JavaMonitorEnter|ThreadPark'
本机输出:
mode=reentrant counter=100 elapsedMs=1196
jdk.ThreadPark 76 2885
jdk.JavaMonitorEnter 0 0
ReentrantLock 的等待发生在 AQS 的 LockSupport.park 上,记录为 jdk.ThreadPark,不是 JavaMonitorEnter。如果你在 JFR 里只筛 JavaMonitorEnter,会得出「ReentrantLock 没有竞争」的错误结论。
想看膨胀,需要显式开启事件:
java -XX:StartFlightRecording=filename=/tmp/vol4-d/locks/inflate.jfr,settings=profile,jdk.JavaMonitorInflate#enabled=true,dumponexit=true \
-cp out LockDemo sync
jfr summary /tmp/vol4-d/locks/inflate.jfr | grep -E 'JavaMonitorInflate|JavaMonitorEnter'
本机输出:
jdk.JavaMonitorEnter 11 235
jdk.JavaMonitorInflate 6 119
6 个膨胀事件里包含 loan-counter-1 进入监视器时的膨胀,cause = "Monitor Enter"。也有 JFR 自己内部监视器的膨胀,所以不要假设 6 个全是业务锁;要看 monitorClass 和栈。
JIT 的锁优化:消除、粗化与嵌套
字节码里的 monitorenter 不代表机器码里一定有一次加锁。C2 有三个相关优化,本机默认都开着:
java -XX:+PrintFlagsFinal -version 2>&1 | grep -E 'DoEscapeAnalysis|EliminateLocks|EliminateNestedLocks'
bool DoEscapeAnalysis = true {C2 product} {default}
bool EliminateLocks = true {C2 product} {default}
bool EliminateNestedLocks = true {C2 product} {default}
- 锁消除:锁对象不逃逸出当前方法/线程时,C2 可以证明没有其他线程能拿到它,于是去掉加锁,典型写法是方法内
new Object()后synchronized。 - 锁粗化:连续多次对同一对象加解锁,JIT 可能合并成一次更大的临界区,循环里的短同步块是常见目标;嵌套锁消除则省略同一线程重复进入的内层锁。
这些优化发生在 JIT 编译之后,解释执行阶段仍然会走监视器。所以「字节码里有 monitorenter」和「运行时一定有竞争」是两件事。反过来说,不能因为 JFR 里没有 JavaMonitorEnter 就断言「锁被消除了」——也可能是根本没有竞争,事件没达到阈值。
在 libraryd 里落地
LoanCounter 的同步方法保证了 borrowed 和 returned 的读改写不丢更新,但也把三个操作串行化了。先量一下临界区:如果里面只有两次字段自增,锁的持有时间在纳秒级,竞争不会严重;真正的风险是把 I/O、日志、下游调用放进同步块。
一个更细的替代是 LongAdder:
private final LongAdder borrowed = new LongAdder();
private final LongAdder returned = new LongAdder();
public void recordBorrow() { borrowed.increment(); }
public void recordReturn() { returned.increment(); }
public long active() { return borrowed.sum() - returned.sum(); }
LongAdder 在高并发下把计数分散到多个 cell,写路径几乎不竞争;代价是 sum() 不是原子快照,可能在并发更新时读到「借出已加、归还还没加」的中间状态。对「当前在借数量」这种展示型指标可以接受,对账务结算不行。libraryd 的面板展示用 LongAdder,借阅/归还的状态迁移仍然用锁或数据库事务。
如果确实需要「等不到就失败」而不是无限排队,用 ReentrantLock.tryLock(timeout):
if (!lock.tryLock(200, TimeUnit.MILLISECONDS)) {
throw new LibraryException(ErrorCode.DB_UNAVAILABLE, "借阅计数器繁忙");
}
try {
// 临界区
} finally {
lock.unlock();
}
tryLock 让请求快速失败,避免在监视器队列里堆几分钟;超时值要比下游超时短、比正常持锁时间长。
JFR 的启动参数可以直接加到 libraryd-app 的启动脚本:
java -XX:StartFlightRecording=filename=/var/log/libraryd/locks.jfr,settings=profile,dumponexit=true \
-jar libraryd-app.jar
出问题后先 jfr summary 看 JavaMonitorEnter、JavaMonitorWait、ThreadPark 的数量,再 jfr print 看具体栈和 previousOwner。不要一上来就抓全量 ExecutionSample,锁问题在 CPU 采样里经常看不见。
常见错误与边界
错误一:写「偏向锁默认开启」。 JDK 15 起默认禁用,JDK 25 的 PrintFlagsFinal 里已经没有 UseBiasedLocking;硬加 -XX:-UseBiasedLocking 会直接启动失败,本机报 Unrecognized VM option 'UseBiasedLocking'。旧资料里的「偏向锁适合单线程」在 JDK 25 不成立。
错误二:用 JavaMonitorEnter 找 ReentrantLock 竞争。 本机 reentrant 模式 JavaMonitorEnter=0,ThreadPark=76。AQS 的等待记录在 jdk.ThreadPark 里,两个事件要一起看。
错误三:在同步块里做 I/O 或调用下游。 锁的持有时间从纳秒变成毫秒,其他线程全部排队;JFR 会显示 duration 很长,previousOwner 指向做 I/O 的线程。临界区只放共享状态的读改写。
错误四:用 String 或包装类型当锁对象。 字符串常量池和 Integer 缓存会让不相关的代码共享同一把锁,死锁范围扩大到无法推理。锁对象用 private final Object 或专用的锁类。
边界:JFR 的 JavaMonitorEnter 有阈值。 profile 配置默认 20 ms,短于阈值的等待不会记录。看到 0 不等于没有竞争,先确认阈值和事件是否开启。
边界:JEP 491 不改变锁的成本。 JDK 25 里 synchronized 不再 pin 虚拟线程,但它仍然是互斥锁。虚拟线程只是让排队不占载体,不会让临界区变短,也不会让竞争消失。
小结
- 对象头里的 Mark Word 复用同一组比特表示哈希、分代年龄和锁状态;64 位压缩指针下典型是 8 字节 Mark Word 加 4 字节类指针。
- JDK 25 没有
UseBiasedLocking;本机PrintFlagsFinal509 行里查不到,解锁诊断参数后也只有MonitorDeflationMax=1000000。 synchronized方法用ACC_SYNCHRONIZED,方法块用monitorenter/两条monitorexit;异常路径的解锁由异常表保证。- 竞争加剧时锁膨胀为
ObjectMonitor;本机显式开启后记录到 6 个jdk.JavaMonitorInflate事件。 synchronized竞争看JavaMonitorEnter,wait/notify看JavaMonitorWait,ReentrantLock看ThreadPark;本机 reentrant 模式是 0 个 Enter、76 个 Park。- JIT 的锁消除、锁粗化和嵌套锁消除都默认开启,字节码里的
monitorenter不等于运行时一定加锁。
锁讲完,下一节看一种「不占平台线程也能等待」的线程:虚拟线程如何挂到载体上、什么时候卸载、JDK 25 里还剩哪些 pin 场景。
阅读导航:上一节:8.1 线程栈与转储:从 jstack 到 JSON 线程容器 · 下一节:8.3 虚拟线程的运行时:Continuation、载体与调度 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。