【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中核心实现是ReentrantReadWriteLockReadWriteLockrwLocknewReentrantReadWriteLock();LockreadLockrwLock.readLock();// 共享锁LockwriteLockrwLock.writeLock();// 独占锁// 读操作多个线程可同时执行publicStringreadData(){readLock.lock();try{returnsharedData;}finally{readLock.unlock();}}// 写操作独占执行publicvoidwriteData(StringnewVal){writeLock.lock();try{sharedDatanewVal;}finally{writeLock.unlock();}}适用场景读多写少。比如配置缓存、元数据读取等用读写锁能让大量读线程并发执行性能远高于互斥锁。轻量级锁 vs 重量级锁对比维度轻量级锁重量级锁等待方式自旋等待忙等占用CPU挂起等待释放CPU进入阻塞队列实现层级JVM层面用户态CAS操作操作系统层面内核态Mutex适用场景锁持有时间短、竞争不激烈锁持有时间长、竞争激烈加锁开销小只是一条CPU原子指令大系统调用用户态↔内核态切换等待开销空转消耗CPU不消耗CPU但线程切换开销大线程状态线程始终处于RUNNABLE状态线程进入BLOCKED状态锁记录位置线程栈帧中的 Lock Record堆中对象关联的 ObjectMonitorJava中的定位synchronized的低竞争优化形态synchronized的最终兜底形态两种锁的工作流程可视化设线程切换开销为S锁持有时间为T。如果T S自旋的好处不切换大于好处避免CPU空转 →选轻量级锁/自旋如果T SCPU空转的消耗大于切换的节省 →选重量级锁/挂起具体场景举例场景锁持有时间推荐锁类型给计数器i加锁几纳秒轻量级锁自旋写入一个大文件或网络IO几百毫秒重量级锁挂起保护一段简单赋值极短无锁CAS更好总结⭐锁策略核心问题关键特性 / 适用场景乐观锁 vs 悲观锁冲突概率多大读多写少用乐观写多用悲观公平锁 vs 非公平锁锁该按什么顺序给需要公平可配置追求性能用非公平可重入 vs 不可重入我能重复加这个锁吗Java的锁基本都是可重入的自旋锁 vs 挂起等待等锁时CPU让不让临界区短用自旋临界区长用挂起读写锁读和写能拆开管吗读多写少场景的终极优化利器轻量级 vs 重量级加锁代价多大竞争少用轻量竞争多升级重量锁升级synchronized 锁自动升级路径⭐为了解决重量级锁挂起等待带来的内核态切换开销JDK 6引入了偏向锁和轻量级锁synchronized的锁状态会随着竞争情况逐步升级且不可降级。锁升级路径为无锁 → 偏向锁 → 轻量级锁 → 重量级锁。synchronized 锁自动升级路径_可视化简单说明无锁 └─ 一个线程来了 → 偏向锁记录线程 ID不加锁就来 └─ 另一个线程也来了 → 轻量级锁CAS 自旋原地等待 └─ 自旋太久抢不到 → 重量级锁系统互斥量线程挂起排队对象头与锁状态JVM通过对象头中的Mark Word来记录锁状态。不同状态下Mark Word的存储内容不同锁状态标志位偏向位存储内容说明无锁010对象哈希码、分代年龄偏向锁011持有锁的线程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(){StringBuffersbnewStringBuffer();// 局部变量无逃逸sb.append(Hello);sb.append( World);returnsb.toString();}// 优化后JVM实际执行的效果相当于使用了无锁的StringBuilder这项优化让我们不必过度担心使用线程安全类带来的性能损耗只要作用域未逃逸JVM会智能处理。锁粗化与锁消除相反锁粗化Lock Coarsening解决的是锁操作过于零碎的问题。如果JIT检测到在一段代码中相邻的多个同步块反复使用同一个锁对象它会将这些零散的锁合并成一个范围更大的同步块。典型场景循环体内的加锁// 优化前每次循环都加锁、解锁for(inti0;i1000;i){synchronized(this){doSomething();// 简单操作}}// 优化后JVM将锁扩展到循环外部synchronized(this){for(inti0;i1000;i){doSomething();}}这样做虽然增大了单个线程的锁持有时间但显著减少了加锁和解锁的次数从而节省了CPU开销。