1. 面试官视角:为什么 synchronized 是必考题?
如果你是一名Java开发者,无论你是准备面试还是日常开发,synchronized这个词几乎每天都会在你眼前晃悠。但你真的懂它吗?我见过太多工作了3-5年的候选人,被问到“synchronized的锁升级过程是怎样的?”或者“偏向锁被撤销的时机有哪些?”时,要么支支吾吾,要么只能背出“无锁、偏向锁、轻量级锁、重量级锁”这几个名词,再往深里问就露馅了。这恰恰是面试官最爱问synchronized的原因——它像一面镜子,能清晰照出一个Java程序员对并发编程理解的深度,是区分“会用API”和“理解底层”的关键分水岭。
从面试官的角度看,考察synchronized绝不仅仅是让你背八股文。它是一道综合性的“体检题”,能同时检验你的多个维度:第一,基础语法和语义,你是否清楚它可以用在哪些地方(方法、代码块),不同用法有什么区别?第二,JVM内存模型(JMM)知识,你是否理解synchronized如何保证可见性、有序性和原子性,它与volatile、final等关键字的内存语义有何异同?第三,JVM底层实现原理,也就是常说的锁升级(锁膨胀)过程,这直接关系到你对HotSpot虚拟机源码级别的理解。第四,实战经验与问题排查能力,你是否在实际项目中因使用不当导致过性能问题或死锁?如何排查和优化?能回答好这四个层面,才算是真正“硬核”地掌握了synchronized。
所以,这篇解析不会停留在“synchronized是悲观锁、可重入锁”这种表层。我们会像剥洋葱一样,从Java语言规范到JVM实现,从字节码指令到操作系统调用,层层深入,把每一个细节背后的“为什么”都讲透。无论你是即将面对一线大厂技术面的求职者,还是希望夯实并发根基的资深工程师,这篇文章都将提供你所需的全部弹药。
2. 从Java语言到字节码:synchronized的语法糖与本质
很多初学者对synchronized的第一印象是“简单”,加在方法或代码块前就行了。但它的“简单”背后,是编译器为我们自动添加的大量字节码指令。理解这一步,是理解其所有高级特性的基础。
2.1 三种使用方式及其字节码差异
synchronized的用法有三种:实例方法、静态方法、同步代码块。它们在字节码层面的实现有显著不同。
1. 同步实例方法当你在一个非静态方法前加上synchronized关键字时,例如:
public synchronized void increment() { count++; }编译器会为这个方法添加一个ACC_SYNCHRONIZED访问标志。这个标志本身不直接对应任何字节码指令,但它是一个明确的信号。当JVM执行到这个方法时,如果检测到ACC_SYNCHRONIZED标志,它会在调用该方法时,自动尝试获取该实例对象(即this)的监视器锁(Monitor Lock)。如果获取成功,则执行方法体;方法执行完毕后(无论是正常返回还是异常抛出),JVM会自动释放该监视器锁。如果获取失败,当前线程会被阻塞,直到锁被释放。
2. 同步静态方法静态方法的同步锁住的是类的Class对象。
public static synchronized void staticIncrement() { staticCount++; }其字节码标志同样是ACC_SYNCHRONIZED,但锁的对象不同。JVM会去获取当前方法所属类的Class对象(例如MyClass.class)的监视器锁。这意味着,即使有多个不同的实例,它们调用这个静态同步方法时也会相互竞争同一把锁(类锁),而实例同步方法锁的是各自的对象。
3. 同步代码块这是最灵活,也最能体现底层原理的方式。
public void add(Object obj) { synchronized(obj) { // 临界区代码 } }编译后,查看其字节码,你会看到明确的monitorenter和monitorexit指令:
aload_1 // 将引用obj压入操作数栈 dup // 复制栈顶值(obj引用) astore_2 // 将复制的引用存储到局部变量表(用于后续monitorexit) monitorenter // 尝试获取obj的监视器锁 ... // 临界区代码 aload_2 // 将存储的obj引用压栈 monitorexit // 释放obj的监视器锁 goto 结束位置 ... // 异常处理部分 aload_2 monitorexit // 确保在异常路径上也释放锁 athrow 结束位置: ...这里有几个关键点:首先,锁对象是显式指定的(obj),可以是任何Java对象。其次,monitorenter和monitorexit是成对出现的,并且编译器会自动生成一个异常处理器,确保即使在临界区代码抛出异常,锁也能被正确释放,避免死锁。这是synchronized关键字提供的隐式安全性之一。
注意:
synchronized锁的是对象,而不是代码或方法。所谓“同步方法”,本质上是将整个方法体作为同步代码块,锁对象是this或Class对象。这个观念一定要扭转过来。
2.2 可重入性(Reentrancy)的字节码体现
synchronized是可重入锁。这意味着同一个线程可以多次获取同一把锁而不会导致死锁。这在递归调用或一个同步方法调用另一个同步方法时非常有用。
public synchronized void methodA() { methodB(); // 可重入的关键 } public synchronized void methodB() { // do something }线程在进入methodA时已经持有了this锁,当它进入methodB时,会再次尝试获取this锁。如果锁不可重入,线程将在methodB入口处永久等待自己释放锁,即死锁。JVM如何实现可重入?在对象头中的Mark Word里,有一个字段记录了持有该锁的线程ID以及一个锁计数器。当线程第一次获取锁时,JVM记录线程ID并将计数器置为1。同一线程再次获取时,计数器递增。释放锁时,计数器递减。只有当计数器归零时,才表示锁被真正释放,其他线程才有机会获取。这个逻辑完全由JVM在运行时处理,对字节码指令monitorenter/exit是透明的。
3. 对象头与Mark Word:锁信息的存储基石
要理解锁升级,必须深入Java对象的内存布局,尤其是对象头(Object Header)。在HotSpot虚拟机中,一个对象在堆内存中的存储分为三部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。而锁的状态信息,就存储在对象头的Mark Word区域。
在64位JVM下(默认开启指针压缩),Mark Word的长度是64位(8字节)。它是一个“多功能”的字段,其存储的内容会根据对象的状态动态变化。下图展示了Mark Word在不同状态下的格式:
| 锁状态 | 存储内容(64位) | 标志位 |
|---|---|---|
| 无锁 (Unlocked) | 对象的hashCode(31位)、分代年龄(4位)、偏向模式(1位,0)、锁标志位(2位,01) | 01 |
| 偏向锁 (Biased) | 持有偏向锁的线程ID(54位)、Epoch(2位)、分代年龄(4位)、偏向模式(1位,1)、锁标志位(2位,01) | 01 |
| 轻量级锁 (Lightweight Lock) | 指向栈中锁记录(Lock Record)的指针(62位) | 00 |
| 重量级锁 (Heavyweight Lock) | 指向操作系统互斥量(mutex)和条件变量(condition variable)的指针,即指向Monitor对象的指针(62位) | 10 |
| GC标记 | 与垃圾回收相关的信息 | 11 |
核心要点解析:
- 锁标志位(lock bits):最后2位是锁状态的“身份证”。
01代表无锁或偏向锁,具体是哪种由前面的偏向模式位(1位)决定。00代表轻量级锁,10代表重量级锁,11与GC相关。 - 哈希码(hashCode)的存储:对象的
hashCode()方法返回的哈希码是懒加载的,只有在第一次调用Object::hashCode()或System::identityHashCode()时才会计算并存储。在无锁状态下,它可以存储在Mark Word中。但是,一旦对象进入偏向锁状态,Mark Word被线程ID占用,就没有空间存哈希码了。如果这时调用hashCode(),JVM会立即撤销偏向锁,膨胀为重量级锁,因为重量级锁的Monitor对象里有独立空间存储hashCode。轻量级锁同样没有空间存储哈希码。 - 为什么是“升级”而不是“降级”?锁升级的路径基本是单向的:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。降级虽然在某些GC场景(如STW)下会发生,但非常罕见。这是因为升级过程是竞争加剧的体现,是为了在保证线程安全的前提下,从性能最优(无竞争)逐步退让到功能最全(高竞争)。降级带来的收益很小,且实现复杂,所以JVM默认不进行锁降级。
理解Mark Word的布局,是分析所有锁状态变化的基础。接下来,我们就沿着锁升级的路径,一步步拆解。
4. 锁升级全流程深度拆解:从偏向锁到重量级锁
锁升级(Lock Inflation)是synchronized性能优化的核心,其目标是减少在无竞争或低竞争情况下的锁开销。整个过程是JVM自适应优化的典范。
4.1 偏向锁(Biased Locking):消除无竞争同步的开销
设计目标:在“锁只会被同一个线程多次访问”的理想情况下,消除同步操作本身的开销。例如,在大部分Web应用中,许多对象(如线程私有的SimpleDateFormat,或某些缓存对象)从生到死都只被一个线程访问。
工作原理:
- 加锁:当第一个线程访问同步块时,JVM会检查对象Mark Word中的锁标志位和偏向模式位。如果处于可偏向状态(匿名偏向状态或无锁状态),则通过CAS操作将当前线程ID写入Mark Word。如果成功,该线程就持有了偏向锁。注意,此时并没有真正的“加锁”操作,只是打了个标记。
- 执行:在持有偏向锁的线程后续进入同步块时,JVM只需检查Mark Word中的线程ID是否是自己。如果是,直接通过验证,无需任何同步操作(如CAS、操作系统调用),性能接近无锁。
- 撤销(Revoke):这是偏向锁最复杂的部分。当有另一个线程尝试竞争这个偏向锁时,持有偏向锁的线程需要被撤销偏向锁。撤销是一个安全点(Safepoint)操作,需要暂停持有锁的线程(STW)。
- 如果原持有线程已经不活动(如已终止),则直接将对象置为匿名偏向(线程ID为空)或无锁状态,允许新线程通过CAS重新偏向。
- 如果原持有线程仍然存活,则遍历该线程的栈,找到所有与该锁对象相关的锁记录(Lock Record),将锁记录和对象头的Mark Word进行比对,决定是升级为轻量级锁还是重量级锁。这个过程相对耗时。
为什么JDK 15后默认关闭偏向锁?正因为偏向锁的撤销成本高昂,在存在明显锁竞争的现代应用(如高并发微服务)中,偏向锁带来的收益往往小于其初始化、撤销的开销。从JDK 15开始,偏向锁被默认禁用(-XX:-UseBiasedLocking)。但在理解其原理上,它仍是经典的设计。
实操心得:如果你的应用是偏向锁友好的(例如,大量线程局部对象),可以通过JVM参数
-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0在启动时立即开启偏向锁。但务必通过jstack或JFR监控锁竞争情况,评估其实际收益。
4.2 轻量级锁(Lightweight Lock):应对轻度竞争
当偏向锁被撤销,或者一开始就存在多个线程轻度竞争时,锁会升级为轻量级锁。它的核心思想是:通过CAS自旋来避免直接进入操作系统内核态的阻塞,适用于锁持有时间非常短,且线程交替执行的场景。
加锁流程(Slow Path):
- 在当前线程的栈帧中,创建一个名为锁记录(Lock Record,或Displaced Mark Word)的空间。
- 将对象当前的Mark Word复制到锁记录中(称为Displaced Mark Word)。
- 然后,使用CAS操作尝试将对象头中的Mark Word替换为指向该锁记录的指针。如果成功,当前线程获得锁,并将锁标志位改为
00。 - 如果CAS失败,说明已经有其他线程抢先获得了轻量级锁,这时会启动自旋等待。自旋的目的是期望持有锁的线程能很快释放锁。
解锁流程:
- 使用CAS操作,将Displaced Mark Word(即之前备份的原始Mark Word)写回对象头。
- 如果CAS成功,则解锁完成。
- 如果CAS失败,说明在持有锁期间,锁已经膨胀为重量级锁了(有其他线程竞争导致)。此时,解锁操作需要走重量级锁的释放流程,唤醒等待队列中的线程。
自旋的代价与自适应自旋(Adaptive Spinning): 自旋空转会消耗CPU。如果锁被持有的时间很长,或者竞争激烈,自旋就会变成巨大的性能浪费。因此,HotSpot引入了自适应自旋。JVM会根据之前同一个锁的自旋成功情况动态调整自旋次数。如果最近自旋经常成功,JVM就认为这个锁很适合自旋,会允许更长的自旋时间;反之,如果很少成功,JVM可能会直接放弃自旋,减少CPU空转。
4.3 重量级锁(Heavyweight Lock):最终保障
当轻量级锁自旋失败(超过阈值),或者一个线程在持有轻量级锁时,又有新的线程来竞争,锁就会膨胀为重量级锁。这是synchronized的最终形态,其实现依赖于操作系统提供的互斥量(Mutex)。
Monitor对象(管程): 重量级锁的核心是一个称为ObjectMonitor的对象(C++实现),它存在于堆中(或者JVM的元空间)。对象头中的Mark Word(此时锁标志位为10)存储着指向这个ObjectMonitor对象的指针。ObjectMonitor内部维护着几个关键队列:
_owner:指向持有锁的线程。_EntryList:处于阻塞(BLOCKED)状态的线程队列。当一个线程尝试获取锁失败后,会被放入这个队列,等待操作系统调度将其挂起。_WaitSet:处于等待(WAITING)状态的线程队列。当持有锁的线程调用Object.wait()方法后,会释放锁并进入这个队列。
从用户态到内核态的切换: 这是重量级锁性能开销的主要来源。当线程无法获取锁时,它会被操作系统挂起(从运行态变为阻塞态),并放入等待队列。这个挂起操作需要进行上下文切换,从用户态切换到内核态,由操作系统内核进行线程调度。后续当锁被释放,需要唤醒等待线程时,又需要进行一次上下文切换。频繁的上下文切换会严重消耗CPU资源,导致系统吞吐量下降。
重量级锁的公平性问题:synchronized内置的Monitor机制是非公平锁。当锁被释放时,正在自旋尝试获取轻量级锁的线程,以及刚到达准备获取锁的线程,会和_EntryList中被唤醒的线程一起竞争,谁先抢到就是谁的,并不保证先阻塞的线程先获得锁。这种策略在高并发下通常能获得更高的吞吐量。
5. 内存语义:synchronized如何保证可见性与有序性
synchronized不仅能保证原子性(互斥执行),还能保证可见性和有序性。这源于Java内存模型(JMM)为synchronized规定严格的内存语义。
1. 可见性(Visibility)保证JMM规定:
- 线程在解锁(monitorexit)一个锁之前,必须把自己工作内存中对共享变量的修改刷新到主内存。
- 线程在加锁(monitorenter)一个锁时,会清空本地工作内存中该共享变量的值,从而必须从主内存中重新读取最新值。
这就建立了一个“同步”机制:前一个线程的修改结果,对后续获得同一个锁的线程一定是可见的。这解决了CPU缓存不一致带来的内存可见性问题。
2. 有序性(Ordering)保证synchronized通过“互斥”间接保证了有序性。由于临界区内的代码在任意时刻只能被一个线程执行,因此线程观察到的临界区内代码的执行顺序,就是程序顺序(Program Order)。这防止了临界区内的代码发生重排序(尽管编译器仍可能在临界区内进行不改变单线程语义的重排)。
更重要的是,synchronized遵循管程(Monitor)的Happens-Before规则:
- 同一个锁的解锁操作 Happens-Before 于后续对这个锁的加锁操作。 这条规则与
volatile的写入-读取规则类似,是构建线程间操作顺序的基础。
与volatile的对比:
volatile只保证单个变量的读写原子性、可见性,以及防止指令重排序(内存屏障)。synchronized保证整个临界区代码的原子性、可见性和有序性,功能更强大,但开销也更大。- 在仅需要保证一个共享变量的可见性,且操作本身是原子(如赋值)时,
volatile是更轻量级的选择。如果需要复合操作(如i++),则必须使用synchronized。
6. 实战避坑与性能调优指南
理解了原理,最终要落到实战。下面是我在多年开发和调优中总结的关于synchronized的常见“坑”和优化建议。
6.1 锁粒度选择:粗粒度 vs 细粒度
错误示例(粗粒度过大):
public class OrderService { private final Object globalLock = new Object(); public void createOrder() { synchronized(globalLock) { /* 耗时IO操作 */ } } public void updateOrder() { synchronized(globalLock) { /* 计算操作 */ } } public void queryOrder() { synchronized(globalLock) { /* 只读操作 */ } } }所有方法共用一把全局锁,queryOrder这样的只读操作也会阻塞createOrder,并发性能极差。
优化建议(细化锁粒度):
public class OrderService { private final Map<Long, Object> orderLocks = new ConcurrentHashMap<>(); public void updateOrder(Long orderId) { Object lock = orderLocks.computeIfAbsent(orderId, k -> new Object()); synchronized(lock) { // 只锁住特定订单的操作 } } }使用与业务数据(如订单ID)关联的锁对象,将锁的竞争范围从整个服务缩小到单个业务实体,大幅提升并发度。注意,这里使用ConcurrentHashMap来管理锁对象,避免为每个订单永久创建锁对象导致内存泄漏。
6.2 死锁(Deadlock)的识别与预防
死锁的四个必要条件:互斥、持有并等待、不可剥夺、循环等待。synchronized直接涉及前三个。
经典死锁代码:
// 线程1 synchronized (lockA) { Thread.sleep(100); synchronized (lockB) { ... } } // 线程2 synchronized (lockB) { Thread.sleep(100); synchronized (lockA) { ... } }排查与预防:
- 使用工具诊断:
jstack是首选。运行jstack -l <pid>,在输出中查找deadlock关键词,JVM能自动检测并报告死锁链。 - 统一锁顺序:强制所有线程以相同的全局顺序获取锁。例如,规定必须先获取
lockA,再获取lockB。 - 使用尝试锁(tryLock):
synchronized不支持,但你可以使用ReentrantLock的tryLock(long, TimeUnit)方法,获取失败时进行回退或重试,打破“持有并等待”。 - 设置超时:同样,
synchronized原生不支持,ReentrantLock支持带超时的tryLock。
6.3 锁竞争热点分析与优化
在高并发场景下,即使细化了锁粒度,某些“热点”资源(如全局计数器、库存中心)仍可能成为瓶颈。
诊断工具:
- JFR (Java Flight Recorder):低开销的性能剖析工具,可以清晰看到哪些锁上发生了最严重的竞争(
lock-instance事件)。 - Async Profiler:可以生成火焰图,直观显示线程在锁等待(
park状态)上花费的CPU时间比例。
优化策略:
- 锁分离(Lock Striping):
ConcurrentHashMap是典范。它将数据分成多个段(Segment/JDK8后是桶),每个段独立加锁。写全局计数器时,可以考虑使用类似的思想,例如按线程ID或请求来源进行哈希分片,每个片一个计数器,最后汇总。 - 乐观锁与CAS:对于争用激烈的“读多写少”场景,考虑使用
AtomicLong、LongAdder(JDK8+)等基于CAS的原子类。LongAdder内部使用了分段累加的思想,在高并发写入时性能远优于synchronized和AtomicLong。 - 无锁数据结构:深入研究
Disruptor、Amino等无锁队列框架,它们在极高性能要求的场景下可以完全避免锁。
6.4 对性能的误解与澄清
误解一:synchronized一定比ReentrantLock慢。在低竞争场景下,经过锁升级优化后的synchronized性能与ReentrantLock相差无几,甚至可能更优,因为JVM能对其进行深度优化。ReentrantLock的优势在于灵活性:可中断、可超时、可尝试获取、支持公平锁、可以绑定多个条件变量(Condition)。选择哪个,取决于业务需求,而非单纯的性能臆测。
误解二:应该尽量避免使用synchronized。恰恰相反,对于大多数并发控制场景,synchronized应是首选。它的优点非常明显:语法简单、由JVM自动释放锁、与wait()/notify()机制天然集成、经过长期优化极其稳定。只有在synchronized的功能无法满足需求时(如需要上述ReentrantLock的灵活特性),才考虑使用显式锁。
误解三:锁住的对象越小越好。锁对象的选择至关重要。必须锁住所有竞争线程都能看到、且唯一对应的那个对象。错误示例如下:
// 错误!每个线程锁的是自己新创建的Object,根本起不到同步作用 public void wrongMethod() { Object lock = new Object(); synchronized(lock) { // ... } } // 正确:使用共享的、final的对象作为锁 private final Object lock = new Object(); public void correctMethod() { synchronized(lock) { // ... } }7. 从synchronized看JVM的锁优化趋势
通过对synchronized的深度剖析,我们也能管中窥豹,看到JVM在并发优化上的思路演变。
1. 从“重量”到“轻量”,再到“避免”锁升级路径本身就是这一思路的体现:先尝试无开销的偏向锁,不行再尝试用户态自旋的轻量级锁,最后才退回到开销大的内核态重量级锁。而JDK 15默认关闭偏向锁,则反映出在普遍多核、高竞争的环境下,过于复杂的优化策略本身可能成为负担,有时“少即是多”。
2. 硬件友好的优化自适应自旋、锁消除(Lock Elimination)、锁粗化(Lock Coarsening)等优化,都是JVM在运行时根据代码模式和硬件特性(如CPU缓存一致性协议)进行的智能调整。例如,锁消除是逃逸分析的成果,如果JVM证明一个锁对象不可能被其他线程访问,就会直接去掉同步操作。
3. 与新的并发编程模型融合随着Project Loom的推进,虚拟线程(Virtual Threads)成为热点。在虚拟线程模型中,一个线程因为I/O阻塞而被挂起是极其廉价的。这可能会改变我们对“锁竞争”成本的认知。传统的synchronized在虚拟线程上工作良好,但那些会导致平台线程( carrier thread )被阻塞的重量级锁竞争,其影响可能被放大或转化。未来,synchronized的优化可能会与虚拟线程的调度器更深度地结合。
我个人在性能调优时有一个习惯:不会一开始就质疑synchronized的性能。我会先写出正确、清晰的同步代码,然后借助JFR、jstack等工具进行压测和 profiling。只有当数据明确显示某个synchronized块是真正的性能瓶颈(竞争激烈、持有时间长)时,我才会考虑更复杂的优化方案,比如改用ReentrantLock、使用并发容器、甚至重构业务逻辑来降低竞争。在绝大多数情况下,synchronized的简洁性和可靠性带来的价值,远超过那一点点可能存在的、未经证实的性能差异。把基础原理吃透,在合适的场景做出合适的选择,这才是应对“硬核”面试和复杂系统的真正底气。