锁升级(synchornized)
锁的获取和释放流程(线程希望改变对象头的字段属性)
synchronized 锁的获取和释放流程是一个动态的、逐级升级的过程。从性能最高的偏向锁,到轻量级锁,最后到保底的重量级锁。
下面我将以 JDK 6 以后的 HotSpot 虚拟机为例,详细描述这个完整的流转过程。
第一阶段:偏向锁 (Biased Locking)(只改变对象本身的Mark Word字段)
默认开启(JDK 15 前),假设只有一个线程在来回进出同步块。
1. 获取流程
- 检查 Mark Word:线程 A 访问同步块,检查对象头的 Mark Word。
标志位是否为 01(无锁/偏向锁)?
是否为偏向模式(biased_lock=1)?
- CAS 竞争:
情况 A(无主):如果 Thread ID 为空,线程 A 使用 CAS 将自己的 Thread ID 写入对象头。如果成功,获取偏向锁成功。
情况 B(有主,且是自己):如果 Thread ID 已经是线程 A,直接进入,无需 CAS。这是效率最高的场景。
情况 C(有主,且是别人):如果 Thread ID 是线程 B,说明有竞争(或者 B 还没释放)。偏向锁失效,开始撤销偏向锁,并准备升级为轻量级锁。
2. 释放流程
偏向锁不会主动释放。只有当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁(撤销)。
撤销需要等待全局安全点(Safe Point,此时没有字节码在执行),暂停拥有偏向锁的线程,判断它是否还在同步块中:
如果不活跃:对象头设为无锁状态(Neutral),允许新线程竞争。
如果还活跃:升级为轻量级锁。
3. 什么是偏向模式
偏向模式(Biased Mode) 是 JVM 对象头(Object Header)中 Mark Word 的一种特定状态。
它是为了配合偏向锁(Biased Locking)机制而设计的。简单来说,处于偏向模式的对象,就像是一个“心里已经有人了”的对象。
1. 技术定义
在 64 位虚拟机的对象头 Mark Word 中,偏向模式由最低的 3 个比特位 共同决定:
最后 2 位(Lock bits):必须是 01(这与“无锁态”相同)。
倒数第 3 位(Biased bit):必须是 1。
| 状态 | 偏向位 (Biased Bit) | 锁标志位 (Lock Bits) | 说明 |
|---|---|---|---|
| 无锁 (Normal) | 0 | 01 | 对象干净,谁都可以来抢。 |
| 偏向模式 (Biased) | 1 | 01 | 对象开启了偏向功能,可能已经记录了某个线程 ID。 |
| 轻量级锁 | - | 00 | 指向栈帧 Lock Record。 |
| 重量级锁 | - | 10 | 指向堆中 Monitor。 |
2. 偏向模式包含的信息
当一个对象处于偏向模式(Bit 设为 1)时,Mark Word 的其余部分(54 bit)会被重新划分,用来存储以下关键信息:
- Thread ID (54 bit):
记录了持有该偏向锁的线程 ID。
如果这里是 0(空),说明虽然开启了偏向模式,但还没有偏向任何线程(可偏向状态)。
如果这里有值,说明该对象已经“归属”于某个线程了(已偏向状态)。
- Epoch (2 bit):
偏向时间戳。用于批量重偏向(Bulk Rebias)的优化。
用来判断对象头里的 Thread ID 是否已经过时了(比如那个类已经发生了批量撤销,之前的偏向记录就无效了)。
- GC Age (4 bit):
- 对象的分代年龄,这部分雷打不动,无论什么锁状态都得保留。
3. 为什么要有“偏向模式”这个开关?
JVM 启动时,偏向锁功能默认开启(JDK 15 之前),但会有几秒钟的延迟(BiasedLockingStartupDelay)。
延迟期内:新建的对象,偏向位是 0(无锁)。因为 JVM 启动初期会有大量线程竞争(类加载、初始化等),此时偏向锁反而是负担,不如直接用轻量级锁。
延迟期后:新建的对象,偏向位默认为 1(匿名偏向,Thread ID 为 0)。这意味着这个对象准备好被某个线程独占了。
4. 总结
偏向模式就是一个标志位(开关)。
开(1):告诉 JVM,“这个对象允许使用最高效的偏向锁优化”。如果线程 A 来了,就把名字刻上去,以后 A 再来都不用 CAS,直接进。
关(0):告诉 JVM,“这个对象禁止使用偏向锁”。如果要加锁,请直接从轻量级锁(CAS)开始。
一旦发生竞争(有两个线程抢),偏向模式就会被撤销(Revocation),偏向位变为 0,后续无法再回到偏向模式(除非发生批量重偏向)。
第二阶段:轻量级锁 (Lightweight Locking)(不仅改变对象本身的Mark Word字段,还需要在线程的栈帧中添加LockRecord)
假设多线程交替执行,没有即时的冲突。
1. 获取流程
构建锁记录 (Lock Record):线程在自己的栈帧中创建一个 Lock Record 空间。
拷贝对象头:将对象头当前的 Mark Word 复制到 Lock Record 中(称为 Displaced Mark Word)。
CAS 抢锁:
线程尝试用 CAS 将对象头的 Mark Word 替换为指向 Lock Record 的指针。
成功:对象头标志位变为 00,表示获取轻量级锁成功。
失败:JVM 检查对象头是否指向当前线程的栈帧?
是(重入):在栈中再放入一个 Displaced Mark Word 为 null 的 Lock Record 作为重入计数。
否(竞争):说明此时有线程 B 已经持有了锁,或者正在抢。当前线程 A 自旋(Spin)等待(自适应自旋)。如果自旋失败,锁膨胀为重量级锁。
2. 释放流程
出栈检查:取出栈顶的 Lock Record。
处理重入:如果 Displaced Mark Word 为 null,说明是重入退出,直接丢弃,流程结束。
CAS 还原:如果 Displaced Mark Word 不为 null(最后一层),尝试用 CAS 把备份的 Mark Word 还原回对象头。
成功:锁释放完成,对象恢复为无锁状态。
失败:说明在持有锁期间,锁已经膨胀了(对象头变成了指向 Monitor 的重量级指针)。此时需要进行重量级锁的释放流程。
3.什么是LockRecord
Lock Record(锁记录) 是 JVM 在当前线程的栈帧(Stack Frame)中开辟的一块内存空间,它是轻量级锁的核心实现载体。
你可以把它理解为线程随身携带的“锁凭证”或“临时笔记本”。
1. 为什么需要 Lock Record?
在轻量级锁阶段,JVM 不想去申请昂贵的、操作系统级别的重量级锁(Monitor)。
JVM 的策略是:“既然只有你一个线程在用,那你就在自己的栈里记一下就好了,别去骚扰操作系统。”
这个“记一下”的地方,就是 Lock Record。
2. Lock Record 里存了什么?
它主要包含两个核心字段:
- Displaced Mark Word(被置换的 Mark Word)
作用:备份。
解释:当对象被锁住时,对象头里的原始信息(如 HashCode、分代年龄)会被覆盖成指向锁记录的指针。为了不丢失这些信息,JVM 先把它们拷贝到 Lock Record 里存起来。等解锁的时候,再还回去。
- Owner(所有者指针)
作用:引用。
解释:指向当前被锁住的那个对象。这就形成了一个双向连接(对象头指锁记录,锁记录指对象),明确表示“这个栈帧里的锁记录对应的是哪个对象”。
3. Lock Record 的两种特殊用法
A. 这里的“锁”
当 Lock Record 的 Displaced Mark Word 存的是非空的原始对象头信息时,它代表真正的锁持有。
B. 这里的“重入计数器”
当 Lock Record 的 Displaced Mark Word 为 null 时,它代表锁重入。
每重入一次 synchronized 块,栈里就会多压入一个 Lock Record。
为了节省开销,重入的 Lock Record 不需要再备份对象头了(因为第一个已经备份了),直接设为 null。
退出时,看到 null 就知道:“哦,这只是退出了一层嵌套,还没真正释放锁呢。”
总结图解
想象你(线程)去住酒店(对象):
无锁:房间门上写着“标准间,设备齐全”(原始 Mark Word)。
加锁:你把门上的字条撕下来,揣进自己兜里(Lock Record)。然后在门上贴一张新条子:“这房间归此时在我兜里有条子的人(指针)”。
解锁:你把兜里的原始字条拿出来,贴回门上,撕掉你的占位条。一切恢复原状。
第三阶段:重量级锁 (Heavyweight Locking)(需要到操作系统中申请Monitor,改变Monitor和Mark Word,在线程的栈帧中依然存在LockRecord只不过里面没有存储内容 )
发生了并发竞争,使用操作系统互斥量 (Mutex)。
1. 获取流程 (monitorenter)
膨胀:对象头的 Mark Word 被修改为指向堆中 ObjectMonitor 对象的指针(标志位 10)。
竞争 Monitor:线程尝试通过 CAS 将 Monitor 的 _owner 字段设为自己。
失败处理:
入队:如果抢不到,线程被封装成 ObjectWaiter 对象,放入 _cxq(竞争队列)或 _EntryList。
阻塞:调用 park() 挂起当前线程(进入内核态),线程状态变为 BLOCKED,等待被唤醒。
2. 释放流程 (monitorexit)
清理 Owner:将 Monitor 的 _owner 字段置为 null,_recursions 减为 0。
唤醒:检查 _EntryList 或 _cxq 是否有等待的线程。
unpark:如果有,选择一个“继承人”(Heir),调用 unpark() 唤醒它。被唤醒的线程会再次尝试竞争 _owner(注意:synchronized 是非公平锁,被唤醒不代表一定能抢到,可能输给新来的线程)。
为什么锁支持升级却不支持降级
主要是为了性能和实现复杂度上的考虑
单来说,JVM 设计为“锁只能升级不能降级”的核心原因是:为了性能(避免震荡)和实现复杂度的考量。
虽然在极少数特定情况(如 GC 期间)锁可能会被重置,但在用户程序的运行期间,一旦锁升级到了重量级锁,就不会再退回到轻量级锁。
以下是具体的三个原因:
1. 避免“性能震荡”(最重要的原因)
想象一下,如果锁支持降级,会发生什么?
场景:双十一零点,流量激增,锁迅速升级为重量级锁。
00:05:流量稍微停顿了 10 毫秒。
降级:JVM 发现没人抢了,赶紧把锁降级为轻量级锁(这需要一系列复杂的操作)。
00:06:流量又来了。
升级:轻量级锁瞬间撑不住,又要申请互斥量,升级为重量级锁(这又是一次昂贵的内核态切换)。
后果:
如果锁频繁地在“轻”和“重”之间来回跳变(Upgrade/Downgrade Ping-Pong),系统会把大量的 CPU 时间浪费在锁的转换上,而不是处理业务逻辑。
策略:JVM 采取了“悲观”的策略——既然这个对象曾经发生过激烈的竞争,那么 JVM 认为它未来大概率还会发生竞争,所以干脆保持重量级锁的状态,省得来回折腾。
2. 只有重量级锁才支持 wait() / notify()
这是结构上的硬伤。
轻量级锁:依靠栈帧中的 Lock Record,它没有存储等待队列(WaitSet)的地方。
重量级锁:依靠堆中的 ObjectMonitor,它里面有 _WaitSet 和 _EntryList。
问题:
如果一个线程调用了 Object.wait(),锁必须升级为重量级锁(因为只有 Monitor 才能存这个等待的线程)。
此时,如果想降级,JVM 必须确保 _WaitSet 里没有线程在睡觉。如果还有线程在 wait(),你把锁降级了,这个在睡觉的线程去哪找呢?这会极大地增加 JVM 维护锁状态的复杂度。
3. 降级的“性价比”极低
升级:是为了“保命”。如果不升级,轻量级锁一直在自旋,CPU 飙到 100%,系统会崩。所以必须升级为重量级锁让线程挂起。
降级:是为了“省油”。但是,持有一个重量级锁(在没有竞争的时候),其实并不会比轻量级锁慢太多(只是多了一次指针跳转)。
为了省这一点点油,去承担复杂的降级检测逻辑和潜在的性能震荡风险,JVM 开发者认为不划算。
特殊情况:什么时候会“降级”?
虽然运行期间不降级,但在 GC(垃圾回收) 的时候,锁是有可能被“重置”的。
当 JVM 进入 SafePoint(安全点) 进行垃圾回收时,所有的线程都暂停了。JVM 会检查这些对象:
如果对象已经没人用了(垃圾),直接回收。
如果对象还在用,但之前的锁竞争已经结束了(比如持有锁的线程都已经退出了),JVM 可能会将对象头恢复为无锁状态(Neutral)。
这严格来说不算“运行时降级”,而是一次“洗牌重置”。下次再有线程来加锁,还是得从偏向锁或轻量级锁重新开始。
总结
升级是被迫的:因为轻量级锁扛不住了,不升级系统会死。
不降级是为了稳:既然曾经“打过架”,说明这里是由于热点,保持重量级锁能避免反复申请释放资源的开销。
不同锁的标志位
| 锁状态 | 存储内容 | 标志位 |
|---|---|---|
| 无锁 | 对象的hashCode、对象分代年龄、是否是偏向锁(0) | 01 |
| 偏向锁 | 偏向线程ID、偏向时间戳、对象分代年龄、是否是偏向锁(1) | 01 |
| 轻量级锁 | 指向栈中锁记录的指针 | 00 |
| 重量级锁 | 指向互斥量的指针 | 11 |
锁在线程执行过程中出现异常的释放
jvm在此时会自动释放锁