在Java并发编程中,synchronized关键字看似简单,背后却牵扯出一整套复杂的同步子系统:锁状态的逐级升级、ObjectMonitor的分配与释放、以及GC安全点对整个过程的约束。理解监视器与锁膨胀如何与安全点同步相互影响,是排查高并发场景下延迟尖刺、jstack卡顿等疑难问题的关键。

对象头与锁膨胀的完整路径
Java对象在堆中的布局由三部分组成:对象头、实例数据和对齐填充。在64位JVM默认开启压缩指针的情况下,对象头为12字节,其中Mark Word占8字节,另外4字节是类型指针。Mark Word是整个锁状态机的核心,它用锁标志位字段来区分当前对象处于无锁、偏向锁、轻量级锁还是重量级锁状态。
锁膨胀的本质是Mark Word中存储的指针随竞争程度不断升级的过程。无锁状态下Mark Word保存对象哈希码与分代年龄;偏向锁状态下保存偏向线程ID;轻量级锁状态下保存指向线程栈中Lock Record的指针;重量级锁状态下保存指向ObjectMonitor的指针。HotSpot在JDK 15之前默认支持偏向锁,通过-XX:+UseBiasedLocking开启,但由于维护成本过高且收益在现代应用中越来越小,JDK 15起默认禁用并标记废弃。一个简单的验证方式如下:
public class LockStateDemo {
static final Object lock = new Object();
public static void main(String[] args) throws Exception {
// 使用JOL工具查看对象头布局
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock) {
// 轻量级锁状态下,Mark Word变为指向栈中Lock Record的指针
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
}
}
}需要注意的是,锁膨胀通常是单向的:一旦升级为重量级锁,即使后续竞争完全消失,锁也不会自动降级回轻量级锁。这意味着短暂的激烈竞争会让对象长期停留在重量级状态,后续每次进入monitor都会付出系统调用的开销。
ObjectMonitor的结构与竞争流程
当轻量级锁自旋失败后,虚拟机会执行膨胀过程,为对象分配或关联一个ObjectMonitor。在HotSpot的实现中,ObjectMonitor包含几个关键字段:_owner指向持有锁的线程,_EntryList存放阻塞在锁上的线程,_WaitSet存放调用wait()后被挂起的线程,_cxq(ContentionList)则是一个单向链表,新来的竞争线程先进入这个队列。整个竞争流程可以概括为:线程先尝试CAS直接获取monitor,失败后进入_cxq,持有者释放锁时会从_cxq或_EntryList中唤醒一个线程。
重量级锁的阻塞依赖操作系统的互斥量与条件变量实现,线程的挂起和恢复都需要陷入内核态,这是重量级锁开销的主要来源。与之相对,轻量级锁只是线程栈上的CAS操作,完全不涉及内核。两种机制的代价差距可达数十倍,这也是虚拟机宁愿先自旋、再膨胀的原因:通过自适应自旋策略,赌一把短时间内锁会被释放,避免昂贵的内核态切换。
wait与notify也与ObjectMonitor紧密相关。调用wait()的线程会清空_owner并进入_WaitSet,notify()只是将线程从_WaitSet移到_cxq或_EntryList,真正被唤醒还要等锁被释放。这也解释了为什么wait必须在synchronized块内调用,否则会抛出IllegalMonitorStateException。
监视器与GC安全点的交互
安全点是JVM能够安全执行全局性操作的位置,例如STW垃圾回收、代码反优化、栈替换等。进入安全点的方式主要有两种:基于轮询的全局安全点同步,以及针对单个线程的异步握手。全局安全点同步时,JVM设置一个待处理标志,所有Java线程会在解释器分支跳转处、方法返回处或编译代码的循环回边处检查这个标志并主动停下来。
监视器在这里扮演了一个微妙角色。当线程在synchronized代码块中阻塞在重量级锁上时,它已经不再执行Java字节码,此时它被视为处于安全点阻塞状态,不会阻止其他线程进入安全点。但问题出在另外两个场景:一是线程持有monitor并执行一段超长的计数循环,如果编译后的代码缺少安全点轮询点,其他线程必须等它跑完才能进入安全点,表现为整停顿;二是jstack、jcmd等工具在部分JDK版本中需要发起安全点,锁竞争严重时会观察到明显的命令卡顿。
ObjectMonitor本身的内存分配也与GC有交互。膨胀时monitor优先从全局缓存中复用,避免频繁向堆申请内存。被丢弃的monitor会被缓存起来供下次膨胀使用,这减少了分配压力,但也意味着大量并发锁可能导致缓存的monitor数量膨胀,占用额外内存。通过JFR的Java Monitor Blocked事件,可以方便地观察到这些阻塞行为。
实践排查与调优建议
排查锁与安全点问题的第一步是打开安全点日志。旧版本可使用-XX:+PrintSafepointStatistics配合-XX:+PrintGCApplicationStoppedTime,JDK 11及以后推荐使用统一日志参数,具体命令如下:
# JDK 11+ 统一日志方式查看安全点 java -Xlog:safepoint=info:file=safepoint.log:time,uptime:filecount=5 -jar app.jar # 观察线程阻塞情况,注意该操作自身也可能触发安全点 jstack <pid> > thread.txt
如果发现某类安全点操作耗时集中在同步相关项目(例如RevokeBias偏向锁撤销或FindDeadlocks),说明锁竞争已经影响了全局停顿。针对长循环问题,JDK 10引入了循环内安全点并默认开启,修复了长计数循环无法及时进入安全点的问题。如果仍发现系统迟迟到不了安全点,需要检查是否是手写的长循环内缺少方法调用,必要时可将其拆分为小块,或在循环中插入可被识别的轻量操作。
从设计层面减少锁膨胀的思路包括:缩小临界区,只把真正需要互斥的语句放进synchronized;使用ReentrantLock时优先尝试tryLock避免无限期阻塞;读多写少的场景改用StampedLock或LongAdder;极端高并发下可考虑无锁的CAS算法。归根结底,理解监视器的膨胀路径和安全点的同步机制,能让并发优化从凭感觉猜测变成有据可依的分析。