1. 从解释到 JIT
一句话总结: JIT 把「先解释执行、再对热点编译」结合起来,用启动速度换峰值性能,是现代动态语言运行时的性能支柱。
纯解释器启动快但没有优化,AOT(提前编译)优化充分但无法利用运行时信息。JIT 的路线是:程序开始时解释执行,运行时收集执行概况(profile),一旦发现某段代码高频执行就现场编译成机器码。V8、HotSpot、PyPy、CPython 3.13(试验性 JIT)走的都是这条「先解释、后编译」的渐进式道路,本质是把编译成本摊到真正值得优化的代码上。
# 解释循环: 每条指令都在解释器分派循环里执行
def interpret(bytecode, state):
ip = 0
counters = {} # 记录每个循环入口的执行次数
while ip < len(bytecode):
op, arg = bytecode[ip]
if op == "LOOP_START":
counters[arg] = counters.get(arg, 0) + 1
if op == "CONST":
state.append(arg)
elif op == "ADD":
b, a = state.pop(), state.pop()
state.append(a + b)
ip += 1
return counters
bc = [("LOOP_START", 0), ("CONST", 1), ("CONST", 2), ("ADD", None)] * 1000
counters = interpret(bc, [])
print("循环 0 执行次数:", counters)
| 执行模型 | 启动速度 | 峰值性能 | 运行时信息 |
|---|---|---|---|
| 纯解释 | 最快 | 低 | 无 |
| 字节码 + JIT | 快 | 高 | 有 profile |
| 纯 AOT | 需编译 | 高 | 无(除非 PGO) |
JIT 的关键洞察是「二八定律」:程序绝大多数时间花在一小部分热点代码上。把编译资源集中投向热点,就能用 20% 的编译开销换取 80% 的性能收益。于是下一个问题浮出水面:怎么精确地找出热点?
理解 JIT 性能要把握「预热(warm-up)」曲线:程序刚开始运行时走解释器,速度慢;热点逐步被编译后吞吐上升,最终趋于峰值。基准测试若不给足够的预热时间,测出的只是解释器速度,这正是微基准测试容易踩的坑。
# 模拟预热曲线: 编译发生后单次迭代耗时骤降
def warm_up_curve(iterations, compile_at=50_000):
times = []
for i in range(iterations):
if i < compile_at:
times.append(1.0) # 解释执行: 慢
elif i < compile_at + 10_000:
times.append(0.6) # 编译进行中
else:
times.append(0.1) # 机器码: 快
return times
curve = warm_up_curve(200_000)
print("预热前 5 次耗时:", curve[:5])
print("峰值耗时:", min(curve))
2. 热点探测与计数器
一句话总结: 运行时用方法调用计数器和回边计数器记录执行频次,超过阈值的方法进入编译队列,是自适应优化的第一环。
热点探测(hotspot detection)通常靠计数器实现。HotSpot JVM 对每个方法维护一个调用计数器:每次方法被调用就递增,达到阈值(默认 10000 左右)即触发 C1 编译。循环则是靠回边计数器:每次循环跳回起点就递增,防止「方法只调用一次但内部跑了十亿次循环」这种情况漏出热点。计数器会在编译触发后清零,避免重复计数。
class MethodProfile:
def __init__(self, threshold=10000, loop_threshold=1000):
self.invocations = 0
self.backedges = 0
self.threshold = threshold
self.loop_threshold = loop_threshold
def on_call(self):
self.invocations += 1
if self.invocations >= self.threshold:
return "compile_now"
return "keep_interpreting"
def on_backedge(self):
self.backedges += 1
return "compile_now" if self.backedges >= self.loop_threshold else "keep_interpreting"
m = MethodProfile()
for i in range(10005):
m.on_call()
print("调用后状态:", m.invocations >= m.threshold)
| 计数器 | 统计对象 | 作用 |
|---|---|---|
| 调用计数器 | 方法入口 | 捕获高频方法 |
| 回边计数器 | 循环回跳 | 捕获长循环 |
| 类型/分支概况 | 接收者类型 | 指导内联与去虚化 |
除次数外,现代 JIT 还会记录概况信息(profile):某个虚方法调用点上接收者的实际类型、某个分支的偏向概率、某个字段的类型分布。这些信息让编译器敢做激进假设,比如「这个接口调用实际只有一种实现,直接去虚化」。概况信息是 JIT 相对 AOT 的核心优势,PGO(profile-guided optimization)正是把这份优势部分搬到 AOT 世界的尝试。
3. 分层编译
一句话总结: 分层编译用多级编译器渐进提升代码质量,C1 快但弱、C2 强但慢,HotSpot 与 V8 都用层级来平衡编译时间与峰值性能。
编译本身要花时间,把每个热点都直接交给最强优化器会拖慢整体。分层编译(tiered compilation)的答案是多级流水线:HotSpot 的 C1 编译器做少量优化、编译极快,C2 做全面优化但慢。程序先在解释器里跑,热点方法先用 C1 编译并继续采集概况,收集足够信息后再用 C2 重编译成更优版本。V8 早期也有 Baseline 与 TurboFan 两个层级,Sparkplug/TurboFan 的演变同样是多级思想。
def choose_tier(profile, has_rich_profile):
"""HotSpot 风格: 解释 -> C1 -> C2 的升级决策."""
if profile.invocations < 2000:
return "interpreter"
if not has_rich_profile:
return "C1 (轻量编译, 继续采集)"
return "C2 (深度优化)"
class TieredRuntime:
def __init__(self):
self.tier = {} # 方法 -> 当前层级
def tick(self, method, profile):
cur = self.tier.get(method, 0)
if cur == 0 and profile.invocations > 2000:
self.tier[method] = 1 # 升到 C1
elif cur == 1 and profile.invocations > 200000:
self.tier[method] = 2 # 升到 C2
return self.tier[method]
rt = TieredRuntime()
print(rt.tick("foo", MethodProfile(threshold=1)))
| 层级 | 编译器 | 编译速度 | 优化强度 |
|---|---|---|---|
| 0 | 解释器 | 无编译 | 无 |
| 1 | C1/Baseline | 快 | 弱 |
| 2 | C2/TurboFan | 慢 | 强 |
层级之间不是单向流动:C2 编译失败、编译队列积压、或者某些方法不值得深度优化时,会退回低层级。分层编译的调度还考虑编译线程池的负载、方法的大小、调用热度等因素,本质上是一个「在正确时间用正确编译器处理正确方法」的运行时调度问题。
4. OSR:栈上替换
一句话总结: OSR 让正在解释栈帧上执行的循环直接切换到编译版本,不必等方法返回,是长循环得到即时优化的关键机制。
普通 JIT 升级发生在方法调用边界:解释器跑到方法入口发现已编译,就直接进入编译版本。但一个方法内部的长循环可能几百万次迭代都不返回,等到方法结束才升级毫无意义。栈上替换(On-Stack Replacement,OSR)解决这个问题:在回边点把正在解释的帧原地替换成编译版本的帧,程序从编译代码的对应位置继续执行。
def osr_decision(backedge_count, threshold=100000):
"""长循环在回边处决定是否 OSR 进入编译版本."""
if backedge_count >= threshold:
return "OSR: 当前栈帧替换为编译帧, 从 OSR 入口继续"
return "继续解释执行"
for i in range(5):
print(i, osr_decision(i * 50000))
OSR 的难点在于帧迁移:解释器帧里的局部变量、操作数栈、锁记录都要搬到编译帧的对应位置,而编译帧的布局可能完全不同。HotSpot 通过在编译代码里保留 OSR 入口点,编译时就知道从解释帧恢复哪些值;C2 还针对 OSR 编译专门处理逃生分析,因为循环里逃逸的对象与普通编译路径行为不同。OSR 与反向 OSR(从编译版本回退解释)互补,构成自适应优化的最后一环。
| 切换类型 | 发生位置 | 用途 |
|---|---|---|
| 普通升级 | 方法调用入口 | 高频方法 |
| OSR | 回边(循环内) | 长循环 |
| 去优化 | 编译代码内断言失败 | 放弃激进假设 |
5. 去优化与激进优化
一句话总结: 编译器基于概况做激进假设,一旦运行时假设破裂就触发去优化,把执行交还解释器或低层级代码,保证正确性兜底。
JIT 敢于激进是因为有**去优化(deoptimization)**兜底。编译器可能做这些假设:某接口调用只有一种实现(去虚化)、某对象不逃逸(栈上分配)、某分支恒真(死代码化)。生成代码里会在这些假设点埋守卫(guard)检查;守卫失败说明概况失效,执行必须退回到安全的状态。这种「乐观假设 + 悲观兜底」的机制也叫 speculative optimization。
def speculative_call(receiver, impl_cache, interpreter):
"""虚拟调用点: 假设接收者类型与缓存一致则直接调用."""
cached = impl_cache.get(receiver.__class__)
if cached is not None:
return cached(receiver) # 快路径: 去虚化调用
return interpreter(receiver) # 慢路径: 回退解释/虚分派
class A:
def run(self): return "A"
cache = {A: lambda o: "A (内联)"}
print(speculative_call(A(), cache, lambda o: o.run()))
class DeoptFrame:
"""去优化帧: 记录可恢复的解释器状态."""
def __init__(self, locals_, stack, pc):
self.locals = locals_
self.stack = stack
self.pc = pc
def to_interpreter(self):
return f"恢复解释执行 pc={self.pc} locals={self.locals}"
print(DeoptFrame(["x=1"], [], 42).to_interpreter())
| 激进优化 | 守卫内容 | 守卫失败处理 |
|---|---|---|
| 去虚化 | 接收者类型 | 虚分派/解释执行 |
| 栈上分配 | 对象不逃逸 | 堆上重新分配 |
| 循环展开 | 迭代次数/边界 | 跳到剩余循环 |
| 常量折叠 | 字段/全局不变 | 撤销折叠结果 |
去优化的代价很高(丢弃编译状态、回到解释器),因此编译器会把频繁失效的优化降级:如果某个去虚化守卫连续失败多次,就放弃该优化,改用保守实现。JIT 由此形成一个「尝试激进 → 守卫失败 → 降级」的反馈闭环,这也是自适应优化名称的由来。
6. 安全点与编译屏障
一句话总结: 安全点让 GC 与线程协作在确定的执行位置发生,编译屏障保证激进优化不破坏内存可见性与语言语义。
JIT 生成的代码常驻内存、随意使用寄存器,垃圾回收器要在任意时刻安全地移动或回收对象,就必须约定安全点(safepoint):代码在这些位置上的寄存器与栈内容对 GC 可见且可枚举。HotSpot 的代码在回边、方法调用、循环入口等位置轮询安全点标志;GC 触发时挂起所有线程,等它们全部抵达安全点再扫描根。
class SafepointManager:
def __init__(self):
self.polling_page = bytearray(4096) # 轮询页
def sync(self, thread, regs):
if self.polling_page[0]: # 轮询点: 检查 GC 请求
return "safepoint 暂停, 保存寄存器与栈"
return "继续执行"
编译屏障(compiler barrier)则是更广的概念:在泛型内存模型(如 Java 的 JMM、C++ 的 memory model)下,编译器不能随便重排、合并或消除带副作用的访问。JIT 优化内存访问时必须尊重屏障,例如 volatile 读的语义要保留、final 字段的可见性要保证。屏障既是正确性的护栏,也是优化的成本,编译器要在「允许的变换」与「禁止的变换」之间精确划界。
| 机制 | 保护对象 | 常见实现 |
|---|---|---|
| 安全点 | GC 根枚举 | 轮询页 + 状态标志 |
| 写屏障 | 跨代引用 | card table 标记 |
| volatile 屏障 | 内存可见性 | 内存栅栏指令 |
7. 跨平台 JIT 与编译后端
一句话总结: 现代 JIT 常把中间表示交给 LLVM 或 Cranelift 等可移植后端,自己专注前端语义与优化,从而一次实现、多平台受益。
手写每个目标架构的指令选择、寄存器分配与指令调度是巨大工程,所以跨平台 JIT 普遍复用现成的编译后端。Rust 的 cranelift 直接为 JIT 而生:模块化、增量、低延迟;LLVM 的 ORC/JIT 也提供运行时编译能力。前端(语言到 IR)与后端(IR 到机器码)分离后,语言团队只需实现一次 IR 生成,就能在 x86、ARM、RISC-V 上运行。
# 伪代码: JIT 三阶段流水线
def jit_compile(module, backend="cranelift"):
hir = build_hir(module) # 1. 前端: 语言 -> HIR
mir = lower_to_mir(hir) # 2. 中端: HIR -> 机器无关 MIR
machine_code = backend(mir) # 3. 后端: MIR -> 目标机器码
return Stub(module, machine_code) # 用桩占位, 触发时才真正链接
class Stub:
def __init__(self, module, code):
self.module, self.code, self.patched = module, code, False
def call(self, *args):
if not self.patched:
self.patched = True # 首次调用后补丁真实入口
return self.code(*args)
| 后端 | 设计目标 | 使用方 |
|---|---|---|
| Cranelift | 低延迟、增量 | wasmtime、Rust 调试后端 |
| LLVM ORC | 通用、强优化 | Julia、Rust 发行版 |
| 自研 | 极致定制 | V8 TurboFan、HotSpot C2 |
延迟链接(lazy linking)是跨平台 JIT 的常见技巧:方法第一次真正执行前先放一个桩,调用到桩时再完成链接,避免一次性编译全部模块。内联缓存(inline cache)则把「接收者类型 → 目标代码」的映射直接嵌进调用点,用自修改代码把频繁路径打磨成直接跳转。跨平台 JIT 的边界在于:复用通用后端意味着失去针对特定语言语义的深度定制,语言团队要在「自研的控制力」与「复用的生产力」之间做战略取舍。
8. 总结
| 主题 | 核心结论 |
|---|---|
| JIT 路线 | 先解释后编译,把编译投入聚焦热点 |
| 热点探测 | 调用/回边计数器 + 概况信息定位热点 |
| 分层编译 | C1 快弱、C2 慢强,渐进提升质量 |
| OSR | 回边处替换栈帧,让长循环即时优化 |
| 去优化 | 守卫 + 回退兜底激进假设 |
| 安全点 | 约定 GC 可枚举执行的执行位置 |
| 跨平台后端 | 复用 Cranelift/LLVM,一次实现多平台 |
JIT 编译是「自适应」一词的最佳注脚:它在运行时观察程序、做出假设、编译优化,再在假设失败时优雅回退。热点探测、分层编译、OSR 与去优化构成一条完整的闭环,安全点与编译屏障则为激进优化划定正确性边界。理解这套机制,才能真正看懂 V8 与 HotSpot 为什么能把动态语言推到接近静态语言的性能,也为设计自己的运行时提供了可借鉴的路线图。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。