7.3 锁升级:偏向、轻量级与重量级


7.3 锁升级:偏向、轻量级与重量级

本节摘要:同一把 synchronized 锁在不同竞争程度下有三种形态:偏向锁对"只来一个人"的场景几乎零成本,轻量级锁用 CAS 自旋应付"错峰竞争",重量级锁动用操作系统互斥量接住"正面硬碰"。本节拆解对象头标记字里的升级状态机,用 JOL 工具亲眼看锁形态变化,并解释偏向锁为何在新版本被默认禁用——理解竞争度与锁形态的对应关系,6.3 病案三的修复决策就有了机制依据。

上一节说 synchronized 的代价"与竞争程度挂钩",本节把这句话拆成完整的机制。设计动机先立住:绝大多数锁在绝大多数时间根本没有竞争——方法里顺手同步一下,实际从头到尾只有一根线程在进出。为这种场景付操作系统级的锁成本显然荒唐,于是 HotSpot 给锁设计了"按需变重"的路径:没竞争时近乎免费,竞争来了才逐级加码。

状态机:三级形态与不可逆升级

锁形态记录在对象头的标记字里,升级路径是一条状态机:

偏向锁:第一根线程获取后,对象头记下线程号。此后同一线程再进出,只需比对线程号,无 CAS、无屏障,成本趋近于零。它是"给几乎无竞争的同步兜底"的设计——大量历史代码习惯性加 synchronized,偏向锁让这种习惯在无竞争时免于付费。

轻量级锁:第二根线程到来,偏向撤销,升级为轻量级。获取方在栈上划出一块锁记录,用 CAS 尝试把对象头指向自己;尝试失败不立刻挂起,而是自旋几轮(适应性的,前几轮成功率高就多转几轮)——赌的是持有者马上就放。适用场景是"交替使用、临界区极短"的锁:等待成本小于挂起与唤醒成本。

重量级锁:自旋也失败(或竞争者太多),升级为重量级:关联操作系统互斥量,失败的线程真正挂起进等待队列,持有者释放时再唤醒。挂起与唤醒是系统调用加上下文切换,成本最高,但不再空烧 CPU——在高竞争长临界区下,它反而是正解。

关键性质是升级不可逆:偏向降不了级,重量级回不到轻量级。"竞争度决定形态"因此是关于历史的描述——一把锁一旦经历过激烈竞争,就算后来冷清了也保持重量级形态。诊断锁问题的隐藏推论:系统冷启动后前几分钟的锁行为与稳定期不同,6.3 的"连抓几份 jstack"要跨过这个窗口。

亲眼看升级:JOL 实验手记

用 OpenJDK 的 JOL(Java Object Layout)工具观察对象头变化。实验设计:同一把锁,先单线程进入,再起竞争,打印对象头:

import org.openjdk.jol.info.ClassLayout; import java.util.concurrent.*; public class LockUpgrade { static final Object LOCK = new Object(); public static void main(String[] args) throws Exception { System.out.println("=== before any sync ==="); System.out.println(ClassLayout.parseInstance(LOCK).toPrintable()); synchronized (LOCK) { } // 线程一偏向 System.out.println("=== after biased by main ==="); System.out.println(ClassLayout.parseInstance(LOCK).toPrintable()); ExecutorService pool = Executors.newFixedThreadPool(4); for (int i = 0; i < 4; i++) { pool.submit(() -> { for (int j = 0; j < 100_000; j++) { synchronized (LOCK) { } // 多线程竞争 } }); } pool.shutdown(); pool.awaitTermination(1, TimeUnit.MINUTES); System.out.println("=== after contention ==="); System.out.println(ClassLayout.parseInstance(LOCK).toPrintable()); } }
$ java -XX:+BiasedLockingStartupDelay=0 LockUpgrade === before any sync === java.lang.Object object internals: OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: Mark) 0x0000000000000001 (non-biasable; weightable) === after biased by main === OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: Mark) 0x00007f...5 (biased: main) === after contention === OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: Mark) 0x00007f...0a (inflated monitor)

三段输出正好对应状态机的三站:无锁(weightable)、偏向(biased 标记主线程)、膨胀(inflated monitor,重量级)。BiasedLockingStartupDelay 设零是为了跳过 JVM 启动后默认的偏向延迟。实验里还能验证"不可逆":竞争平息后再打印,对象头不会退回 biased。

偏向锁的退场:一个值得读懂的设计决策

JDK 15 起偏向锁默认禁用(JDK 18 移除),理由写在官方提案里,值得每个性能工程师读一遍:偏向锁的收益建立在"无竞争"假设上,但现代应用里,涉及同步的热点路径往往多线程交错;而偏向的维护成本(撤销需要 safepoint 停顿)在真实负载里越来越收不回本。撤销偏向要在全局安全点里做簿记——高频撤销的服务反而被它拖累。这是"默认值跟着负载结构演变"的活案例:不是偏向锁设计错了,是它的适用负载在当代软件里变少了。

对应到工程实践:还在维护 JDK 8 老服务时,-XX:-UseBiasedLocking 是一个值得做对照实验的开关(某些高撤销场景关闭后吞吐反而上升);新项目则无需关心此参数,把精力放在锁粒度设计上。

从升级路径反推锁优化顺序

机制看清后,6.3 病案三的"缩临界区优先"就有了完整解释链:临界区短,自旋就能等到锁,锁停在轻量级形态,不发生昂贵的挂起唤醒;临界区长,任何形态都要面对排队,轻量级自旋只会白烧 CPU。工程决策的优先级因此是:能不用锁就不用(不可变对象、线程封闭)、用并发结构代替显式锁、必须用时缩临界区并控制竞争度——至于"换更细的锁对象"与"读写分离",都排在这些之后。

⚠️ 常见坑:迷信自旋参数。-XX:PreBlockSpin 一类的自旋调整是定向手术,只对"临界区极短且竞争者两三个"的特定形态有效;现代 HotSpot 的适应性自旋已经自我调节,手动干预多数场景是负优化。先看 jstack 里的竞争形态,再谈参数。

💡 关键直觉:锁的形态史就是"对无竞争场景逐步免责"的历史。评审同步代码时问一句:这把锁的预期竞争画像是什么样?只答得出"多线程会访问",说明还没想清楚——是交替、是并发、还是单向,对应的最优形态完全不同。

要点回顾

  • 三级形态:偏向(单线程零成本)、轻量级(CAS 加自旋,交替竞争)、重量级(系统互斥,正面竞争);
  • 升级不可逆:竞争历史决定锁形态,冷启动窗口的锁行为与稳定期不同;
  • 对象头标记字是舞台:JOL 可直接观测 biased 与 inflated 状态,机制验证不必靠猜;
  • 偏向锁已默认退场:撤销成本在 safepoint 内结算,现代负载收益难抵成本——默认值跟着负载结构走;
  • 优化顺序:免锁、并发结构、缩临界区优先,细粒度与读写分离靠后,自旋参数最后;
  • 与 6.3 的呼应:BLOCKED 聚集意味着锁已膨胀,修复的杠杆点在让锁"不值得变重"。

锁的机制讲完,还剩最后一课:把"一根线程一兆栈、挂起唤醒走内核"的整套装进成本账本重算一遍——虚拟线程凭什么让百万并发成为常态,以及它改变不了什么。下一节既是本章收官,也是全册终点。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U