synchronized 与锁升级

举报
yd_94907100 发表于 2026/10/23 23:17:25 2026/10/23
【摘要】 synchronized 与锁升级synchronized 是 Java 最基础的同步原语。JDK 6 之前它是彻头彻尾的重量级锁,直接依赖操作系统互斥量,线程阻塞要陷入内核态,代价高昂。JDK 6 引入了偏向锁、轻量级锁和自适应自旋,让它在无竞争和低竞争场景下的开销大幅下降。本文从对象头结构讲起,把锁升级的每一步落到具体字段和源码上。 一、锁存在哪里:对象头 Mark Word每个 Ja...

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 静态方法,它们锁的不是同一个对象,互不阻塞,容易误判。

八、常见坑 / 最佳实践

  1. 缩小同步范围。只包裹真正需要互斥的临界区,不要把 IO、远程调用、日志等耗时操作放进同步块。
  2. 锁对象用 private final 专用实例,不要锁 this、字符串常量、包装类对象。
  3. 避免嵌套锁。必须嵌套时统一全局加锁顺序,并优先用 tryLock 带超时。
  4. 不要在同步块中调用外部回调。回调可能反向获取锁,形成死锁;ConcurrentHashMap.computeIfAbsent 的映射函数就是典型例子。
  5. 优先用并发容器和原子类。AtomicInteger、ConcurrentHashMap 比手写 synchronized 更细粒度,也更不容易出错。
  6. JDK 15+ 不要依赖偏向锁。默认已关闭,针对偏向锁的性能假设需要重新评估。

九、小结

  • 锁状态记录在对象头 Mark Word 中,通过 lock 位区分无锁、偏向、轻量级和重量级。
  • 偏向锁靠 ThreadID 比较避免 CAS,轻量级锁靠栈帧 Lock Record 加 CAS 自旋,重量级锁膨胀为 ObjectMonitor 并阻塞线程。
  • 锁升级不可逆,一旦膨胀为重量级锁就不会降级。
  • JIT 还会做锁消除和锁粗化,这两项与锁升级是独立优化。
  • synchronized 自动释放、低竞争性能好;需要超时、可中断、多条件时用 ReentrantLock。
  • 死锁靠统一加锁顺序和 tryLock 超时预防,锁对象必须是 final 专用实例。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。