跳至正文
来两杯美式
返回

一文拿下 JVM(下):逃逸分析与代码优化

By 来两杯美式
发布于

这是「一文拿下 JVM」系列的第三篇,重点讲 JIT 阶段最核心的优化——逃逸分析。当年(2017)Java 8 默认开启,HotSpot 通过它能把部分对象直接消解在栈帧上,连 GC 都不需要。

一、什么是逃逸分析

逃逸分析(Escape Analysis)并不是直接的优化手段,而是一个代码分析。 它通过动态分析对象的作用域,为其他优化手段提供依据。

如果不存在逃逸行为,可对该对象进行:

发生逃逸有两种情况:

类型说明后果
方法逃逸一个对象在方法内创建,被方法体之外引用(方法返回该对象 / 创建的对象赋值给成员变量)方法执行完毕后,该对象无法被 GC 回收(被其他变量引用)
线程逃逸如类变量或实例变量,可能被其他线程访问到跨线程共享,性能下降

逃逸分析示意

二、相关参数

参数作用
-XX:+DoEscapeAnalysis开启逃逸分析
-XX:+PrintEscapeAnalysis开启逃逸分析后,可通过此参数查看分析结果
-XX:+EliminateAllocations开启标量替换
-XX:+PrintEliminateAllocations开启标量替换后,查看标量替换情况
-XX:+EliminateLocks开启同步消除

三、四大优化手段

3.1 同步消除

线程同步的代价是相当高的,同步的后果是降低并发性和性能

逃逸分析可以判断出某个对象是否始终只被一个线程访问。如果只被一个线程访问,那么对该对象的同步操作就可以转化成没有同步保护的操作——这样就能大大提高并发程度和性能。

// 锁对象的锁消除示例
public void doSomething() {
    Object lock = new Object();
    synchronized (lock) {
        // 业务逻辑
    }
}

上面的 lock 对象永远不会逃逸出 doSomething() 方法(没被返回、没赋给成员变量),因此 JIT 会把 synchronized 完全消除。

3.2 标量替换

Java 虚拟机中的原始数据类型(int、long 等数值类型以及 reference 类型等)都不能再进一步分解,它们就可以称为标量

相对的,如果一个数据可以继续分解,那它称为聚合量。Java 中最典型的聚合量是对象

如果逃逸分析证明一个对象不会被外部访问,并且这个对象是可分解的,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。拆散后的变量便可以被单独分配在栈帧上。

class Point {
    private int x;
    private int y;
}

public void calc() {
    Point p = new Point();
    p.x = 1;
    p.y = 2;
    System.out.println(p.x + p.y);
}

// 经标量替换后,等价于:
public void calc() {
    int x = 1;
    int y = 2;
    System.out.println(x + y);
}

标量替换后,对象根本没有被创建——连「对象」这个概念都不存在了。GC 没有任何工作要做。

3.3 栈上分配

严格说,HotSpot 并没有实现真正意义的栈上分配,实际上是标量替换

把对象打散成标量后,直接分配在栈帧的局部变量表里。方法结束、栈帧出栈,标量自动消亡——零 GC 压力。

3.4 缺点

栈上分配受限于栈的空间大小。一般自我迭代类的需求以及大的对象空间需求操作,将导致栈的内存溢出——故只适用于一定范围之内的内存范围请求。

四、实测对比

摘自当年笔记。JDK 1.8 默认开启了逃逸分析

4.1 标量替换测试

逃逸分析测试

通过上例:

注意:JDK 1.8 默认打开分层编译(Tiered Compilation),使用参数 -XX:-TieredCompilation 关闭分层编译后,堆中 User 数量进一步减少

性能差异

800 倍的性能差距——这就是逃逸分析的力量。

4.2 同步消除测试

同步消除的收益相对小(约 17%),因为大部分代码本来就很少锁。但如果用了 Vector / Hashtable 这类历史包袱类,收益会非常明显。

五、:= 与 = 的区别

当年笔记里一个不起眼的小点,但很多新手栽过。

典型场景:JVM 工具(如 jhat / VisualVM)展示对象字段时,= 是构造时的初始值,:= 是当前实际值。两者不一致说明对象字段被修改过。

六、实战建议

6.1 不要刻意为逃逸分析写代码

逃逸分析是 JIT 自动做的优化,绝大多数情况下你不需要关心它。如果你发现某段代码性能差,先从算法和数据结构层面优化,再考虑 JVM 层。

6.2 但要避免「逃逸陷阱」

// 反例 1:方法返回内部对象
public Object getFoo() {
    Foo foo = new Foo();    // foo 逃逸到方法外
    foo.init();
    return foo;
}

// 反例 2:赋值给成员变量
private List<Cache> caches = new ArrayList<>();
public void addCache() {
    Cache c = new Cache();   // c 逃逸到成员变量
    caches.add(c);
}

// 反例 3:传入到其他线程
new Thread(() -> {
    use(obj);               // obj 逃逸到另一个线程
}).start();

这三种写法都让对象无法享受逃逸分析的红利——但它们都是业务上必须的。所以逃逸分析是一种自动优化而不是强约束

6.3 别相信「栈上分配一定快」

栈上分配是手段,目的是减轻 GC 压力。如果你的应用 GC 压力本来就不大(Young GC 几十毫秒、Full GC 几乎不发生),开不开逃逸分析体验差异不大。

当年(2017)有人用「关闭逃逸分析性能反而变好」的例子——这是因为逃逸分析本身也要消耗 CPU 去做分析,对短生命周期应用是负优化。

七、小结

优化手段触发条件收益限制
同步消除锁对象不逃逸消除锁开销仅针对单线程访问的锁
标量替换对象不逃逸 + 可分解对象不进入堆对象不能跨方法使用
栈上分配同标量替换同标量替换同标量替换
方法内联调用足够频繁减少方法调用开销大方法无法内联

下一篇「一文拿下 JVM(番外):OOM 排查与 MAT / JVisualVM 实战」讲所有 JVM 问题中最让开发者头疼的那一类——线上 OOM 怎么救、dump 文件怎么读、MAT 怎么看。


分享这篇文章:
通过邮件分享这篇文章✓ 链接已复制
所属专题
JVM
第 3 / 6 篇
查看系列全部文章
  1. 01.一文拿下 JVM(上):内存区域全景
  2. 02.一文拿下 JVM(中):参数配置与常用命令
  3. 03.一文拿下 JVM(下):逃逸分析与代码优化
  4. 04.一文拿下 JVM(番外):OOM 排查与 MAT / JVisualVM 实战
  5. 05.一文拿下 JVM(终章):对象创建的 7 个步骤
  6. 06.一文拿下 JVM(锁篇):synchronized 锁的升级与三种锁对比

上一篇
一文拿下 JVM(番外):OOM 排查与 MAT / JVisualVM 实战
下一篇
一文拿下 JVM(中):参数配置与常用命令