很多人学 JVM,学到垃圾回收、类加载、内存模型的时候还能跟上,一到“锁”这个章节就开始发怵。原因也简单,锁这个东西在 JVM 里不是独立存在的,它一头连着操作系统内核,一头连着 Java 对象头,中间还夹着 JIT 编译器的一堆优化,知识密度特别大。但锁又是 JVM 面试题和日常性能排查里绕不开的硬骨头,尤其是背了一堆“偏向锁会升级成轻量级锁”之类的结论,真要你说清楚在什么条件下升级,或者为什么 JDK 15 又把偏向锁给废弃了,很多人就开始含糊。
这篇文章我想按照实际排查问题的思路,把 JVM 锁这摊事从头到尾撸一遍。我不打算只讲结论,我会把锁设计的动机、升级路径上的关键判断、以及 JUC 工具和 synchronized 之间的取舍逻辑都拆开说,最后再给一份面试和实战里常见的误区清单。相信耐心读完,你对 JVM 锁的理解会有一个质的提升。
1. JVM 锁的底层博弈:互斥的代价与优化的空间
1.1 锁的本质是并发控制,但核心矛盾在于“挂起线程”太贵
先从一个最基础的问题说起:为什么 JVM 需要锁,而且需要一大堆不同类型的锁?答案很简单——因为多线程并发访问共享资源时,需要保证临界区的互斥。但这个互斥如果直接交给操作系统来实现,代价是很高的。
在 Java 里,synchronized 关键字在底层映射为 monitor 锁,依赖操作系统的 mutex 互斥量。当一个线程拿不到锁、需要被阻塞挂起的时候,操作系统要执行线程上下文切换,切换到内核态,把当前线程的 CPU 寄存器状态、程序计数器、内核栈全部保存下来,再加载另一个线程的状态。这个切换开销是非常大的,实测下来一次上下文切换通常在微秒这个量级,和一条普通 CPU 指令的纳秒级耗时相比,损耗要高出几个数量级。
这就形成了一个矛盾:我们明明只是想保护一小段共享变量的读写,结果因为锁竞争,却要付出线程挂起和唤醒的巨额代价。尤其是在锁竞争并不激烈的时候,这种代价显得极其不值。JVM 锁优化的整个演进史,本质上就是围绕“尽量减少线程挂起”这个目标展开的。
1.2 对象头:所有锁优化方案的物理载体
要理解 JVM 如何优化锁,就必须先认识对象头,因为锁的优化状态其实是记录在对象头里的。在 HotSpot 虚拟机中,Java 对象在堆内存中的存储布局分为三块:对象头、实例数据、对齐填充。其中对象头又分成两部分,第一部分叫 Mark Word,用于存储对象自身的运行时数据,比如哈希码、GC 分代年龄、锁状态标志等,第二部分是类型指针,指向方法区的类元数据,如果对象是数组,还要额外记录数组长度。
Mark Word 是一个很有意思的设计,它不是固定存储一种数据,而是根据对象状态动态复用。在 64 位 JVM 下,Mark Word 是 64 位,不同锁状态下各个位段的布局完全不同。比如无锁状态下,前 56 位存储哈希码和分代年龄,最后 2 位是锁标志位 01;偏向锁标志位是 1 + 锁标志位 01;轻量级锁状态下,前 62 位是指向栈中锁记录的指针,最后 2 位是 00;重量级锁状态下,前 62 位是指向 monitor 对象的指针,最后 2 位是 10。这 2 位的锁标志位组合,正是 JVM 判断当前锁处于什么状态的依据。
我第一次看对象头的时候觉得特别抽象,后来把它类比成共享单车的锁状态:无锁就是车没锁,谁都能骑;偏向锁是这个车记住了上一个骑它的人,只要还是这个人来骑,直接开锁走人,不用去运营后台登记;轻量级锁就是车没记住人,但骑车的人动作很快,在车前转一圈确认没有别的人在抢,就赶紧骑走;重量级锁就是这车边上围了一圈人在排队,谁来都得按顺序来。这样再去看 JVM 锁升级,会直观很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. synchronized 的锁升级之路:从偏向锁到重量级锁的完整演进
2.1 偏向锁:为“只有一个线程访问”的场景做优化
JDK 1.6 之后,HotSpot 对 synchronized 做了一系列优化,其中最先引入的是偏向锁。偏向锁的优化目标非常明确:很多情况下,一个锁不仅没人竞争,而且总是被同一个线程反复获取。比如某个同步代码块里的逻辑只被单线程执行,或者虽然方法被声明成了 synchronized,但从头到尾只有一个线程调用,这种情况下每次加锁解锁都要走 CAS 甚至互斥逻辑,纯属浪费。
偏向锁的思路是,当锁第一次被线程 A 获取时,JVM 会在 Mark Word 里记录线程 A 的 ID,相当于在锁上盖了个章“这个锁偏向线程 A”。之后线程 A 再进入同步块时,只要判断 Mark Word 里存的线程 ID 是不是自己,如果是,虚拟机就可以什么都不做,直接进入临界区,连 CAS 操作都省了。这里有一个关键细节:偏向锁的撤销并不是在获取锁时触发的,而是在另一个线程 B 尝试获取这个锁的时候才触发。如果线程 B 来了,发现锁偏向的是线程 A,才会去撤销偏向、升级成轻量级锁。
需要特别注意,偏向锁有一个已知的“小毛病”——它默认有 4 秒左右的延迟开启时间。因为 JVM 启动初期,几乎所有锁都是多线程竞争过的,如果立刻开启偏向锁,大量的偏向撤销反而会带来额外开销。这块可以通过 JVM 参数 -XX:BiasedLockingStartupDelay=0 来关闭延迟,不过在实际生产环境里我建议保持默认,没必要为了在启动阶段省这几秒去做这种调整,收益
