1. 从对象头说起:锁状态的底层表示
在HotSpot虚拟机中,每个对象都有一个对象头(Object Header),其中存储了与锁相关的标记信息。对象头通常包含两部分:
- Mark Word:存储对象自身的运行时数据(如哈希码、GC分代年龄等)
- Klass Pointer:指向对象类型数据的指针
当对象作为同步锁使用时,Mark Word会根据锁状态变化而改变其存储内容。32位JVM的Mark Word在不同锁状态下的结构如下:
| 锁状态 | 25bit | 4bit | 1bit(偏向锁位) | 2bit(锁标志位) |
|---|---|---|---|---|
| 无锁 | 对象的hashCode | 对象分代年龄 | 0 | 01 |
| 偏向锁 | 线程ID + Epoch | 对象分代年龄 | 1 | 01 |
| 轻量级锁 | 指向栈中锁记录的指针 | - | - | 00 |
| 重量级锁 | 指向互斥量的指针 | - | - | 10 |
| GC标记 | - | - | - | 11 |
注意:64位JVM的Mark Word结构会有所不同,但基本设计思路相同,只是位数分配有所调整
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 锁升级的全过程解析
2.1 初始状态:无锁
当对象刚被创建时,处于无锁状态。此时:
- 任何线程都可以直接访问该对象
- 对象头中的锁标志位为01
- 偏向锁标志位为0(未启用偏向锁)
2.2 第一次加锁:偏向锁的启用
当第一个线程访问同步块时:
- JVM检查对象头中的偏向锁标志位
- 如果为0(未启用),则通过CAS操作尝试将Mark Word中的线程ID替换为当前线程ID
- 如果成功,则进入偏向模式,将偏向锁标志位置为1
偏向锁的核心思想是:假设大多数情况下锁总是由同一线程多次获得。通过避免重复的同步操作来提升性能。
2.3 竞争出现:升级为轻量级锁
当第二个线程尝试获取锁时:
- JVM发现对象已处于偏向模式
- 检查Mark Word中的线程ID是否与当前线程相同
- 如果不相同,则撤销偏向锁(通过CAS将Mark Word恢复为无锁状态)
- 两个线程通过CAS操作竞争将对象头中的指针替换为指向各自栈中锁记录的指针
- 成功的线程获得轻量级锁,失败的线程则自旋等待
轻量级锁的实现依赖于:
- 栈帧中的锁记录(Lock Record)空间
- CPU的CAS指令支持
- 适度的自旋策略
2.4 竞争加剧:膨胀为重量级锁
当自旋超过一定阈值(默认10次)或等待线程数超过CPU核心数的一半时:
- JVM将锁膨胀为重量级锁
- 在堆中创建ObjectMonitor对象(管程)
- 将Mark Word指向ObjectMonitor
- 未获取锁的线程进入阻塞状态,放入等待队列
重量级锁的特点:
- 依赖操作系统的互斥量(mutex)实现
- 线程阻塞和唤醒需要从用户态切换到内核态
- 适用于高竞争场景,但性能开销较大
3. 锁降级的特殊情况
虽然锁升级是主要方向,但在某些特殊情况下也会发生锁降级:
- 当持有重量级锁的线程释放锁时
- 如果此时没有其他线程等待获取该锁
- JVM可能会将锁降级为轻量级锁或无锁状态
不过在实际应用中,锁降级的情况较为少见,JVM实现中对此的处理也比较保守。
4. 性能对比与优化建议
4.1 各锁状态的性能特点
| 锁类型 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 偏向锁 | 单线程重复访问 | 几乎无同步开销 | 撤销时有额外开销 |
| 轻量级锁 | 低竞争、短时间同步 | 避免线程阻塞 | 自旋消耗CPU |
| 重量级锁 | 高竞争、长时间同步 | 减少CPU空转 | 线程切换开销大 |
4.2 实际调优经验
-
偏向锁延迟优化:
- 默认情况下JVM在启动后4秒才启用偏向锁(-XX:BiasedLockingStartupDelay=4000)
- 对于明确知道会有大量同步操作的应用,可以设置为0立即启用
-
自旋锁优化:
- 自适应自旋(-XX:+UseSpinning):JVM根据历史数据动态调整自旋次数
- 最大自旋次数(-XX:PreBlockSpin=10):默认10次
-
锁消除优化:
- 逃逸分析(-XX:+DoEscapeAnalysis)可以检测出不可能存在共享数据竞争的锁
- 同步消除(-XX:+EliminateLocks)会移除这些不必要的同步操作
-
锁粗化优化:
- 当检测到一连串连续对同一对象加锁解锁操作时
- JVM会将锁范围扩展到整个操作序列外部
5. 常见问题排查
5.1 锁竞争导致的性能问题
症状:
- CPU使用率高但吞吐量低
- 大量线程处于BLOCKED状态
- 在jstack日志中看到大量线程等待同一锁
排查方法:
- 使用jstack获取线程dump
- 查找"waiting to lock <0x000000076bf62200>"类似信息
- 结合业务代码分析锁竞争点
5.2 偏向锁撤销风暴
症状:
- 大量RevokeBiasd操作
- 同步操作性能突然下降
解决方案:
- 对于明确存在多线程竞争的场景,禁用偏向锁:
- -XX:-UseBiasedLocking
- 或者调整偏向锁撤销阈值
5.3 死锁检测
虽然锁升级机制本身不会导致死锁,但错误的同步设计可能引发死锁。可以使用以下工具检测:
- jstack的deadlock检测功能
- JConsole的线程检测面板
- VisualVM的线程分析功能
6. 从字节码看同步实现
通过javap查看同步代码的字节码,可以看到:
- monitorenter指令:获取对象监视器
- monitorexit指令:释放对象监视器
例如以下代码:
java复制public void syncMethod() {
synchronized(this) {
// 同步代码块
}
}
对应的字节码:
code复制public void syncMethod();
Code:
0: aload_0
1: dup
2: astore_1
3: monitorenter // 进入同步块
4: aload_1
5: monitorexit // 正常退出同步块
6: goto 14
9: astore_2
10: aload_1
11: monitorexit // 异常退出同步块
12: aload_2
13: athrow
14: return
可以看到JVM为同步块生成了完整的异常处理机制,确保锁一定能被释放。
7. 不同JVM实现的差异
虽然锁升级的基本思想相同,但不同JVM实现可能有差异:
-
HotSpot:
- 最成熟的锁升级实现
- 支持所有锁状态转换
- 提供丰富的调优参数
-
OpenJ9:
- 偏向锁实现有所不同
- 更注重低延迟而非高吞吐
- 锁膨胀策略更为保守
-
GraalVM:
- 支持在编译时优化锁操作
- 对于已知单线程路径可以完全消除同步
- 对逃逸分析的支持更强
在实际应用中,应当根据具体JVM实现调整同步策略和调优参数。
