要说Java并发编程里哪个关键字被问得最多,synchronized排第二,没人敢排第一。从新手入门到资深架构师面试,几乎每一轮都可能被问到。我自己面试别人的时候也喜欢从synchronized切入,因为这个问题能很好地判断一个人是背了八股,还是真正理解并发底层的运行机制。很多人能熟练说出"锁升级"三个字,但追问到Mark Word、ObjectMonitor、偏向锁撤销这些细节时,往往会卡住。
这篇文章把synchronized的底层原理一次讲透。我们从字节码指令开始,逐步拆解对象头、锁升级的完整链路、编译期的锁优化,最后再聊几个面试中常见的追问。无论你是准备面试,还是单纯想把并发基础打牢,这篇都值得花十分钟仔细看完。
1. synchronized的起点:从字节码指令说起
1.1 反编译一个同步方法,看到的不只是关键字
很多人以为synchronized只是一个简单的修饰符,就像final、static一样,JVM自然就懂了。但实际上,synchronized在字节码层面有非常具体的指令对应。我们用一段最简单的代码来看:
java复制public class SyncDemo {
public void test() {
synchronized (this) {
System.out.println("hello");
}
}
}
用javap -c -v反编译之后,关键字节码如下:
text复制 3: monitorenter
4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
7: ldc #3 // String hello
9: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
12: aload_0
13: monitorexit
14: goto 22
17: astore_1
18: aload_0
19: monitorexit
20: aload_1
21: athrow
22: return
注意两个指令:monitorenter和monitorexit。前者进入同步代码块时执行,后者退出同步代码块时执行。细心的人会看到这里有两条monitorexit,一条是正常退出路径(13行),一条是异常退出路径(19行)。这是JVM在编译阶段自动生成的异常处理逻辑,保证即使在同步块里抛了异常,锁也能被正确释放。这一点在面试里值得主动提出来,能体现你确实看过字节码,而不是只背了概念。
如果你看的是同步方法而非同步代码块,字节码又不一样。方法上会多一个ACC_SYNCHRONIZED标志位,JVM在方法调用时检查这个标志,如果设置了,调用线程需要先获取monitor,方法执行完再释放。本质上和monitorenter/monitorexit是同一个机制,只是入口不同。
1.2 monitor机制:每个Java对象都自带一把锁
刚才提到的monitorenter,语义是"请求获取对象的monitor"。那monitor到底是什么?很多人到这里就模糊了。简单说,monitor可以理解成一个"管程",是操作系统级别的同步原语,但JVM在实现时做了一层抽象。每一个Java对象在创建时,都会在内存中对应一个monitor对象,只不过这个monitor不是一开始就存在的,而是等到真正有并发竞争时才会创建。
更具体地说,在HotSpot虚拟机里,monitor对应的是ObjectMonitor这个C++类。它是一个独立的、重量级的数据结构,包含了_owner(当前持有锁的线程)、_EntryList(等待获取锁的线程队列)、_WaitSet(调用了wait的线程队列)、_recursions(重入次数)等关键字段。当你用synchronized争取一个对象的锁,而该对象已经处于重量级锁状态时,JVM就会构建这样一个ObjectMonitor,然后让线程进入它的等待队列。
这里有个面试中经常被问的细节:synchronized是可重入的。当一个线程已经持有某个对象的monitor,再次进入同一个monitor时,_recursions计数加1,不需要重新竞争锁。这个机制保证了同一个类里synchronized方法可以互相调用,不会把自己锁死。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 对象头与Mark Word:锁信息到底存在哪
2.1 一个Java对象在内存里长什么样
要理解锁升级,必须先搞懂Java对象的内存布局。一个Java对象在HotSpot虚拟机中由三部分组成:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。对象头又分两部分:Mark Word和Klass Pointer(类型指针)。如果是数组对象,还有一个数组长度字段。
其中Mark Word才是今天的重头戏。它是一块固定大小的内存区域,在64位虚拟机里占8个字节(64位),在32位虚拟机里占4个字节。这块内存非常"精打细算",同一个位置在不同状态下存储着完全不同的信息。这就是为什么Mark Word是理解synchronized底层原理的关键——锁状态全部记录在这8个字节里。
2.2 Mark Word里的位分配
以64位虚拟机为例(小端模式),Mark Word的位分配大致如下:
| 锁状态 | 存储内容 | 标志位 |
|---|---|---|
| 无锁 | 对象的hashCode、分代年龄 | 01 |
| 偏向锁 | 偏向线程ID、epoch、分代年龄、是否偏向 | 01 |
| 轻量级锁 | 指向栈中锁记录的指针 | 00 |
| 重量级锁 | 指向ObjectMonitor的指针 | 10 |
| GC标记 | 空(不存储任何信息) | 11 |
注意无锁状态和偏向锁状态的标志位都是01,JVM是怎么区分呢?靠的是Mark Word里的biased_lock位(1位)。如果biased_lock是0,表示无锁;是1,表示偏向锁。我在面试候选人时,很多人能说出标志位的值,但说不清无锁和偏向锁为什么标志位一样,就是这个细节没吃透。
还有一个容易忽略的点:hashCode和偏向锁的冲突。无锁状态下,Mark Word里存的是对象的identity hash code(系统哈希,不是重写后的hashCode)。一旦对象计算过这个hashCode,它就无法进入偏向锁状态,因为偏向锁需要把Mark Word的一部分位腾出来存线程ID和epoch。这是偏向锁在JDK 15之后被默认禁用、JDK 18被废弃的原因之一——它的维护成本太高,收益在低竞争场景下又有限。
2.3 一个类比:Mark Word就像房间门口的白板
如果把获取锁比作进入会议室,Mark Word就是门口挂的一块白板。无锁时,白板上写着房间号(hashCode)和使用记录(分代年龄)。偏向锁时,白板上写着"常客:线程A",只要A来,直接进,不用登记。轻量级锁时,白板上写着"正在使用:请找管理员登记",线程需要在栈里创建一条登记记录(Lock Record),通过CAS把白板内容改成自己的记录。重量级锁时,白板已经被撤掉,换成了一套完整的门禁系统(ObjectMonitor),所有线程都要去门禁那里排队。
这个类比不是百分百精确,但对我们理解锁升级的"代价递增"思路很有帮助。锁升级的本质就是:一开始用最便宜的方式(一个线程独占,直接白板记个名),随着竞争加剧,逐步换成更重、但更公平有序的机制。
3. 锁升级完整链路:无锁、偏向锁、轻量级锁、重量级锁
3.1 偏向锁的启动延迟与获取过程
先说一个很多人不知道的参数:-XX:BiasedLockingStartupDelay,默认值是4000毫秒。为什么要有这4秒延迟?因为JVM启动初期,有大量内部同步操作是单线程或低竞争的,如果这时就启用偏向锁,频繁的创建和撤销偏向锁反而影响性能。所以HotSpot在启动4秒后才开启偏向锁。实测中,如果你在JVM刚启动的前4秒内跑同步代码,会发现锁状态直接是无锁、不经过偏向锁;4秒之后,偏向锁才生效。
偏向锁的获取过程可以用一句话概括:如果当前Mark Word是偏向状态,且偏向线程是自己,直接进入;如果不是,通过CAS尝试修改Mark Word中的线程ID。这里的CAS是针对整个Mark Word的,一旦失败,说明存在竞争,就进入偏向锁撤销流程。
很多人把偏向锁理解成"没有代价的锁",其实不准确。偏向锁的代价在于撤销。当一个线程尝试获取偏向锁,但Mark Word显示的偏向线程是另一个线程A,此时需要等到A到达安全点(Safe Point)才能撤销。如果A已经不存活,或者A不再需要这个锁,JVM把偏向锁改为无锁状态;如果A还持有这把锁,JVM会尝试把偏向锁升级为轻量级锁。这个安全点停顿的开销,在高竞争场景下是非常明显的。
3.2 轻量级锁:CAS的舞台
偏向锁失效后,就轮到轻量级锁。这里的"轻量"是相对于重量级锁(ObjectMonitor)而言的,它不涉及线程阻塞和唤醒,而是使用CAS操作加锁。
轻量级锁的加锁过程大概是这样的:
- 线程在栈帧中创建Lock Record空间,用于存放锁对象的Mark Word副本。
- 通过CAS尝试把锁对象头部的Mark Word替换为一个指向Lock Record的指针。
- 如果CAS成功,锁对象进入轻量级锁状态,当前线程持有锁。
- 如果CAS失败,说明锁已被其他线程占用,当前线程会尝试自旋等待(Spin),而不是立刻阻塞。
自旋这个机制值得多说两句。JDK 6之后,自旋锁默认开启,但不是固定的自旋次数,而是自适应自旋。JVM会根据上一次同一个锁上的自旋结果动态调整:如果上次自旋成功,下次允许更多次自旋;如果自旋失败,下次就减少甚至取消自旋。自适应自旋的初衷是避免线程阻塞唤醒带来的用户态与内核态切换开销,但如果自旋时间太长,又浪费CPU。这是一个典型的"以时间换空间"的折中。
3.3 重量级锁:走向ObjectMonitor
如果一个线程自旋等待很久,还是拿不到锁,锁就会升级为重量级锁,线程会被真正阻塞,进入操作系统的等待队列。这时Mark Word里存储的就是指向ObjectMonitor的指针。
ObjectMonitor内部有几个关键队列:
| 字段 | 作用 |
|---|---|
| _owner | 当前持有锁的线程 |
| _EntryList | 等待获取锁的线程队列 |
| _WaitSet | 调用了wait()之后挂起的线程队列 |
| _recursions | 可重入计数 |
重量级锁依赖操作系统的互斥量(Mutex)实现,所以每次获取和释放锁都要在用户态和内核态之间切换,开销很大。这也是"重量"二字的由来。在JDK 1.6之前,synchronized只有重量级锁一种实现,所以性能很差,大家才转而使用ReentrantLock。JDK 1.6引入锁升级机制后,synchronized的性能已经不再是问题。
3.4 锁升级为什么是单向的
关于锁升级,有一个非常重要的细节:升级是单向的,不可逆。锁可以从偏向锁升级到轻量级锁,再升级到重量级锁,但不会从重量级锁降级回轻量级锁或偏向锁。为什么不做降级?因为锁的状态记录在对象的Mark Word里,一旦升级到重量级锁并关联了ObjectMonitor,对象头里已经没有足够空间再存储其他锁状态信息。而且如果允许降级,还需要额外的同步机制来保证状态切换的线程安全,会引入新的竞争点,得不偿失。
这个单向性在面试中可以主动说出来,因为它能体现你对锁机制的理解深度。我面试时经常追问:"偏向锁撤销后还能不能重新变回偏向锁?"答案是局部可以,但整体不会降级。偏向锁被撤销后,如果后续没有竞争,线程通过CAS重新设置Thread ID,这个对象又可以进入偏向状态。但一旦升级到轻量级锁或重量级锁,就不会再回去了。这一点很多资料没讲清楚。
4. 编译期的锁优化:锁消除与锁粗化
4.1 逃逸分析驱动的锁消除
除了运行时锁升级,JIT编译器在编译阶段也会对synchronized做一些优化,最典型的就是锁消除(Lock Elimination)。锁消除的判断依据是逃逸分析——如果一个锁对象只在一个线程内被访问,根本没有逃逸出当前作用域,那么这个锁就是不必要的,JIT会直接把它消除掉。
举个例子:
java复制public String concat(String a, String b) {
StringBuffer sb = new StringBuffer();
sb.append(a);
sb.append(b);
return sb.toString();
}
StringBuffer的append方法是synchronized的。但在这个方法里,sb对象完全没有逃逸出concat方法,JVM可以通过逃逸分析确认这一点,于是把这些锁全部消除。这就是为什么你写代码时可以放心用StringBuffer——JIT会帮你优化掉不必要的锁。当然,如果sb被作为返回值返回,或者传给了其他方法,逃逸分析就会认为它逃逸了,锁消除也就不再适用。这个点我在面试中建议主动展开,因为它能展示你对JIT优化机制的理解。
4.2 锁粗化的适用场景
锁粗化(Lock Coarsening)是另一个编译期优化。它的思路是把多个连续的加锁-解锁操作合并成一个更大范围的锁操作。比如在循环里反复对一个对象加锁:
java复制for (int i = 0; i < 100; i++) {
synchronized (lock) {
// 每次都只做很小的操作
}
}
JIT如果发现循环体的每次加锁解锁没有其他线程介入,就会把锁扩大到整个循环外面,减少反复获取释放锁的开销。这个优化看起来简单,但实际触发条件很严格,JIT需要分析锁对象的MonitorEnter和MonitorExit是否可以被安全地移动。
这里要提醒一点:锁粗化不是万能的。如果你的循环体很重,每次加锁后执行了大量计算,JIT通常不会做粗化,因为扩大临界区反而会增加锁持有时间,降低并发度。所以写代码时不要依赖锁粗化,小而精确的临界区依然是推荐做法。
4.3 为什么说JDK 1.6之后的synchronized不能叫"重锁"
很多人对synchronized的印象还停留在"性能差、重量级",这其实是老黄历了。从JDK 1.6开始,HotSpot引入了偏向锁、轻量级锁、自适应自旋、锁消除、锁粗化这一整套优化,synchronized在低竞争场景下的性能已经和ReentrantLock没有明显差距。只有在高竞争、高并发场景下,ReentrantLock的非公平性、可中断性、超时机制等API层面的灵活性才会成为优势。
所以在面试中,如果有人问"synchronized和ReentrantLock怎么选",回答"ReentrantLock因为性能更好"是不准确的。正确的回答方向应该是:先考虑synchronized,因为它用法简单、自动释放锁、JVM层面有持续优化;只有当需要可中断、可超时、公平锁、多条件队列等高级特性时,才考虑ReentrantLock。这个回答逻辑能体现你是从原理出发思考,而不是背结论。
5. 面试追问实战:这些坑你踩过吗
5.1 高频追问的5个问题
我在面试中常用的synchronized追问列表如下,你不妨自己也测一下:
| 追问 | 考察点 | 参考回答思路 |
|---|---|---|
| synchronized修饰静态方法和实例方法有什么区别 | 锁对象的不同 | 静态方法锁的是Class对象,实例方法锁的是当前实例 |
| 偏向锁为什么有4秒延迟 | JVM启动优化 | 避免启动期间频繁撤销偏向锁 |
| 轻量级锁加锁失败就一定会升级为重量级锁吗 | 自旋机制 | 不立刻升级,先自旋一定次数或时间 |
| wait和sleep的区别 | monitor机制 | wait释放锁并进入WaitSet,sleep不释放锁 |
| synchronized可以修饰构造方法吗 | 语法机制 | 不能,构造方法本身就是线程安全的(对象还没发布) |
这里我想重点说一下synchronized修饰构造方法这个问题。语法上是禁止的,因为构造方法执行期间,对象的引用还没有完全安全发布,即使加了锁,其他线程也无法通过正常途径获取到这个半初始化对象的锁。面试回答时如果能补充一句"对象的发布时机决定了构造方法不需要也不能加锁",会让面试官觉得你有并发安全发布的知识储备。
5.2 偏向锁在JDK 15之后的命运
最后聊一个更新的变化。JDK 15(JEP 374)默认禁用了偏向锁,JDK 18(JEP 423)正式废弃了偏向锁。原因在于:偏向锁的维护成本高,它在Mark Word里存储线程ID和epoch字段,需要额外的撤销逻辑;而现代应用普遍使用线程池,大量线程竞争同一个锁,偏向锁的收益很小,反而拖慢性能。Java官方甚至建议:如果确实需要偏向锁,可以通过-XX:+UseBiasedLocking手动开启(仅限JDK 15之前)。
这个知识点在面试中其实是很好的加分项。当大家都在讲"锁升级"的时候,你能补充一句"但偏向锁在JDK 15之后默认被禁用,JDK 18被正式废弃",立刻就显得你的知识是及时更新的,而不是背了网上两三年前的旧文章。
5.3 实际排查中的一个工具技巧
分享一个我在实际排查并发问题时用过的技巧:用jol(Java Object Layout)工具直接查看对象头和Mark Word的变化。JOL是OpenJDK提供的工具,能够打印出对象在内存中的详细布局。我在测试锁升级时写过一段代码,通过jol.info()打印对象头信息,观察synchronized加锁前后Mark Word的变化,这种直观的观察比单纯看资料理解深得多。
java复制import org.openjdk.jol.info.ClassLayout;
public class MarkWordDemo {
public static void main(String[] args) throws Exception {
Object obj = new Object();
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
synchronized (obj) {
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
}
}
实测输出可以看到:无锁状态时Mark Word里是hashCode,加了synchronized之后Mark Word变成指向Lock Record的指针。如果你在启动参数里加上-XX:BiasedLockingStartupDelay=0,还能观察到偏向锁状态下Thread ID被写入的过程。这个工具比单纯看原理文档直观得多,强烈建议你自己跑一遍。
5.4 最后两句话的经验
说到底,synchronized底层原理的面试考察,核心不是考察你记住了多少细节,而是看你有没有建立"锁是一种有代价的协调机制"这个认知。偏向锁、轻量级锁、重量级锁不是在PK谁更好,而是在不同竞争强度下选择最合适的方式。我自己在写代码时,始终遵循一个原则:先用最简单的方式保证正确性,再考虑性能优化。synchronized就是那个最简单的方式——它不需要你手动释放锁、不会出现死锁、跨方法调用自动重入,JVM还帮你在背后做了大量优化。
所以面试的时候,不用紧张。把今天这篇文章里的知识点串成一条线——从字节码指令到对象头,从锁升级到编译期优化,再补上JDK 15之后偏向锁的变化——你就能把synchronized讲得既有深度又有层次。至少在我这儿,这样的候选人会比只背八股的人留下深得多的印象。
