news 2026/10/31 9:36:01

深入解析synchronized底层原理:从对象头到锁升级与内存屏障

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入解析synchronized底层原理:从对象头到锁升级与内存屏障

1. 从一个仓库路径说起:这个标题到底在讲什么

第一次看到这个标题,很多人会愣一下——java hdporn,docs/java/concurrent/SynBottom.md · wt1814/wt-note - Gitee.com,这串东西看起来像是把仓库地址、文件路径和某个关键词硬拼在了一起。我先把话说清楚:这个标题里真正有价值、可以拿来展开的技术内核,是docs/java/concurrent/SynBottom.md这个路径所指向的内容——Java 并发编程中 synchronized 的底层原理笔记。至于前面那串看起来突兀的字符,属于标题拼接时混入的噪声,跟技术主题没有实质关系,我们在正文里不会围绕它做任何展开,只把它当作一个“标题被污染”的案例来看待。

所以这篇博文要聊的,是一个 Java 并发领域里被问了无数遍、但真正讲透的人并不多的主题:synchronized 关键字从字节码到 JVM 底层、再到硬件层面的完整实现链路。文件名里的SynBottom其实已经点明了意图——“Syn”是 synchronized 的缩写,“Bottom”是底层的意思,合起来就是“synchronized 底层”。这类笔记通常出现在个人学习仓库的docs/java/concurrent/目录下,属于那种“自己整理一遍才算真懂”的知识点。

它能解决什么问题?简单说,就是让你在面对“synchronized 和 ReentrantLock 怎么选”“为什么加了锁还是出现可见性问题”“偏向锁到底还该不该讲”这类问题时,不再靠背结论,而是能从对象头、Monitor、内存屏障这些层面自己推出来。适合的读者是已经写过 Java 多线程代码、但对底层机制一知半解的开发者,也适合准备面试时想把并发这块讲清楚的人。下面我按自己整理这类笔记的习惯,把整条链路拆开讲。

2. 整体设计思路:为什么按“对象头 → Monitor → 锁升级”这条线走

2.1 先想清楚:synchronized 到底锁的是什么

很多人背过“synchronized 锁的是对象”,但再追问一句“锁对象的什么”就卡住了。我整理这块内容时,第一条主线就是把这个“什么”定位到对象头里的 Mark Word。这是整条链路的地基,地基不稳,后面讲偏向锁、轻量级锁全是空中楼阁。

一个 Java 对象在内存里分三块:对象头、实例数据、对齐填充。对象头又分 Mark Word 和类型指针(Klass Pointer)。在 64 位 JVM 下,Mark Word 占 8 字节,这 8 个字节里塞了哈希码、GC 分代年龄、锁标志位、偏向线程 ID 等信息,而且这些信息是复用同一块内存的——不同锁状态下,这 8 字节的解读方式完全不同。这个“复用”设计是理解锁升级的关键:因为空间有限,所以只能按状态切换含义,而不是每种状态各留一块地方。

我为什么把这条放在最前面?因为后面所有的“锁升级”,本质就是 Mark Word 里那几位标志位在变。你只要记住这张状态表,很多现象就能自己解释。

锁状态Mark Word 关键内容锁标志位
无锁哈希码、分代年龄01
偏向锁偏向线程 ID、Epoch、分代年龄01(偏向位为 1)
轻量级锁指向栈中锁记录的指针00
重量级锁指向 Monitor 对象的指针10
GC 标记空11

这张表我建议直接背下来,面试和排查都用得上。注意无锁和偏向锁的标志位都是 01,靠偏向位区分,这是很多人记混的地方。

2.2 方案选型:为什么讲底层要落到 Monitor 和内存屏障

整理这类笔记时有个取舍:是只讲“加锁能保证原子性和可见性”这种结论,还是往下挖到 C++ 层的 ObjectMonitor 和 CPU 的 lock 指令。我的选择是必须挖到底,理由很实在——只讲结论的笔记,过两个月自己都懒得看;讲到底层的笔记,每次回看都能捡到新东西。

具体来说,synchronized 的底层实现分两条腿走路:互斥靠 Monitor(管程),可见性和有序性靠内存屏障。这两条腿缺一不可。很多人以为加了锁就万事大吉,其实锁释放时插入的 store-load 屏障才是保证“一个线程的修改对另一个线程可见”的真正原因。如果只讲 Monitor 不讲屏障,那“为什么 synchronized 能解决可见性问题”就永远是个黑盒。

所以整篇笔记的骨架我定成:对象头打底 → Monitor 讲互斥 → 字节码讲编译器怎么生成加解锁指令 → 锁升级讲性能优化 → 内存屏障讲可见性 → 最后落到实践选型。这条线是从“是什么”到“怎么做”再到“为什么这样设计”,符合认知顺序,也方便随时跳读。

2.3 一个容易被忽略的设计意图:锁升级是为了省成本

锁升级这条线,很多笔记讲成了“四种状态依次转换”的流水账,但没讲清楚为什么要设计这么多状态。我的理解是:这一切都是为了在“无竞争”和“有竞争”两种场景下都尽量少付出代价。

无竞争时,如果每次都走操作系统互斥量(mutex),那代价太大了——用户态到内核态切换一次就是几千个时钟周期。所以 JVM 设计了偏向锁:第一次某个线程拿到锁,就在 Mark Word 里记下它的 ID,下次这个线程再来,比对一下 ID 就直接进去,连 CAS 都省了。这就是“偏向”二字的含义——锁偏袒第一个拿到它的线程。

一旦出现第二个线程来抢,偏向就失效了,升级成轻量级锁,用 CAS 自旋来抢。自旋的好处是不用进内核态,坏处是空转烧 CPU。所以自旋有次数限制,抢不到就升级成重量级锁,老老实实排队挂起。

这条“偏向 → 轻量 → 重量”的路径,本质是一条成本递增、竞争适应性递增的曲线。理解了这一点,你就明白为什么 JDK 15 之后默认关闭偏向锁——现代应用里锁竞争普遍存在,偏向锁那套撤销逻辑反而成了负担。这个背景知识,是很多老笔记里没有的,但恰恰是理解“为什么现在面试还在问偏向锁”的关键。

3. 核心细节拆解:Monitor、字节码与锁升级的实操要点

3.1 Monitor:互斥到底是怎么实现的

synchronized 在 JVM 层面靠 Monitor(管程)实现,HotSpot 里对应的 C++ 类是ObjectMonitor。每个对象在需要重量级锁时,会关联一个 ObjectMonitor,它内部有几个关键字段:_owner记录当前持有锁的线程,_entryList是等待锁的线程队列,_waitSet是调用了 wait 的线程集合,还有_count重入计数。

这里有个细节值得单独拎出来:重入是怎么实现的。synchronized 是可重入锁,同一个线程再次进入同步块时,不会把自己锁死。实现方式就是_owner指向当前线程时,_count加一;退出时减一,减到零才真正释放。这个设计对应到 Java 语义,就是“同一个线程可以重复获取同一把锁”。

线程抢锁的流程大致是这样:线程进来先看_owner是不是自己,是就重入;不是就尝试 CAS 把_owner设成自己;CAS 失败就进_entryList排队,然后 park 挂起。释放时唤醒_entryList里的线程重新竞争。注意这里是非公平的——新来的线程可能插队抢在排队线程前面,这也是 synchronized 不保证公平性的根源。

注意:_waitSet和_entryList是两个不同的队列,别搞混。调wait()进的是_waitSet,被notify()唤醒后转移到_entryList重新竞争锁;而直接抢锁失败的线程进的是_entryList。这个区分在排查“为什么 notify 了线程还没动”这类问题时特别有用。

3.2 字节码层面:monitorenter 和 monitorexit 怎么配对

写一段最简单的同步代码:

public class SyncDemo { private final Object lock = new Object(); private int count = 0; public void increment() { synchronized (lock) { count++; } } }

用javap -c反编译,能看到monitorenter和monitorexit指令。这里有个关键点:正常路径有一个 monitorexit,异常路径还有一个。编译器会自动生成一个异常处理器,保证即使同步块里抛异常,锁也能释放。这就是为什么 synchronized 不需要你手动 try-finally——编译器替你做了。

同步方法则不一样,它没有 monitorenter/monitorexit 指令,而是在方法的访问标志里加一个ACC_SYNCHRONIZED。JVM 在方法调用时检查这个标志,有就自动加锁解锁。两种方式语义一致,只是实现路径不同。

我整理笔记时特意强调了这个差异,因为面试里常有人问“同步方法和同步代码块有什么区别”,标准答案里“一个靠标志位一个靠指令”就是从这里来的。另外,同步代码块的锁粒度更细,能减少锁持有时间,这是实践中的选型依据。

3.3 锁升级的完整路径与触发条件

锁升级不是一步到位的,每一步都有明确的触发条件。我把这条路径整理成下面这张表,方便对照:

阶段触发条件实现手段代价
偏向锁首次加锁,无竞争Mark Word 记录线程 ID极低,仅一次 CAS
偏向撤销第二个线程来抢在安全点撤销偏向中等,需 STW
轻量级锁偏向失效后竞争CAS 抢栈中 Lock Record低,自旋
重量级锁自旋失败或竞争激烈关联 ObjectMonitor,park 挂起高,内核态切换

偏向锁的撤销是个重点。它不是随时能撤的,必须等到安全点(Safe Point),因为要修改线程栈上的锁记录,得保证线程状态一致。这也是为什么高并发下偏向锁反而拖慢性能——撤销要 STW,频繁撤销就是频繁停顿。JDK 15 默认关闭偏向锁,-XX:-UseBiasedLocking,原因就在这里。

轻量级锁的自旋次数,早期是固定值,后来改成自适应自旋(adaptive spinning),根据上一次在同一把锁上的自旋成功率动态调整。这个优化思路很值得学:用历史成功率来预测未来,比拍脑袋定个固定值靠谱得多。

3.4 内存屏障:可见性到底靠什么保证

前面说锁释放时插入 store-load 屏障,这里展开讲。JMM(Java 内存模型)定义了四种屏障:LoadLoad、StoreStore、LoadStore、StoreLoad。synchronized 的语义要求是:解锁前的写操作,必须对后续加锁的线程可见。

实现上,monitorexit 之前会插入 StoreStore 屏障,保证前面的普通写都刷到主存;monitorexit 之后插入 StoreLoad 屏障,防止后面的读写重排到解锁之前。monitorenter 之后则插入 LoadLoad 屏障,保证锁内的读不会重排到加锁之前。

这套屏障组合,配合硬件的缓存一致性协议(比如 x86 的 MESI),才最终保证了“一个线程解锁、另一个线程加锁后能看到前者所有修改”。很多人只记“synchronized 保证可见性”这个结论,但不知道背后是屏障在干活,一旦遇到“为什么我加了锁还是读到旧值”的问题就懵了——大概率是锁的不是同一个对象,屏障根本没生效。

提示:可见性的前提是锁同一个对象。两个线程锁不同对象,各自插各自的屏障,互相之间没有任何 happens-before 关系,读到旧值完全正常。这是实践中最常见的坑之一。

4. 实操过程:从写代码到验证锁状态的完整链路

4.1 环境准备与验证工具

要验证锁升级,光看代码不行,得能观察到运行时的锁状态。我常用的组合是:JDK 8(保留偏向锁,方便观察完整升级路径)+ JOL(Java Object Layout)工具 +-XX:+PrintFlagsFinal确认参数。

JOL 的引入很简单,Maven 里加依赖:

<dependency> <groupId>org.openjdk.jol</groupId> <artifactId>jol-core</artifactId> <version>0.17</version> </dependency>

然后写个 main 方法打印对象头:

import org.openjdk.jol.info.ClassLayout; public class HeaderDemo { public static void main(String[] args) { Object o = new Object(); System.out.println(ClassLayout.parseInstance(o).toPrintable()); } }

输出里能看到 Mark Word 的二进制。注意 JVM 启动后前几秒偏向锁是延迟开启的(-XX:BiasedLockingStartupDelay,默认 4000ms),所以刚启动打印出来是无锁状态,等几秒再打印才是可偏向状态。这个延迟是很多人第一次验证时“怎么没看到偏向”的原因。

4.2 一步步观察锁升级

我按这个顺序验证,每一步都打印对象头:

  1. 无锁状态:new 一个对象,等过偏向延迟,打印,看到偏向位为 1 但线程 ID 为空,这是“可偏向但未偏向”。
  2. 偏向锁:单线程 synchronized 加锁后打印,Mark Word 里出现线程 ID。
  3. 轻量级锁:开两个线程交替加锁,第二个线程来抢时偏向撤销,升级轻量级,Mark Word 变成指向栈锁记录的指针。
  4. 重量级锁:两个线程同时长时间持有竞争,自旋失败后升级,Mark Word 指向 ObjectMonitor。

这里有个实操细节:用 JOL 打印时最好在同步块内部打印,否则锁已经释放,看到的又是无锁状态。我第一次验证时就犯了这个错,打印出来全是无锁,还以为工具坏了。

4.3 参数计算:自旋次数与锁粗化阈值

自适应自旋没有固定次数,但早期版本有-XX:PreBlockSpin默认 10 次。现在这个参数基本不用调,但理解它的意义有好处:自旋次数太少,抢不到就挂起,上下文切换成本高;太多,空转烧 CPU。自适应就是让 JVM 自己根据成功率动态平衡。

锁粗化(Lock Coarsening)是另一个编译器优化:如果一段代码里连续对同一个对象加解锁多次,JIT 会把它们合并成一次大锁。比如循环里反复synchronized(lock),编译器可能把锁提到循环外。这个优化的前提是“中间没有其他线程能观察到不一致状态”,所以不是所有情况都能粗化。

锁消除(Lock Elimination)则更激进:如果逃逸分析发现锁对象根本没逃出当前线程,那这把锁就是多余的,直接消掉。典型场景是方法内 new 一个局部对象加锁,这个对象外面根本拿不到,锁自然没意义。这两个优化都是 JIT 在运行时做的,你用-XX:+EliminateLocks控制(默认开)。

4.4 一个完整的可复现示例

下面这段代码可以用来观察偏向锁到轻量级锁的转换,我实测过很多次:

public class LockUpgradeDemo { static final Object LOCK = new Object(); public static void main(String[] args) throws Exception { Thread.sleep(5000); // 等偏向锁延迟结束 System.out.println("初始: " + ClassLayout.parseInstance(LOCK).toPrintable()); synchronized (LOCK) { System.out.println("偏向: " + ClassLayout.parseInstance(LOCK).toPrintable()); } Thread t1 = new Thread(() -> { synchronized (LOCK) { System.out.println("t1 持有: " + ClassLayout.parseInstance(LOCK).toPrintable()); } }); t1.start(); t1.join(); Thread t2 = new Thread(() -> { synchronized (LOCK) { System.out.println("t2 持有: " + ClassLayout.parseInstance(LOCK).toPrintable()); } }); t2.start(); t2.join(); } }

跑下来你会看到 Mark Word 从“可偏向”到“已偏向 t1”,再到 t2 来抢时变成轻量级锁指针。这个过程的输出值得反复看几遍,比看十篇文字描述都直观。

5. 常见问题与排查技巧实录

5.1 加了锁还是读到旧值,问题出在哪

这是最高频的困惑。排查顺序我总结成三步:先确认锁的是不是同一个对象,再确认有没有跨线程共享,最后确认是不是指令重排导致的错觉。

锁不同对象是最常见的。比如两个线程各自 new 了一个锁对象,或者锁的是 String 常量但拼接方式不同导致不是同一个引用。第二种是锁对象被替换了,比如synchronized(this.lock)但中途lock被重新赋值。第三种是误以为锁能解决一切,其实锁只保证临界区内的操作,临界区外的读写照样可能乱序。

注意:synchronized锁的是对象,不是变量。锁一个 Integer 尤其危险,因为小整数有缓存,不同地方拿到的可能是同一个对象,导致意外的锁竞争;而超出缓存范围又是不同对象,导致锁失效。锁对象最好用private final Object,别用基本类型的包装类。

5.2 死锁怎么快速定位

死锁的典型现象是线程都卡住不动,CPU 不高。定位靠jstack,它会自动检测死锁并打印出来,输出里会有Found one Java-level deadlock字样,下面列出互相等待的线程和持有的锁。

预防死锁的经验:固定加锁顺序是最有效的手段。如果所有线程都按同一个顺序获取多把锁,环形等待就不可能形成。其次是加超时,tryLock(timeout)拿不到就放弃并释放已持有的锁,避免无限等待。synchronized 本身不支持超时,这也是它在复杂场景下不如 ReentrantLock 灵活的地方。

5.3 锁竞争激烈时性能上不去

先看是不是锁粒度太粗。把大同步块拆小,只包住真正需要互斥的代码,是第一步。然后看能不能用更细粒度的锁,比如分段锁(ConcurrentHashMap 早期就是这么干的)。再不行就考虑无锁方案,CAS 或者 LongAdder 这类分散热点的结构。

还有一个容易忽略的点:锁对象的选择影响缓存行。如果锁对象和热点数据在同一个缓存行,多核之间会互相失效缓存行(伪共享),性能反而下降。这种情况可以用填充(padding)把锁对象隔开,或者干脆用@Contended注解(需要开-XX:-RestrictContended)。

5.4 常见问题速查表

现象可能原因排查手段
加锁后仍读到旧值锁的不是同一对象打印锁对象 identityHashCode
线程卡住 CPU 低死锁或等待jstack 看线程状态
高并发下性能骤降锁升级到重量级JOL 看 Mark Word
偏向锁频繁撤销多线程交替访问关闭偏向锁对比
同步块内异常锁未释放理论上不会,编译器保证检查是否手动锁了别的

5.5 几个踩过的坑

第一个坑是在同步块里调用外部方法。外部方法可能耗时很长,也可能反过来获取别的锁,前者拉长锁持有时间,后者可能引发死锁。我的习惯是同步块里只放纯内存操作,IO 和远程调用一律挪到锁外。

第二个坑是锁对象用 this。用 this 做锁,等于把锁暴露给外部,任何拿到这个对象引用的代码都能加锁,锁的边界就失控了。除非是设计好的(比如某些工具类的约定),否则一律用私有 final 对象。

第三个坑是误以为 synchronized 能替代 volatile。synchronized 确实能保证可见性,但它有互斥开销;如果一个场景只需要可见性不需要原子性,用 volatile 更轻量。反过来,volatile 不能保证复合操作的原子性,count++这种该加锁还得加锁。

6. 从底层回到选型:synchronized 与 ReentrantLock 怎么选

6.1 能力对比:不只是性能差异

很多人选型的依据是“哪个快”,但实测下来,低竞争场景两者差距很小,高竞争场景 ReentrantLock 的可调性更好。真正的差异在能力上:

能力synchronizedReentrantLock
自动释放是否,需 finally
可中断否是,lockInterruptibly
超时获取否是,tryLock
公平锁否可选
条件变量wait/notify 单一多 Condition
锁状态查询否是

这张表说明:需要超时、中断、多条件队列这些高级能力时,ReentrantLock 是唯一选择;普通互斥场景,synchronized 更省心,不会忘记释放。

6.2 性能认知的变迁

早期 JDK 里 synchronized 是重量级锁,性能确实差,所以那会儿大家都推 ReentrantLock。但 JDK 6 之后引入了偏向锁、轻量级锁、自适应自旋,synchronized 的性能大幅提升,很多场景下和 ReentrantLock 持平甚至更好。所以“synchronized 慢”这个结论已经过时了,现在还这么说的人,多半是知识没更新。

我的实践建议是:默认用 synchronized,遇到它满足不了的需求(超时、中断、公平、多条件)再换 ReentrantLock。这个顺序能让你少写很多 try-finally,代码也更干净。

6.3 一个容易被问到的细节:锁消除与逃逸分析

前面提过锁消除,这里补充一个判断依据:JIT 通过逃逸分析判断锁对象是否逃出当前线程。如果对象只在方法内创建和使用,没被返回、没被存到字段、没被传给其他线程,那它就是“不逃逸”的,锁可以被安全消除。

这个优化的意义在于:你写 synchronized 是为了正确性,JIT 帮你把不必要的开销去掉,两者不冲突。所以不要为了“性能”而刻意不写锁,该写就写,JIT 会处理。当然,前提是锁对象真的不逃逸,如果对象被发布出去了,锁消除就不会发生。

7. 整理这类底层笔记的一点个人习惯

我整理SynBottom这类笔记时,有个坚持了很多年的习惯:每个结论都要能自己动手验证一遍。比如“偏向锁撤销要 STW”,光看文字记不住,用 JOL 打印一次 Mark Word 变化,再配合-XX:+PrintGC看停顿,印象就深了。笔记里我一般会附上验证代码和预期输出,过几个月回看,直接跑一遍就能回忆起来。

另一个习惯是把版本差异标清楚。偏向锁在 JDK 15 默认关闭,这个信息如果笔记里不写,几年后自己看会困惑“为什么我验证不出来”。所以涉及 JVM 参数和默认行为的地方,我都会注明适用的 JDK 版本,避免误导。

最后说个实用的:这类底层知识,光看不动手,过一周就忘。我的做法是每整理完一个主题,就写一个最小可复现的 demo 放进仓库,配上注释说明“这段代码验证了什么”。时间长了,这个 demo 集合本身就是最好的复习材料,比任何笔记都管用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/31 9:35:38

Rancher 2.11到2.12升级问题解析与解决方案

1. Rancher 2.11到2.12升级核心问题解析 最近在帮客户做Rancher 2.11到2.12的版本升级时&#xff0c;遇到了几个典型问题&#xff1a;审计日志异常、集群状态不一致和界面缓存问题。这些问题看似独立&#xff0c;实则都与Rancher的架构变更和数据处理机制有关。 审计日志在2.1…

作者头像 李华
网站建设 2026/10/31 9:35:36

MySQL JSON数据类型应用与优化全指南

1. JSON数据类型在MySQL中的核心价值 MySQL 5.7版本引入的JSON数据类型&#xff0c;彻底改变了传统关系型数据库处理半结构化数据的方式。与直接将JSON存储为TEXT或VARCHAR类型相比&#xff0c;专门的JSON数据类型具有三大不可替代的优势&#xff1a; 首先&#xff0c;存储引擎…

作者头像 李华
网站建设 2026/10/31 9:35:21

SpringBoot+Redis构建博物馆高并发票务系统实践

1. 项目背景与核心需求 恐龙博物馆门户景点系统是一个典型的文博类数字化平台&#xff0c;其核心目标是解决传统博物馆信息化程度低、游客服务单一的问题。这类系统通常需要处理高并发访问、多媒体内容展示、票务管理等多维度需求。 我在实际开发中发现&#xff0c;采用Spring…

作者头像 李华
网站建设 2026/10/31 9:34:14

风电功率预测实战:LightGBM在小样本高噪声时序数据中的应用

简介&#xff1a;本资源是一套基于LightGBM机器学习模型的风电功率预测完整实践项目&#xff0c;面向计算机、自动化、新能源等相关专业的本科生与研究生&#xff0c;适用于毕业设计、课程设计及期末大作业等教学实践场景。项目提供从数据预处理、模型训练&#xff08;train.py…

作者头像 李华
网站建设 2026/10/31 9:34:11

阶梯型矩阵实战:判定条件、高斯消元与数值稳定性

开头先抛一个我前阵子遇到的事。给一批实验数据做多元线性拟合&#xff0c;代码跑完系数看着挺正常&#xff0c;可残差平方和比预估的大了一个数量级。查了大半天&#xff0c;问题不在数据预处理&#xff0c;也不在优化器&#xff0c;而是我用来判断设计矩阵是否满秩的那段阶梯…

作者头像 李华
网站建设 2026/10/31 9:33:12

Claude Skills与MCP协议:构建可验证AI能力单元

1. 这不是“插件市场”&#xff0c;而是Claude能力生态的底层重构你点开GitHub上那个标着“awesome-claude-skills”的仓库&#xff0c;第一眼看到的可能是一长串链接和分类——但如果你真把它当成另一个“VS Code插件列表”来用&#xff0c;大概率会在三天内删掉所有配置&…

作者头像 李华