lightweight lock explained
⚙️ 轻量级锁(Lightweight Lock)——底层原理详解
轻量级锁是 HotSpot 为了在低竞争场景下把同步开销降到最小而设计的机制。它的核心思想是:尽量在用户态(线程栈/寄存器/CPU)完成加锁与解锁操作,避免进入内核态的阻塞/唤醒,从而提高并发性能。
以下把轻量级锁的机制分成细粒度步骤来讲,尽量贴近 HotSpot 的实现细节:
1️⃣ 相关概念与结构
- 对象头(Object Header / Mark Word):对象头包含锁标志位、hashCode、年龄等信息。不同状态下,Mark Word 的含义不同(指向线程 ID / 指向 Lock Record / 指向 Monitor)。
- Lock Record(锁记录):线程在栈帧中为每次尝试加锁分配的结构,保存原始的 Mark Word(displaced header)及其他辅助信息。
- CAS(Compare-And-Swap):用于将对象头原子地替换为指向栈帧中 Lock Record 的指针。
- 自旋(Spin):当 CAS 失败,线程短期内循环重试,以期在不阻塞的情况下成功获取锁。
- 膨胀(Inflation):当轻量级竞争无法解决时,对象的锁状态会膨胀为重量级锁(Monitor),改用操作系统级阻塞机制管理。
2️⃣ 获取轻量级锁的逐步流程(加锁时)
假设线程 T 要执行 synchronized(obj):
读取并检查 Mark Word
- JVM 读取对象头的 Mark Word,通过最后两位标志位(lock bits)来判断锁状态:
01表示无锁状态(Unlocked),00表示轻量级锁(Lightweight Locked),10表示重量级锁(Heavyweight Locked),11则用于偏向锁(Biased Locking)。 - 若对象处于偏向锁且偏向于当前线程,直接获得锁(无 CAS);若偏向于其他线程,需要撤销偏向,可能导致轻量级或重量级路径。
- JVM 读取对象头的 Mark Word,通过最后两位标志位(lock bits)来判断锁状态:
在栈上创建 Lock Record 并拷贝 Mark Word
- 线程在自己的栈帧内分配一个 Lock Record(也称为 Lock Record 或 Displaced Header 存储区),把当前对象头(原始 Mark Word)复制进去。这个 Lock Record 存有原来 Mark Word 的值,用于解锁时恢复。
尝试用 CAS 将对象头替换为指向 Lock Record 的指针
- 目标是把对象头的值原子地从旧 Mark Word 替换为一个指向当前线程栈上 Lock Record 的指针(并且把锁标志位设置为轻量级标志)。
- 这个 CAS 操作是关键:它保证了替换操作的原子性。如果 CAS 成功,说明当前没有其他线程在同一时刻把对象头改走,当前线程就获得了轻量级锁。
CAS 成功的语义
- 对象头现在指向 Lock Record,Lock Record 中保存了原始 Mark Word(displaced header)。
- 线程可以进入临界区执行同步代码,且无需进入内核或阻塞。
3️⃣ CAS 失败后的处理(竞争出现)
当另一个线程也尝试加锁时,可能发生 CAS 失败,常见流程:
检测到竞争:CAS 失败
- CAS 失败意味着另外的线程已经把对象头替换掉(或在同时操作),这是轻度竞争的信号。
自旋(短时间重试)
- JVM 会让失败的线程进行一段时间的自旋(根据 JVM 及 CPU 架构,这个自旋次数是有阈值和策略的),通过循环重试 CAS 来等待持锁线程尽快释放锁。
- 如果持锁线程很快释放(例如临界区很短),自旋线程能在用户态获取锁,避免阻塞开销。
自旋失败或检测到多线程激烈竞争
- 如果自旋若干次仍失败,或者检测到多线程同时竞争,JVM 将决定膨胀(inflate):把轻量级锁升级为重量级锁(Monitor)。
- 膨胀过程通常会创建一个 Monitor 对象,并把对象头中的内容替换为指向 Monitor 的指针。随后所有线程进入 Monitor 的 EntryList 管理,使用内核阻塞/唤醒语义。
4️⃣ 解锁(释放轻量级锁)的细节
当持锁线程到达 monitorexit(synchronized 代码块结束)时:
尝试恢复对象头(恢复 displaced header)
- 持锁线程会尝试用 CAS 将对象头恢复为 Lock Record 中保存的原始 Mark Word(即“把对象头还给被保存的原值”)。
- 如果恢复 CAS 成功,说明没有其他线程在争用,解锁完成,轻量级锁路径成功完成一次加/解锁周期。
恢复失败的含义
- 如果 CAS 恢复失败,说明在解锁期间其他线程正在尝试获取锁(或已经成功获取),此时 JVM 会采取不同策略:
- 把锁膨胀为重量级锁,并把等待线程移动到 Monitor 的 EntryList(阻塞队列)。
- 在 Monitor 路径下执行更复杂的唤醒/调度逻辑。
- 如果 CAS 恢复失败,说明在解锁期间其他线程正在尝试获取锁(或已经成功获取),此时 JVM 会采取不同策略:
5️⃣ 与偏向锁、重量级锁的相互作用
- 偏向锁 → 轻量级锁:当对象处于偏向锁但被其他线程尝试获取时,虚拟机会先撤销偏向(偏向撤销可能需要 safepoint),撤销后尝试轻量级路径或直接膨胀。
- 轻量级锁 → 重量级锁(膨胀):当轻量级路径无法通过自旋解决竞争,JVM 会膨胀为 Monitor,并切换为重量级管理。
- 膨胀是昂贵的:膨胀与回退(deinflate)有成本,JVM 会尽量避免频繁膨胀/回退。
6️⃣ Lock Record(锁记录)的内部构成(简化视图)
Lock Record 存于线程栈帧内,一般包含:
- 原始 Mark Word(displaced header)
- 对象引用(被锁定的对象)
- 可能的嵌套锁链信息(支持 reentrant)
当线程离开同步块,Stack 上的 Lock Record 会被弹出,并用于恢复对象头或触发膨胀流程。
7️⃣ 为什么轻量级锁能提高性能?
- 用户态完成:常见的短临界区无需进入内核,避免了线程挂起/唤醒的昂贵上下文切换。
- CAS 快速路径:在无竞争或低竞争情况下,CAS 成功率高,自旋代价小,吞吐量好。
- 偏向+轻量级策略:先偏向同一线程,再轻量级解决少量竞争,只有高竞争才进入重量级,最大化常见场景性能。
8️⃣ 实践中的注意与陷阱
- 自旋会消耗 CPU:对长临界区或高并发场景,自旋可能更糟糕,导致 CPU 饱和。此时使用锁或其它并发策略更好。
- 膨胀成本:频繁膨胀/回退会带来额外开销。设计时尽量避免在同一对象上产生高频率竞争。
- 偏向锁与 hashCode 冲突:如果在加锁前调用
hashCode(),可能破坏偏向锁的优化路径,导致性能下降。
9️⃣ 用伪代码理解轻量级锁(简化)
1 | // try to acquire |
10️⃣ 小结(要点回顾)
- 轻量级锁是 HotSpot 在用户态实现的、以 CAS 与自旋为核心的锁优化策略,适用于短临界区和低冲突场景。
- 它的关键在于把对象头暂时替换为指向线程栈中 Lock Record 的指针,并在解锁时恢复原始对象头。
- 竞争时通过有限自旋尝试避免阻塞,严重竞争则膨胀为重量级锁,由 Monitor 管理阻塞与唤醒。
理解这些细节能帮助你在设计并发程序时作出更合适的权衡:选择偏向/轻量级优化,还是直接使用显式锁(ReentrantLock)或并发数据结构。