《Java 虚拟机原理与性能调优》8.2 锁的运行时实现:对象头、Monitor 与膨胀

JDK 25 已经没有偏向锁。本节用 PrintFlagsFinal 确认 UseBiasedLocking 消失,用 javap 看 synchronized 方法与方法块的字节码差异,再用 JFR 记录 JavaMonitorEnter、Wait 与 Inflate,对比 synchronized 和 ReentrantLock 的真实竞争事件。

本节目标:从对象头与 Monitor 讲清 synchronized 在运行时发生了什么,并用 JFR 证明竞争发生在哪。
适用版本:JDK 25(Temurin 25.0.5+8);偏向锁已移除;JFR jdk.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 的锁状态在运行时决定,策略是:

  1. 没有竞争时,走轻量级锁路径(thin lock),在 Mark Word 和线程栈的 Lock Record 之间做一次 CAS。
  2. 竞争加剧、或者需要 wait/notify、或者锁被长时间持有时,膨胀为重量级锁,分配一个 ObjectMonitor,Mark Word 指向它。
  3. 竞争消失后,监视器可以被回收(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;本机 PrintFlagsFinal 509 行里查不到,解锁诊断参数后也只有 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、载体与调度 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发