"Java 慢"是个过时的偏见。现代 JVM 之所以能在长时间运行的服务端逼近 C/C++ 的性能,核心就在于 JIT(Just-In-Time)即时编译器和它背后的一整套激进优化。其中逃逸分析(Escape Analysis)尤其有趣:它能让"new 出来的对象不进堆"这种听起来违反直觉的事情发生。这篇文章把 JIT 的工作机制和逃逸分析拆开讲。

场景:为什么刚启动的服务慢,跑久了就快了

很多人观察到一个现象:服务刚发布上线,前几分钟接口响应慢、CPU 高;运行一段时间后逐渐变快、趋于平稳。这就是 JIT 在起作用。Java 程序启动时,字节码先由解释器逐条执行——通用但慢。JVM 同时在统计每个方法、每个循环的执行次数,一旦某段代码"热"到一定程度,就把它编译成本地机器码。这种"边解释边编译热点"的混合模式,正是 HotSpot 名字的由来。

机制一:分层编译与热点探测

HotSpot 有两个编译器:C1(client,编译快、优化浅)和 C2(server,编译慢、优化深)。现代 JVM 默认开启分层编译(Tiered Compilation),把执行分成多个层级,大致是:

1
2
3
Level 0: 解释执行
Level 1/2/3: C1 编译(带不同程度的性能计数)
Level 4: C2 编译(最高优化)

热点的判定靠两类计数器:方法调用计数器和回边计数器(循环回跳次数)。当计数超过阈值,就触发编译。循环里的回边计数还支持栈上替换(OSR,On-Stack Replacement)——一个正在执行的长循环可以在不退出方法的情况下,中途切换到编译后的版本继续跑。

可以用参数观察这个过程:

1
2
3
4
5
# 打印编译事件
java -XX:+PrintCompilation -jar app.jar

# 打印某方法的内联决策(需要 diagnostic 选项)
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining -jar app.jar

机制二:JIT 的招牌优化——内联

内联(Inlining)是 JIT 的"优化之母"。它把被调用方法的方法体直接展开到调用点,消除方法调用开销(压栈、跳转、返回)。更重要的是,内联之后调用方和被调用方的代码合并,给后续的常量传播、死代码消除、逃逸分析创造了更大的优化视野。

1
2
int area(int w, int h) { return w * h; }
int total() { return area(3, 4) + area(5, 6); }

内联后 total 可能直接被折叠成返回常量 42。但内联有预算:方法太大(字节码超过阈值)就不内联,否则编译产物膨胀、指令缓存命中率下降。这就是为什么"超长方法反而难被优化"——它过不了内联门槛。这也解释了一个常见误区:手动把方法拆小或合并,未必比 JIT 自己判断更好。

机制三:逃逸分析

逃逸分析判断一个对象的作用域会不会"逃出"当前方法或线程。分三种逃逸状态:

  • 不逃逸:对象只在方法内部使用,不会被外部引用。
  • 方法逃逸:作为返回值或参数传给其他方法。
  • 线程逃逸:被赋给静态字段、实例字段,可能被其他线程访问。

对于不逃逸的对象,JIT 可以做三件大事:

标量替换(Scalar Replacement):这是逃逸分析最大的收益。如果对象不逃逸,JIT 干脆不创建这个对象,而是把它的字段拆成一个个独立的局部变量(标量),直接放进寄存器或栈。于是"分配"消失了,GC 压力随之消失。

1
2
3
4
5
6
7
public int distance(int x1, int y1, int x2, int y2) {
Point p1 = new Point(x1, y1); // 不逃逸
Point p2 = new Point(x2, y2); // 不逃逸
int dx = p1.x - p2.x;
int dy = p1.y - p2.y;
return dx * dx + dy * dy;
}

这里两个 Point 都不逃逸。开启逃逸分析后,JIT 把 p1.x 直接当作 x1 用,两个 new Point 根本不会在堆上分配。注意一个广泛流传的误解:很多人说"逃逸分析会把对象分配到栈上(栈上分配)"。实际上 HotSpot 主要落地的是标量替换,并没有真正实现把整个对象按 C 结构体放栈上;标量替换在效果上类似但机制不同——它把对象拆没了。

锁消除(Lock Elision):如果一个加了锁的对象不逃逸出线程,那这把锁不可能被竞争,JIT 直接把同步去掉。经典例子是 StringBuffer(内部同步)作为局部变量使用时,append 上的锁会被消除。

栈上分配:对于少数情况,不逃逸对象的生命周期可绑定方法栈帧。

控制参数(默认开启):

1
java -XX:+DoEscapeAnalysis -XX:+EliminateAllocations -jar app.jar

工程权衡与边界

  • 去优化(Deoptimization):JIT 的优化建立在"乐观假设"上,比如基于当前类层次内联了某个虚方法。如果之后动态加载了新子类、覆写了该方法,假设被打破,JVM 必须回退到解释执行(去优化),重新编译。频繁去优化会造成性能抖动。
  • 逃逸分析不是免费的:它本身有编译时开销,且分析是保守的——稍微复杂的逃逸路径(对象进了集合、被反射访问、被传给无法内联的方法)就会判定为逃逸,标量替换失效。所以"小对象、短生命周期、调用链能被内联"才是逃逸分析能发力的前提。
  • 预热(Warmup)的代价:基准测试(benchmark)必须充分预热,否则测的是解释器+半编译状态,结果毫无意义。这也是 JMH 框架存在的核心理由。
  • 代码缓存(Code Cache):编译产物存在固定大小的 Code Cache 里。微服务、大量动态代理/lambda 的应用可能撑满它,一旦满了 JIT 停止编译,性能断崖式下跌。可用 -XX:ReservedCodeCacheSize 调整。

小结

JIT 让 Java 在运行时根据真实执行画像做激进优化:分层编译挑出热点,内联打通优化视野,逃逸分析靠标量替换消灭短命对象的分配、靠锁消除去掉无竞争的同步。理解这些机制,你才能写出对 JIT 友好的代码——保持方法精简以便内联、让临时对象不逃逸以便消除分配——而不是凭直觉做反优化。