天不负,终于、终于还是没结束~比如咱们没有提到的:
-
锁消除 (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. 背景:为什么要自旋?
当线程尝试获取锁失败时,有两种选择:
- 挂起 (Block) :交给操作系统,进入等待队列。优点是不占 CPU,缺点是上下文切换开销大(用户态<->内核态)。
- 自旋 (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)。
- 过程 :
- Stop-The-World (STW):JVM 必须暂停所有应用线程!
- 扫描栈:检查持有偏向锁的线程是否还活着,是否在锁的代码块内。
- 升级:将锁升级为轻量级锁(自旋锁)。
- 后果 :如果应用存在交替竞争 (线程 A 用一会儿,线程 B 用一会儿),会导致频繁的 STW 和锁升级/撤销循环。这种抖动 (Thrashing) 会让性能比直接使用轻量级锁更差!
C. 代码复杂度与维护成本
- 偏向锁的逻辑极其复杂,占据了 JVM 同步代码的大量行数,却只带来微小的收益。移除它可以简化 JVM 代码库,便于后续优化。
3. 废弃后的影响与应对
- 默认行为变化 :
- JDK 15+:默认
-XX:-UseBiasedLocking(关闭偏向锁)。 - 锁直接进入 无锁 -> 轻量级锁 (自旋) 流程。
- JDK 15+:默认
- 性能影响 :
- 大多数应用 :无感知,甚至性能略微提升(避免了撤销偏向的 STW 开销)。
- 极端单线程场景:理论上损失了"零成本"入锁的优势,但如前所述,CAS 很快,损失可忽略不计。
- 迁移建议 :
- 无需修改代码。
synchronized依然有效。 - 如果你的应用极度依赖偏向锁(老旧系统),可以在 JDK 15/16 中通过
-XX:+UseBiasedLocking强制开启,但建议在 JDK 17 升级前重构代码,减少对单线程锁热点的依赖。
- 无需修改代码。
| 优化策略 | 核心思想 | 触发条件 | 现状/趋势 |
|---|---|---|---|
| 锁消除 | 没竞争,就不锁 | 逃逸分析证明对象未逃逸 | 活跃。JIT 自动优化,无需干预。 |
| 锁粗化 | 频繁锁,合并锁 | 循环内或连续方法调用同一锁 | 活跃。建议手动在循环外上锁。 |
| 自适应自旋 | 看脸色,定时长 | 根据上次自旋成功率和核数动态调整 | 活跃。现代 CPU 下的标准配置。 |
| 偏向锁 | 独享锁,免 CAS | 单线程重复进入同步块 | 已废弃 (JDK 15+)。因撤销代价大且 CAS 变快。 |
给开发者的终极建议
- 信任 JVM,但不要依赖魔法:JIT 很聪明(消除、粗化),但写出结构清晰的代码(如手动锁粗化)永远是最稳妥的。
- 关注"竞争"而非"锁" :性能瓶颈通常不是
synchronized本身,而是锁竞争导致的线程阻塞 。- 减少锁粒度(拆分流)。
- 减少锁持有时间(IO 移出锁外)。
- 使用无锁数据结构(
LongAdder,ConcurrentHashMap)。
- 拥抱新版本 :JDK 17+ 移除了偏向锁,说明 JVM 团队认为简单的轻量级锁 + 快速 CAS 是未来的方向。不要再去纠结偏向锁的参数调优了。
- 监控 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 会遍历字节码,追踪对象的引用流向:
- 对象创建在哪里?
- 它的引用被赋值给了谁?
- 谁又使用了这个引用?
- 最终,这个引用有没有跑出当前栈帧(方法)或当前线程
基于逃逸分析的三大优化
一旦确认对象未逃逸,JVM 会执行以下"魔法":
-
栈上分配 / 标量替换 (Scalar Replacement) ⭐⭐⭐
- 原理 :通常对象必须在堆上分配(需要 GC 回收)。如果对象没逃逸,JVM 可以把它拆散成基本类型(标量),直接分配到Java 虚拟机栈(栈帧)上,甚至根本不创建对象实例,只用几个局部变量代替。
- 好处 :
- 无需 GC:方法执行完,栈帧弹出,数据自动销毁,零 GC 压力。
- 速度极快:栈内存分配只是移动指针,比堆分配快得多。
- 注意 :HotSpot 目前主要实现的是标量替换(不创建对象实体,直接用局部变量代替字段),效果等同于栈上分配。
-
锁消除 (Lock Elimination) ⭐⭐
- 原理:如果对象没逃逸,说明只有当前线程能访问它,不存在多线程竞争。
- 操作 :JVM 直接移除代码中的
synchronized锁指令。 - 案例 :前文提到的
StringBuffer在方法内局部使用,锁被消除,性能等同StringBuilder。
-
同步消除 (Synchronization Elimination)
- 如果线程间通过某些机制(如 happens-before 规则)已经保证了可见性,JVM 可能会移除多余的 volatile 读写的内存屏障。
如何开启/验证?
- 默认开启 :JDK 7+ 默认开启
-XX:+DoEscapeAnalysis。 - 查看日志 :添加
-XX:+PrintCompilation -XX:+PrintInlining -XX:+DoEscapeAnalysis可以看到 JIT 编译时的优化信息。 - 强制关闭 :
-XX:-DoEscapeAnalysis(用于测试性能差异)