JVM:字节码、JIT 与垃圾回收
The JVM: Bytecode, JIT, and Garbage Collection
HotSpot 这个名字来自一个经验判断:程序里真正值钱的是一小撮反复执行的热点,而不是每一字节码都值得同等待遇。它的前身是 Animorphic 的 Strongtalk,血统接到 Self 语言那一套类型反馈与内联缓存。Sun 在 1997 年买下这项技术,2000 年的 Java 1.3 把它做成默认虚拟机。今天 OpenJDK 里的 HotSpot 仍然是同一句话的放大版:解释器先跑,分析器数次数,编译器按温度分层介入,猜错了再去优化拆掉。
这篇文章要拆的是温度怎么变成机器码,以及停顿怎么从“全世界停住”演变成可配置的收集器家族。Java 程序员看见的是字节码和对象,运行时看见的是一份永远可能被推翻的投机编译假设。
破除误解
误解
**误解一:JVM 就是“先解释、够慢了再整体编译”。实情**
分层编译从 Java 7 起成为默认。解释器负责冷代码和收集剖面;C1(client 编译器)出码快、优化浅,用来让方法尽快离开解释器;C2(server 编译器)做激进内联、逃逸分析、循环变换,出码慢、假设多。一个方法可以在这些档位之间升级,也可以在假设失败时降回去。没有单一的“编译完成”时刻。温度在变,代码版本就在变。
误解二:垃圾回收就是 stop-the-world,所以 Java 一定卡。
串行收集器确实停全世界。并行收集器把停顿里的工作用多线程做完,停顿仍在,只是变短。CMS 试图把标记做成并发,最终仍留下一次较短的停顿,并在 Java 14 被移除。G1 从 Java 9 成为默认,按区域回收,停顿目标可设。ZGC 与 Shenandoah 把停顿进一步压到以毫秒计的根扫描量级,代价是读屏障、更多的并发协议和更高的运行时开销。选择收集器,是在吞吐、停顿、内存占用和 JDK 版本之间做产品决策,不是在“会不会卡”的二元神话里站队。
误解三:JIT 生成的代码一旦跑起来,语义就和字节码永久绑定。
投机优化依赖类层次、调用点的接收者类型、空指针很少出现这类剖面。新类被加载、调用点变成多态、某个“从来不是 null”的值变成 null,假设就碎了。去优化要把编译帧还原成解释器能继续的状态:局部变量、操作数栈、监视器。没有这条还原路径,投机就是不可撤销的撒谎。HotSpot 愿意赌,是因为它付得起悔棋。
剖面、内联与理想图
解释器里的调用点记着接收者的类型。单态时,内联缓存把目标地址写死,外加一次类检查;多态时扩成一张小表;再乱就退回虚分派。C2 拿到足够稳定的剖面后,把热点方法连同被内联进来的被调用者,建成一张理想图(Sea of Nodes 风格的 SSA)。在这张图上,空检查可以外提,锁可以因逃逸分析而消失,循环可以变成向量化的机器指令。图越肥,优化越有空间;编译时间也越长。所以不是所有方法都配得上 C2。
栈上替换让正在跑的长循环不必等下一次调用才用上新版本。循环回边遇到安全点,可以把解释帧或 C1 帧换成 C2 帧。这是温度机制的细节,却决定了科学计算式的长方法会不会永远困在浅优化档。安全点本身是另一套协议:线程定期检查“是否有人要求全世界在一个已知位置停下”——为了 GC、去优化、或者某些类重定义。安全点太稀,停顿会拖到下一次检查;太密,热循环里多出一轮轮的轮询。运行时把检查插在回边和调用点,是在这两头之间折中。
对象模型与 JIT 互相咬合。对象头里的标记字要服务同步、GC 和哈希码。偏向锁曾经假设“这个对象只会被一个线程锁住”,在 Java 18 之后的版本里逐步退场,因为现代负载里锁的所有权跳得太勤,偏向的收益不再稳定。压缩指针把 64 位堆上的引用收成 32 位偏移,省内存、也改变了 JIT 生成的地址计算。这些看起来像 VM 内部琐事的选择,会从对象对齐一直传到 C2 的寻址模式。读 HotSpot,不能把“语言语义”和“这一版的对象头布局”当成互不相干的两层。
模板解释器值得单独说一句。它不是一个巨大的 switch (opcode)。每个字节码对应一小段预先生成的机器码模板,靠寄存器约定把操作数栈映射到物理寄存器。这让解释器已经不像教科书里的字节码循环那么慢,也为从解释器帧长到 C1 帧提供了稳定的形状。Self 和 Strongtalk 传下来的那套“先让常见路径很快,再为罕见路径准备退路”,在解释器这一层就已经开始了。
收集器家族:同一份堆,多套停顿经济学
堆被分成新生代与老年代,是因为大多数对象死得快。Minor GC 只扫年轻的区域,成本跟活着的年轻对象有关,跟堆的总大小关系较弱。对象活过几次复制,才晋升。这个假说对大多数服务端负载足够好,对巨大的缓存式老年代则不够——于是有了 CMS、G1、ZGC 这些主要在老年代或全堆上动刀的收集器。
G1 把堆切成大小相近的区域,每次回收挑选“回收收益高”的一组区域,并尽量把停顿控制在目标以内。它仍然有停顿,只是把停顿变成可预测的预算,而不是一次与堆大小成正比的全堆扫描。ZGC 用彩色指针和读屏障让应用线程在并发标记、转移时仍能把引用修正到正确的副本;停顿主要花在根上,不随堆增大而线性变长。这是另一份账单:每条读引用的路径都可能多一次屏障逻辑,JIT 必须把屏障插进生成的代码里。收集器和编译器在这里不是两个子系统,它们共享同一份对象访问协议。
并发与 GC 的交界还有分配。线程本地分配缓冲让大多数 new 不必去抢全局锁;缓冲耗尽才到公共的空闲空间去取。分配失败可以触发一次收集。于是“创建对象”这条语言里最普通的句子,背后是一套关于缓冲、晋升阈值和收集器触发器的经济。逃逸分析若证明对象不会逃出方法,C2 甚至能把对象拆到寄存器里,让这次 new 根本不进堆。那是编译器在帮收集器减少工作,而不是收集器变快了。
Project Loom 的虚拟线程(Java 21)再次改写了这幅图。百万级的轻量线程不能每个都配一个操作系统栈和一次完整的安全点协商。调度从“操作系统看见的线程”部分挪回 JVM 内部。GC 和 JIT 必须理解:被挂起的虚拟线程,其栈帧可能作为普通堆对象存在。运行时的温度机制仍在,只是“线程”这个词不再自动等于一次操作系统的停顿单位。
这台机器不是语言教程
虚方法表、接口调用、装箱、逃逸分析消掉的分配,全是规格与剖析共同决定的代码形状。语言课讲语法糖;运行时把糖还原成调用点,再决定敢不敢内联。偏置锁被拆除,是因为容器里的调度让“锁几乎总被同一线程拿”的假设失效。虚拟线程改的是调度与栈,不改对象图的体积,也不改收集器的物理定律。把虚拟线程当成“不用再管垃圾回收”,会在堆上堆出更多短命任务对象之后第一次看见暂停。
代码缓存和元空间是堆外的两本账。动态生成类、反射、模板引擎会把它们撑满,表现像泄漏。这不是堆上限能看见的。容器内存限制若只按堆来设,会在元空间或本地内存上被内核杀掉。JDK 后来学会读容器限额,但那只是让默认值别按宿主机内存去猜,并不免除你理解这台机器有几块独立的内存账。
剖析如何把规格变成这一台机器上的代码
同一份字节码,在不同输入分布下会变成不同的机器码。调用点见到的具体类型越稳定,内联越敢做;越多态,越要留虚调用。这就是热点:不是“跑了一会儿所以快”,而是“观察够了,所以敢赌”。去优化是赌输的手续费。容器里负载抖动大,剖面还没稳定实例就被杀掉,分层永远爬不完。预热策略、保存剖析数据、或改用提前编译,都是在承认:这台机器的峰值性能不是开关,是一段需要被调度的时间。
垃圾回收家族则是另一份对分布的赌。分代赌大多数对象早死;分区赌可以挑选性价比高的区域先收;着色指针赌屏障比停顿更划算。选错收集器,等于用错误的分布假设去收真实的对象寿命。调参之前先量寿命直方图,比背诵一组推荐参数更接近这台机器的脾气。
把这台机器写进容量计划,至少要分开三本账:堆、元空间与代码缓存、本地内存与线程栈。容器限额必须覆盖三者之和,再留收集器与编译器工作时的余量。只按堆去买内存,会在另外两本账上被杀死,日志却看起来像应用泄漏。值班手册里把这三本账并列,是把虚拟机当成系统对象而不是当成语言的最低要求。
类验证、链接和初始化的失败,会出现在代码明明已经编译过的时刻。缺依赖、循环初始化、模块未声明的访问,都是这台机器在运行时才做完的检查。构建系统过了,只说明字节码写出来了,不说明虚拟机愿意在这一份类路径上把图连起来。容器镜像少了一层依赖,症状是链接错误而不是编译错误。
本地代码接口是绕过字节码规格的路径。一旦进入本地库,验证器不再看见栈类型,安全点与屏障靠约定维持。这是性能逃逸口,也是崩溃和停顿错位的常见来源。把关键循环下放到本地库之后,必须把那些库纳入同一套值班:崩溃日志、安全点延迟、以及它们是否挡住收集器前进。
容量计划至少要分开三本账:堆、元空间与代码缓存、本地内存与线程栈。容器限额必须覆盖三者之和,再留收集器和编译器工作时的余量。只按堆去买内存,会在另外两本账上被杀死,日志却看起来像应用泄漏。
值班手册里把这三本账并列,是把虚拟机当成系统对象而不是当成语言的最低要求。预热、停顿和类加载失败,都应先问发生在哪一本账上,再决定调哪一组开关。
字节码契约、invokedynamic 与 AOT
class 文件是语言中立的契约:魔数、常量池、方法字节码、验证器在加载时检查栈类型。Scala、Kotlin、Clojure 都往这份契约上编译。虚拟机看见的不是 Java 语法,是描述符和指令。
Java 7 的 JSR 292 引入 invokedynamic。这条指令不绑定固定目标,而绑定一个引导方法,第一次执行时返回 CallSite,之后可以被 JIT 当成普通调用优化。设计目标是让动态语言把方法查找变成 JVM 认识的调用点。Java 8 的 lambda 立刻借用它:不再为每个 lambda 生成一个匿名类文件,而是按需旋转出函数对象。调用指令变成了语言演化的挂钩。
对照是提前编译。GraalVM Native Image 在构建期做封闭世界分析,输出本地可执行文件,启动可以到毫秒。账单是反射、动态代理、运行时生成字节码必须在构建配置里登记。JIT 买到的是剖析驱动的投机;AOT 买到的是零热身。同一份字节码可以走两条后端,这才是 JVM 作为目标格式的好处。把 AOT 当成 JIT 的无损替换,是在忽略剖析这一维。
跨域连接
- 概率论:分层编译是一场用经验分布下注的过程。调用点的类型剖面是有限样本上的频率估计,C2 把众数当成接下来还会成立的事件来生成代码。它敢赌,是因为去优化把赌输的代价变成一次有界的帧重建,而不是未定义行为。多态上限、编译阈值、反优化风暴时的降档,都是在给这张分布设置最小样本量和止损。当负载的类型分布非平稳,昨天的热点会变成今天的去优化循环,期望值翻负,运行时只能停止在那一点上下注。
- 玻尔兹曼统计力学:分代假说把堆当成大量粒子的系综:大多数对象寿命极短,少数对象进入长寿命态。Minor GC 扫的是短寿命粒子占主导的那一层,成本跟“仍然活着的年轻对象”有关,就像自由能只依赖宏观统计量而不依赖每一个微观轨迹。晋升阈值是一条人为的分界,把系综切成两个温度不同的区域。ZGC 一类并发收集器则更像在等温过程里持续修正位置,不允许一次把整个系综冻结来做全局重排。假说失效的负载——对象几乎都不死——会让这套统计力学账单崩掉,那时换收集器就是换一条状态方程。
- 证明:JIT 里的类型检查和类层次假设是可废止的证明。编译器在当前类加载状态下推出“这个调用只有一个目标”,并据此内联;新类加载是新证据,旧证明被撤销。去优化是撤销之后的证明重建:必须能回到字节码语义这个公理系统。这和静态 AOT 编译追求的一劳永逸证明不同。Java 的动态加载选择了可废止路线,于是运行时必须永远背着一张“如何把机器帧还原成字节码状态”的账本。没有这本账,投机优化在逻辑上就不合法。
- 图论:C2 的理想图是方法内数据流的有向图,内联把调用边收缩成子图展开。逃逸分析问的是对象是否沿着引用边逃出当前子图;锁消除问的是监视器节点能否因这张图的局部性而删除。GC 的区域选择则是另一张图上的割:哪些区域之间的记忆集边少、回收收益高。编译器和收集器都在图上做收缩与切割,只是一张图的顶点是指令,另一张图的顶点是堆区域。读懂 HotSpot,往往是在两张图之间切换,而不是在“语言特性列表”里打勾。
参考文献
- Paleczny, M., Vick, C., Click, C. The Java HotSpot Server Compiler. JVM Research and Technology Symposium, 2001.
- Hölzle, U., Chambers, C., Ungar, D. Debugging Optimized Code with Dynamic Deoptimization. ACM PLDI, 1992.
- Detlefs, D. et al. Garbage-First Garbage Collection. ACM ISMM, 2004.
- Tene, G., Iyengar, B., Wolf, M. C4: The Continuously Concurrent Compacting Collector. ACM ISMM, 2011.(对照:低停顿收集的另一条工业路线)
- OpenJDK. JEP 248: Make G1 the Default Garbage Collector;JEP 333/377: ZGC;JEP 444: Virtual Threads. 对应 JDK 版本说明。
延伸阅读
- Click, C. 关于 Sea of Nodes 的公开讲座与笔记(理想图为何把控制与数据放在同一张图上).
- OpenJDK HotSpot 源码树中的
src/hotspot/share/c1、c2、gc目录结构说明——比任何二手图都准确地标出分层边界. - Ungar, D. & Smith, R. B. Self: The Power of Simplicity. OOPSLA, 1987.(类型反馈与原型对象的前史)