我做了十几年Java开发,也面试过不少人,每次问到synchronized,大部分人都能说出“加锁、线程安全、性能差”,但再往下追问“它底层到底怎么实现的”“偏向锁和轻量级锁在什么条件下触发”“Monitor里面到底长了什么样”,能答上来的人就明显变少了。这篇文章就专门把这些底层细节一次讲透,从字节码指令到JVM运行时数据,再到锁升级的完整链路,该怎么答、怎么避坑,都会说到。准备面试的朋友可以把它当成一个复习提纲,已经工作的同学也可以对照排查一下自己项目里的锁用得对不对。
1. synchronized的前置认知:它到底在解决什么问题
1.1 线程安全问题从哪来
先看一段最普通的代码:
java复制public class Counter {
private int count = 0;
public void increment() {
count++;
}
}
count++这行代码在字节码层面其实是由四条指令完成的:getstatic取值、iconst_1准备1、iadd做加法、putstatic写回。单线程执行没问题,但多线程并发执行时,线程A做完加法还没来得及写回,线程B就已经把旧值读走了,最后结果就会小于预期。这个现象在并发领域叫竞态条件,也就是多个线程同时读写共享数据,最终结果取决于线程的调度顺序。
synchronized解决的就是这个问题,它通过互斥机制保证同一时刻只有一个线程能执行被保护的代码块,其他线程必须等待。这个“互斥”看起来简单,但底层牵扯到操作系统的线程调度、内存可见性、指令重排序等一系列问题,JVM为了让synchronized在不同场景下都能有不错的性能,又做了一套极其精密的锁升级机制。
1.2 synchronized的三种使用形态与锁对象
很多人知道synchronized能修饰方法或代码块,但未必清楚不同的使用方式其实对应不同的锁对象,这个问题面试里经常被拿来试探基础扎不扎实。
java复制// 修饰实例方法:锁的是当前实例对象 this
public synchronized void instanceMethod() {
// 方法体
}
// 修饰静态方法:锁的是当前类的 Class 对象
public static synchronized void staticMethod() {
// 方法体
}
// 修饰代码块:锁的是括号里指定的对象
public void blockMethod() {
synchronized (this) {
// 代码块
}
}
这里有一个很容易被忽视的点:静态方法锁的是Class对象,实例方法锁的是实例对象,这两把锁是两把完全不同的锁。如果同一个类里既有静态同步方法又有实例同步方法,它们之间并不会互斥,因为两个线程竞争的锁对象根本不是同一个。
提示:手动加锁时,锁对象一定要选多个线程共享的那个。如果每个线程都new一个Object作为锁,那锁就完全失效了,因为大家锁的都不是同一个对象。
1.3 一个容易被忽视的前提:锁必须建立在同一个对象上
Java中任何一个对象都可以成为锁,这是synchronized灵活性的体现。但锁要生效,前提是竞争锁的多个线程必须作用于同一个对象。很多初学阶段写出的bug,本质上都是这里出了问题:
java复制public class BadLock {
private final Object lock = new Object();
public void doSomething() {
synchronized (lock) {
// 这里没问题
}
}
}
看起来好像没什么问题,但如果一个类的每个实例都有自己独立的lock对象,那么两个线程各自new一个BadLock实例,然后各自调用doSomething,加锁形同虚设。真实的项目中,锁对象一般就选this或者类的Class对象,甚至专门定义一个static final的Object作为类级别的锁。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 字节码层面的真相:monitorenter与monitorexit
2.1 反编译看本质
先写一段最简单的同步代码块:
java复制public class SyncDemo {
public void test() {
synchronized (this) {
System.out.println("hello");
}
}
}
用javap -verbose SyncDemo.class反编译,能看到test方法的字节码中有两条关键指令和它们周围的异常表信息:
java复制public void test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0 // 将 this 入栈
1: dup
2: astore_1 // 将 this 保存到局部变量 slot 1
3: monitorenter // 进入监视器,获取锁
4: getstatic // System.out
7: ldc // "hello"
9: invokevirtual // println
12: aload_1 // 加载 this
13: monitorexit // 退出监视器,释放锁
14: goto 22 // 正常执行完,跳转到 return
17: astore_2 // 异常路径:保存异常到 slot 2
18: aload_1 // 加载 this
19: monitorexit // 异常路径也要释放锁
20: aload_2 // 加载异常
21: athrow // 抛出异常
22: return
Exception table:
start end handler type
4 14 17 any
17 20 17 any
这里的monitorenter和monitorexit就是理解synchronized底层原理的入口,它们背后关联的是每个Java对象都会带有的一个Monitor监视器。
2.2 为什么同步代码块有两条monitorexit
很多人第一次看到这段字节码时会产生疑惑:代码块里明明只有一个synchronized(this),为什么monitorexit出现了两次?
这是因为JVM必须保证锁一定能被释放,哪怕代码块里抛出了异常,也要先把锁释放掉,再让异常冒泡出去。正常的释放路径走第一条monitorexit,然后执行goto跳到return;异常路径则通过异常表找到对应的handler,在handler里执行第二条monitorexit释放锁,然后重新抛出异常。这个设计保证了同步块在异常场景下不会发生死锁。
2.3 同步方法与同步代码块在字节码上的差异
如果给方法直接加上synchronized关键字,反编译出来的字节码与此前截然不同——方法上没有monitorenter和monitorexit指令,而是在方法的访问标志flags里增加了一个ACC_SYNCHRONIZED标记:
java复制public synchronized void syncMethod();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
// 方法体,没有 monitorenter / monitorexit
JVM在调用方法时会检查这个标记,如果方法带有ACC_SYNCHRONIZED,调用线程就需要先获取对应的Monitor锁,方法执行完毕后再释放。无论是正常return还是异常抛出,锁都会在方法结束后自动释放。
从实现机制上说,同步方法和同步代码块最终都会依赖Monitor,只是写法不同,JVM处理入口和出口的方式略有差异。面试时能把这个区别说出来,说明你真的看过字节码。
3. 锁升级链路:从偏向锁到重量级锁
3.1 为什么JDK 6之后要引入锁升级
在JDK 5以及更早的版本中,synchronized是纯粹的重量级锁,线程获取锁失败后会被阻塞在操作系统的内核态,涉及用户态和内核态的切换,开销非常大。这也是当年很多人说“synchronized性能差”的根本原因,并不是这个关键字本身有什么问题,而是它当时的实现方式太消耗资源。
JDK 6对synchronized做了大规模优化,核心思路是引入了锁升级机制。也就是锁并不一定一开始就是重量级的,而是根据竞争情况从偏向锁逐步升级到轻量级锁,最后才升级到重量级锁。这个设计的出发点很符合实际应用场景:大部分锁在绝大多数时间里,其实只有一个线程在访问,压根不存在竞争,那何必一上来就动用操作系统级别的互斥量呢。
注意:锁升级是单向的,只能从偏向锁到轻量级锁再到重量级锁,不能降级。这个方向的设定是JVM对锁状态收敛的一种保守策略,避免反复切换带来的额外开销。
3.2 偏向锁:假设只有一个线程访问
偏向锁的设计思想很朴素:如果一个锁一直只有一个线程访问,那这个线程每次获取和释放锁都用CAS来做,成本还是有点高,干脆在对象头里记录这个线程的ID,下次这个线程再来的时候,直接判断一下线程ID对不对,对就什么都不用做,直接进入同步块。
对象头中的Mark Word是偏向锁实现的关键,在无锁状态下,Mark Word里存储的是对象的hashcode、分代年龄等信息;升级到偏向锁后,Mark Word变成偏向线程ID、偏向时间戳、偏向锁标志位等信息。一个线程第一次获取锁时,通过CAS把Mark Word中的偏向线程ID设置为自己的线程ID,之后再来,只需要比较线程ID是否一致。
偏向锁在应用启动初期会有一个延迟机制,JVM默认在JVM启动后4秒才开启偏向锁。因为启动初期存在大量锁竞争,这段时间的锁竞争往往来自JVM内部初始化的各种同步操作,开启偏向锁反而不划算。
java复制// 启动时可以通过 JVM 参数调整偏向锁相关行为
-XX:BiasedLockingStartupDelay=0 // 关闭偏向锁延迟,JVM 启动立即开启
-XX:-UseBiasedLocking // 完全禁用偏向锁
3.3 轻量级锁:用CAS代替系统互斥量
只有当多个线程交替竞争同一个锁,且竞争并不激烈时,偏向锁才会撤销并膨胀为轻量级锁。轻量级锁的获取流程是这样的:线程在栈帧中建立一个锁记录的空间,尝试通过CAS把对象头中的Mark Word替换成指向这个锁记录的指针。CAS成功,说明锁获取成功;CAS失败,说明有别的线程正在持有或者竞争同一个锁。
轻量级锁的核心优势在于不需要操作系统介入,也不需要把线程挂起,获取和释放锁主要靠CAS自旋。但它有一个前提:竞争不能太激烈。如果两个线程同时竞争,又或者持锁线程执行时间过长,自旋的线程会白白消耗CPU时间。正因为这个原因,当竞争加剧时,轻量级锁就会膨胀为重量级锁。
3.4 重量级锁:Monitor的高成本实现
重量级锁依赖操作系统的互斥量,线程获取不到锁时会被阻塞挂起,进入内核态,等锁被释放后再被唤醒。这个过程的性能代价主要体现在两次上下文切换上:一次是线程从用户态进入内核态被挂起,另一次是锁释放后线程从内核态恢复回用户态。
重量级锁对应的是对象Monitor,每个Java对象在JVM内部都关联一个ObjectMonitor,它里面有owner、entryList、waitSet等关键字段。owner指向持有锁的线程,entryList存放所有被阻塞等待锁的线程,waitSet存放调用了wait方法后进入等待状态的线程。这一整套机制保证即使在高竞争场景下,线程的执行顺序也是可控的。
3.5 锁升级不可逆:批量重偏向与批量撤销
锁升级虽然不可逆,但JVM提供了批量重偏向和批量撤销机制,用来处理一个锁在多个线程之间交替访问的场景。
假设一个锁对象被线程A反复访问,后来线程B也开始访问,每次访问都会触发偏向锁撤销,这个撤销过程本身是有代价的。为了避免频繁撤销,JVM引入了批量重偏向机制:当一个类的对象发生多次偏向锁撤销后,JVM会认为这些对象后续更可能被新线程使用,于是直接把偏向线程ID整体改为新线程。再往后,如果撤销次数继续增加,JVM会干脆批量撤销这个类的所有偏向锁,让它们恢复到无锁状态,后续直接走轻量级锁的流程。
这个细节不容易注意,但面试官如果真的深挖锁升级,往往就会考到这里。
3.6 锁升级链路小结
整个锁升级的路径可以用一个流程来概括:
无锁状态: 对象正常使用没有任何线程竞争,Mark Word记录hashcode、分代年龄等。
偏向锁: 只有一个线程反复访问,Mark Word记录偏向线程ID。
轻量级锁: 第二个线程开始竞争,偏向锁撤销,通过CAS自旋获取锁。
重量级锁: 竞争进一步加剧或持锁时间过长,锁膨胀为重量级锁,线程阻塞在Monitor的entryList中。
面试时能够把这个链路从头到尾讲清楚,并且指出每一步的性能决策逻辑,这一题基本就稳了。
4. Monitor对象内部细节:入口区、等待区、哨兵
4.1 Monitor的三个关键区域
Java中的每个对象都可以关联一个Monitor,这个关联关系在对象头里是通过Mark Word中指向ObjectMonitor的指针来表示的。ObjectMonitor内部最核心的组成部分有三个:
- owner:指向当前持有锁的线程,相当于门卫,同一时间只能有一个人进来。
- entryList:所有尝试获取锁但还没获取到的线程都在这里排队,是一个阻塞队列。
- waitSet:已经获得锁但主动调用wait()释放锁的线程,会进入这个等待集合。
这三块区域对应了synchronized和wait/notify之间协作的基础架构。一个线程进入同步代码块时,先看owner是否为null,如果为null则尝试占用,如果不为null则进入entryList排队;持有锁的线程调用wait,会释放锁并进入waitSet;其他线程调用notify,会从waitSet中唤醒一个线程,让它重新去竞争锁。
4.2 wait/notify如何依赖Monitor
很多人会把synchronized和wait/notify割裂开理解,其实它们在底层的联系非常紧密。调用wait方法的前提是当前线程必须持有对应对象的Monitor锁,否则会抛出IllegalMonitorStateException。wait的语义是把当前线程放入waitSet,同时释放owner的持有权;notify则是从waitSet中挑选一个线程唤醒,让它重新进入entryList去锁竞争。
这里有个老生常谈但值得再强调的点:notify只能唤醒一个线程,而且是随机唤醒,所以为了保证程序逻辑正确,通常都用notifyAll而不是notify。如果场景确实只需要唤醒一个线程,用notifyAll也不会出错,只是多个线程被唤醒后会多做一些无谓的锁竞争。
注意:调用wait后线程进入的是waitSet,而不是entryList。这个区别决定了它在锁释放后的调度位置。被notify唤醒的线程即使立刻拿到执行权,也要重新加入锁竞争,并不能指定某个线程优先获得锁。
4.3 可重入性:同一个线程为何能反复进入
synchronized有一个重要特性是可重入。也就是说,同一个线程在外层方法获取到锁之后,进入内层方法时如果还需要同一把锁,能够直接通过,不需要重新排队。
这个机制的底层实现是:ObjectMonitor中记录了owner线程,同时还有一个计数器用来记录这个线程获取锁的次数。线程每次重新获取锁,计数器加一,每释放一次锁,计数器减一,只有当计数器归零时,锁才真正释放。
java复制public class ReentrantDemo {
public synchronized void outer() {
inner(); // 同线程再次申请同一把锁
}
public synchronized void inner() {
// 直接进来,不需要重新竞争
}
}
如果没有可重入机制,这种递归调用或者方法间嵌套调用就会出现死锁。面试时能主动提到计数器这个细节,会比只说“可重入”有说服力得多。
5. JVM层面的锁优化手段
5.1 锁消除
JVM在即时编译阶段会做逃逸分析,如果发现某个锁对象只能被当前线程访问,根本不可能被其他线程共享,那么这个锁就没有存在的必要,JVM会直接把锁消除掉。
java复制public void concat(String s1, String s2) {
String result = new StringBuffer().append(s1).append(s2).toString();
}
StringBuffer的append方法是同步的,但这里的StringBuffer对象完全在方法内部创建,不会被其他线程获取,属于典型的局部对象不逃逸场景。JVM在运行时如果检测到这种情况,就会把append上的同步逻辑直接去掉,避免不必要的加锁开销。
5.2 锁粗化
与锁消除相对的优化是锁粗化。如果JVM检测到一段代码中同一个对象被连续多次加锁、解锁,而且这些加锁操作之间没有其他线程需要访问共享数据,它就会把这一系列细粒度的锁合并成一个更大范围的锁。
java复制public void append() {
StringBuffer sb = new StringBuffer();
for (int i = 0; i < 10000; i++) {
sb.append(i); // 连续 10000 次加锁和解锁
}
}
如果每次append都走一遍完整的加锁流程,即使有偏向锁和轻量级锁的优化,还是会有性能损耗。锁粗化会让JVM把这10000次加锁合并成一次,从循环外就开始持锁,循环结束再释放。
5.3 逃逸分析对锁的影响
逃逸分析不止影响锁消除,它还决定了对象是否能分配到栈上。如果一个对象没有发生逃逸,JVM可能直接在栈上分配空间,方法结束后自动销毁,不需要走GC流程。这在热点代码中能减少大量对象的创建和回收压力,也间接降低了锁竞争的可能性,因为线程私有的对象本身就不会有竞争。
提示:逃逸分析是JVM在即时编译阶段做的优化,不是解释执行时生效的。所以判断一个代码是否真的会被优化,需要看它是否达到JIT编译的阈值,一般通过-XX:+PrintCompilation观察编译情况。
5.4 自旋与自适应自旋
当一个线程获取轻量级锁失败时,它不会马上升级到重量级锁,而是先尝试自旋,也就是循环忙等一小段时间,看持锁线程是否很快释放。自旋可以减少线程阻塞和唤醒带来的上下文切换开销,但自旋本身会占用CPU,如果持锁时间太长,自旋就变成浪费。
JVM采用自适应自旋来解决这个问题,也就是根据上次自旋获取锁的成功率,以及当前持锁线程的运行状态,动态调整自旋的次数。上次成功率高,这次就多转几次;上次没成功,这次就少转或者不转。自适应自旋是JVM用历史数据指导未来决策的典型例子。
6. 面试官常用的追问路径与应对思路
6.1 追问一:锁对象怎么选
面试官通常会给出一个场景,比如要保护一个HashMap或者一个计数器,问你锁应该加在哪里。
这里要回答清楚:锁对象必须和共享数据存在稳定的关联,多个线程访问同一份数据时必须竞争同一把锁。比较常见的方案是把锁对象定义为私有final字段,避免外部代码拿到反向锁去做奇怪的逻辑。如果直接用this,潜在风险是外部代码可能通过synchronized(instance)干扰你的内部锁逻辑,虽然大多数场景不敏感,但在框架或者基础组件里这是一个真实存在的隐患。
6.2 追问二:锁升级的触发条件
锁升级的触发条件是最容易被问到细节的地方。偏向锁撤销的条件是第二个线程尝试获取锁,并且当前偏向线程已经不在临界区;轻量级锁升级到重量级锁的条件,是自旋一定次数后仍然获取不到锁,或者持锁线程被阻塞了。
面试时如果能把每个阶段的触发条件说清楚,再补一句“锁升级的延迟和偏向锁撤销都是JVM内部根据统计信息自动决策的”,就体现了对JVM调优机制的深度理解。
6.3 追问三:重量级锁为什么那么慢
这个问题考的是对操作系统级别的理解。重量级锁的慢,本质上不是加锁这个动作本身慢,而是线程阻塞和唤醒需要从用户态切换模式再切换回来,这个上下文切换的时间通常是微秒级别的,而普通CAS操作是纳秒级别,这个差距就是性能损耗的来源。
另外,重量级锁还会带来缓存失效的问题。一个线程修改共享变量后,其他线程的CPU缓存中对应的缓存行会失效,后续读取需要重新从主存加载,进一步增加了时间开销。
6.4 追问四:synchronized和ReentrantLock怎么选
这个问题的标准回答思路是:synchronized是JVM原生支持的语法层面的锁,使用简单,JDK 6之后性能已经不比ReentrantLock差太多;ReentrantLock则提供了更灵活的API,比如可中断、可限时等待锁、支持公平锁、多条件变量。
实际项目中最常用的区分标准只有一个:如果你需要非公平锁之外的特性,比如锁等待超时或者可中断,那就用ReentrantLock;否则优先用synchronized,代码更简洁,也更容易被JVM优化。
7. 常见误区和避坑清单
7.1 误区一:String作为锁对象
很多人在实现本地缓存或者组件的时候,喜欢用字符串作为锁:
java复制synchronized ("lock") { ... }
字符串常量在JVM中会被缓存,同一个字符串字面量在代码多处出现时其实指向同一个对象。这会导致一个完全无关的代码块因为恰好使用了同一个字符串,就被同一把锁串起来了,造成莫名其妙的阻塞。如果非要按字符串加锁,应该用字符串的intern方法加上显式管理,或者直接new一个独立的Object。
7.2 误区二:锁升级是绝对的性能优化
锁升级确实优化了低竞争场景,但它在高竞争场景下反而可能不如直接使用重量级锁。因为偏向锁和轻量级锁的撤销、膨胀过程本身有额外的CAS操作和状态判断,当一个锁长期处于高竞争状态,这些前置流程就成了多余的开销。
所以如果业务明确是高性能高并发的热点数据,就需要评估synchronized是否是最优选择,有时直接上ReentrantLock甚至无锁方案会更可控。
7.3 误区三:对静态方法加锁等于锁住整个类
前面说过,静态同步方法锁的是类的Class对象,实例同步方法锁的是实例对象。如果项目中有个工具类,所有方法都加上了static synchronized,那所有调用这个工具类的线程,无论处理的数据是否相关,都会被同一把全局锁串行化,并发能力会大打折扣。
更合适的方式是用局部锁对象控制细粒度,或者在工具类中避免使用静态同步方法,而是让调用方自行管理锁。
7.4 误区四:持有锁期间不要做耗时操作
持锁期间执行耗时操作会让其他线程等待时间变长,甚至引发性能雪崩。常见的耗时操作包括IO、网络请求、数据库访问、大规模的集合遍历复制等。
正确的做法是尽量缩小同步块的粒度,只在真正需要保护共享数据的那几行代码上加锁。锁的范围越小,系统的并发能力和吞吐量就越高。
java复制// 错误示范:整个方法都是同步的
public synchronized void save() {
// 大量 IO 操作
// 实际只有一行需要保护
}
// 正确示范:只保护共享数据的操作
public void save() {
// 大量 IO 操作
synchronized (this) {
// 只有这一行需要保护
}
}
7.5 一个值得再提的细节:锁对象的可见性
synchronized不仅能保证互斥,还能保证内存可见性。线程释放锁时,会将锁内修改的共享变量强制刷新到主内存;线程获取锁后,会从主内存重新读取共享变量。这个内存语义保证了在同步块内对共享变量的修改,对后续获取同一把锁的线程是可见的。
这也是为什么在并发场景中,很多人用synchronized来同时兼顾互斥和可见性,而不一定非得用volatile。
我在实际工作中见过不少因为锁粒度过大导致的接口性能问题,有些接口直接整体加synchronized,吞吐量一下就跌下去了。排查这类问题时除了看线程堆栈和锁竞争情况,还要关注持锁时间。解决思路通常就两个方向:一是缩小锁范围,二是换无锁并发工具比如ConcurrentHashMap、LongAdder,或者用轻量级的原子类替代部分加锁逻辑。synchronized本身并不笨重,真正笨重的往往是使用方式。把它底层的这层逻辑摸清楚之后,面试能答得上来,写代码时也知道该在什么位置下锁,怎么下锁更合理。
