我最早意识到“锁”这件事没那么简单,是在一个压测现场。某天下午,一个简单的商品库存扣减接口,QPS 到了两千左右就开始频繁出现线程阻塞,CPU 飙高但吞吐死活上不去。直觉告诉我瓶颈在锁竞争上,但当我试图回答“Synchronized 到底锁住了什么、它凭什么保证线程安全”的时候,发现脑子里能调用的只是一堆碎片:对象头、偏向锁、轻量级锁、重量级锁、内存屏障……它们每个词都熟悉,但要串成一条完整的链路,我说不清楚。
后来我花了整整一个月时间,啃源码、翻文档、做实验,才把这条链路彻底打通。回头看,Java 并发编程里最值得花时间去啃的基础,其实就是一件事:搞清楚一把锁从无到有、从轻到重的完整生命周期,以及它和内存可见性之间那层看不见的关系。搞懂了这个,你会发现之前遇到的很多性能玄学问题,本质都写在对象头的几个比特位里。
这篇文章会从对象头开始,一路讲到锁升级的各个状态,再深入到 JDK 底层如何通过内存屏障保证并发安全。适合两类人看:一类是已经写过不少并发代码、但遇到性能问题只能靠猜的 Java 开发者;另一类是正在准备高级岗位面试、被问到“Synchronized 底层原理”时只能答出三句话的人。读完你会获得一套完整的知识框架,以及我在排查锁性能问题时积累的实操经验。
1. 对象头:一切锁状态的“信息中心”
很多人一听到“对象头”三个字就觉得这是底层 C++ 的事,和写 Java 业务代码没关系。但恰恰相反,Synchronized 锁的所有状态信息,都存在对象的对象头里。理解对象头,是理解锁机制的第一步,也是最关键的一步。
1.1 对象在内存里的三段式布局
任何一个 Java 对象(数组除外),在 HotSpot 虚拟机里都由三部分组成:对象头(Object Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头又细分成两部分:Mark Word(标记字)和 Klass Pointer(类型指针)。如果是数组对象,还有一个额外的 Array Length 字段记录数组长度。在 64 位 JVM 默认开启压缩指针的场景下,一个普通对象的对象头通常占据 12 字节(Mark Word 8 字节 + Klass Pointer 4 字节),再加上实例数据和对齐填充,凑成 8 字节的整数倍。
这里最关键的是 Mark Word。它是一块极度“精打细算”的内存区域——JVM 在有限的 64 位空间里,塞入了哈希码、GC 分代年龄、锁状态标志、偏向线程 ID、偏向时间戳等信息。也就是说,这块内存不是固定不变的,它会根据对象当前的状态动态复用每一位。
| 状态 | 64位虚拟机Mark Word中的存储内容(简化) | |--------------|--------------------------------------------------------------| | 无锁 | unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | 01 | | 偏向锁 | thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | 01 | | 轻量级锁 | ptr_to_lock_record:62 | 00 | | 重量级锁 | ptr_to_monitor:62 | 10 | | GC标记 | empty | 11 |看这张表,有几点值得细品:
- 锁标志位只有 2 bit,但能区分五种状态,是因为无锁和偏向锁都使用了最后两位的“01”,靠前面的 biased_lock 位(1 bit)再做一次细分。
- 无锁状态下的 31 位空间存的是对象的 identity hash code,这个哈希码只有在调用
System.identityHashCode()或Object.hashCode()(且未重写)时才会被计算并写入。如果对象已经进入偏向锁状态,再调用 hashCode,会导致偏向锁被撤销。 - 轻量级锁状态下,Mark Word 里存的不再是业务数据,而是指向线程栈帧中 Lock Record 的指针;重量级锁状态下,存的是指向 Monitor 对象的指针。
理解了这个布局,你再看锁升级的过程,思路会清晰很多。所谓“升级”,本质就是 Mark Word 里的字段和状态位在不断变化,从存哈希码变成存线程 ID,再变成存指针。
1.2 为什么锁信息非要塞在对象头里
设计者把锁状态放在对象头里,是一个极具工程智慧的决定。想想看:锁的粒度是“对象”,每个对象都可能被多线程访问,如果锁状态单独维护一份全局表,每次加锁解锁都要查表,开销不可接受。把状态直接内嵌到对象头,加锁时 JVM 只需要通过对象引用拿到 Mark Word,做一次 CAS 操作就能完成状态切换,成本极低。
另一个好处是,锁状态和对象生命周期天然绑定。对象被 GC 回收时,锁状态自动消失,不需要额外的清理逻辑。这种“状态跟着对象走”的设计,让 JVM 在实现上省掉了大量管理代码。
1.3 一个实验:看看对象头里的数据变化
纸上谈兵没意思,我写过一个 JOL(Java Object Layout)小实验来观察对象头的实际内容。核心代码就几行:
import org.openjdk.jol.info.ClassLayout; public class ObjectHeaderDemo { static class MyObject { int value; } public static void main(String[] args) { MyObject obj = new MyObject(); // 打印无锁状态下的对象布局 System.out.println(ClassLayout.parseInstance(obj).toPrintable()); synchronized (obj) { // 打印加锁后的对象布局 System.out.println(ClassLayout.parseInstance(obj).toPrintable()); } } }建议你亲自跑一遍,会看到 Mark Word 那 8 个字节从“无锁状态”变成“轻量级锁/偏向锁指针”的过程。我第一次跑这个实验的时候,最大的感受是:原来 JVM 的锁升级不是抽象概念,而是实实在在的内存位变化。
跑实验时注意 JVM 参数-XX:BiasedLockingStartupDelay=0,把偏向锁的启动延迟关掉。否则 JVM 启动后前几秒内创建的对象不会启用偏向锁,你看到的会是轻量级锁而不是偏向锁。
2. 锁升级的完整链路:从偏向锁到重量级锁
HotSpot 里 Synchronized 的锁不是一成不变的,它会根据竞争激烈程度动态升级,方向是单向的:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。这个设计基于一个朴素观察:大多数时候,一把锁只有一个线程在反复获取。为这个场景去走重量级锁的内核态切换,太浪费了。
2.1 偏向锁:单线程反复获取的优化
偏向锁的思想非常直接:如果一把锁只有同一个线程获取,那把锁“偏向”这个线程,持有它的线程再次进入同步块时,不再做任何同步操作。
具体流程是:
- 第一个线程 T1 进入 synchronized 块,JVM 检查 Mark Word 的偏向锁标志位。如果发现对象处于无锁且可偏向状态,通过 CAS 把 Mark Word 改为偏向锁状态,记录 T1 的线程 ID。
- T1 再次进入同步块时,检查线程 ID 是否匹配。匹配则直接进入,整个过程零 CAS、零原子操作。
- 如果 T2 来竞争,JVM 先判断持有偏向锁的线程 T1 是否还存活。如果 T1 已退出同步块或已死亡,则把锁“重新偏向”到 T2。
这里有个关键细节:偏向锁的撤销必须在安全点(SafePoint)执行。安全点是 JVM 中所有线程都可能被暂停的位置,比如 GC 发生时。因为在安全点,JVM 才能安全地修改对象头,不至于和正在执行同步块代码的线程产生竞争。
2.2 轻量级锁:CAS 自旋替代互斥
当有第二个线程开始真正竞争偏向锁时,偏向锁会被撤销,升级为轻量级锁。轻量级锁的核心机制是:
- 每个线程在进入同步块时,会在自己的栈帧中创建一个 Lock Record(锁记录)空间。
- JVM 尝试用 CAS 把对象头里的 Mark Word 复制到 Lock Record 中,同时把 Mark Word 更新为指向 Lock Record 的指针。
- 如果 CAS 成功,线程就持有了轻量级锁,Mark Word 的状态位变成“00”。
- 如果 CAS 失败,说明存在竞争,JVM 先检查当前持有锁的线程是否是当前线程——如果是,说明是可重入的,就再往栈里压一条 Lock Record;如果不是,说明发生了竞争,膨胀为重量级锁。
轻量级锁不加锁期间,线程会通过自旋(Spin)等待。自旋就是空循环忙等,不放弃 CPU 时间片。JDK 6 之后引入了自适应自旋,JVM 会根据之前自旋等待的成功率,动态调整自旋次数,避免空转浪费 CPU。
轻量级锁适合“竞争很微弱、但确实存在并发访问”的场景。我用一个支付场景类比:两个人偶尔同时去抢同一个优惠券,大部分时候一个人抢完就走了,另一个人等个几微秒也拿到了。这种场景用轻量级锁完全没问题,但如果双十一零点一万人同时抢同一个券,还让其他九千九百九十九个人自旋等待,CPU 就直接被打爆了。
2.3 重量级锁:真正的互斥与阻塞
当自旋超过阈值或竞争进一步加剧,轻量级锁就膨胀为重量级锁。重量级锁状态下,Mark Word 中的指针不再指向栈帧里的 Lock Record,而是指向一个名为 ObjectMonitor 的 C++ 对象。
ObjectMonitor 才是真正的“锁实现”。它的核心结构包括:
_owner:指向持有锁的线程_WaitSet:调用 wait() 后进入等待状态的线程队列_EntryList:还在等待获取锁的线程队列_recursions:重入计数
获取重量级锁的线程,如果没有竞争到锁,会进入_EntryList队列,线程状态变为 BLOCKED,主动让出 CPU。这涉及操作系统层面的线程挂起和唤醒,需要内核态与用户态的切换——这也是重量级锁“重”的核心原因。
2.4 锁升级路上必须注意的几件事
在实际排查中,有几个锁升级相关的点经常被忽略:
偏向锁不一定总是加分项。如果应用里每个锁都只会被一个线程访问,偏向锁确实有收益;但如果是高并发场景,多线程频繁竞争同一种锁,偏向锁的撤销和重偏向反而会带来额外的安全点停顿。所以对于高并发应用,可以通过
-XX:-UseBiasedLocking显式关闭偏向锁。早年我做过一个高并发订单系统,关闭偏向锁后毛刺明显减少,就是因为避免了很多次安全点暂停。hashCode 会破坏偏向锁。前面提到过,偏向锁状态下 Mark Word 里没有空间存 hash code。如果某线程调用了
Object.hashCode()(且没重写),JVM 就会撤销偏向锁,升级为轻量级锁来腾出空间存哈希码。所以如果一个对象被用作锁,同时又重写了 hashCode 或用作了 HashMap 的 key,偏向锁根本发挥不了作用。锁升级是单向不可逆的。一旦对象进入重量级锁,即使之后竞争消失了,它也不会自动降级回轻量级锁。这是 JVM 为了简单性和安全性做的取舍,代价是:一个曾经被激烈竞争过的锁对象,后续即使只有一个线程访问,也要走一遍重量级锁的流程。这解释了“热点对象要尽早规避,不要等它成了重量级锁再优化”。
这里多说一句面试中常问的点:为什么在 JDK 1.6 之前 Synchronized 性能差,1.6 之后显著提升?答案就是 1.6 加入了偏向锁、轻量级锁、自适应自旋等机制,让锁能够“按需分配重量”,大多数场景根本不会走到重量级锁那一步。
3. Monitor 机制:Synchronized 的真正底牌
Synchronized 的字节码层面实现,是进入同步块时执行monitorenter指令,退出时执行monitorexit指令。这两个指令背后操作的实体,就是 Monitor——监视器锁。
3.1 从字节码到 C++ 对象
写一小段代码,反编译看一下:
public class SyncDemo { public void syncMethod() { synchronized (this) { System.out.println("locked"); } } }用javap -v SyncDemo反编译后,你会看到monitorenter和两处monitorexit——一处在同步块正常结束位置,另一处在异常处理路径里。之所以有两处出口,是为了保证即使同步块内抛异常,锁也能被释放。这是 JVM 层面的天然保障,也是为什么 Java 的锁可以在出现异常时不产生死锁(前提是你没在 finally 里做异常处理)。
Monitor 内部的关键成员_owner指向当前持有锁的线程。当一个线程执行monitorenter时,JVM 会尝试获取 Monitor:如果_owner为空,直接获取成功;如果_owner是当前线程,_recursions加 1,实现重入;否则,线程进入_EntryList阻塞等待。
3.2 wait/notify 与 Monitor 的协作
很多人搞不清wait()和notify()到底做了什么,其实是这样的:线程在持有 Monitor 锁的前提下调用wait(),会释放锁,同时把自己放入_WaitSet队列;线程状态变为 WAITING。另一个线程调用notify()时,JVM 会从_WaitSet中挑一个线程移到_EntryList,和其他等待锁的线程一起参与竞争。
这个机制用生活场景类比,就像一家只有一个窗口的银行网点:一个客户办理业务时,其他客户在休息区等待(_EntryList)。办理中的客户如果发现需要补充材料,会告诉柜员“我去旁边打印”,暂时离开窗口(_WaitSet 等待)。等材料准备好了再回到柜台排队。这里有个细节:从 _WaitSet 回到柜台后,这位客户不会直接办理,而是要到 _EntryList 重新排队。
这个问题带出一个经典面试题:为什么wait()必须在synchronized块里调用?答案是,wait()需要操纵 Monitor 的_WaitSet,如果不在 synchronized 块里就没有持有锁,无法安全地把自己加入等待队列,也无法保证状态检查和挂起之间的原子性。
3.3 可重入的实现细节
Synchronized 的锁是可重入的,同一个线程可以多次获取同一把锁而不死锁。实现方式前面已经提到:Monitor 里维护了一个_recursions计数器。
每个monitorenter会让_recursions加 1,每个monitorexit让_recursions减 1。只有_recursions变为 0 时,线程才真正释放锁。这保证了:
synchronized void a() { b(); // 同一个线程调用另一个 synchronized 方法 } synchronized void b() { // 不需要重新竞争锁,直接进入 }如果是非可重入锁,上面这段代码会直接死锁。这也是为什么早期用 Lock 或自己实现锁时,要特别注意同一个线程递归进入同步区域的问题。
3.4 Monitor 的膨胀成本到底有多高
有人问:Monitor(重量级锁)到底慢在哪?最核心的成本是线程阻塞/唤醒。线程从 Running 变为 Blocked,需要操作系统把线程挂起;被唤醒时,又要从内核态切换回用户态,经过线程调度器重新分配 CPU。
一次线程上下文切换的开销大概是几十微秒级别,如果是跨 CPU 核的唤醒,还涉及缓存一致性问题——被唤醒的线程需要的共享数据可能还在别的核的缓存里,需要经过缓存一致性协议同步。在高并发场景下,成百上千个线程同时被挂起和唤醒,对操作系统和 JVM 都是巨大压力。
这也是许多高并发系统对某些热点代码段不用 Synchronized,而用AtomicLong或 LongAdder 的原因——不阻塞线程,用无锁算法和原子变量解决问题。
4. 内存屏障:看不见的并发“棱镜”
如果只理解到锁升级,你依然回答不了另一个非常核心的问题:锁是怎么保证“一个线程释放锁之后,另一个线程一定能看到修改后的值”?这个问题的答案在 Java 内存模型(JMM)和内存屏障(Memory Barrier)里。
4.1 硬件层面:为什么需要屏障
现代 CPU 为了提速,引入了三层缓存结构:L1、L2、L3。每个 CPU 核心有自己私有的 L1/L2 缓存,多核共享 L3。同时,CPU 会通过指令流水线乱序执行(Out-of-Order Execution),编译器也会做指令重排序(Instruction Reordering)。这一切优化带来的副作用是:程序写的顺序,不一定是 CPU 实际执行的顺序。
举个例子,你在代码里写:
int a = 1; int b = 2;CPU 很可能会先生成b=2的操作,再执行a=1。单线程下这没问题,结果等价。但多线程下问题就来了:如果 b 是另一个线程判断某个条件是否成立的标志,而 a 是它需要读取的数据,重排序就会导致另一个线程先看到 b=2,再去读 a 时 a 还没写。
这个时候就需要内存屏障(Memory Barrier)。它是一条特殊的 CPU 指令,作用是阻止屏障两侧的指令被重排序,同时保证缓存中的修改对其他核心可见。JMM 基于这个硬件概念,抽象出了四类屏障:
| 屏障类型 | 作用 | 举例场景 |
|---|---|---|
| LoadLoad | 屏障前的读操作先于屏障后的读操作 | 先读 flag,再读 data |
| StoreStore | 屏障前的写操作先于屏障后的写操作 | 先写 data,再写 flag |
| LoadStore | 屏障前的读操作先于屏障后的写操作 | 先读某个校验位,再写另一块数据 |
| StoreLoad | 屏障前的写操作先于屏障后的读操作 | 写数据后再读状态量,最重量级的一种 |
4.2 JMM 层面:volatile 与锁的屏障指令
在 JMM 中,线程内的所有操作被视为一个序列,但允许在满足 as-if-serial 语义(单线程执行结果不变)的前提下重排序。为了保证并发安全,JMM 定义了 happen-before 规则。其中和锁直接相关的有两条:
- 监视器释放规则:一个锁的解锁操作 happen-before 于后续对该锁的加锁操作。
- volatile 变量规则:对一个 volatile 变量的写,happen-before 于随后对这个 volatile 变量的读。
这两条规则的底层,就是内存屏障。
以volatile为例,JVM 在 volatile 读写前后插入对应的屏障:
- 在 volatile 写之前插入 StoreStore 屏障,保证它前面的普通写操作已经完成,从而让 volatile 写能“发布”前面所有写入的数据。
- 在 volatile 写之后插入 StoreLoad 屏障,保证这个写对所有线程可见,同时阻止后续读操作越过它重排序。
- 在 volatile 读之后插入 LoadLoad 和 LoadStore 屏障,确保后续读/写不会读到旧值。
引出关键问题:Synchronized 到底依赖什么屏障?答案是,Synchronized 的底层通过 lock 前缀指令 和 Monitor 的互斥机制,共同构建了 happens-before 关系。在 x86 平台上,释放锁的动作本质上会触发一条LOCK前缀指令或具备等价语义的操作,它充当了全功能屏障(同时具备 StoreStore、StoreLoad、LoadStore、LoadLoad 的效果)。
换句话说:线程 A 在释放锁之前做的所有写操作,在线程 B 获取同一把锁之后,全部可见。锁不仅是互斥,还是一条天然的数据同步管道。这正是很多并发问题的核心:你用错了锁、或锁粒度没覆盖到共享变量,就等于切断了一条该死的数据通道。
4.3 锁、volatile、final:JMM 的立体防线
既然说到 JMM,干脆把几条最常用的 happen-before 规则串一遍:
- 程序次序规则:单线程内,按代码顺序,前一个操作 happen-before 后一个。
- 监视器锁规则:加锁与解锁构成 happens-before。
- volatile 规则:volatile 读写出现在 happen-before 链中。
- 传递性:A happen-before B,B happen-before C,则 A happen-before C。
- 线程启动/终止/中断规则:线程 start 之前的操作,对线程内部可见;线程内所有操作 happen-before 线程终止检测到;interrupt 调用 happen-before 被中断线程的后续代码。
这组规则拼在一起,保证了只要有正确的同步手段,数据永远在预期的时间点对齐。整个 Java 并发体系的安全基石,本质上就是这套 happens-before 关系网。
4.4 内存屏障没拦住的两个“意外”
说两个我在实践中踩过的坑:
第一,JMM 是抽象模型,硬件实现有差异。在 x86 平台,因为 CPU 的内存模型较强(store 不会被乱序),很多场景编译器不会真的插入最重的 StoreLoad 屏障。但换到 ARM 或 RISC-V,CPU 弱内存模型下,同样的代码需要更严格的屏障指令。JVM 在不同架构上生成的屏障指令不同,所以同一段并发代码,在不同芯片上表现可能不一样——但 JMM 的语义保证是一样的。换句话说,JMM 保证了 Java 程序员不需要关心底层硬件,把屏障指令的选择交给了 JVM,而 JVM 在不同架构上背了大量“适配代码”。
第二,双重检查锁定(DCL)陷阱。很多人知道单例要加 volatile:
class Singleton { private static volatile Singleton instance; static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }如果instance不加 volatile,会发生什么?new Singleton()在底层是三条指令:分配内存、初始化字段、把引用赋给 instance。JVM 可能把第二步和第三步重排序,导致另一个线程判断 instance 非空,直接返回一个尚未完成初始化的对象。volatile 在这里的作用不是锁互斥,而是插入内存屏障,禁止初始化操作和引用赋值之间的重排序,同时让写对其他线程立即可见。
5. 实践中的锁性能排查:我亲测的几条经验
原理讲完,说点真正能落地的实战经验。锁的性能问题排查,大部分时候不需要猜测,有明确的路数可循。
5.1 抓线程转储,识别锁竞争类型
当系统出现吞吐下降、线程大量 BLOCKED 时,第一步是抓线程转储(Thread Dump)。JDK 自带的jstack可以直接使用:
jstack -l <pid> > threaddump.txt拿到 dump 后,重点看几个信号:
- 大量线程处于 BLOCKED 状态,且 waiting to lock 同一个对象,说明重量级锁竞争严重。
- 线程处于 RUNNABLE 但长时间没有进展,且 CPU 占用率极高,需要考虑是不是出现了自旋问题或 dead loop。
- 如果有线程处于 WAITING 状态且 wait on 某个 monitor,说明是 wait/notify 协作逻辑里出现不配对。
这些信号结合业务场景,基本能定位到具体锁对象。我曾经在排查一个秒杀系统时,dump 出来发现三千多个线程都阻塞在同一个订单锁对象上,业务逻辑本身只用了几毫秒,但因为锁被长期持有,其他线程等待时间高达几百毫秒。那种情况优化方向就变成了:缩短锁持有时间,而不是换锁类型。
5.2 锁粒度优化的几种实用姿势
真正遇到锁竞争问题时,改锁实现往往是最后一步。大多数情况下先做这几件事:
- 缩小锁的范围:把大同步块拆小,只锁真正需要保护的短代码段。
- 读写分离:读多写少的场景,用
ReentrantReadWriteLock或长 Adder 系列优化。 - 规避多次加锁:循环体内不要有锁操作,能提出循环就提出去。
- 用锁分段替代全局锁:比如分段锁或 LongAdder 的 Cell 数组思路,把一把大锁拆成多把小锁,分摊竞争。
- 考虑无锁方案:CAS + 复合对象、
ConcurrentHashMap的分桶 CAS、CopyOnWriteArrayList的写时复制,都是锁的替代品。
拿 ConcurrentHashMap 举例:JDK 7 里它用的是 ReentrantLock 的 Segment 锁分段,JDK 8 改成 CAS + Synchronized。改的背后,本质上是利用不同场景的锁特性做组合优化:CAS 处理无竞争情况,Synchronized 处理真有竞争的情况,配合锁升级的轻量级机制,综合性能优于 Segment 方式。
5.3 用 JMH 做锁性能基准测试
如果你要做的优化是“决策性质”的(换一种锁),那就要用数据说话。JMH(Java Microbenchmark Harness)是标准工具,可以精确测量微秒级操作的性能差异。
一个特别小的对比示例:
@Benchmark @Threads(8) public void testSynchronized() { synchronized (lock) { counter++; } } @Benchmark @Threads(8) public void testAtomic() { atomicCounter.incrementAndGet(); }跑一下你会发现,低并发下 Synchronized 可能和 Atomic 性能接近;达到一定竞争量级后,Atomic 的 CAS 在冲突严重时会出现“自旋放大效应”,而 Synchronized 走到重量级锁后又是另一番局面。这类对比实验的意义不是告诉你“哪个绝对更快”,而是帮你理解你当前的应用落在哪个性能区间。
5.4 JVM 参数调整:哪些值得动
锁相关的 JVM 参数不多,但每一个影响都很大:
-XX:-UseBiasedLocking:关闭偏向锁。高并发多线程竞争场景下建议关闭,避免偏向锁撤销的开销。-XX:BiasedLockingStartupDelay=0:关闭偏向锁启动延迟。希望立即启用偏向锁时使用。-XX:+UseSpinning/-XX:PreBlockSpin:JDK 6 之后默认启用自适应自旋,多数情况下不建议手动改这些参数。-XX:MaxTenuringThreshold:这个参数不直接控制锁,但影响对象进入老年代的时机,从而间接影响偏向锁失效和 GC 性能。谨慎调整。
我的建议是:没有足够的数据支撑,不要盲目调整这些参数。生产环境任何 JVM 参数的修改都要评估性能基线,A/B 对比验证。我见过一个团队为了提升并发性能直接把偏向锁关闭,结果因为锁竞争本就不激烈,性能没有任何提升,反而因为少了一层优化,单线程访问加锁路径变慢了几个百分点。
6. 高频面试题与思考题速查
技术分享的最后,整理几个平时反复被问到的问题。这些问题如果从对象头和内存屏障的视角去回答,会显得非常扎实。
6.1 为什么 Synchronized 是重量级锁?它在什么时候变重?
回答这个问题要分两层:字节码层面的monitorenter/monitorexit是语言机制;真正“变重”是锁升级到重量级锁之后,线程会被挂起、进入操作系统的内核态调度,此时开销最大。Synchronized 并不是天生重量级,它也有偏向锁和轻量级锁状态,只是在竞争激烈时自动膨胀。用一句话概括:Synchronized 的重量取决于竞争,这是 JDK 1.6 之后最大的变化。
6.2 volatile 和 Synchronized 的区别是什么?
最核心的区别有两点:volatile 保证可见性和有序性,不保证原子性;Synchronized 同时保证可见性、有序性和原子性(通过锁互斥)。语义上可以理解为:volatile 解决的是“变量发布与读取”的安全,Synchronized 解决的是“复合操作为什么不能被打断”的安全。两者不是替代关系,很多场景需要组合使用,比如 DCL 单例。
6.3 为什么 DCL 写法里需要 volatile?把第二个判空去掉行不行?
单靠 Synchronized 保证的是同步块内的原子性,但同步块外的第一次判空不在锁保护范围内。如果没有 volatile 禁止重排序,一个线程可能看到 instance 非空、但对象还没有完成构造的状态,直接返回一个半初始化对象。第二个判空是为了避免重复创建实例,但因为多线程情况下对象可能尚未完成构造,所以 volatile 才是真正的安全保证。两件事缺一不可。
6.4 锁可以被同一个线程多次获取,底层怎么实现?
Monitor 里的_recursions计数器。每一次monitorenter加锁计数器加一,对应monitorexit减一,计数器归零时才真正释放锁。这保证了递归调用、嵌套同步块不会导致死锁。
6.5 为什么 wait/notify 必须在 synchronized 内使用?
wait()操作的是 Monitor 的_WaitSet队列。调用它之前必须获得锁,否则无法排斥并发修改,也无法安全地完成状态检查和线程挂起之间的原子操作。如果不在锁保护范围内,wait()无法保证等待期间数据不会被其他线程修改,逻辑会陷入竞态。这是一个设计层面的强约束,不是语法糖。
思考题也留几个:为什么偏向锁撤销要在安全点?轻量级锁的 Lock Record 为什么放在线程栈里?可以顺着这两个问题,自己找找 HotSpot 源码看看,理解会更加深入。
写在最后
我自己的一个最大体悟:Java 并发这一块,如果只是背结论,永远都会在不经意间翻车。只有从对象头一路走到内存屏障,把锁从无到有、从轻到重的每个阶段都在心里过一遍,再遇到奇怪的问题时才能真正“有的放矢”。另外想分享一个小技巧:学习底层知识最有效的方法,是自己动手做实验,JOL 看对象头、jstack 看线程转储、JMH 做基准对比,这三个工具组合起来,足以让你把书面的原理变成自己掌握的经验。建议你现在就打开 IDE,把那几个实验跑一遍。