Administrator
发布于 2021-09-09 / 4496 阅读
51

JIT 编译与逃逸分析:为什么这段代码没被优化

起因:压测时前 3 分钟的数据全不能看

9 月初给一个轨迹计算服务做压测,用 JMeter 压 10 分钟,发现吞吐曲线很怪:第 1 分钟 3200 QPS,第 3 分钟 7100 QPS,之后稳定在 8900 QPS 左右。同样的机器、同样的并发,吞吐差了 2.8 倍。

这其实就是 JIT 预热的典型曲线。但当时组里有人问了一句"为什么这段代码没被优化掉",我借机把分层编译和逃逸分析完整捋了一遍,顺带发现自己写的一个方法白分配了几百 MB 内存。

分层编译:五档执行状态

HotSpot 的执行不是"解释"和"编译"二选一,JDK 8 默认开启分层编译(-XX:+TieredCompilation),代码会在五档之间迁移:

层级含义触发阈值(默认值)
0解释执行,收集调用计数和回边计数-
1C1 编译,无 profilingTier3InvocationThreshold 之外的情况
2C1 + 调用/回边计数 profilingTier3InvocationThreshold=200
3C1 + 完整 profiling(含分支、类型)Tier3BackEdgeThreshold=60000
4C2 编译(或 JVM CI 的 Graal)Tier4InvocationThreshold=5000 / Tier4BackEdgeThreshold=40000

C1 编译快(几十毫秒)、优化少,目标是尽快脱离解释执行;C2 编译慢(几百毫秒到几秒)、优化激进,目标是峰值性能。中间那几档的 profiling 数据决定了 C2 的优化方向,比如"这个虚调用点 99% 是 A 类型",C2 就敢内联成 A 并加一个类型判断的守卫。

-XX:+PrintCompilation 能看到整个过程:

$ java -XX:+PrintCompilation -jar app.jar
   2891  1       3       java.lang.String::hashCode (67 bytes)
   2902  2       3       java.lang.String::charAt (29 bytes)
   3140  3   !   4       java.util.concurrent.ConcurrentHashMap::putVal (432 bytes)
   5981  4     n 0       java.lang.System::currentTimeMillis (0 bytes)   [native]
   8902  5 %     4       com.xxx.track.GeoCalculator::totalDistance @ 42 (311 bytes)
  12443  6       3       com.xxx.track.GeoCalculator::totalDistance (311 bytes)   made not entrant

格式是 时间戳 编译ID 属性 层级 方法名 字节码大小。属性里几个常见的:

  • %:OSR(栈上替换)编译,也就是方法还在栈上就替换成编译版,一般是循环跑太多次触发(回边计数)。
  • !:方法里有异常处理器。
  • s:同步方法。
  • n:native 方法。
  • made not entrant:这份编译产物不再接收新调用,通常是因为发生了去优化(deopt),比如类型假设被打破。

上面第 5 行是 OSR 编译,说明 totalDistance 里的循环跑得太猛,还没返回就被要求换成本地代码了。第 6 行 C1 版本被标记 not entrant,是因为 C2 版本接手了。

出问题的那段代码

回到正题。我有个计算轨迹总距离的方法,简化之后是这样:

public class GeoCalculator {

    static class Point {
        final double x, y;
        Point(double x, double y) { this.x = x; this.y = y; }
        double distanceTo(Point other) {
            double dx = this.x - other.x;
            double dy = this.y - other.y;
            return Math.sqrt(dx * dx + dy * dy);
        }
    }

    private static final Point ORIGIN = new Point(0, 0);

    public double totalDistance(List<RawPoint> raws) {
        double sum = 0;
        for (int i = 0; i < raws.size(); i++) {
            RawPoint r = raws.get(i);
            Point p = new Point(r.getX(), r.getY());   // 期望被标量替换掉
            sum += p.distanceTo(ORIGIN);
        }
        return sum;
    }
}

Point 是纯局部对象,没被返回、没被存字段,按逃逸分析的规则应该被标量替换:不真的分配对象,直接把 xy 拆成两个局部变量(标量),整个循环零分配。

但用 JMH 1.32 测出来分配速率是 778 MB/s,说明完全没有消除。

用 PrintInlining 看内联决策

逃逸分析能不能生效,前提是相关的方法被内联到同一个编译单元里。逃逸分析是方法级的(JIT 编译时做),跨方法它看不到。所以先看内联:

$ java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining \
       -XX:+PrintCompilation -jar app.jar
@ 42   com.xxx.track.GeoCalculator$Point::distanceTo (35 bytes)   virtual call
  @ 12   com.xxx.track.GeoCalculator$Point::distanceTo (35 bytes)   not inlineable
  @ 12   com.xxx.track.GeoCalculator$ColoredPoint::distanceTo (35 bytes)   not inlineable
  @ 12   com.xxx.track.GeoCalculator$Point::distanceTo (35 bytes)   megamorphic virtual call

原因找到了。项目里有个 ColoredPoint extends Point,虽然轨迹计算这条路径上从来没用过它,但 JVM 在这个调用点收集到的类型 profile 里有两个接收者,超过了双态内联的阈值(-XX:BimorphicInlining 相关,默认超过 2 个就按 megamorphic 处理),于是放弃了内联

内联失败导致两件事:

  1. distanceTo 保持为真实的方法调用,Point 对象作为 this 和参数传出去,JIT 认为它"方法逃逸"了,不敢做标量替换。
  2. 循环里每次迭代都是一次虚方法分派,无法做进一步优化。

逃逸分析的三类优化

顺便把这三个概念理清,它们经常一起被提到但作用不同:

优化参数作用
标量替换-XX:+EliminateAllocations不分配对象,把字段拆成局部变量
锁消除-XX:+EliminateLocks锁对象未逃逸时,去掉同步
栈上分配-XX:+DoEscapeAnalysis 配套HotSpot 实际只做标量替换,没有严格意义的栈上分配

控制它们的总开关是 -XX:+DoEscapeAnalysis(JDK 8 起默认开启),三个子项也都默认开启。想验证效果可以逐个关掉对比。

改法一:把类和方法变成 final

static final class Point {                 // 加 final,不可能有子类
    final double x, y;
    Point(double x, double y) { this.x = x; this.y = y; }
    final double distanceTo(Point other) {   // 方法也 final
        double dx = this.x - other.x;
        double dy = this.y - other.y;
        return Math.sqrt(dx * dx + dy * dy);
    }
}

类加了 final 之后,Point::distanceTo 这个调用点不再是虚调用(没有重写可能),JIT 无条件内联。ColoredPoint 我另建了一个独立的基类体系,不继承 Point

再看内联日志,变成了:

@ 42   com.xxx.track.GeoCalculator$Point::distanceTo (35 bytes)   inline (hot)
  @ 4    com.xxx.track.GeoCalculator$Point::<init> (21 bytes)   inline (hot)

两个字:inline (hot)

改法二:注意内联的体积限制

还有一次我遇到内联失败,原因是方法太大。HotSpot 有两个硬限制:

-XX:MaxInlineSize=35           # 非热点方法,字节码超过 35 字节就不内联
-XX:FreqInlineSize=325         # 热点方法,超过 325 字节就不内联
-XX:MaxInlineLevel=9           # 内联嵌套深度

我有个 buildReport() 方法,因为历史原因堆到了 480 字节字节码(javap -c 可以量),远超 325,导致它内部所有小方法调用都失去了被内联的机会,连带逃逸分析全部失效。拆成四个方法之后,每块都在 120 字节以内,全部内联成功。

$ javap -c -p com.xxx.track.GeoCalculator | grep -A1 "public double totalDistance"
  public double totalDistance(java.util.List<com.xxx.track.RawPoint>);
    Code:
       0: dconst_0
       ...
     311: dreturn          # 311 是 Code 属性里的长度,接近 325 上限

JMH 实测对比

JMH 配置:5 轮 warmup(每轮 1 秒)+ 5 轮测量(每轮 1 秒),3 个 fork,输入 1000 个点的 list。

@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
@State(Scope.Benchmark)
@Fork(3)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 5, time = 1)
public class GeoBenchmark {
    private List<RawPoint> raws;

    @Setup
    public void setup() {
        raws = IntStream.range(0, 1000)
                .mapToObj(i -> new RawPoint(i * 1.5, i * 2.3))
                .collect(Collectors.toList());
    }

    @Benchmark
    public double polymorphic() { return calculatorV1.totalDistance(raws); }

    @Benchmark
    public double finalVersion() { return calculatorV2.totalDistance(raws); }
}

结果(-prof gc 拿分配数据):

版本吞吐平均耗时分配速率每次分配
多态版(内联失败)24,312 ops/s41.1 us/op778 MB/s32,016 B/op
final 版(内联成功)61,730 ops/s16.2 us/op0.03 MB/s≈ 0 B/op

吞吐 2.54 倍,分配从每次 32 KB 降到接近 0(剩下的 0.03 MB/s 是 JMH 框架自身的开销)。因为不再分配,Young GC 次数从每秒 90 多次降到基本没有,这也是吞吐提升的一部分来源。

反过来看预热问题

明白这些之后,开头那个压测曲线就好解释了。前 1 分钟大部分代码还在解释执行或者 C1 阶段,C2 编译要攒够 5000 次调用或者 40000 次回边。我们那个服务启动后核心方法调用量上得慢,导致前 3 分钟一直在低速档。

处理办法:

  • 压测必须预热。我们改成先跑 5 分钟预热再开始计数,数据稳定多了。JMH 的默认 warmup 也是这个道理。
  • 线上可以在发布后做一次流量预热,或者用 -XX:CompileThreshold 降低阈值(不建议,会让更多方法被编译,占用 CodeCache)。
  • 如果想让某些方法启动即编译,可以用 JIT 编译命令文件:-XX:CompileCommandFile=hotmethod.txt,里面写 compileonlyinline 规则。这个我用得少,调试时临时用。

去优化:优化不是不可逆的

C2 的激进优化建立在假设上,比如"这个虚调用点只见过 A 类型"、"这个分支从没走过"。假设一旦被打破,JVM 会去优化(deoptimization):丢弃这份编译产物,退回解释执行重新收集 profile。

日志长这样:

  12443  6       3       com.xxx.track.GeoCalculator::distanceTo (35 bytes)   made not entrant
  12444  7 %     4       com.xxx.track.GeoCalculator::distanceTo (35 bytes)   made zombie
  12501  8       3       com.xxx.track.GeoCalculator::distanceTo (35 bytes)   COMPILE SKIPPED: already compiled

made not entrant 是不再接收新调用(老调用还在栈上跑完),made zombie 是彻底没人用了,CodeCache 可以回收它。

频繁去优化会显著拖慢程序,因为它意味着"编译 + 回退 + 重新编译"的循环。常见的触发原因:

  • 虚调用点出现了第三种类型(从双态变 megamorphic),之前内联的代码失效。
  • 类加载导致继承关系变化,比如原来只有一个实现类,突然又加载了一个。
  • 分支 profile 反转,某个从来不走的分支突然频繁走。

我们踩过第二种。有个 PaymentProcessor 接口,线上只注册了一个实现类,C2 把它内联了。后来加了个灰度开关动态加载第二个实现类,加载的那一瞬间全站 P99 抖了 200 ms——全是去优化的开销。

查看去优化事件:

-XX:+UnlockDiagnosticVMOptions -XX:+TraceDeoptimization

另外要留意 CodeCache。它是有上限的(JDK 11 默认 240 MB),编译产物存满了之后 JIT 会停止编译,性能断崖式下跌,而且日志里只有一行不起眼的提示:

CodeCache is full. Compiler has been disabled.
Try increasing the code cache size using -XX:ReservedCodeCacheSize=
$ jcmd 1 VM.native_memory summary | grep -A2 "Code"
$ jstat -compiler 1
Compiled Failed Invalid   Time   FailedType FailedMethod
   48211      3       1  412.33          1 com/xxx/Foo bar

我们有个跑了两年的老服务遇到过一次,原因是用了大量动态代理(CGLIB 生成了几万个类),每个类都要编译。最后把 -XX:ReservedCodeCacheSize 从 240 MB 调到 512 MB 解决。如果监控里发现 P99 在没有任何变更的情况下缓慢劣化,值得看一眼 CodeCache。

就写到这。如果哪天你也被《JIT 编译与逃逸分析:为什么这段代码没被优化》里同一个坑绊住,回来翻这篇,能省半小时。

参考