第七章 JIT编译器:JIT编译流程、中间表示(IR)、寄存器分配与指令选择

聊到JIT编译器,很多人第一反应就是“快”。但快不是凭空来的,背后是一整套精密的流水线作业。我做了这么多年Android Runtime,每次看JIT的编译流程,都觉得它像一条高效的工厂生产线——从字节码进来,到机器码出去,中间每一步都有讲究。

今天我们就来拆解这条生产线。我会重点讲三个核心环节:编译流程、中间表示(IR)、以及寄存器分配与指令选择。嗯,这三个环节搞明白了,你对JIT的理解就能上一个台阶。

7.1 JIT编译流程:从字节码到机器码

JIT编译不是一上来就全量编译的。ART里的JIT走的是“按需编译”路线——只有热点代码才会被编译。我个人习惯把JIT编译流程分成四个阶段:

  1. 字节码输入:从DEX文件中读取方法字节码
  2. IR构建:将字节码转换为中间表示(HIR)
  3. 优化与寄存器分配:在IR上做各种优化,然后分配物理寄存器
  4. 指令选择与发射:生成目标平台的机器码

说白了,这就是一个“翻译+优化”的过程。你想想看,字节码是跑在虚拟机的栈上的,而机器码是直接操作寄存器的,这两者之间差了好几个抽象层级。JIT要做的,就是把栈操作变成寄存器操作,同时还要保证性能。

核心要点:JIT编译是方法级别的,不是整个应用一起编译。只有被多次调用的方法才会触发JIT编译。这个阈值在ART里默认是10000次调用或者循环回边计数达到一定值。

我曾经在调试一个启动性能问题时,发现某个方法被调用了9999次就是不触发JIT编译,排查了半天才发现是计数器的精度问题。嗯,这种边界情况,你写代码时很难想到,但线上就是会遇到。

7.2 中间表示(IR):JIT的“通用语言”

中间表示,简称IR,是JIT编译器的核心数据结构。ART的JIT使用的是SSA形式的HIR(High-level Intermediate Representation)。为什么需要IR?直接字节码转机器码不行吗?

答案很简单:没有IR,优化就无从谈起。字节码是栈式的,每条指令都隐含了栈操作,这种形式做优化非常困难。而IR是寄存器式的,每个变量都有明确的定义和使用链,优化起来就顺手多了。

ART的HIR有几个关键特点:

  • SSA形式:每个变量只赋值一次,通过Phi节点处理控制流合并
  • 类型化:每个指令都有明确的类型信息(int、long、float、reference等)
  • 控制流图(CFG):方法被表示为基本块组成的控制流图

我举个例子,一个简单的加法操作:

// Java源码
int a = 1;
int b = 2;
int c = a + b;

// 字节码(简化)
iconst_1
istore_1
iconst_2
istore_2
iload_1
iload_2
iadd
istore_3

// HIR(简化)
%1 = IntConstant 1
%2 = IntConstant 2
%3 = Add %1, %2

看到区别了吗?HIR直接表达了“谁加谁”,没有栈操作的噪音。这就是IR的价值——让优化器能看清数据的流动。

避坑指南:我曾经在移植一个优化pass时,忽略了Phi节点的处理,结果生成的代码在循环中出现了变量值错乱。SSA形式的Phi节点是控制流合并的关键,处理不好就会出大问题。记住:每个基本块的入口处,如果有多个前驱,就要考虑Phi节点。

7.3 寄存器分配:有限的资源,无限的变量

寄存器分配,说白了就是“抢椅子”游戏。IR里有成百上千个虚拟寄存器,但物理寄存器只有那么几个(ARM64有31个通用寄存器,但大部分都有特殊用途)。怎么把这些虚拟寄存器映射到有限的物理寄存器上?

ART的JIT使用的是线性扫描寄存器分配算法(Linear Scan Register Allocation)。这个算法比图着色算法简单,但效果足够好,特别适合JIT这种对编译时间敏感的场景。

算法的核心思路:

  1. 计算每个虚拟寄存器的活跃区间(live interval)
  2. 按活跃区间的起始位置排序
  3. 遍历每个活跃区间,分配物理寄存器
  4. 如果寄存器不够用,就把变量溢出(spill)到栈上

我画了一张图,帮你理解这个过程:

线性扫描寄存器分配示意图 v1 活跃区间 v2 活跃区间 v3 活跃区间 v4 活跃区间 分配结果: r0 → v1 r1 → v2 r2 → v3 r3 → v4 ⚠ 如果只有3个物理寄存器,v4需要溢出到栈上

你看,v1到v4的活跃区间有重叠,但重叠部分不多。线性扫描算法会尽量让不重叠的变量共享同一个物理寄存器。如果实在挤不下,就只能把变量存到栈上——这就是溢出。

注意:溢出是有代价的。每次访问溢出的变量,都要执行一次load/store操作,这比寄存器访问慢一个数量级。所以寄存器分配的好坏,直接影响编译后代码的性能。我在优化一个图像处理算法时,发现JIT编译后的代码有大量栈操作,调整了寄存器分配策略后,性能提升了30%。

7.4 指令选择:把IR变成机器认识的话

指令选择,就是把IR指令映射成目标CPU的机器指令。这一步看似简单,其实坑很多。同一个IR操作,可能有多种机器指令实现方式,选哪个?

举个例子,ARM64上实现一个32位整数加法:

// HIR中的Add指令
%3 = Add %1, %2

// 可能的机器指令选择
// 方案1:普通加法
ADD w0, w1, w2

// 方案2:如果其中一个操作数是常量,可以用立即数加法
ADD w0, w1, #42

// 方案3:如果结果要写回其中一个操作数,可以用累加形式
ADD w0, w0, w1

ART的指令选择器会考虑以下几个因素:

  • 操作数类型:32位还是64位?浮点还是整数?
  • 操作数位置:在寄存器里还是立即数?
  • 目标寄存器约束:有些指令要求特定寄存器
  • 副作用:是否影响标志位?

我个人习惯把指令选择看作一个“模式匹配”问题。ART里用的是树模式匹配(Tree Pattern Matching),把IR指令树和机器指令模板做匹配,匹配上了就生成对应的机器码。

这里有个表格,展示了常见的IR到ARM64的指令映射:

IR指令 ARM64指令 说明
Add ADD / ADDS 整数加法,ADDS会更新标志位
Sub SUB / SUBS 整数减法
Mul MUL 整数乘法
Div SDIV / UDIV 有符号/无符号除法
Load LDR / LDP 加载,LDP是双字加载
Store STR / STP 存储,STP是双字存储
Branch B / B.cond 无条件/条件分支
Return RET 函数返回

嗯,这里要注意一点:指令选择不是简单的1:1映射。有些IR指令可能对应多条机器指令,有些机器指令又能覆盖多个IR模式。比如ARM64的LDP指令,一次加载两个64位值,如果IR里有两个连续的Load,指令选择器可能会合并成一条LDP。

实战经验:我在做指令选择优化时,发现一个很有意思的现象——ARM64的立即数范围有限(比如ADD的立即数只能是12位可移位模式),如果IR里的常量超出这个范围,指令选择器就得生成多条指令来构造这个常量。这种情况下,提前在IR优化阶段做常量折叠和常量传播,能减少很多不必要的指令。

7.5 三个环节的协同工作

JIT编译的三个核心环节不是孤立的。IR构建为优化和寄存器分配提供了基础,寄存器分配的结果又会影响指令选择。我画了一张流程图,展示它们之间的关系:

字节码输入 DEX文件中的方法 IR构建 SSA形式HIR 优化 常量折叠/死代码消除 寄存器分配 线性扫描算法 指令选择 树模式匹配 机器码输出 ARM64/x86指令 虚线表示优化反馈:寄存器分配结果会影响指令选择,优化结果会影响寄存器分配 整个流程在方法粒度上执行,热点方法才会触发完整编译

你看,优化和寄存器分配之间是有反馈的。优化做得好,寄存器分配就轻松;寄存器分配发现压力大,可以反馈给优化器做更多的溢出优化。这种协同设计,是ART JIT能保持高性能的关键。

最后说一句,JIT编译器的设计没有银弹。每个环节都有取舍——编译时间 vs 代码质量,寄存器压力 vs 溢出代价,指令选择复杂度 vs 匹配精度。我做了这么多年,最大的体会就是:理解每个环节的权衡,比记住具体算法更重要

总结一下:JIT编译流程就是字节码→IR→优化→寄存器分配→指令选择→机器码。IR是优化的基础,寄存器分配决定数据访问效率,指令选择决定代码密度和执行速度。三个环节环环相扣,任何一个环节出问题,最终生成的代码性能都会打折扣。


公众号:蓝海资料掘金营,微信deep3321