synchronized 大概是 Java 并发面试里出场率最高的关键字,没有之一。一方面它出现在各种业务代码里,另一方面面试官又特别喜欢追问“JVM 底层到底怎么实现的”,很多人在这一层就开始支支吾吾:背得出“锁升级”三个字,但问起对象头里锁状态存在哪、monitorenter 指令做了什么、ObjectMonitor 是什么,就讲不清了。这篇文章我就从 JVM 视角把 synchronized 的底层实现完整拆一遍,包括字节码、对象头内存布局、Monitor 机制、锁升级路径,以及它和 Java 内存模型的关系。适合正在准备 JVM 面试题、或者想弄懂线上锁竞争问题的人,当知识索引和排障底子都够用。
1. 从字节码说起:synchronized 编译之后到底长什么样
1.1 同步代码块与同步方法的字节码差异
很多人以为 synchronized 是 JVM 用一堆复杂的 C++ 代码直接支持的,但其实 Java 编译器把 synchronized 翻译成字节码时,逻辑非常清晰。看一个最简单的同步代码块:
java复制public class SyncDemo {
public void demo() {
synchronized (this) {
System.out.println("hello");
}
}
}
用 javap -c -v SyncDemo 反编译之后,核心字节码是这样的:
java复制public void demo();
Code:
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: getstatic #2
7: invokevirtual #3
10: aload_1
11: monitorexit
12: goto 20
15: astore_2
16: aload_1
17: monitorexit
18: aload_2
19: athrow
20: return
注意这里的关键指令是 monitorenter 和 monitorexit,一个是进入锁,一个是释放锁。
仔细看会发现,字节码里出现了两次 monitorexit:第 11 行是正常执行完后的释放,第 17 行是异常路径上的释放。编译器在生成代码时,会把 synchronized 块包进一个隐式的 try-finally 结构里,不管代码执行到一半是抛异常还是正常返回,最终都会执行到 monitorexit 把锁还回去。这就是为什么异常时 synchronized 锁不会泄露的原因,它在字节码层面就保证了。
而同步方法的实现方式又不一样了,它根本没有 monitorenter / monitorexit。比如:
java复制public synchronized void demoSync() {
System.out.println("hello");
}
反编译后可以看到方法描述符上多了一个标志位:
java复制public synchronized void demoSync();
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
这里的关键是 ACC_SYNCHRONIZED 标志。JVM 在调用一个方法时,会检查方法的 access_flags 里有没有这个标记:如果有,调用线程就需要先获取这个对象(或 Class 对象)的 monitor,方法执行结束后再释放。也就是说,同步代码块和同步方法的锁获取路径不同,但最终都绕到同一个概念上——Monitor,下面会细说。
1.2 monitorenter / monitorexit 的语义
《Java 虚拟机规范》里对 monitorenter 的语义描述得很清楚:每个对象都有一个与之关联的 monitor,线程执行到 monitorenter 时必须获取该对象的 monitor 所有权。如果 monitor 已经被其他线程持有,则当前线程阻塞等待,直到 monitor 被释放;如果当前线程已经持有该 monitor,则支持重入,计数器加一。
注意最后一点,“当前线程已经持有的话直接重入”。这意味着 synchronized 是可重入锁,同一个线程可以多次进入同一个锁,不会自己把自己锁死。可重入的这个特性在字节码层面没有额外指令,是靠更底层的对象头和 Monitor 结构来实现的,这部分在讲锁升级的时候会展开。
还有一个容易忽略的语义:monitorexit 只能由持有该 monitor 的线程执行,否则会抛 IllegalMonitorStateException。而且 synchronized 的锁释放是自动的,编译器生成的字节码里已经帮你安排好了 monitorexit,所以不会像 Lock 接口那样还需要手动 unlock,也不用担心忘了释放导致死锁。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 对象头与 Mark Word:锁信息到底存在哪
2.1 对象在堆里的内存布局
面试里问“synchronized 锁的是谁”,答案其实是一个 Java 对象。但锁信息具体放在对象哪里?这就需要先搞清楚一个普通 Java 对象在 HotSpot 虚拟机里的内存布局。
一个对象在堆内存中由三部分组成:
- 对象头(Object Header):前 8 字节(64 位 JVM 上)或更多,存 Mark Word;后面还有指向类元数据的 Klass Pointer。
- 实例数据(Instance Data):真正存字段值的地方。
- 对齐填充(Padding):HotSpot 要求对象大小是 8 字节整数倍,不够就补位。
以 64 位 JVM 默认开启压缩指针的情况为例,一个普通对象的对象头大概是 12 字节:8 字节 Mark Word + 4 字节 Klass Pointer(压缩后才能 4 字节)。Mark Word 就是锁信息的“主战场”,所以别小看对象头,synchronized 的偏向锁、轻量级锁、重量级锁状态全藏在这里。
2.2 Mark Word 的位分布
Mark Word 在 64 位 JVM 下是 8 字节(64 位),它不是一个固定含义的结构,而是根据锁状态复用不同位段的动态结构。这是理解锁升级最关键的地方。
以 HotSpot 64 位 JVM 大致分布为例:
| 锁状态 | 锁标志位 | Mark Word 布局(64 位) |
|---|---|---|
| 无锁 | 01 | unused:25 bit + identity_hashcode:31 bit + unused:1 bit + age:4 bit + biased_lock:1 bit + lock:2 bit |
| 偏向锁 | 01 | thread:54 bit + epoch:2 bit + unused:1 bit + age:4 bit + biased_lock:1 bit + lock:2 bit |
| 轻量级锁 | 00 | ptr_to_lock_record:62 bit + lock:2 bit |
| 重量级锁 | 10 | ptr_to_heavyweight_monitor:62 bit + lock:2 bit |
| GC 标记 | 11 | 无意义,GC 时使用 |
注意无锁和偏向锁的标志位都是 01,区分它们靠 biased_lock 那一位:biased_lock=1 表示当前是偏向锁,biased_lock=0 则无锁。
这里有几个细节值得记住:
identity_hashcode不是默认就有的。只有当你调用System.identityHashCode()或hashCode()(且没有重写时)才会计算出来写进 Mark Word。一旦这个 31 位 hashcode 被写入了无锁态的 Mark Word,对象就无法再进入偏向锁了,因为偏向锁要复用这 31 位存线程 ID。这是个很经典的隐含知识点。age是对象年龄,GC 里对象晋升老年代用的,4 位,最大 15,这也是-XX:MaxTenuringThreshold默认最大 15 的原因。- 无锁状态下如果还没算过 identityHashCode,那最初 Mark Word 的 64 位基本都是零。
关于对象头,还有一个验证手段。使用 OpenJDK 的 JOL(Java Object Layout)工具,可以直观看到 Mark Word 变化:
xml复制<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>
然后写个简单测试:
java复制import org.openjdk.jol.info.ClassLayout;
public class JolTest {
static class LockObj {
private int value;
}
public static void main(String[] args) throws InterruptedException {
LockObj obj = new LockObj();
System.out.println("无锁状态:");
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
synchronized (obj) {
System.out.println("持有锁状态:");
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
}
}
运行前最好加两个 JVM 参数,让结果更稳定:
bash复制-XX:BiasedLockingStartupDelay=0
-XX:+UseCompressedOops
JOL 打印出来的头部字节中,前 8 字节就是 Mark Word。实际操作你会看到,线程没进 synchronized 和进了 synchronized 后,这 8 个字节的内容完全不同,直观地反映出锁状态的变化。
2.3 Klass Pointer、对齐填充与锁无关
Klass Pointer 指向对象的类型元数据,也就是这个对象属于哪个类。它和锁没什么直接关系,但是要注意关闭压缩指针(-XX:-UseCompressedOops)时,对象头会从 12 字节变成 16 字节,这是因为 Klass Pointer 变成 8 字节了。对锁状态分析不影响,但 JOL 打印出来的布局会不一样,排障时不要被它干扰。
对齐填充就更简单了,HotSpot 要求对象大小是 8 的倍数,如果一个对象只有 12 字节头加 4 字节 int,那还不够 16 字节,就得补 0 凑够。这块区域没有任何逻辑意义。
3. Monitor 与重量级锁:管程模型的落地实现
3.1 什么是 Monitor,HotSpot 里的 ObjectMonitor
Monitor 直译叫“监视器”,也叫管程(Monitors)。Java 早期设计同步机制时,参考的就是操作系统的管程模型。C 语言里有一套著名的并发原语叫 Monitor,Java 的 synchronized 在 JVM 层面的重量级锁实现,本质上就是围绕一个叫 ObjectMonitor 的 C++ 结构体展开的。
在 HotSpot 源码(比如 synchronizer.cpp)里,ObjectMonitor 大概有这么几个关键字段:
_owner:持有当前 monitor 的线程,初始为 null。_count:重入计数或竞争计数,体现可重入特性。_recursions:当前线程重入次数,这就是 synchronized 可重入的底层实现依据。_EntryList:等待获取锁的线程队列。_WaitSet:调用了 wait() 的线程等待队列。_object:关联的 Java 对象。
当锁膨胀为重量级锁之后,Mark Word 里的 62 位就不再存线程 ID 或 Lock Record 指针了,而是直接存一个指向 ObjectMonitor 的指针。
3.2 重量级锁获取与释放的完整过程
当线程执行到 monitorenter,且对象已经膨胀为重量级锁时,会调用 ObjectMonitor 的 enter 方法,过程大致如下:
- 尝试 CAS 将
_owner从 null 改成当前线程,成功则直接获得锁,流程结束。 - 如果失败,说明锁被别人持有,当前线程进入
_EntryList排队,并尝试自旋一小段?不,重量级锁的路径是直接挂起,但 HotSpot 在特定实现版本里会先做一次简单自旋尝试,整体取决于版本和参数。 - 线程被挂起后,会等待持有锁的线程调用 exit,释放时会把
_owner重新置为 null,并唤醒_EntryList里排队的线程。 - 被唤醒的线程再走一遍 CAS 抢锁逻辑,抢不到就继续等。
这里你可能会听到“管程模型”这个词,指的就是 _WaitSet、_EntryList、_owner 这几个结构协作的等待/通知模型。注意,重量级锁的等待机制并不保证公平性,_EntryList 里的线程是可能被插队的,所以 synchronized 不是公平锁,而 ReentrantLock 默认也不是公平锁,想要公平只能传 fair=true。
3.3 为什么重量级锁慢
重量级锁之所以叫“重量级”,是因为它依赖操作系统的互斥量(Mutex)来实现线程阻塞和唤醒。线程一旦挂起,就会从用户态切换到内核态,等锁释放后再从内核态切回来。这个用户态/内核态切换的代价非常高,远远大于几条汇编指令的 CAS 操作。
在 Linux 上,HotSpot 的线程挂起/唤醒底层走的是 pthread_mutex_lock / pthread_mutex_unlock,再往下是 futex 系统调用。一次 futex 调用涉及上下文切换、缓存失效,高并发下还可能引发“惊群”问题(多个等待线程同时被唤醒但只有一个能抢到锁)。所以早期 JDK 版本里大家才疯狂吐槽 synchronized 性能差,并发编程都推荐用 Lock 接口。
JDK 6 之后为什么敢说 synchronized 性能没那么差了?因为 JVM 引入了一整套锁优化,把竞争不激烈的场景尽量留在用户态,避免直接走重量级锁。这就是下一章要讲的锁升级路线。
4. 锁升级路线:偏向锁 → 轻量级锁 → 重量级锁
4.1 偏向锁:同一个线程反复进入
偏向锁的设计理念是:大部分情况下,一个锁不仅不存在多线程竞争,而且总是被同一个线程重复获取。既然这样,那每次加锁都搞 CAS 太浪费了,不如把锁直接“偏向”给那个线程。
偏向锁的获取流程:
- 判断 Mark Word 是否处于可偏向状态(biased_lock=1,lock=01)。
- 如果是,用 CAS 把当前线程 ID 写到 Mark Word 的 thread 位段中,写成功就说明当前线程获得了偏向锁。
- 如果 Mark Word 里已经是当前线程 ID,说明重入,直接进入同步代码块,连 CAS 都不用做。
- 如果 CAS 失败,说明有其他线程竞争,触发偏向锁撤销。
注意,偏向锁在 JVM 启动后并不是马上生效的。HotSpot 默认有 4 秒延迟,也就是 -XX:BiasedLockingStartupDelay=4000,这是因为 JVM 启动初期有大量内部对象的 hashCode 计算和线程创建操作,这些场景不适合偏向锁,延迟一段时间能避免频繁撤销的损耗。想观察偏向锁行为,可以显式设置 -XX:BiasedLockingStartupDelay=0。
另一个点:前面提过,对象的 identityHashCode 一旦被计算并且写入 Mark Word,对象就再也不能进入偏向锁了。因为偏向着锁需要复用那 31 位来存线程 ID。这也是为什么你在测试代码里一调用 hashCode(),再看锁状态就会变得很微妙的原因。
4.2 偏向锁撤销、批量重偏向与批量撤销
偏向锁的问题在于它太“偏”了,一旦出现第二个线程来竞争,偏向锁就必须撤销。
撤销的过程比获取复杂得多:
- 它需要等待一个安全点(SafePoint),也就是所有用户线程都暂停下来,JVM 才可以安全地修改对象头。
- 在安全点,JVM 判断持有偏向锁的线程是否还存活,如果已不存活,直接把对象头恢复成无锁状态,让新线程可以重新偏向;如果还活着,则尝试让原线程在安全点“丢掉”偏向锁。
- 撤销完成后,锁可能变成无锁状态,也可能升级为轻量级锁,取决于竞争情况。
这里有两个高频面试概念:批量重偏向(bulk rebias)和批量撤销(bulk revoke)。
当一个类的大量对象都在发生偏向锁撤销时,说明偏向锁的维护成本开始大于收益了。HotSpot 的规则大致是这样:
- 某个类的撤销次数达到 20(
-XX:BiasedLockingBulkRebiasThreshold)时,JVM 会认为这个类的对象适合重新偏向,触发批量重偏向。 - 撤销次数达到 40(
-XX:BiasedLockingBulkRevokeThreshold)时,JVM 会认为这个类根本不适合使用偏向锁,触发批量撤销,之后这个类的对象直接走无锁或轻量级锁路径。
这也是为什么线上很多服务在启动一段时间后,偏向锁就“自动消失”了,不是 bug,是 JVM 在动态调整。
还有个前置知识:JDK 15 里 JEP 374 已经默认禁用偏向锁,JDK 后续版本逐步将其废弃。原因很现实:偏向锁的撤销需要 SafePoint 暂停线程,在现代化容器环境和低竞争场景下反而增加了停顿风险,收益却越来越不明显。所以如果你用 JDK 17 及以上跑测试,会发现默认情况下已经观察不到偏向锁了,这不代表原理错了,是功能被关掉了。
4.3 轻量级锁:CAS 自旋与 Lock Record
当偏向锁被撤销,或者一开始就不满足偏向条件时,锁并不会马上变成重量级锁,而是先进轻量级锁。
轻量级锁的核心是 Lock Record,它存放在当前线程的栈帧中。加锁时要做的事:
- 在当前线程栈帧中开辟一块空间,创建 Lock Record。
- 把对象当前的 Mark Word 复制一份到 Lock Record 里,这叫 Displaced Mark Word。
- 用 CAS 尝试将对象的 Mark Word 修改为指向当前线程 Lock Record 的指针。
CAS 成功,锁直接拿到,轻量级锁状态结束。CAS 失败,说明有别的线程竞争,此时不会立刻膨胀成重量级锁,而是先自旋——也就是在用户态空转一小段时间,反复尝试 CAS。
自旋的设计逻辑是:很多锁的持有时间非常短,让后来的线程直接挂起反而更亏,因为挂起涉及内核态切换,而自旋就是纯用户态操作,快得多。但自旋必须有限度,无限自旋会白白消耗 CPU。JDK 6 以后引入了自适应自旋(Adaptive Spinning),JVM 会根据上一次同一个锁的自旋等待时间、竞争激烈程度动态调整本次自旋次数,不再是拍脑袋定死一个数。
轻量级锁还有一个特点:它支持线程重入。同一个线程重入时,会在栈帧里再创建一个新的 Lock Record,把 Displaced Mark Word 设为 null,表示这是重入记录。释放时遇见 Displaced Mark Word 为 null 的记录就直接弹栈,直到把最外层记录恢复回对象头。这里也呼应了前面说的“可重入”。
4.4 锁升级不可逆与完整判定路径
把整个流程串起来,就是面试里最常背的那条线:
偏向锁 → 轻量级锁 → 重量级锁
具体触发场景:
| 状态 | 触发场景 | 备注 |
|---|---|---|
| 无锁 | 对象创建 | 未参与同步,或偏向撤销后 |
| 偏向锁 | 同一线程重复进入 | 默认延迟 4 秒生效,JDK 15+ 默认禁用 |
| 轻量级锁 | 第二个线程竞争,但竞争不激烈 | 使用 CAS + 自旋,用户态完成 |
| 重量级锁 | 自旋失败或竞争激烈 | 走操作系统 Mutex,用户态/内核态切换 |
锁升级是单向且不可逆的。一旦轻量级锁膨胀为重量级锁,之后除非锁被完全释放,否则不会自动降回去。在实际高并发场景里,当多个线程在一个对象锁上激烈竞争时,Mark Word 很快就会稳定在重量级锁状态。
有人会问,轻量级锁膨胀的具体时机是什么?在 HotSpot 默认实现里,大致是:轻量级锁 CAS 失败后,先自旋,如果自旋达到一定次数仍然拿不到锁,当前线程就会执行锁膨胀逻辑,把对象头改为指向 ObjectMonitor,然后进入 monitor 的阻塞流程。
这里有个面试常问的延伸点:wait/notify 会改变锁状态吗?答案是会。在偏向锁或轻量级锁状态下调用 wait(),JVM 必须把对象膨胀为重量级锁,因为只有 ObjectMonitor 的 _WaitSet 才能实现线程悬挂和唤醒。所以谁也绕不过重量级锁,你一旦用了 wait/notify,本质上就已经和 Monitor 绑定在一起了。
5. synchronized 与 Java 内存模型:可见性和有序性怎么保证
5.1 加锁与解锁是对主内存的“读写屏障”
讨论 synchronized 不能只盯着锁,它还有一个容易忽略的作用:保证内存可见性和有序性。这部分在 JVM 内存模型(JMM)的面试题里非常常见。
JMM 规定每个线程有自己的工作内存,操作变量时先把主内存的值拷贝到工作内存,操作完成后再刷回主内存。如果没有同步机制,一个线程改了共享变量,另一个线程不一定看得到,因为修改可能还在工作内存里没刷出去。
synchronized 在这里起的作用是:
- 加锁时:线程会清空工作内存中与锁对象相关的共享变量缓存,然后从主内存重新加载。也就是说,进入同步代码块后,你读到的共享变量值是最新的。
- 解锁时:线程会把同步块内修改过的共享变量强制刷新到主内存。
这个机制保证了“锁内修改对后续获取同一把锁的线程可见”。这不是 ObjectMonitor 代码里显式写的逻辑,而是 JVM 内存模型层面的语义约束。它的价值不亚于互斥本身,因为互斥只是让你不并发执行,可见性才决定了你执行时看到的数据对不对。
5.2 happens-before 规则与锁的关系
JMM 里定义了 happens-before 规则,用来判定一个操作是否对另一个操作可见。synchronized 对应的就是监视器锁规则:
对一个锁的解锁 happens-before 于后续对这个锁的加锁。
换句话说,线程 A 释放锁之前的所有写操作,线程 B 在获取同一把锁之后都能看到。这个规则不依赖具体 CPU 的内存屏障细节,而是 JMM 给程序员的一个抽象契约。JVM 在生成代码时,会视平台能力插入必要的内存屏障指令,比如在释放锁时做 StoreLoad 之类的屏障,确保写操作刷出,获取锁时做 LoadLoad 屏障,确保不会读到过期数据。
很多人在并发编程里只把 synchronized 当互斥工具用,其实它同时解决了原子性、可见性、有序性三个问题:
- 原子性:同步代码块内的操作不会被打断。
- 可见性:解锁写回主内存,加锁刷新工作内存。
- 有序性:锁内代码不会被 JIT 或 CPU 重排序到锁外(至少从内存语义上是这样保证的)。
正因如此,它才是 Java 并发编程里“最老实”的同步工具。相比 volatile 只解决可见性和有序性,synchronized 是覆盖最全的原语。
顺带说一句,很多人会把 synchronized 和 HashMap 放在一起聊,比如“HashMap 线程不安全,怎么解决”。原因就在于 HashMap 的多线程 put 可能造成数据覆盖、死循环等问题,除了换 ConcurrentHashMap,最粗暴的兜底方案就是给整个 map 的访问加 synchronized。当然,这样会把并发度压到最低,因为所有线程抢同一把锁,所以实际工程中并发场景优先还是选 ConcurrentHashMap,它内部在 JDK 8 以后就是用 CAS + synchronized 来控制节点锁的,既保证了安全性,又细化了锁粒度。
6. 常见问题与排障实录:面试高频与实战经验
6.1 为什么 synchronized 是可重入锁
可重入的底层答案分两个层次。
在重量级锁阶段,ObjectMonitor 里有 _recursions 字段,持有锁的线程再次进入同一把锁时,_recursions 加一,退出时减一,直到减到 0 才真正释放锁。所以同一个线程不会被自己持有的 Monitor 挡住,这就避免了递归方法里死锁的问题。
在偏向锁和轻量级锁阶段,可重入更简单。偏向锁只要发现 Mark Word 里的线程 ID 就是自己,直接放行;轻量级锁则通过栈帧里继续创建 Lock Record 来标记重入,退出时一层层弹栈。
面试可以这么答:“synchronized 的可重入体现在三档锁状态里,偏向锁靠线程 ID 判断,轻量级锁靠多个 Lock Record 支持,重量级锁靠 Monitor 里的 recursion 计数实现。所以 Java 里的 synchronized 天然支持重入,不存在自己把自己锁死的情况。”
6.2 synchronized 与 ReentrantLock 怎么选
这几乎是并发面试的必问题。
先说性能。JDK 6 之后 synchronized 引入了锁升级、锁消除、锁粗化等优化,在低竞争场景下性能已经不输 ReentrantLock,甚至在简单场景下因为无需手动加解锁可能更省。高竞争场景两者也不会有数量级差异,因为它们最终都可能走操作系统级别的挂起。所以“性能差很多”这个老观点在现代 JDK 上已经不成立。
再看功能差异:
| 对比项 | synchronized | ReentrantLock |
|---|---|---|
| 默认锁性质 | 非公平 | 非公平,可指定公平 |
| 中断响应 | 不支持 | 支持 lockInterruptibly |
| 超时等待 | 不支持 | 支持 tryLock(timeout) |
| 条件变量 | 只有一个,用 wait/notify | 可创建多个 Condition |
| 释放方式 | 字节码自动释放 | 必须手动 unlock,通常放 finally |
| 底层实现 | 对象头 + Monitor | AQS + CAS |
我的选型经验是:如果没有特殊需求,优先用 synchronized。代码更短、不会忘释放、异常路径也能自动释放。需要公平锁、可中断、超时抢占或多条件队列时,才换 ReentrantLock。这个选择不是性能驱动的,而是功能需求驱动的。
6.3 线上怎么排查锁竞争
线上问题最常见的表现是:线程池被占满、接口 RT 变高、Thread Dump 里大量线程处于相同状态。这时候就怀疑锁竞争了。
首先用 jstack 抓线程栈,重点看两种状态:
text复制java.lang.Thread.State: BLOCKED (on object monitor)
出现在 synchronized 代码块入口,说明大量线程在等锁。
text复制java.lang.Thread.State: WAITING (on object monitor)
配合 at java.lang.Object.wait(Native Method) 出现,说明有人在等 wait/notify 通知。
定位到具体业务代码后,再看持有锁的线程是不是卡在 IO、数据库调用等慢操作上。如果是,常见的修复手段是缩小同步块范围、降低锁粒度、或者用读写分离的并发容器替换全局锁。这里有个很容易踩的坑:看到 BLOCKED 线程多就认为一定是重量级锁竞争,其实在高版本 JDK 上,轻量级锁自旋也会让线程看起来像在 Runnable 状态反复执行,所以抓线程栈要看整体上下文,不能只看一两个状态。
更系统一点的工具是 JDK Flight Recorder(JFR),它有一个 Lock Instances 事件,可以直接统计哪些锁竞争最激烈、哪些线程持锁最长。在 JDK 11+ 上可以先用:
bash复制jcmd <pid> JFR.start name=lockprobe settings=profile
jcmd <pid> JFR.dump filename=lockprobe.jfr
再用 JDK Mission Control 打开,看 “Lock Instances” 面板,很快就能找到锁热点了。
6.4 实际项目里的避坑笔记
最后分享几个我在实践中踩过的和锁相关的坑,都是细节问题。
第一个坑:偏向锁的“假死”现象。我曾在 JDK 8 低版本上遇到一个诡异场景,某个锁在低竞争下偶尔会有毫秒级抖动。后来定位发现是偏向锁撤销需要等到 SafePoint,而 SafePoint 时机不稳定,造成小概率停顿。如果对延迟敏感,可以在启动参数里加 -XX:BiasedLockingStartupDelay=0 提前进入稳定状态,或者直接 -XX:-UseBiasedLocking 禁用偏向锁。不过 JDK 15+ 默认禁用后,这个坑就很少遇到了。
第二个坑:hashCode() 会破坏偏向锁。写性能测试时,为了生成 key 我随手调了 hashCode(),结果后面所有对象的锁状态都偏离了预期。这不是 bug,而是 Mark Word 位冲突导致的必然行为。所以做锁相关的 JOL 实验时,别随意调用 identityHashCode。
第三个坑:synchronized 锁的粒度不是越小越好。JVM 有锁粗化优化,编译器会把连续加锁、解锁的多个同步块合并成一个,减少锁操作次数。如果你把同步块拆得稀碎,反而可能因为频繁的加解锁和锁状态判断产生额外开销。同步块太小的时候,JVM 不一定会帮你补救,所以在设计时还是要保证同步块内逻辑真的短平快,而不是指望优化兜底。
第四个坑:对象逃逸时别指望锁消除。JVM 的锁消除依赖逃逸分析,只有在确认锁对象不会被其他线程访问时才会把锁消除掉。如果对象通过方法调用传出去了,逃逸分析就失效了,锁该升级还是会升级。调试性能问题时要意识到,synchronized 并不是所有场景都会自动优化掉。
这些实战经验很难从官方文档里直接查出来,但它们往往才是决定线上性能表现的关键点。对我来说,理解 synchronized 的底层实现不仅仅是为了通过面试,更是排查并发问题时能快速定位到对象头、Monitor、自旋这些真正起作用的机制,少走很多弯路。
