【Java EE】锁策略、锁升级、锁消除和锁粗化

锁策略、锁升级、锁消除和锁粗化

  • 锁策略
    • [悲观锁 vs 乐观锁](#悲观锁 vs 乐观锁)
    • [公平锁 vs 非公平锁](#公平锁 vs 非公平锁)
    • [可重入锁 vs 不可重入锁](#可重入锁 vs 不可重入锁)
    • [自旋锁 vs 挂起等待锁](#自旋锁 vs 挂起等待锁)
    • [互斥锁 vs 读写锁](#互斥锁 vs 读写锁)
    • [轻量级锁 vs 重量级锁](#轻量级锁 vs 重量级锁)
    • 总结⭐
  • 锁升级
    • [synchronized 锁自动升级路径⭐](#synchronized 锁自动升级路径⭐)
      • 对象头与锁状态
      • [无锁 → 偏向锁(Biased Locking)](#无锁 → 偏向锁(Biased Locking))
      • [偏向锁 → 轻量级锁](#偏向锁 → 轻量级锁)
      • [轻量级锁→ 重量级锁](#轻量级锁→ 重量级锁)
  • 锁消除
  • 锁粗化

本文将深入理解Java中的常见锁策略 ,并重点探讨JVM层面的三大优化手段:锁升级(Lock Escalation)、锁消除(Lock Elimination)与锁粗化(Lock Coarsening)

锁策略

从不同的维度看,锁可以分为多种类型:

悲观锁 vs 乐观锁

  • 悲观锁 :总是假设最坏的情况------每次读写数据,别人都会来修改。所以它会在操作前先加锁,阻塞其他线程。

  • 乐观锁 :很天真地认为冲突一般不会发生,所以先不加锁,直接操作。更新时,再检查一下数据有没有被别人动过。如果没被改,就写入;如果被改了,就重试或放弃。

synchronizedReentrantLock都是典型的悲观锁。

适用场景 :乐观锁适合读多写少 的场景,能减少加锁开销;悲观锁则适合写操作频繁的场景,避免无休止的重试。

公平锁 vs 非公平锁

多线程排队等锁,锁被释放时,该轮到谁?

  • 公平锁:严格遵循先来后到。线程A比B先来排队,A就一定能比B先拿到锁。
  • 非公平锁:不排队。锁一释放,所有等待的线程(甚至刚来的新线程)一起哄抢,谁抢到算谁的。

synchronized就是典型的非公平锁。ReentrantLock则支持通过构造参数自由选择是公平还是非公平。

公平锁虽然看起来更公平,但它进行线程调度和维护等待队列的成本更高。非公平锁性能更好,但可能导致某些线程始终抢不到锁,造成饥饿。

可重入锁 vs 不可重入锁

一个已经拿到锁的线程,还能再拿一次这把锁吗?

  • 可重入锁:允许。同一个线程可以多次获取同一把锁,不会自己把自己锁死。比如一个同步方法里调用另一个同步方法。
  • 不可重入锁 :不允许。线程第二次获取锁时会阻塞,直到自己释放,但这永远不可能发生,于是造成死锁

synchronizedReentrantLock都是可重入锁

可重入锁的完整实现逻辑

可视化

自旋锁 vs 挂起等待锁

当线程抢锁失败,是原地等待还是暂时放弃CPU?

  • 自旋锁(Spin Lock) :抢锁失败的线程不放弃CPU资源,而是原地死循环 ,反复尝试获取锁,直到成功。
    优点 :一旦锁被释放,自己能瞬间感知并获取,没有线程调度的延迟。
    缺点:如果锁被持有很久,自旋的线程会空耗CPU,造成浪费。

  • 挂起等待锁 :线程抢锁失败后,直接进入阻塞状态,让出CPU资源。等锁释放后,系统再重新调度唤醒它。
    优点 :不浪费CPU资源,线程阻塞期间CPU可以去做更有意义的事。
    缺点:从阻塞到被唤醒,存在调度延迟。

互斥锁 vs 读写锁

  • 互斥锁是最简单也最严格的锁模式。它的规则只有一条: 任何时刻,只能有一个线程持有锁,无论是读还是写。

    复制代码
    线程A(读)🔒 ──────────── 🔓
    线程B(读)         ⏳等待   🔒 ──── 🔓
    线程C(写)         ⏳等待          ⏳等待  🔒 ──── 🔓
             ───────────────────────────────────────────→ 时间
  • 读写锁(ReadWriteLock):读写锁把读和写区别对待,引入了三种状态:
    无锁状态 :没有任何线程持有锁。
    读锁(共享锁) :多个线程可以同时 持有,彼此不阻塞。
    写锁(独占锁):一次只能有一个线程持有,且与其他所有锁互斥。

    复制代码
    线程A(读)🔒共享 ──────────── 🔓
    线程B(读)🔒共享 ──────────── 🔓
    线程C(写)         ⏳等待        🔒独占 ──── 🔓
             ───────────────────────────────────────────→ 时间

实际规则表

当前锁状态 申请读锁 申请写锁
无锁 ✅ 获得读锁 ✅ 获得写锁
已被读锁持有 ✅ 可重入/共享 ❌ 阻塞
已被写锁持有 ❌ 阻塞 ✅ 仅持有线程可重入

在Java中,核心实现是 ReentrantReadWriteLock

java 复制代码
ReadWriteLock rwLock = new ReentrantReadWriteLock();
Lock readLock = rwLock.readLock();   // 共享锁
Lock writeLock = rwLock.writeLock(); // 独占锁

// 读操作:多个线程可同时执行
public String readData() {
    readLock.lock();
    try {
        return sharedData;
    } finally {
        readLock.unlock();
    }
}

// 写操作:独占执行
public void writeData(String newVal) {
    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就会大方地去掉锁。

java 复制代码
// 优化前:看似每次append都要加锁
public String buildString() {
    StringBuffer sb = new StringBuffer(); // 局部变量,无逃逸
    sb.append("Hello");
    sb.append(" World");
    return sb.toString();
}
// 优化后:JVM实际执行的效果相当于使用了无锁的StringBuilder

这项优化让我们不必过度担心使用线程安全类带来的性能损耗,只要作用域未逃逸,JVM会智能处理。

锁粗化

与锁消除相反,锁粗化(Lock Coarsening) 解决的是锁操作过于零碎的问题。如果JIT检测到在一段代码中,相邻的多个同步块反复使用同一个锁对象,它会将这些零散的锁合并成一个范围更大的同步块。

典型场景:循环体内的加锁

java 复制代码
// 优化前:每次循环都加锁、解锁
for(int i = 0; i < 1000; i++) {
    synchronized(this) {
        doSomething(); // 简单操作
    }
}

// 优化后:JVM将锁扩展到循环外部
synchronized(this) {
    for(int i = 0; i < 1000; i++) {
        doSomething();
    }
}

这样做虽然增大了单个线程的锁持有时间,但显著减少了加锁和解锁的次数,从而节省了CPU开销。

相关推荐
wu8587734571 小时前
Java AI Harness 落地:拥抱框架还是回归本质?深度解析选型之道
java·人工智能·回归
北风toto1 小时前
SpringBoot 获取配置文件值、获取环境变量的方式
java·spring boot·后端
空中海1 小时前
Nacos3: 面试题库
java·面试·职场和发展
摇滚侠1 小时前
sqlplus “/ as sysdba“ 什么意思
java·数据库·oracle
user_lwl1 小时前
解决langchain4j+deepseek使用过程中reasoning_contect报错并适配DeepSeekV4
java·后端
架构源启2 小时前
2026 进阶篇:Spring Boot响应式编程 + Spring AI 1.1.4 流式实战 + Vue前端完整实现(避坑指南)
java·前端·vue.js·人工智能·spring boot·spring·ai编程
csdn2015_2 小时前
Java List 去重
java·windows·list
pqq的迷弟2 小时前
多租户实现方案
java·多租户
随风,奔跑2 小时前
Mybatis-Plus学习笔记
java·笔记·学习·mybatis