锁优化高级策略:JVM 的“灵活执法”艺术

天不负,终于、终于还是没结束~比如咱们没有提到的:

  • 锁消除 (JIT 优化)。

  • 锁粗化

  • 自旋锁 的自适应策略。

  • 偏向锁 在 JDK 15+ 被废弃的原因及影响

之前的文章可能让大家觉得锁升级(无锁->偏向->轻量->重量)是单向的、死板的。但实际上,HotSpot 虚拟机(JVM)像一位极具智慧的老交警,它会根据路况(竞争程度)实时调整执法策略,甚至直接"撤销岗哨"来最大化通行效率。而提到的四个缺失点,正是 JVM 从"机械执行"进化到"智能感知"的关键!

第一部分:锁消除 (Lock Elimination) ------ "撤销岗哨"

1. 什么是锁消除?

这是 JIT (Just-In-Time) 编译器 在运行时进行的一种激进优化。

  • 场景 :代码中显式写了 synchronized,但 JIT 通过逃逸分析 (Escape Analysis) 发现,这个锁保护的变量根本不会被其他线程访问(即对象没有"逃逸"出当前线程或方法)。
  • 操作 :既然没有竞争,为什么要上锁?JIT 会直接把锁指令(monitorenter/monitorexit)删掉,就像交警发现这段路根本没车,直接把岗哨撤了,让大家全速通过。

2. 经典案例:线程安全的 StringBuffer

复制代码
public String concatStrings(String a, String b) {
    // StringBuffer 是线程安全的,内部方法都有 synchronized
    StringBuffer sb = new StringBuffer();
    sb.append(a);
    sb.append(b);
    return sb.toString();
}
  • 表象append 方法里有 synchronized(this)
  • 实质sb 对象是在方法内部创建的(栈上分配),它只被当前线程持有,不可能有其他线程访问到它。
  • JIT 优化 :JVM 检测到 sb 未逃逸 ,直接消除 append 方法里的所有锁操作。
  • 结果StringBuffer 在这一刻的性能等同于非线程安全的 StringBuilder

3. 如何验证?

启动参数:-XX:+PrintCompilation -XX:+PrintInlining -XX:+DoEscapeAnalysis

你会看到 JIT 编译日志中显示锁被移除的信息。

💡 启示 :不要过度优化。有时候直接用 JDK 提供的线程安全类(如 Vector, StringBuffer),在单线程局部场景下,JVM 会自动帮你优化成非锁模式。过早优化是万恶之源。

第二部分:锁粗化 (Lock Coarsening) ------ "延长绿灯"

与锁消除相反,如果代码中频繁地加锁、解锁 ,而中间的操作非常简单,JVM 会将这些零散的锁操作合并成一个大的锁范围。

  • 比喻 :连续有 100 辆车要通过路口。

    • 笨交警:每过一辆车,红灯->绿灯->红灯->绿灯...(频繁切换,效率极低)。

    • 聪明交警 :直接亮长绿灯,让这 100 辆车连续通过,最后再变红。这就是锁粗化

      // 原始代码:细粒度锁,性能差
      StringBuffer sb = new StringBuffer();
      for (int i = 0; i < 1000; i++) {
      synchronized(sb) { // 每次循环都加锁、解锁
      sb.append(i);
      }
      }

      // JIT 优化后(逻辑等价):
      synchronized(sb) { // 锁范围扩大到整个循环
      for (int i = 0; i < 1000; i++) {
      sb.append(i); // 内部不再加锁解锁
      }
      }

  • 原理:加锁和解锁涉及 CAS 操作、内存屏障等,开销很大。如果确定在循环期间没有其他线程干扰(或者即使有,合并锁也更划算),JVM 会自动扩大锁粒度。

💡 启示 :写代码时,尽量手动将锁的范围扩大(在循环外上锁),不要依赖 JIT。虽然 JIT 能做,但手动优化更可控,且能减少 JIT 编译器的负担。

第三部分:自旋锁的自适应策略 (Adaptive Spinning)

1. 背景:为什么要自旋?

当线程尝试获取锁失败时,有两种选择:

  1. 挂起 (Block) :交给操作系统,进入等待队列。优点是不占 CPU,缺点是上下文切换开销大(用户态<->内核态)。
  2. 自旋 (Spin) :不睡觉,在一个死循环里不断检查锁是否释放。优点是无切换开销 ,缺点是浪费 CPU

决策难点:如果锁马上就好,自旋划算;如果锁要很久,自旋就是浪费电。现在电多宝贵

2. 早期策略:固定次数

在 JDK 6 之前,自旋次数是固定的(默认 10 次)。

  • 问题
    • 如果锁持有时间很长(如 100ms),自旋 10 次(可能只需 1us)就挂起,没问题。
    • 如果锁持有时间很短(如 20 次循环的时间),自旋 10 次不够,线程挂起了,结果刚挂起锁就释放了,白白浪费了一次上下文切换

3. 高级策略:自适应自旋 (Adaptive Spinning)

从 JDK 6 开始,自旋时间不再固定 ,而是由前一次在同一个锁上的自旋情况决定。

  • 机制

    • 历史记录:JVM 会记录每个锁的"自旋历史"。
    • 动态调整
      • 如果上次在这个锁上自旋成功了(说明锁很快释放),这次就多自旋一会儿(比如 100 次)。
      • 如果上次自旋失败了(说明锁竞争激烈,持有时间长),这次就少自旋甚至不自旋,直接挂起。
    • 处理器感知:如果是多核处理器,自旋更有意义(因为其他核可能正在释放锁);如果是单核,自旋毫无意义(必须挂起让出 CPU)。
  • 代码体现

    这在 HotSpot 源码中由 ObjectSynchronizer 处理,涉及 PreSpinThreshold 等参数(通常无需手动调优)。

💡 启示:在高并发、锁竞争极短的场景下(如高频交易计数器),自适应自旋能显著降低延迟。但如果业务逻辑复杂,锁持有时间长,自旋反而会增加 CPU 负载。

第四部分:偏向锁 (Biased Locking) 的废弃 (JDK 15+)

这是一个重大转折点。偏向锁曾是 JDK 6-14 的明星优化,但在 JDK 15 中被标记为废弃(Deprecated),并在 JDK 17+ 中彻底移除。

1. 偏向锁是什么?

  • 假设 :绝大多数锁都是单线程访问的(无竞争)。
  • 机制
    • 当第一个线程获取锁时,JVM 在对象头(Mark Word)记录下该线程的 ID。
    • 之后该线程再次进入同步块,无需任何 CAS 操作,只需检查一下对象头的线程 ID 是不是自己。是?直接通过!
    • 收益:在无竞争场景下,同步成本几乎为零。

2. 为什么被废弃? (核心原因)

A. 现代硬件的变化
  • CAS 太快了 :偏向锁的核心优势是避免 CAS。但在现代 CPU 上,CAS 指令已经极快(纳秒级)。偏向锁带来的性能提升(省掉一次 CAS)在现代硬件上变得微乎其微
  • 多核普及:现在的服务器都是多核,完全单线程访问共享数据的场景越来越少。
B. "撤销偏向"的代价太高 (致命伤)
  • 场景 :一旦有第二个线程 来竞争这个锁(哪怕只有一次),偏向锁就必须撤销 (Revoke)
  • 过程
    1. Stop-The-World (STW):JVM 必须暂停所有应用线程!
    2. 扫描栈:检查持有偏向锁的线程是否还活着,是否在锁的代码块内。
    3. 升级:将锁升级为轻量级锁(自旋锁)。
  • 后果 :如果应用存在交替竞争 (线程 A 用一会儿,线程 B 用一会儿),会导致频繁的 STW 和锁升级/撤销循环。这种抖动 (Thrashing) 会让性能比直接使用轻量级锁更差
C. 代码复杂度与维护成本
  • 偏向锁的逻辑极其复杂,占据了 JVM 同步代码的大量行数,却只带来微小的收益。移除它可以简化 JVM 代码库,便于后续优化。

3. 废弃后的影响与应对

  • 默认行为变化
    • JDK 15+:默认 -XX:-UseBiasedLocking (关闭偏向锁)。
    • 锁直接进入 无锁 -> 轻量级锁 (自旋) 流程。
  • 性能影响
    • 大多数应用无感知,甚至性能略微提升(避免了撤销偏向的 STW 开销)。
    • 极端单线程场景:理论上损失了"零成本"入锁的优势,但如前所述,CAS 很快,损失可忽略不计。
  • 迁移建议
    • 无需修改代码。synchronized 依然有效。
    • 如果你的应用极度依赖偏向锁(老旧系统),可以在 JDK 15/16 中通过 -XX:+UseBiasedLocking 强制开启,但建议在 JDK 17 升级前重构代码,减少对单线程锁热点的依赖。
优化策略 核心思想 触发条件 现状/趋势
锁消除 没竞争,就不锁 逃逸分析证明对象未逃逸 活跃。JIT 自动优化,无需干预。
锁粗化 频繁锁,合并锁 循环内或连续方法调用同一锁 活跃。建议手动在循环外上锁。
自适应自旋 看脸色,定时长 根据上次自旋成功率和核数动态调整 活跃。现代 CPU 下的标准配置。
偏向锁 独享锁,免 CAS 单线程重复进入同步块 已废弃 (JDK 15+)。因撤销代价大且 CAS 变快。

给开发者的终极建议

  1. 信任 JVM,但不要依赖魔法:JIT 很聪明(消除、粗化),但写出结构清晰的代码(如手动锁粗化)永远是最稳妥的。
  2. 关注"竞争"而非"锁" :性能瓶颈通常不是 synchronized 本身,而是锁竞争导致的线程阻塞
    • 减少锁粒度(拆分流)。
    • 减少锁持有时间(IO 移出锁外)。
    • 使用无锁数据结构(LongAdder, ConcurrentHashMap)。
  3. 拥抱新版本 :JDK 17+ 移除了偏向锁,说明 JVM 团队认为简单的轻量级锁 + 快速 CAS 是未来的方向。不要再去纠结偏向锁的参数调优了。
  4. 监控 STW :既然偏向锁撤销会引发 STW,而在高版本中它被移除了,这意味着由于锁竞争导致的停顿将更加可预测(主要是重量级锁的挂起/唤醒),这对延迟敏感型应用其实是好事。

赠送一个逃逸

这打字实属和文章不和谐,但是赠送的、自然要被大家知道,高调一些

1、什么是"逃逸" (Escape)?

在 Java 中,对象 通常创建在堆内存 (Heap) 中。

  • "逃逸"一个对象被创建后,它的引用(Reference)是否被"泄露"到了当前方法栈帧或当前线程之外

如果对象的引用跑出了当前的作用域,被其他方法、其他线程甚至全局变量持有了,我们就说这个对象**"逃逸"** 了。一旦逃逸,JVM 就无法保证只有单线程访问它,因此不能进行某些激进的优化(如栈上分配、锁消除)

2、三种典型的逃逸场景

全局逃逸 (Global Escape)

  • 定义 :对象被赋值给静态变量实例成员变量

  • 后果:整个应用生命周期内都可能访问到它,绝对无法优化,必须留在堆上。

    static List<String> globalList = new ArrayList<>(); // 对象直接逃逸到全局

    public void method() {
    String s = new String("hello");
    globalList.add(s); // s 的引用被存到了全局变量,s 逃逸了!
    }

参数逃逸 (Argument Escape)

  • 定义:对象作为参数传递给了其他方法。

  • 后果:接收参数的方法可能会把引用存起来,或者传给更多人。JVM 通常保守地认为它逃逸了(除非它能内联分析出接收方法没存引用)。

    public void method() {
    User u = new User();
    saveToDb(u); // u 作为参数传出去了,可能逃逸
    }

    void saveToDb(User user) {
    // 这里可能把 user 存到缓存、数据库连接等地方
    }

线程逃逸 (Thread Escape)

  • 定义 :对象被发布给了另一个线程(如放入 BlockingQueue,或作为 Thread 启动参数)。

  • 后果:其他线程可能随时访问,必须保证线程安全(加锁)。

    public void method() {
    Task t = new Task();
    new Thread(t).start(); // t 逃逸到了新线程
    }

🌟未逃逸 (No Escape):优化的金矿!

如果对象只在当前方法内部使用:

  • 没有传给外部方法。

  • 没有赋值给成员变量。

  • 没有发布给其他线程。

  • 结论:只有当前线程能访问它,不存在竞争,甚至不需要在堆上分配。

    public int calculate() {
    BigDecimal temp = new BigDecimal("100"); // 只在方法内用
    temp = temp.add(new BigDecimal("200"));
    return temp.intValue();
    // 方法结束,temp 销毁。它从未"逃逸"出 calculate 方法。
    }

2、什么是"逃逸分析" (Escape Analysis)
  • 逃逸分析 是 HotSpot JVM 在 JIT 编译期(运行时)进行的一种静态代码分析技术。
  • 目的不是优化代码本身,而是收集信息,告诉 JIT 编译器:"嘿,这个对象没逃逸,你可以对它动手脚了!";这在回收过程中占用重要的地位和作用!
分析过程

JVM 会遍历字节码,追踪对象的引用流向:

  1. 对象创建在哪里?
  2. 它的引用被赋值给了谁?
  3. 谁又使用了这个引用?
  4. 最终,这个引用有没有跑出当前栈帧(方法)或当前线程
基于逃逸分析的三大优化

一旦确认对象未逃逸,JVM 会执行以下"魔法":

  1. 栈上分配 / 标量替换 (Scalar Replacement) ⭐⭐⭐

    • 原理 :通常对象必须在堆上分配(需要 GC 回收)。如果对象没逃逸,JVM 可以把它拆散成基本类型(标量),直接分配到Java 虚拟机栈(栈帧)上,甚至根本不创建对象实例,只用几个局部变量代替。
    • 好处
      • 无需 GC:方法执行完,栈帧弹出,数据自动销毁,零 GC 压力。
      • 速度极快:栈内存分配只是移动指针,比堆分配快得多。
    • 注意 :HotSpot 目前主要实现的是标量替换(不创建对象实体,直接用局部变量代替字段),效果等同于栈上分配。
  2. 锁消除 (Lock Elimination) ⭐⭐

    • 原理:如果对象没逃逸,说明只有当前线程能访问它,不存在多线程竞争。
    • 操作 :JVM 直接移除代码中的 synchronized 锁指令。
    • 案例 :前文提到的 StringBuffer 在方法内局部使用,锁被消除,性能等同 StringBuilder
  3. 同步消除 (Synchronization Elimination)

    • 如果线程间通过某些机制(如 happens-before 规则)已经保证了可见性,JVM 可能会移除多余的 volatile 读写的内存屏障。
如何开启/验证?
  • 默认开启 :JDK 7+ 默认开启 -XX:+DoEscapeAnalysis
  • 查看日志 :添加 -XX:+PrintCompilation -XX:+PrintInlining -XX:+DoEscapeAnalysis 可以看到 JIT 编译时的优化信息。
  • 强制关闭-XX:-DoEscapeAnalysis (用于测试性能差异)
相关推荐
清 澜2 小时前
深度学习连续剧——手搓梯度下降法
c++·人工智能·面试·职场和发展·梯度
野犬寒鸦2 小时前
面试常问:什么是TCP连接:虚拟对话通道的奥秘
服务器·网络·后端·tcp/ip·面试·tcpdump
语戚3 小时前
从 JVM 底层拆解:i++、++i、i+=1、i=i+1 的实现逻辑与坑点
java·开发语言·jvm·面试·自增·指令·虚拟机
野生技术架构师3 小时前
Java面试精选:数据库 + 数据结构 +JVM+ 网络 +JAVA+ 分布式
java·数据库·面试
q1cheng3 小时前
(1)分组统计 + 筛选、(2)自连接去重和(3)子查询方式
面试
张元清3 小时前
每个 React 开发者都需要的 10 个浏览器 API Hooks
前端·javascript·面试
你这个代码我看不懂3 小时前
JVM栈、方法区和堆内存
java·开发语言·jvm
星辰_mya3 小时前
Fork/Join 框架与并行流:CPU 密集型的“分身术”
java·开发语言·面试
ErizJ3 小时前
面试 | gin gorm go-zero
面试·golang·gin·gorm·gozero