【Java EE】锁策略、锁升级、锁消除和锁粗化
锁策略、锁升级、锁消除和锁粗化
- 锁策略
- 悲观锁 vs 乐观锁
- 公平锁 vs 非公平锁
- 可重入锁 vs 不可重入锁
- 可重入锁的完整实现逻辑
- 自旋锁 vs 挂起等待锁
- 互斥锁 vs 读写锁
- 轻量级锁 vs 重量级锁
- 两种锁的工作流程
- 总结⭐
- 锁升级
- synchronized 锁自动升级路径⭐
- 对象头与锁状态
- 无锁 → 偏向锁(Biased Locking)
- 偏向锁 → 轻量级锁
- 轻量级锁→ 重量级锁
- 锁消除
- 锁粗化
本文将深入理解Java中的常见锁策略,并重点探讨JVM层面的三大优化手段:锁升级(Lock Escalation)、锁消除(Lock Elimination)与锁粗化(Lock Coarsening)。
锁策略
从不同的维度看,锁可以分为多种类型:
悲观锁 vs 乐观锁
悲观锁:总是假设最坏的情况——每次读写数据,别人都会来修改。所以它会在操作前先加锁,阻塞其他线程。
乐观锁:很天真地认为冲突一般不会发生,所以先不加锁,直接操作。更新时,再检查一下数据有没有被别人动过。如果没被改,就写入;如果被改了,就重试或放弃。
synchronized和ReentrantLock都是典型的悲观锁。
适用场景:乐观锁适合读多写少的场景,能减少加锁开销;悲观锁则适合写操作频繁的场景,避免无休止的重试。
公平锁 vs 非公平锁
多线程排队等锁,锁被释放时,该轮到谁?
- 公平锁:严格遵循先来后到。线程A比B先来排队,A就一定能比B先拿到锁。
- 非公平锁:不排队。锁一释放,所有等待的线程(甚至刚来的新线程)一起哄抢,谁抢到算谁的。
synchronized就是典型的非公平锁。ReentrantLock则支持通过构造参数自由选择是公平还是非公平。
公平锁虽然看起来更公平,但它进行线程调度和维护等待队列的成本更高。非公平锁性能更好,但可能导致某些线程始终抢不到锁,造成饥饿。
可重入锁 vs 不可重入锁
一个已经拿到锁的线程,还能再拿一次这把锁吗?
- 可重入锁:允许。同一个线程可以多次获取同一把锁,不会自己把自己锁死。比如一个同步方法里调用另一个同步方法。
- 不可重入锁:不允许。线程第二次获取锁时会阻塞,直到自己释放,但这永远不可能发生,于是造成死锁。
synchronized和ReentrantLock都是可重入锁。
可重入锁的完整实现逻辑
可视化
自旋锁 vs 挂起等待锁
当线程抢锁失败,是原地等待还是暂时放弃CPU?
自旋锁(Spin Lock):抢锁失败的线程不放弃CPU资源,而是原地死循环,反复尝试获取锁,直到成功。
优点:一旦锁被释放,自己能瞬间感知并获取,没有线程调度的延迟。
缺点:如果锁被持有很久,自旋的线程会空耗CPU,造成浪费。挂起等待锁:线程抢锁失败后,直接进入阻塞状态,让出CPU资源。等锁释放后,系统再重新调度唤醒它。
优点:不浪费CPU资源,线程阻塞期间CPU可以去做更有意义的事。
缺点:从阻塞到被唤醒,存在调度延迟。
互斥锁 vs 读写锁
互斥锁是最简单也最严格的锁模式。它的规则只有一条:任何时刻,只能有一个线程持有锁,无论是读还是写。
线程A(读)🔒 ──────────── 🔓 线程B(读) ⏳等待 🔒 ──── 🔓 线程C(写) ⏳等待 ⏳等待 🔒 ──── 🔓 ───────────────────────────────────────────→ 时间读写锁(ReadWriteLock):读写锁把读和写区别对待,引入了三种状态:
无锁状态:没有任何线程持有锁。
读锁(共享锁):多个线程可以同时持有,彼此不阻塞。
写锁(独占锁):一次只能有一个线程持有,且与其他所有锁互斥。线程A(读)🔒共享 ──────────── 🔓 线程B(读)🔒共享 ──────────── 🔓 线程C(写) ⏳等待 🔒独占 ──── 🔓 ───────────────────────────────────────────→ 时间
实际规则表:
| 当前锁状态 | 申请读锁 | 申请写锁 |
|---|---|---|
| 无锁 | ✅ 获得读锁 | ✅ 获得写锁 |
| 已被读锁持有 | ✅ 可重入/共享 | ❌ 阻塞 |
| 已被写锁持有 | ❌ 阻塞 | ✅ 仅持有线程可重入 |
在Java中,核心实现是ReentrantReadWriteLock:
ReadWriteLockrwLock=newReentrantReadWriteLock();LockreadLock=rwLock.readLock();// 共享锁LockwriteLock=rwLock.writeLock();// 独占锁// 读操作:多个线程可同时执行publicStringreadData(){readLock.lock();try{returnsharedData;}finally{readLock.unlock();}}// 写操作:独占执行publicvoidwriteData(StringnewVal){writeLock.lock();try{sharedData=newVal;}finally{writeLock.unlock();}}适用场景:读多写少。比如配置缓存、元数据读取等,用读写锁能让大量读线程并发执行,性能远高于互斥锁。
轻量级锁 vs 重量级锁
| 对比维度 | 轻量级锁 | 重量级锁 |
|---|---|---|
| 等待方式 | 自旋等待(忙等,占用CPU) | 挂起等待(释放CPU,进入阻塞队列) |
| 实现层级 | JVM层面,用户态CAS操作 | 操作系统层面,内核态Mutex |
| 适用场景 | 锁持有时间短、竞争不激烈 | 锁持有时间长、竞争激烈 |
| 加锁开销 | 小(只是一条CPU原子指令) | 大(系统调用,用户态↔内核态切换) |
| 等待开销 | 空转消耗CPU | 不消耗CPU,但线程切换开销大 |
| 线程状态 | 线程始终处于RUNNABLE状态 | 线程进入BLOCKED状态 |
| 锁记录位置 | 线程栈帧中的 Lock Record | 堆中对象关联的 ObjectMonitor |
| Java中的定位 | synchronized的低竞争优化形态 | synchronized的最终兜底形态 |
两种锁的工作流程
可视化
设线程切换开销为
S,锁持有时间为T。
- 如果T < S:自旋的好处(不切换)大于好处(避免CPU空转) →选轻量级锁/自旋
- 如果T > S:CPU空转的消耗大于切换的节省 →选重量级锁/挂起
具体场景举例:
| 场景 | 锁持有时间 | 推荐锁类型 |
|---|---|---|
给计数器i++加锁 | 几纳秒 | 轻量级锁(自旋) |
| 写入一个大文件或网络IO | 几百毫秒 | 重量级锁(挂起) |
| 保护一段简单赋值 | 极短 | 无锁CAS更好 |
总结⭐
| 锁策略 | 核心问题 | 关键特性 / 适用场景 |
|---|---|---|
| 乐观锁 vs 悲观锁 | 冲突概率多大? | 读多写少用乐观,写多用悲观 |
| 公平锁 vs 非公平锁 | 锁该按什么顺序给? | 需要公平可配置,追求性能用非公平 |
| 可重入 vs 不可重入 | 我能重复加这个锁吗? | Java的锁基本都是可重入的 |
| 自旋锁 vs 挂起等待 | 等锁时CPU让不让? | 临界区短用自旋,临界区长用挂起 |
| 读写锁 | 读和写能拆开管吗? | 读多写少场景的终极优化利器 |
| 轻量级 vs 重量级 | 加锁代价多大? | 竞争少用轻量,竞争多升级重量 |
锁升级
synchronized 锁自动升级路径⭐
为了解决重量级锁(挂起等待)带来的内核态切换开销,JDK 6引入了偏向锁和轻量级锁,synchronized的锁状态会随着竞争情况逐步升级,且不可降级。
锁升级路径为:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。
synchronized 锁自动升级路径_可视化
简单说明
无锁 └─ 一个线程来了 → 偏向锁(记录线程 ID,不加锁就来) └─ 另一个线程也来了 → 轻量级锁(CAS 自旋,原地等待) └─ 自旋太久抢不到 → 重量级锁(系统互斥量,线程挂起排队)对象头与锁状态
JVM通过对象头中的Mark Word来记录锁状态。不同状态下Mark Word的存储内容不同:
| 锁状态 | 标志位 | 偏向位 | 存储内容说明 |
|---|---|---|---|
| 无锁 | 01 | 0 | 对象哈希码、分代年龄 |
| 偏向锁 | 01 | 1 | 持有锁的线程ID、偏向时间戳 |
| 轻量级锁 | 00 | - | 指向栈中锁记录(Lock Record)的指针 |
| 重量级锁 | 10 | - | 指向操作系统互斥量(Monitor)的指针 |
无锁 → 偏向锁(Biased Locking)
- 思想:大多数时候,锁总是由同一个线程多次获取。JVM会偏向于第一个获取锁的线程。
- 过程:当线程T1首次访问同步块时,JVM通过CAS将T1的线程ID写入对象头。之后T1再次进入同步块时,无需任何同步操作,直接执行。
- 撤销:当线程T2尝试竞争锁时,JVM会暂停T1,检查T1是否仍在执行同步块。若已退出则撤销偏向锁;若仍在执行则升级为轻量级锁。
- 注意:从JDK 15开始,偏向锁特性被标记为废弃,因为它在高并发场景下的维护成本(如撤销时的STW)甚至高于收益。
偏向锁 → 轻量级锁
- 思想:多个线程虽然是竞争关系,但往往是交替执行,即“几乎没有实际竞争”。
- 过程:线程在进入同步块前,在栈帧中创建锁记录(Lock Record),将Mark Word复制到锁记录中,然后通过CAS自旋尝试将对象头中的Mark Word替换为指向锁记录的指针。
- 竞争失败:如果自旋等待后仍未获得锁,说明竞争加剧,锁膨胀为重量级锁。
轻量级锁→ 重量级锁
- 机制:依赖操作系统底层的互斥量(Mutex)实现。未获取到锁的线程不再自旋,而是进入阻塞态,等待被唤醒。
- 代价:涉及系统调用和线程上下文切换,CPU开销大,但在高竞争场景下能保证系统吞吐量。
锁消除
锁消除(Lock Elimination)是一项编译器优化技术。JIT编译器在动态编译同步块时,如果通过逃逸分析(Escape Analysis)发现锁对象只被一个线程访问(即没有逃逸出当前线程),就会认为该锁不存在竞争,从而直接移除掉锁的申请与释放逻辑。
典型场景:
在方法内部使用StringBuffer(线程安全,方法加锁)或Vector时,如果该对象是局部变量且未被其他线程引用,JIT就会大方地去掉锁。
// 优化前:看似每次append都要加锁publicStringbuildString(){StringBuffersb=newStringBuffer();// 局部变量,无逃逸sb.append("Hello");sb.append(" World");returnsb.toString();}// 优化后:JVM实际执行的效果相当于使用了无锁的StringBuilder这项优化让我们不必过度担心使用线程安全类带来的性能损耗,只要作用域未逃逸,JVM会智能处理。
锁粗化
与锁消除相反,锁粗化(Lock Coarsening)解决的是锁操作过于零碎的问题。如果JIT检测到在一段代码中,相邻的多个同步块反复使用同一个锁对象,它会将这些零散的锁合并成一个范围更大的同步块。
典型场景:循环体内的加锁
// 优化前:每次循环都加锁、解锁for(inti=0;i<1000;i++){synchronized(this){doSomething();// 简单操作}}// 优化后:JVM将锁扩展到循环外部synchronized(this){for(inti=0;i<1000;i++){doSomething();}}这样做虽然增大了单个线程的锁持有时间,但显著减少了加锁和解锁的次数,从而节省了CPU开销。
