☰
Synchronized底层原理:从对象头到锁升级与内存屏障
2026/10/22 11:58:38 网站建设 项目流程

我最早意识到“锁”这件事没那么简单,是在一个压测现场。某天下午,一个简单的商品库存扣减接口,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 偏向锁:单线程反复获取的优化

偏向锁的思想非常直接:如果一把锁只有同一个线程获取,那把锁“偏向”这个线程,持有它的线程再次进入同步块时,不再做任何同步操作。

具体流程是:

  1. 第一个线程 T1 进入 synchronized 块,JVM 检查 Mark Word 的偏向锁标志位。如果发现对象处于无锁且可偏向状态,通过 CAS 把 Mark Word 改为偏向锁状态,记录 T1 的线程 ID。
  2. T1 再次进入同步块时,检查线程 ID 是否匹配。匹配则直接进入,整个过程零 CAS、零原子操作。
  3. 如果 T2 来竞争,JVM 先判断持有偏向锁的线程 T1 是否还存活。如果 T1 已退出同步块或已死亡,则把锁“重新偏向”到 T2。

这里有个关键细节:偏向锁的撤销必须在安全点(SafePoint)执行。安全点是 JVM 中所有线程都可能被暂停的位置,比如 GC 发生时。因为在安全点,JVM 才能安全地修改对象头,不至于和正在执行同步块代码的线程产生竞争。

2.2 轻量级锁:CAS 自旋替代互斥

当有第二个线程开始真正竞争偏向锁时,偏向锁会被撤销,升级为轻量级锁。轻量级锁的核心机制是:

  1. 每个线程在进入同步块时,会在自己的栈帧中创建一个 Lock Record(锁记录)空间。
  2. JVM 尝试用 CAS 把对象头里的 Mark Word 复制到 Lock Record 中,同时把 Mark Word 更新为指向 Lock Record 的指针。
  3. 如果 CAS 成功,线程就持有了轻量级锁,Mark Word 的状态位变成“00”。
  4. 如果 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 锁升级路上必须注意的几件事

在实际排查中,有几个锁升级相关的点经常被忽略:

  1. 偏向锁不一定总是加分项。如果应用里每个锁都只会被一个线程访问,偏向锁确实有收益;但如果是高并发场景,多线程频繁竞争同一种锁,偏向锁的撤销和重偏向反而会带来额外的安全点停顿。所以对于高并发应用,可以通过-XX:-UseBiasedLocking显式关闭偏向锁。早年我做过一个高并发订单系统,关闭偏向锁后毛刺明显减少,就是因为避免了很多次安全点暂停。

  2. hashCode 会破坏偏向锁。前面提到过,偏向锁状态下 Mark Word 里没有空间存 hash code。如果某线程调用了Object.hashCode()(且没重写),JVM 就会撤销偏向锁,升级为轻量级锁来腾出空间存哈希码。所以如果一个对象被用作锁,同时又重写了 hashCode 或用作了 HashMap 的 key,偏向锁根本发挥不了作用。

  3. 锁升级是单向不可逆的。一旦对象进入重量级锁,即使之后竞争消失了,它也不会自动降级回轻量级锁。这是 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 锁粒度优化的几种实用姿势

真正遇到锁竞争问题时,改锁实现往往是最后一步。大多数情况下先做这几件事:

  1. 缩小锁的范围:把大同步块拆小,只锁真正需要保护的短代码段。
  2. 读写分离:读多写少的场景,用ReentrantReadWriteLock或长 Adder 系列优化。
  3. 规避多次加锁:循环体内不要有锁操作,能提出循环就提出去。
  4. 用锁分段替代全局锁:比如分段锁或 LongAdder 的 Cell 数组思路,把一把大锁拆成多把小锁,分摊竞争。
  5. 考虑无锁方案: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,把那几个实验跑一遍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询