直接说结论:JVM里的锁,是Java并发编程中最容易“以为自己懂了,一追问就露馅”的知识点。很多人背熟了“偏向锁→轻量级锁→重量级锁”这条升级路径,但一问到“偏向锁凭什么敢偏向第一个线程”“轻量级锁怎么 CAS 对象头”“重量级锁的 ObjectMonitor 到底在等什么”,就答不上来了。这篇文章我从底层数据结构一路往上讲,把 synchronized 的完整锁升级链路、JIT 对锁的自动优化、JUC 里各种 Lock 的实现差异,以及从 JVM 内的锁过渡到跨进程的分布式锁这整条线索串起来。不绕弯子,直接把锁的本质拆开给你看。
1. 锁的底层承载:对象头和 Mark Word 才是真正的战场
要理解 JVM 的锁,第一件事是把“锁”这个概念从抽象拉回具体。Java 中每一把锁都不是凭空存在的,synchronized 锁在对象上,而对象在 JVM 堆内存里的布局,才是锁状态的真正物理载体。
1.1 对象头里的 Mark Word 究竟存了什么
一个 Java 对象在堆内存中由三部分组成:对象头(Object Header)、实例数据(Instance Data)、对齐填充(Padding)。对象头又分为两部分:Mark Word 和 Klass Pointer(类型指针)。在 64 位 JVM 且开启指针压缩的默认配置下,Mark Word 占 8 字节,Klass Pointer 占 4 字节。
这 8 字节的 Mark Word,是整个锁机制的核心。它的存储内容会随着锁状态变化而动态复用,同一块内存,在不同时刻承载着完全不同的含义。下面这张表是把 Mark Word 的位布局摊开后的样子:
| 锁状态 | 64 位 Mark Word 存储内容(关键位) |
|---|---|
| 无锁 | 对象的 hashCode(25 位)、分代年龄(4 位)、偏向标志位(1 位,0)、锁标志位(2 位,01) |
| 偏向锁 | 持有偏向锁的线程 ID(54 位)、epoch(2 位)、分代年龄(4 位)、偏向标志位(1 位,1)、锁标志位(2 位,01) |
| 轻量级锁 | 指向栈中锁记录的指针(62 位)、锁标志位(2 位,00) |
| 重量级锁 | 指向 ObjectMonitor 的指针(62 位)、锁标志位(2 位,10) |
| GC 标记 | 空(用于 GC 标记)、锁标志位(2 位,11) |
这里有个细节值得注意:无锁状态下,Mark Word 存的是 hashCode,而且是调用 System.identityHashCode() 时生成并写入的。这意味着一个很隐蔽的结论——一旦对象调用了 identityHashCode,它就无法再进入偏向锁状态,因为偏向锁需要借用 Mark Word 里的位来存线程 ID。这是很多人踩过却没察觉的坑。
1.2 为什么锁状态能共用同一块内存
这背后的设计思路是典型的空间换时间。JVM 设计者把对象头压缩到极致,8 字节内既要有 hashCode 又要表达锁状态,只能通过位标志位来区分当前这 8 字节的“解释模式”。锁标志位(lock bits)从 01 到 00 到 10 到 11,本质上就是在切换“如何解读这 8 字节”的协议。
可以用一个生活化的类比:Mark Word 就像一块带标签的黑板。黑板还是那块黑板,但黑板左上角贴的标签不同,上面写的字含义就不同。标签写着“偏向锁”,黑板内容就是线程 ID;标签写着“轻量级锁”,黑板内容就成了指向栈帧的指针。
理解了这层复用关系,后续看锁升级,本质就是在追问:同一块 Mark Word,在什么条件下被改写成什么内容。所有的 CAS 操作、所有的状态流转,目标都是这 8 字节。
1.3 实操:用 JOL 亲眼看一下对象头
空谈理论不如自己验证。OpenJDK 提供的 jol(Java Object Layout)工具可以直接打印对象内存布局,我在分析锁问题时几乎每次都先用它确认当前 JVM 的配置和对象实际布局。引入依赖后,一个 main 方法就能看:
java复制public class ObjectLayoutDemo {
public static void main(String[] args) {
Object obj = new Object();
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
}
输出中会清晰显示 Mark Word 的 8 字节十六进制值。注意,在默认配置下你看到的偏向锁可能是延迟开启的——HotSpot 通过 -XX:BiasedLockingStartupDelay 参数默认延迟 4 秒才启动偏向锁,这是为了避免 JVM 启动阶段大量无意义的偏向锁撤销。想立刻看到偏向锁效果,可以启动时加 -XX:BiasedLockingStartupDelay=0。
我用 JOL 验证过一次印象很深:一个刚 new 出来的对象,打印出的 Mark Word 末尾两个 bit 就是 01(无锁),前面若干位是 0,说明 hashCode 还没被计算过。一旦调用 System.identityHashCode() 再打印,那几位的值就变了。这个实验强烈建议你自己跑一遍,比看十篇文章都管用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. synchronized 锁升级全链路:从偏向到重量级的每一步都发生了什么
synchronized 在 JDK 1.6 之后经历了大规模优化,核心思路就是“线程竞争越激烈,锁越重;竞争越弱,锁越轻”。整个过程是一条完整的升级链路:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。这条链路的关键在于,锁只能升级不能降级(但存在批量撤销机制),所以每一次升级都意味着系统意识到竞争加剧了。
2.1 偏向锁:假设只有一个线程用锁
偏向锁的逻辑是:既然大部分锁在大部分时间里只被一个线程持有,那就直接把这个线程的 ID 写进 Mark Word,下次这个线程再来,什么都不用做,直接进入临界区。
获取偏向锁的过程分两种情况:
- 对象处于可偏向但尚未偏向的状态(Mark Word 中偏向标志位为 1,线程 ID 为空):通过 CAS 把当前线程 ID 写入 Mark Word。成功,则当前线程获得偏向锁;失败,说明有其他线程竞争,进入撤销流程。
- 对象已偏向某个线程 T1,当前线程 T2 来竞争:T2 需要先检查 T1 是否存活。如果 T1 已退出临界区且不再需要锁,则尝试重新偏向;如果 T1 还在临界区活跃,则偏向锁升级为轻量级锁。
偏向锁的撤销是一个需要全局安全点(SafePoint)的操作。在安全点暂停所有线程后,JVM 判断持有偏向锁的线程是否还在执行临界区代码:如果已退出,把对象头恢复为无锁状态,再重新偏向新线程;如果还在执行,则升级为轻量级锁,然后唤醒线程继续跑。
这里有个实操中常遇到的坑:偏向锁是有延迟的。前面提到,JVM 在启动后默认 4 秒内偏向锁不生效。这个设计是因为应用启动初期存在大量并发竞争(类加载、线程创建阶段),此时偏向锁频繁撤销,性能反而不如轻量级锁。如果你的应用需要快速看到偏向锁效果做测试,记得加 -XX:BiasedLockingStartupDelay=0。
2.2 轻量级锁:用 CAS 抢一个锁记录
当第二个线程真正来竞争时,偏向锁会让位,锁升级为轻量级锁。轻量级锁的实现思路是:不依赖操作系统互斥量(mutex),而是在用户态通过 CAS 自旋来解决竞争。
获取流程拆解如下:
- 当前线程的栈帧中分配一块锁记录空间(Lock Record)。
- 用 CAS 尝试把对象头中的 Mark Word 替换为指向锁记录的指针,同时把旧的 Mark Word 存入锁记录中(这个旧值是 Displaced Mark Word)。
- CAS 成功,当前线程获得轻量级锁,对象头中的锁标志位变为 00。
- CAS 失败,说明存在竞争,JVM 先检查对象头中的锁记录指针是否指向当前线程的栈帧。如果是,说明当前线程已经持有锁,属于锁重入,直接进入临界区;如果不是,说明确实有别的线程持有锁,此时轻量级锁膨胀为重量级锁。
轻量级锁的解锁则是反向操作:把锁记录中的 Displaced Mark Word 通过 CAS 写回对象头。如果写回成功,说明整个过程没有竞争,锁顺利释放;如果写回失败,说明锁已经膨胀为重量级锁,还需要去唤醒被阻塞的线程。
这里就能看出轻量级锁的本质:它假设竞争很短暂,线程可以自旋等待。一旦自旋都等不到锁,只能升级,让出 CPU 进入阻塞等待。
2.3 重量级锁:ObjectMonitor 与线程阻塞队列
重量级锁的实现核心是每个对象关联的 ObjectMonitor 对象。ObjectMonitor 内部有几个关键数据结构:
- _owner:指向持有锁的线程
- _WaitSet:调用了
wait()的线程队列 - _EntryList:等待获取锁的线程队列
当一个线程尝试获取重量级锁时,如果 _owner 不为空,它会被放入 _EntryList,然后通过操作系统原语进入阻塞状态(park)。被阻塞的线程不消耗 CPU,但线程状态的切换涉及用户态和内核态的切换,开销大,这就是重量级锁被称为“重量级”的原因。
获取锁的线程执行完临界区后,会唤醒 _EntryList 里的线程,让它们重新参与竞争。注意这里不是严格的公平锁,ObjectMonitor 默认使用非公平策略,新来的线程可能直接抢到锁而不去队尾排队。
2.4 锁升级的参数调优边界
这几个 JVM 参数我在实际排查锁性能问题时经常调整:
| 参数 | 作用 | 使用场景 |
|---|---|---|
-XX:BiasedLockingStartupDelay |
设置偏向锁启动延迟(毫秒) | 压测环境设为 0 快速复现偏向锁问题 |
-XX:BiasedLockingDecayTime |
批量撤销偏向锁的延迟时间 | 调大可减少偏向锁撤销频率 |
-XX:UseSpinning |
启用自旋锁优化 | JDK 1.6 之后默认开启且自适应 |
-XX:PreBlockSpin |
自旋等待次数 | 仅在关闭自适应自旋时生效,通常不需要改 |
需要提醒的是,这些参数在生产环境慎调。HotSpot 的自适应自旋已经很智能,它会根据前一次在同一个锁上的自旋时间和锁拥有者的状态动态调整自旋次数,手动改参数很容易适得其反。
3. 看不见的优化:JIT 编译器对锁的自动处理
除了运行时锁状态的升级,JIT 编译器在编译阶段还会对锁做两类自动优化:锁消除和锁粗化。这两类优化解释了为什么你在个人电脑上写的看似有问题的加锁代码,运行起来性能却没那么糟糕。
3.1 锁消除:编译器把没必要的锁直接删了
锁消除发生在 JIT 编译阶段,核心依据是逃逸分析(Escape Analysis)。如果编译器判断一个对象的访问范围不会逃逸出当前线程或当前方法,那么这个对象上的锁就是无意义的——只被一个线程访问,锁毫无价值。JVM 会直接把这些锁操作从编译后的代码中抹掉。
典型的例子是 StringBuffer 和 Vector 这类内部方法加了同步的集合类。在单线程环境下,如果你在一个方法内部局部使用 StringBuffer,JIT 很可能会把 append 方法上的锁直接消除。代码如下:
java复制public static String concat(String a, String b, String c) {
return new StringBuilder(a).append(b).append(c).toString();
}
如果这里用的是 StringBuffer(所有 append 方法都加了 synchronized),且 JVM 判断这个 StringBuffer 对象没有逃逸出 concat 方法,锁消除就会生效——所有同步操作被移除,性能几乎等同于 String 直接拼接。
有个很容易忽略的点:锁消除依赖逃逸分析,而逃逸分析是 JIT 在 C2(服务端编译器)下才会做的高阶优化。如果你的应用跑在解释执行模式或 C1(客户端编译器)模式下,或者对象通过某种方式逃逸了(比如被返回、被静态引用捕获),锁消除就不会发生。判断某个锁是否被消除,可以用 -XX:+PrintEscapeAnalysis 输出逃逸分析结果。
3.2 锁粗化:把一连串细碎的小锁合并成一把大锁
锁粗化与锁消除方向相反。如果 JVM 检测到同一个对象上连续发生加锁、解锁,且中间几乎没有其他操作,它会把这些锁的边界扩大,用一个更大的锁临界区替代多个细小的临界区。
看这段伪代码:
java复制for (int i = 0; i < 100; i++) {
synchronized (lock) {
list.add(i);
}
}
这个循环在每次迭代中都执行一次加锁、解锁。JIT 在编译时可能将其优化为:
java复制synchronized (lock) {
for (int i = 0; i < 100; i++) {
list.add(i);
}
}
为什么要锁粗化?因为加锁和解锁本身就存在开销,尤其是涉及 CAS 和内存屏障时。一次性加锁 100 次,大部分开销都花在往返上了。合并成一次加锁,性能明显提升。
但对写代码的人而言,这带来了一个需要警惕的事:依赖锁边界来控制并发行为的代码,可能因为锁粗化而改变语义。比如你的本意是每循环一次释放锁,让其他线程有机会插入操作;但粗化后,其他线程在整个循环期间都被挡在外面。这种改动虽然从 JMM(Java 内存模型)规范看没有破坏正确性(临界区内外的 happens-before 关系依然成立),但在业务层面可能让“让出锁”的意图失效。所以,别依赖锁粗化,该自己控制锁粒度时自己控制。
3.3 自适应自旋:JIT 的“试探性等待”
自旋在轻量级锁阶段就参与了竞争,但自适应自旋是 HotSpot 引入的更智能版本。它的核心逻辑是:如果上一次在这个锁上自旋成功(即自旋期间等到了锁释放),JVM 倾向于增加自旋次数;如果自旋失败(等了好多次都没拿到,最后还得阻塞),JVM 会减少甚至取消自旋。
这里的“自旋”本质上就是一个循环不断尝试 CAS 获取锁。自旋的代价是消耗 CPU,但避免了线程阻塞和唤醒时的内核态切换。在实际并发场景中,如果临界区执行时间极短,自旋的收益非常大;如果临界区极长,自旋就是白白空转。
我曾在一次线上问题排查中发现,某个高频接口的线程大量处于 RUNNABLE 状态的 CPU 消耗异常高。用 async-profiler 抓火焰图后发现,CPU 时间大部分耗在 ObjectMonitor::TrySpin 中,说明锁竞争激烈,线程都在自旋空转。当时的调整思路是优化临界区代码(把耗时的远程调用移出锁块),让锁持有时间显著下降,自旋成功率和整体吞吐量随之改善。这比一味调大自旋次数有效得多,毕竟自旋时长和临界区节奏必须匹配。
4. JUC 里的 Lock 与 synchronized 的取舍:不只是“可中断”和“公平锁”的区别
面试常问 synchronized 和 ReentrantLock 的区别,很多人的答案停留在“synchronized 是自动的,ReentrantLock 需要手动解锁”“ReentrantLock 可中断、可公平、可有多个条件变量”。这些都对,但不够底层。我把两者的底层实现拆开,你就知道它们本质上是两种不同路线的锁。
4.1 AQS:JUC 锁的公共地基
ReentrantLock、CountDownLatch、Semaphore、ReentrantReadWriteLock 这些 JUC 组件,底层全部依赖 AbstractQueuedSynchronizer(AQS)。AQS 的核心是一个 volatile 的 state 变量和一个 CLH 变体队列。
state 的含义由子类定义:在 ReentrantLock 中,state 表示锁的重入次数(0 表示未持有,1 表示持有一次,2 表示持有两次,依次类推);在 Semaphore 中,state 表示剩余许可数;在 CountDownLatch 中,state 表示还需要等待的计数。
CLH 队列是一个 FIFO 双向链表,每个等待获取锁的线程都会被包装成一个 Node 节点挂到队尾。当线程获取锁失败时,AQS 会把它加入队列并阻塞;当持有锁的线程释放时,会唤醒队列头部的等待线程。
4.2 synchronized 与 AQS 锁的关键差异
从底层实现细看,两者的差异可以分为三层:
第一层是获取锁的方式。synchronized 在重量级锁阶段,依赖 ObjectMonitor 的 _EntryList 和操作系统 mutex;而 AQS 锁通过 CAS 修改 state + 自旋 + LockSupport.park/unpark 实现线程阻塞与唤醒。
第二层是中断响应。synchronized 在获取锁期间不响应中断(即 Thread.interrupt() 无法打断阻塞在 synchronized 上的线程),而 ReentrantLock.tryLock(timeout) 和 lockInterruptibly() 可以。
第三层是硬件层面的指令依赖。两者最终都依赖处理器的原子指令(如 x86 上的 CMPXCHG,对应 Java 层就是 CAS)来保证操作原子性,但 synchronized 在优化不到位时,退化为重量级锁后走的是内核态的 futex 或 pthread_mutex;而 AQS 在竞争不激烈时始终在用户态自旋。
这里给一个实际的选型建议:能用 synchronized 就用 synchronized。它自动释放锁、不会忘记 unlock、JIT 持续优化,且 JDK 团队一直在对它的运行时优化做投入。ReentrantLock 更适合需要超时、可中断、多条件队列、或者要显式控制公平性的场景。
4.3 ReadWriteLock 和 StampedLock:读写分离的两种路线
读写分离是锁优化的经典思路。ReentrantReadWriteLock 用内部两个锁(ReadLock 和 WriteLock)实现读读不互斥、读写互斥、写写互斥。它内部是共享一个 AQS 状态,用一个 16 位高位表示读锁计数、16 位低位表示写锁计数的方式,把两个锁的状态压缩进一个 state。写锁获取时,要求 state 为 0(无读锁也无写锁);读锁获取时,要求写锁未被持即可。
ReentrantReadWriteLock 有个经典问题:写锁饥饿。如果读线程源源不断进来,写锁可能会一直得不到执行。它默认使用非公平模式,可以在一定程度上缓解这个问题,但无法完全根除。
StampedLock 是 JDK 8 引入的改进版,核心思路是:读锁不再是一个阻塞锁,而是一个“戳记”(stamp)。读线程直接读共享数据,不做任何加锁动作,只是记录一个验证戳。写线程获取写锁时会修改版本戳,此时读线程再读到不一致的数据时,可以通过 validate(stamp) 发现,再决定是否重读或升级为悲观读锁。
StampedLock 的这种设计叫“乐观读”,在读多写少且数据一致性要求不是极端严格的场景下,性能远超 ReentrantReadWriteLock。但要注意:StampedLock 不可重入、且不支持条件变量,使用时需要格外小心。更要命的是,StampedLock 在调用 interrupt() 时能导致 CPU 占用飙升(CPU 狂转),这是一个在 JDK 8 里著名的坑,导致它在很多团队里被列进“禁用名单”。
4.4 实际项目中的锁选型判断框架
我在项目里选锁时,会按以下顺序问自己几个问题:
- 这个锁保护的数据是否只在一个 JVM 内访问? 如果是,继续;如果多实例共享,直接考虑分布式锁。
- 竞争频率高不高? 竞争低,synchronized 足够;竞争高,看临界区耗时。
- 临界区耗时是长是短? 短(微秒级),自旋锁的性价比高;长(毫秒级以上),可考虑读写分离或显式锁 + 超时控制。
- 对等待时间是否敏感? 需要超时退出、可中断等待,选 Lock API。
- 读多写少还是写多读少? 读多写少可考虑读写锁;若追求极致读性能且代码规范扎实,StampedLock 乐观读也值得尝试。
5. JVM 锁的边界与未来:从单机并发到分布式协同
JVM 里的锁无论怎么优化,都有一个无法回避的前提:它的作用域限定在一个 JVM 进程内。一旦应用从单实例部署变成多实例集群,synchronized 和 ReentrantLock 只能锁住当前机器的线程,无法阻止其他机器上的线程并发访问同一份共享资源。这也是分布式锁存在的根本原因。
5.1 JVM 锁与分布式锁的天然断层
单机场景下,所有线程共享同一个堆内存,锁保护的共享数据在同一个内存空间,死锁检测、锁状态检查都是进程内操作,延迟极低。但在分布式场景下,没有共享内存,所有并发控制只能依赖第三方协调者,比如 Redis、ZooKeeper、etcd。
这个断层带来几个直接后果:
- 锁的粒度不同。JVM 内锁粒度可以细到对象级别;分布式锁通常要到业务维度,比如订单 ID、用户 ID。
- 锁的可靠性来源不同。JVM 锁依赖 CPU 原子指令;分布式锁依赖网络和协调服务的可用性,可能面临超时、脑裂、客户端失败等问题。
- 锁的自动释放机制不同。JVM 锁在异常时由 JVM 保证释放;分布式锁必须依赖过期时间或 watch 机制,否则客户端崩溃会导致死锁。
所以,当你看到一张表中 JVM 锁和分布式锁被并列比较,实际上它们解决的不是同一个问题。JVM 锁解决的是线程竞争共享内存的问题;分布式锁解决的是多个进程竞争外部资源的问题。
5.2 从 JVM 锁平滑思维:分布式锁的常见实现
当前主流的分布式锁实现中,Redis 分布式锁(SET NX + 过期时间 + Lua 脚本释放)使用最广,因为它实现简单、性能好。但必须在代码里处理几个关键细节:
- 加锁和设置过期时间必须原子。用一条 SET 命令同时完成
SET key value NX EX seconds,不要先 SETNX 再 EXPIRE,否则中间崩溃会导致锁永不过期。 - 释放锁必须校验持有者。用 Lua 脚本先 GET 比较 value 是否是自己的唯一标识(通常是 UUID),再 DEL。否则可能出现误删别人的锁。
- 过期时间必须大于临界区执行时间。否则锁提前释放,临界区还在执行,另一个线程已经拿到锁进来,并发问题就出现了。更稳妥的做法是加“看门狗”自动续期。
ZooKeeper 分布式锁利用临时顺序节点,通过创建顺序节点 + 监听前一个节点实现公平锁,可靠性比 Redis 高,因为临时节点在客户端会话断开时自动删除,不会出现锁永久占用的问题。代价是性能相对较低。
etcd 分布式锁在中间状态,性能和可靠性都在 Redis 与 ZooKeeper 之间,适合对一致性和性能都有要求的场景。
5.3 锁的未来:JVM 锁在并发领域为什么依然不可替代
即使分布式锁满天飞,JVM 内部的锁机制仍然是所有 Java 并发应用的基石。分布式锁保护的是跨进程的外部资源,但每个进程内部对资源的串行化访问、对本地缓存的一致性保护、对线程池任务状态的同步,全部依赖 JVM 锁。
而且 Java 对锁的优化从未停止。Valhalla 项目带来值对象,Panama 项目带来更高效的外部内存访问,Loom 项目引入虚拟线程后,对锁的实现也在调整——虚拟线程阻塞时的成本远低于平台线程,未来重量级锁的代价可能会大幅下降。
回到项目实践角度,我对“JVM 之锁”的理解有一条主线:锁的本质是让并发访问串行化,而 JVM 锁体系一直在做的事情,是在“串行化的代价”和“并发安全的保障”之间寻找最优解。偏向锁假设无竞争,轻量级锁假设竞争短暂,重量级锁面对真实竞争,JIT 用锁消除和锁粗化让代码更聪明,JUC 用 AQS 提供更精细的控制,最终所有这些组合在一起,构成了 Java 并发世界的规则框架。
在具体项目里,我不会追求“用什么锁最高级”,而是先分析清楚并发模型和锁竞争的特征,再选择最匹配的实现。有一次我们的服务出现严重性能劣化,用 jstack 抓线程转储发现大量线程阻塞在同一个 synchronized 方法上,而那个方法内部只是对一个 HashMap 做 get 操作。问题不在于锁本身,而在于用了一把全局锁保护一个本该用 ConcurrentHashMap 解决的读多写少场景。换上 ConcurrentHashMap 后,CPU 下降 40%,接口延迟从 80ms 降到 10ms,问题瞬间消失。这比研究任何锁升级参数都有效——最好的锁优化,往往是让锁变得不必要。
