第七章 JIT编译器:JIT编译流程、中间表示(IR)、寄存器分配与指令选择
聊到JIT编译器,很多人第一反应就是“快”。但快不是凭空来的,背后是一整套精密的流水线作业。我做了这么多年Android Runtime,每次看JIT的编译流程,都觉得它像一条高效的工厂生产线——从字节码进来,到机器码出去,中间每一步都有讲究。
今天我们就来拆解这条生产线。我会重点讲三个核心环节:编译流程、中间表示(IR)、以及寄存器分配与指令选择。嗯,这三个环节搞明白了,你对JIT的理解就能上一个台阶。
7.1 JIT编译流程:从字节码到机器码
JIT编译不是一上来就全量编译的。ART里的JIT走的是“按需编译”路线——只有热点代码才会被编译。我个人习惯把JIT编译流程分成四个阶段:
- 字节码输入:从DEX文件中读取方法字节码
- IR构建:将字节码转换为中间表示(HIR)
- 优化与寄存器分配:在IR上做各种优化,然后分配物理寄存器
- 指令选择与发射:生成目标平台的机器码
说白了,这就是一个“翻译+优化”的过程。你想想看,字节码是跑在虚拟机的栈上的,而机器码是直接操作寄存器的,这两者之间差了好几个抽象层级。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这种对编译时间敏感的场景。
算法的核心思路:
- 计算每个虚拟寄存器的活跃区间(live interval)
- 按活跃区间的起始位置排序
- 遍历每个活跃区间,分配物理寄存器
- 如果寄存器不够用,就把变量溢出(spill)到栈上
我画了一张图,帮你理解这个过程:
你看,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构建为优化和寄存器分配提供了基础,寄存器分配的结果又会影响指令选择。我画了一张流程图,展示它们之间的关系:
你看,优化和寄存器分配之间是有反馈的。优化做得好,寄存器分配就轻松;寄存器分配发现压力大,可以反馈给优化器做更多的溢出优化。这种协同设计,是ART JIT能保持高性能的关键。
最后说一句,JIT编译器的设计没有银弹。每个环节都有取舍——编译时间 vs 代码质量,寄存器压力 vs 溢出代价,指令选择复杂度 vs 匹配精度。我做了这么多年,最大的体会就是:理解每个环节的权衡,比记住具体算法更重要。
总结一下:JIT编译流程就是字节码→IR→优化→寄存器分配→指令选择→机器码。IR是优化的基础,寄存器分配决定数据访问效率,指令选择决定代码密度和执行速度。三个环节环环相扣,任何一个环节出问题,最终生成的代码性能都会打折扣。
公众号:蓝海资料掘金营,微信deep3321