起因:压测时前 3 分钟的数据全不能看
9 月初给一个轨迹计算服务做压测,用 JMeter 压 10 分钟,发现吞吐曲线很怪:第 1 分钟 3200 QPS,第 3 分钟 7100 QPS,之后稳定在 8900 QPS 左右。同样的机器、同样的并发,吞吐差了 2.8 倍。
这其实就是 JIT 预热的典型曲线。但当时组里有人问了一句"为什么这段代码没被优化掉",我借机把分层编译和逃逸分析完整捋了一遍,顺带发现自己写的一个方法白分配了几百 MB 内存。
分层编译:五档执行状态
HotSpot 的执行不是"解释"和"编译"二选一,JDK 8 默认开启分层编译(-XX:+TieredCompilation),代码会在五档之间迁移:
| 层级 | 含义 | 触发阈值(默认值) |
|---|---|---|
| 0 | 解释执行,收集调用计数和回边计数 | - |
| 1 | C1 编译,无 profiling | Tier3InvocationThreshold 之外的情况 |
| 2 | C1 + 调用/回边计数 profiling | Tier3InvocationThreshold=200 |
| 3 | C1 + 完整 profiling(含分支、类型) | Tier3BackEdgeThreshold=60000 |
| 4 | C2 编译(或 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 是纯局部对象,没被返回、没被存字段,按逃逸分析的规则应该被标量替换:不真的分配对象,直接把 x、y 拆成两个局部变量(标量),整个循环零分配。
但用 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 处理),于是放弃了内联。
内联失败导致两件事:
distanceTo保持为真实的方法调用,Point对象作为this和参数传出去,JIT 认为它"方法逃逸"了,不敢做标量替换。- 循环里每次迭代都是一次虚方法分派,无法做进一步优化。
逃逸分析的三类优化
顺便把这三个概念理清,它们经常一起被提到但作用不同:
| 优化 | 参数 | 作用 |
|---|---|---|
| 标量替换 | -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/s | 41.1 us/op | 778 MB/s | 32,016 B/op |
| final 版(内联成功) | 61,730 ops/s | 16.2 us/op | 0.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,里面写compileonly或inline规则。这个我用得少,调试时临时用。
去优化:优化不是不可逆的
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 编译与逃逸分析:为什么这段代码没被优化》里同一个坑绊住,回来翻这篇,能省半小时。