synchronized 与锁升级
synchronized 与锁升级
synchronized 是 Java 最基础的同步原语。JDK 6 之前它是彻头彻尾的重量级锁,直接依赖操作系统互斥量,线程阻塞要陷入内核态,代价高昂。JDK 6 引入了偏向锁、轻量级锁和自适应自旋,让它在无竞争和低竞争场景下的开销大幅下降。本文从对象头结构讲起,把锁升级的每一步落到具体字段和源码上。
一、锁存在哪里:对象头 Mark Word
每个 Java 对象在堆中都有对象头,其中 Mark Word 记录了哈希码、GC 分代年龄和锁状态。64 位 JVM 下占 8 字节,结构随锁状态变化:
64 位 Mark Word(未开启压缩类指针时的布局示意)
无锁 | unused:25 | hashCode:31 | unused:1 | age:4 | biased_lock:1(0) | lock:2(01) |
偏向锁 | ThreadID:54 | Epoch:2 | unused:1 | age:4 | biased_lock:1(1) | lock:2(01) |
轻量级锁 | 指向栈中 Lock Record 的指针:62 | lock:2(00) |
重量级锁 | 指向 Monitor 对象的指针:62 | lock:2(10) |
GC 标记 | 空 | lock:2(11) |
最后两位 lock 是状态标识:
| lock 值 | 状态 |
|---|---|
| 01 | 无锁或偏向锁,由 biased_lock 位区分 |
| 00 | 轻量级锁 |
| 10 | 重量级锁 |
| 11 | GC 标记 |
锁状态信息不放在对象外部,而是直接内嵌在对象头里,这是 synchronized 比 ReentrantLock 更省内存的原因之一。
二、Monitor:重量级锁的实体
重量级锁对应 HotSpot 的 ObjectMonitor,定义在 C++ 源码 objectMonitor.hpp 中,核心字段包括:
ObjectMonitor
├── _owner // 持有锁的线程
├── _recursions // 重入次数
├── _EntryList // 等待锁的线程队列(阻塞)
├── _WaitSet // 调用 wait() 后进入的等待队列
├── _cxq // 竞争队列,新来的线程先在这里自旋
└── _count // 等待线程数估计
线程获取重量级锁失败时,先进入 _cxq 短暂自旋,仍失败则挂起(park),等待 _owner 释放后由 ObjectMonitor::exit 唤醒。挂起和唤醒都涉及内核态切换,这是重量级锁慢的根本原因。
字节码层面,synchronized 代码块编译为 monitorenter 和 monitorexit:
monitorenter
... 同步代码 ...
monitorexit ← 正常路径
...
monitorexit ← 异常路径,保证释放
方法上的 synchronized 不生成这两个指令,而是在方法访问标志里加 ACC_SYNCHRONIZED,由 JVM 在方法调用时隐式加锁。
三、锁升级的完整路径
┌─────────────┐
│ 无锁 │
└──────┬──────┘
│ 第一个线程进入
┌──────▼──────┐
│ 偏向锁 │ Mark Word 记录 ThreadID,后续同一线程进入无需 CAS
└──────┬──────┘
│ 另一个线程尝试获取
┌──────▼──────┐
│ 轻量级锁 │ 在栈帧建 Lock Record,CAS 替换 Mark Word
└──────┬──────┘
│ CAS 自旋失败 / 自旋超限
┌──────▼──────┐
│ 重量级锁 │ 膨胀为 ObjectMonitor,竞争线程阻塞
└─────────────┘
偏向锁:对象第一次被某线程加锁时,JVM 用 CAS 把该线程 ID 写入 Mark Word,并把 biased_lock 置 1。之后同一线程再进入,只需比较 ThreadID 是否相等,连 CAS 都不用。适用于"锁总是由同一个线程获取"的场景,比如单线程循环调用同步方法。
轻量级锁:当第二个线程来竞争,偏向锁被撤销(需要 STW 到安全点),升级为轻量级锁。线程在自己的栈帧中分配 Lock Record,用 CAS 把 Mark Word 替换为指向该记录的指针。成功则获得锁,失败则自旋等待。
重量级锁:自旋超过阈值(自适应自旋,由前一次在该锁上的成功率动态决定)仍未拿到锁,膨胀为 ObjectMonitor,竞争线程进入阻塞。
注意:JDK 15 起偏向锁默认关闭(JEP 374),JDK 18 起相关代码被移除。JDK 17 上仍可通过
-XX:+UseBiasedLocking启用,但官方已不推荐。理解偏向锁主要是为了读懂老版本行为和面试题,新项目不必依赖它。
四、锁消除与锁粗化
JIT 还会做两项优化,它们发生在编译期,与锁升级是两条独立的路径。
锁消除(Lock Elision):逃逸分析发现锁对象不会逃逸出方法,JIT 直接去掉同步。
public String concat(String a, String b) {
StringBuffer sb = new StringBuffer(); // StringBuffer 方法带 synchronized
sb.append(a).append(b); // 对象不逃逸,锁被消除
return sb.toString();
}
需要开启逃逸分析(JDK 8 起默认开启)。这也是"StringBuffer 一定比 StringBuilder 慢"不完全成立的原因,但依赖 JIT 判断,不应作为编码依据。
锁粗化(Lock Coarsening):连续多次对同一对象加解锁,JIT 会把锁的范围扩大到整个操作序列。
for (int i = 0; i < 1000; i++) {
synchronized (lock) { // 可能被粗化为循环外一次加锁
count++;
}
}
这两项优化都依赖 JIT 的运行时判断,不能作为代码正确性的依据。锁消除的前提是对象确实不逃逸,如果 StringBuffer 被返回或存入字段,锁就不会被消除;锁粗化的前提是操作确实相邻,中间插入方法调用或分支后就未必生效。写代码时仍应按"减少临界区"的原则来,把优化交给 JIT。
五、锁升级的触发条件与代价
升级过程不是无条件发生的,每一步都有明确的触发点和成本:
| 阶段 | 触发条件 | 主要开销 |
|---|---|---|
| 无锁 → 偏向锁 | 首次 monitorenter,且对象可偏向 |
一次 CAS 写 ThreadID |
| 偏向锁 → 轻量级锁 | 另一个线程尝试获取 | 撤销偏向需在安全点 STW |
| 轻量级 → 重量级 | CAS 自旋失败或自旋超限 | 创建 ObjectMonitor,线程挂起 |
| 重量级 → 降级 | 不发生 | 锁升级不可逆 |
偏向锁的撤销代价常被低估:JVM 需要让持有偏向锁的线程到达安全点,检查其栈帧是否还在同步块中,再决定是恢复无锁还是升级为轻量级锁。如果应用中大量对象存在"先由一个线程加锁、随后被另一个线程竞争"的模式,偏向锁反而会带来额外的 STW 开销,这也是 JEP 374 废弃它的原因之一。
轻量级锁的自旋次数由自适应策略决定:如果上一次在这个锁上自旋成功,这次就多转几圈;如果总是失败,就很快放弃并膨胀。这种基于历史表现的策略比固定自旋次数更适应真实负载。
六、synchronized 与 ReentrantLock 对比
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 实现层次 | JVM 原生,字节码指令 | JDK 层,基于 AQS |
| 锁释放 | 自动,异常也会释放 | 必须 finally 中 unlock() |
| 可中断 | 不支持 | lockInterruptibly() |
| 超时 | 不支持 | tryLock(timeout, unit) |
| 公平锁 | 非公平 | 可选公平/非公平 |
| 条件变量 | 一个 wait/notify 集合 |
多个 Condition |
| 性能 | 低竞争下更优,JVM 持续优化 | 高竞争、需要高级功能时更灵活 |
选择原则:能用 synchronized 就用,需要超时、可中断、多条件队列时才用 ReentrantLock。ConcurrentHashMap 在 JDK 8 中选择 synchronized 锁桶头节点,正是因为低竞争下它足够快且省内存。
七、并发场景分析
死锁是最典型的问题。两个线程以相反顺序获取两把锁:
// 线程 1
synchronized (a) {
synchronized (b) { /* ... */ }
}
// 线程 2
synchronized (b) {
synchronized (a) { /* ... */ }
}
用 jstack 可以看到 “Found one Java-level deadlock”,并列出各自持有的锁和等待的锁。预防手段是统一加锁顺序,或者用 ReentrantLock.tryLock 带超时。
锁对象不能变。下面写法等于没加锁:
private Integer lock = 0;
public void update() {
synchronized (lock) {
lock = 1; // 锁对象被替换,后续线程锁的是新对象
}
}
锁对象必须是 final 的专用实例,推荐 private final Object lock = new Object();。
不要锁字符串常量或包装类。"abc" 在常量池中全局唯一,Integer.valueOf(1) 会命中缓存,锁它们等于锁了全局,极易与其他代码互相阻塞。
同步方法锁的是 this,静态同步方法锁的是 Class 对象。如果同一个类里既有 synchronized 实例方法又有 synchronized 静态方法,它们锁的不是同一个对象,互不阻塞,容易误判。
八、常见坑 / 最佳实践
- 缩小同步范围。只包裹真正需要互斥的临界区,不要把 IO、远程调用、日志等耗时操作放进同步块。
- 锁对象用
private final专用实例,不要锁this、字符串常量、包装类对象。 - 避免嵌套锁。必须嵌套时统一全局加锁顺序,并优先用
tryLock带超时。 - 不要在同步块中调用外部回调。回调可能反向获取锁,形成死锁;
ConcurrentHashMap.computeIfAbsent的映射函数就是典型例子。 - 优先用并发容器和原子类。
AtomicInteger、ConcurrentHashMap比手写synchronized更细粒度,也更不容易出错。 - JDK 15+ 不要依赖偏向锁。默认已关闭,针对偏向锁的性能假设需要重新评估。
九、小结
- 锁状态记录在对象头 Mark Word 中,通过
lock位区分无锁、偏向、轻量级和重量级。 - 偏向锁靠 ThreadID 比较避免 CAS,轻量级锁靠栈帧
Lock Record加 CAS 自旋,重量级锁膨胀为ObjectMonitor并阻塞线程。 - 锁升级不可逆,一旦膨胀为重量级锁就不会降级。
- JIT 还会做锁消除和锁粗化,这两项与锁升级是独立优化。
synchronized自动释放、低竞争性能好;需要超时、可中断、多条件时用ReentrantLock。- 死锁靠统一加锁顺序和
tryLock超时预防,锁对象必须是final专用实例。
- 点赞
- 收藏
- 关注作者
评论(0)