编程进阶网 编程进阶网
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机组成原理
  • 操作系统原理
  • 网络协议原理
  • 数据库系统原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机组成原理
  • 操作系统原理
  • 网络协议原理
  • 数据库系统原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • 序卷方法论

  • 数据的本质

  • 运行时模型

    • README
    • 1.类加载机制核心原理
    • 2.对象创建核心流程
    • 3.对象和函数访问原理
    • 4.调用栈与栈帧设计
    • 5.字节码虚拟机执行
    • 6.JIT与运行时优化
      • 00.真实事故引入
        • 0.1 一次性能冷启动雪崩
        • 0.2 代码越跑越快现象
        • 0.3 灵魂三问
        • 0.4 本篇的探索路径
        • 0.5 为什么这个问题值得讲透
        • 0.6 通用三问(不论你用什么语言都要回答的三件事)
      • 01.解释器的天花板与 JIT 的诞生
        • 1.0 动态优化的通用必要性(不限 JVM)
        • 1.1 解释执行的三个固有开销
        • 1.2 为什么不"AOT 一次编译完"
        • 1.3 JIT核心思想:运行时换质量
        • 1.4 AOT vs JIT vs Tiered 决策矩阵
      • 02.热点检测与分层编译
        • 2.1 不是所有代码都值得编译
        • 2.2 计数器法 vs 采样法
        • 2.3 OSR栈上替换机制
        • 2.4 分层编译(Tiered)
        • 2.5 编译队列与并行 JIT
      • 03.内联:JIT 的"原子优化"
        • 3.1 内联是性能的原子操作
        • 3.2 内联触发的"连锁优化"
        • 3.3 多态调用去虚化
        • 3.4 内联预算:为何不能无限内联
        • 3.5 final 为什么能加速 30%
        • 3.6 五语言内联机制对照
      • 04.逃逸分析与栈上分配
        • 4.1 逃逸分析的核心问题
        • 4.2 标量替换:更激进的优化
        • 4.3 锁消除(Lock Elision)
        • 4.4 Go编译器更彻底逃逸分析
        • 4.5 五语言逃逸分析全景
      • 05.去优化:当假设被打破
        • 5.1 JIT 的"乐观假设"
        • 5.2 守卫指令与 deopt 触发
        • 5.3 反复Deopt引发性能悬崖
        • 5.4 PrintCompilation看到deopt
        • 5.5 CHA类层次分析:JIT单态宣言
        • 5.6 去优化的通用模型(所有"赌徒型 JIT"都必须有)
      • 06.跨语言 JIT 设计对比
        • 6.1 主流语言 JIT 对比表
        • 6.2 V8 的 4 层架构(2024 年)
        • 6.3 Tracing JIT vs Method JIT
        • 6.4 GraalVM:用 Java 写 JIT
      • 07.经典陷阱与生产级反模式
        • 7.1 陷阱一:JIT预热不足导致雪崩
        • 7.2 陷阱二:微基准的 JIT 误差
        • 7.3 陷阱三:堵塞代码路径
        • 7.4 陷阱四:反复deopt导致性能悬崖
        • 7.5 陷阱五:Lambda+反射的JIT失效
        • 7.6 陷阱六:字节码增强阻碍JIT内联
        • 7.7 陷阱七:Class.forName在热路径上
        • 7.8 跨语言性能陷阱速查表
      • 08.经典案例串讲
        • 8.1 案例背景:双11雪崩的完整复盘
        • 8.2 第一现场:JIT 预热不足的具体表象
        • 8.3 阶段一:分层编译 + OSR 救火
        • 8.4 阶段二:内联 + 去虚化 + 逃逸分析三连击
        • 8.5 阶段三:deopt 风暴与 CHA 兜底
        • 8.6 案例知识点回归
      • 09.一句话总结
        • 9.1 三层认知阶梯
        • 9.2 JIT 设计的决策树
        • 9.3 七字真言总结
        • 9.4 与下篇的承接
      • 🔗 延伸阅读
    • 7.反射元编程核心设计
    • 8.异常机制设计原理
  • 并发的设计

  • 内存的真相

  • 交互和系统

  • 内功
  • 运行时模型
杨充
2026-05-14
目录

6.JIT与运行时优化

# 2.6 JIT 与运行时优化

📍 本篇位置:第 2 卷 · 运行时模型 · 第 6 篇 🎯 核心矛盾:解释执行慢 10 倍、AOT 编译又丧失运行时灵活性——能不能"鱼和熊掌兼得"?让程序在跑的过程中"越跑越快"? 🧭 设计灵魂:JIT 不是简单的"运行时翻译机"——它是一个建立在乐观假设 + 兜底回退之上的"赌徒哲学",敢于把"99% 路径"用最激进的方式优化,把"1% 异常路径"留给 deopt 兜底 🌐 跨平台覆盖:HotSpot C1/C2/Graal · V8 Ignition+TurboFan+Sparkplug+Maglev · LuaJIT · .NET RyuJIT · CPython 3.13 (实验性 JIT) 🔗 延伸阅读:← 2.5 字节码虚拟机执行 (opens new window) · → 2.7 反射元编程核心设计 (opens new window) · → 2.8 异常机制核心设计 (opens new window) · → 4.x 缓存局部性原理


上一章我们看到字节码经过解释执行可以跨平台运行,但解释执行有一个躲不掉的代价——慢,每条字节码都要"翻译一遍"。那为什么 Java、JavaScript、LuaJIT 在某些场景下能逼近甚至超越 C 的性能?

秘密武器叫 JIT(Just-In-Time Compilation),但它远不只是"把字节码再编译成机器码"这么简单。本章从一个"代码越跑越快"的诡异现象切入,剖开 JIT 的核心:热点检测、分层编译、内联、逃逸分析、去优化。

📢 语言无关声明 本章讨论的是所有支持"运行时换性能"的语言运行时通用的优化哲学——它对以下场景一视同仁:

  • Java/JVM:HotSpot C1/C2、Graal、Azul Falcon
  • JavaScript:V8(Ignition+Sparkplug+Maglev+TurboFan)、JSC(LLInt+Baseline+DFG+FTL)、SpiderMonkey
  • C# / .NET:RyuJIT、Crossgen2、ReadyToRun(AOT+JIT 混合)
  • Python:CPython 3.13 实验性 JIT、PyPy(tracing JIT)
  • Lua:LuaJIT(tracing JIT,性能逼近 C 的代表)
  • C / C++:Profile-Guided Optimization(PGO)、LTO(这是 AOT 等价物)
  • Rust:MIR 优化 + LLVM 优化 + PGO + BOLT(同上)
  • Go:编译期内联 + Go 1.20+ PGO(也是 AOT 等价物)
  • Swift / Dart:AOT + JIT 双模式(Dart)

后面所有"内联、逃逸分析、去虚化、去优化"概念——只要语言运行时有"基于运行时观测做优化决策"的环节(哪怕只是编译期 PGO 数据),结论都适用。AOT 语言的对应做法会在相关章节用"📌 跨语言差异"块标注。

# 目录介绍

  • 00.真实事故引入
    • 0.1 一次性能冷启动雪崩
    • 0.2 代码越跑越快现象
    • 0.3 灵魂三问
    • 0.4 本篇的探索路径
    • 0.5 为什么这个问题值得讲透
    • 0.6 通用三问
  • 01.解释器的天花板与 JIT 的诞生
    • 1.0 动态优化的通用必要性
    • 1.1 解释执行的三个固有开销
    • 1.2 为什么不"AOT 一次编译完"
    • 1.3 JIT核心思想:运行时换质量
    • 1.4 AOT vs JIT vs Tiered 决策矩阵
  • 02.热点检测与分层编译
    • 2.1 不是所有代码都值得编译
    • 2.2 计数器法 vs 采样法
    • 2.3 OSR栈上替换机制
    • 2.4 分层编译(Tiered)
    • 2.5 编译队列与并行 JIT
  • 03.内联:JIT 的"原子优化"
    • 3.1 内联是性能的原子操作
    • 3.2 内联触发的"连锁优化"
    • 3.3 多态调用去虚化
    • 3.4 内联预算:为何不能无限内联
    • 3.5 final 为什么能加速 30%
    • 3.6 五语言内联机制对照
  • 04.逃逸分析与栈上分配
    • 4.1 逃逸分析的核心问题
    • 4.2 标量替换:更激进的优化
    • 4.3 锁消除(Lock Elision)
    • 4.4 Go编译器更彻底逃逸分析
    • 4.5 五语言逃逸分析全景
  • 05.去优化:当假设被打破
    • 5.1 JIT 的"乐观假设"
    • 5.2 守卫指令与 deopt 触发
    • 5.3 反复Deopt引发性能悬崖
    • 5.4 PrintCompilation看到deopt
    • 5.5 CHA类层次分析:JIT单态宣言
    • 5.6 去优化的通用模型
  • 06.跨语言 JIT 设计对比
    • 6.1 主流语言 JIT 对比表
    • 6.2 V8 的 4 层架构(2024 年)
    • 6.3 Tracing JIT vs Method JIT
    • 6.4 GraalVM:用 Java 写 JIT
  • 07.经典陷阱与生产级反模式
    • 7.1 陷阱一:JIT预热不足导致雪崩
    • 7.2 陷阱二:微基准的 JIT 误差
    • 7.3 陷阱三:堵塞代码路径
    • 7.4 陷阱四:反复deopt导致性能悬崖
    • 7.5 陷阱五:Lambda+反射的JIT失效
    • 7.6 陷阱六:字节码增强阻碍JIT内联
    • 7.7 陷阱七:Class.forName在热路径上
    • 7.8 跨语言性能陷阱速查表
  • 08.经典案例串讲
    • 8.1 案例背景:双11雪崩的完整复盘
    • 8.2 第一现场:JIT 预热不足的具体表象
    • 8.3 阶段一:分层编译 + OSR 救火
    • 8.4 阶段二:内联 + 去虚化 + 逃逸分析三连击
    • 8.5 阶段三:deopt 风暴与 CHA 兜底
    • 8.6 案例知识点回归
  • 09.一句话总结
    • 9.1 三层认知阶梯
    • 9.2 JIT 设计的决策树
    • 9.3 七字真言总结

# 00.真实事故引入

# 0.1 一次性能冷启动雪崩

我维护过一个高吞吐 Java 服务(订单处理网关),日均 5 亿请求。某次在双 11 凌晨切流到一组新部署的 JVM 实例,结果发生了诡异的雪崩:

12:00:00  切流,QPS 0 → 50000
12:00:05  P99 延迟从 5ms 飙到 800ms
12:00:08  上游网关熔断,新实例被踢出
12:00:30  保留实例 QPS 翻倍,进一步过载
12:01:00  整体雪崩,订单服务跌零

第一反应:是不是新代码有 bug?是不是配置错了?

但回滚到旧版本依然会发生——只要"全量切流"就崩。

排查 1 小时后发现关键现象:

新实例启动后前 30 秒:
  CPU 100%
  P99 延迟 800ms
  GC 频繁
  
新实例启动 30 秒后:
  CPU 降到 30%
  P99 降到 5ms
  恢复正常

这就是著名的"JVM 预热问题"——前 30 秒还在解释执行字节码,性能极差;30 秒后 JIT 把热点函数编译成机器码,性能跃升 10-50 倍。

这不是 bug,这是 JVM 设计的"必然代价":

解释执行:可以立刻运行,但慢
JIT 编译:要"看一会儿"才能编译,需要预热时间

切流策略错误地假设"实例启动即满血" → 大流量进来时还在解释执行 → 雪崩

修复方案:

# 方案 1:渐进切流(10% → 50% → 100%)
# 方案 2:启动后 warmup(用模拟流量预热)
# 方案 3:JVM 参数调优
java -XX:+TieredCompilation -XX:TieredStopAtLevel=4 \
     -XX:CompileThreshold=1000   # 降低 JIT 触发阈值

# 方案 4:用 GraalVM Native Image AOT 编译
# 启动即满血,但失去 JIT 的运行时优化

关键洞察:"启动慢"不是 JVM 独有的——它是所有"运行时优化型语言"共同的代价。下面横向对照,让你看清"为运行时性能买的单":

运行时 冷启动症状 达到稳态性能时间 业界缓解方案
Java HotSpot 解释执行 → JIT 预热 30 秒–数分钟 CDS / AppCDS / GraalVM Native Image / warmup
JavaScript V8 parse + Ignition → TurboFan 几秒–几十秒 Sparkplug 中间层 / Maglev / 代码缓存(CodeCache)
.NET CoreCLR JIT 编译占用启动 CPU 几秒 ReadyToRun(R2R AOT 预编译)/ Tiered Compilation
Python(CPython 3.13 JIT) 解释器优势 + 实验性 JIT 较快但稳态不如 PyPy PyPy(tracing JIT,需要预热) / mypyc
C / C++(AOT) 无预热(启动即满血) 0 秒 静态二进制本就最优
Go(AOT + PGO) 几乎无预热 0 秒(PGO 数据在编译期) go build -pgo=auto(Go 1.20+)
Rust(AOT + LTO + PGO) 无预热 0 秒 RUSTFLAGS=-C lto、cargo-pgo、BOLT
GraalVM Native Image 无预热 0 秒 但失去运行时 JIT 优化

通用规律——三选二:

启动快 ⬄ 稳态性能 ⬄ 灵活性(反射/热替换/动态加载)
        三者只能选其二

C/Go/Rust  :选 "启动快 + 稳态" → 牺牲灵活性(反射极弱、无热替换)
Java/JS    :选 "稳态 + 灵活" → 牺牲启动速度(必须预热)
Native Image:选 "启动快 + 灵活(受限)" → 牺牲峰值性能(无 JIT)

所以:讨论 JIT 不仅是讨论 Java——是在讨论"为运行时灵活性 + 极致性能买单"的所有语言。后面所有"分层编译"、"内联"、"去优化" 的讨论,C/Go/Rust 程序员请代入 PGO/LTO/编译期内联来理解——它们在做同一件事,只是把决策点放在编译期还是运行期。

# 0.2 代码越跑越快现象

写一段简单的 Java 微基准:

long start = System.nanoTime();
for (int i = 0; i < N; i++) compute(i);
long end = System.nanoTime();
System.out.println("avg: " + (end - start) / N + "ns");

测试结果:

N = 1000        平均 500 ns/次
N = 100000      平均 100 ns/次
N = 10000000    平均 5 ns/次

→ 同样的代码,跑得越多越快,最后比第一次快 100 倍!

这就是 JIT 的"魔法"——它不只是编译,还会根据运行时观察到的数据分布做激进优化。

# 0.3 灵魂三问

这两个真实场景让我反复追问三个问题:

  1. JIT 凭什么能做出比 AOT(提前编译)更好的优化?AOT 编译器看到了全部源码,难道还不如 JIT 在运行时看到的局部信息? —— JIT 的核心优势到底在哪里?
  2. JIT 编译本身是有成本的(占用 CPU、占用内存),凭什么"在线编译"能比"启动时一次编译完"更划算? —— 分层编译的设计逻辑是什么?
  3. 为什么 V8 在 2017 年放弃了 Crankshaft(纯 JIT),改成 Ignition(解释器)+ TurboFan(JIT)的混合架构? —— 这是技术倒退还是进步?

如果你能回答这三个问题,你就理解了为什么 JIT 是过去 30 年最反直觉、却最有效的性能优化技术。

# 0.4 本篇的探索路径

# 0.5 为什么这个问题值得讲透

我想抛三个几乎所有 Java 资深工程师都答不全的问题:

  1. 为什么微基准测试(microbenchmark)一定要用 JMH?直接 for 循环为什么不行? —— 因为 JIT 会做出你预想不到的优化(如循环不变量外提、死代码消除)。
  2. 为什么 final 关键字能让某些代码加速 30%? —— 因为它给 JIT 提供了"该字段不会变"的强假设。
  3. 为什么打开 -XX:+PrintCompilation 后会看到大量 "made not entrant"? —— 这是 deoptimization 在工作。

读完本章你会懂:JIT 不是黑魔法——它是一台精密的"赌博机器",敢赌、会赌、输了能立刻翻盘。

# 0.6 通用三问(不论你用什么语言都要回答的三件事)

抛开 JVM 视角,所有"想又快又灵活"的运行时都在回答这三问——它们才是本章真正的母题:

通用三问:

  1. 为什么不能"编译一次跑到底"?运行时优化到底优在哪?
    • 静态编译器看不到:①真实输入分布 ②运行时实际类型 ③真实热点路径 ④动态加载的代码
    • 这四样东西只有运行时才知道——运行时优化 = "把编译决策推迟到看见真相之后"
  2. 运行时信息(profile)比静态信息强在哪?怎么用?
    • 类型反馈:99% 时是 Cat → 直接内联 Cat.weight,1% 走慢路径
    • 分支概率:99% 走 if-true → 把 false 分支移到冷区,提升 i-cache
    • 值反馈:某常量参数永远是 0 → 把整个分支折叠掉
    • 逃逸数据:某对象 99% 不逃逸 → 标量替换
  3. 优化错了怎么办?(这是 JIT 区别于 AOT 的根本能力)
    • 守卫指令检测假设失败 → deopt 回退到解释器/低层级
    • 重新收集 profile → 重新编译为更保守的版本
    • 这套"乐观假设 + 兜底回退"机制是 JIT 的灵魂

📌 给 C/Go/Rust 程序员的特别提示:

虽然你们没有"运行时 JIT",但通用三问对你们同样成立——只是答案不同:

  • 第 1 问:你们用 PGO(Profile-Guided Optimization)——先跑一遍收集 profile,再用 profile 重新编译。这是 AOT 等价于 JIT 的"运行时信息"
  • 第 2 问:profile 数据(.profdata)直接喂给 LLVM/Go 编译器,做内联/分支预测/布局优化
  • 第 3 问:AOT 没有 deopt——所以必须保守一些,不能赌得太狠(这是 AOT 永远的短板)

把这三问刻在脑子里,下面所有内容都会变成"在三问框架内的具体技术选择"。


# 01.解释器的天花板与 JIT 的诞生

# 1.0 动态优化的通用必要性(不限 JVM)

进入 JVM 解释器讨论前,先把一个所有语言都成立的核心结论说清楚:只要语言运行时想要"既灵活又快",就必然走向"运行时收集信息 → 用信息做决策"——它的物理形式可以是 JIT、PGO、LTO、甚至 link-time speculation。

横向看五种语言怎么实现"动态优化":

语言 运行时优化形式 何时收集 profile 何时使用 profile 是否能 deopt
Java JVM C1+C2 JIT / Graal 解释器/C1 期间持续 每次重编译 ✅ 完整 deopt
JavaScript V8 4 层 JIT(Ignition→Sparkplug→Maglev→TurboFan) feedback vector 实时 每层升级 ✅ 完整 deopt
.NET CoreCLR RyuJIT + Tiered Compilation Tier 0 收集 Tier 1 优化 ✅ Tier downgrade
PyPy/LuaJIT Tracing JIT 录制 trace 选热 trace 编译 ✅ guard failure
Go 编译期 PGO(Go 1.20+) 运行一次"代表性负载"产生 default.pgo 下次 go build 用 ❌ 不能 deopt
C/C++ 编译期 PGO + LLVM BOLT 同上 同上 ❌
Rust 编译期 PGO + LTO 同上 同上 ❌
Swift AOT + 部分 LTO 同上 同上 ❌

两条根本路线:

A. 运行时优化路线(JVM/V8/CLR/PyPy)
   profile 永远新鲜 → 优化决策永远精准 → 可 deopt
   代价:必须预热、占运行时内存

B. 编译期优化路线(C/Go/Rust + PGO)
   profile 只在编译期采集一次 → 用于下次编译
   代价:无法适应运行时分布变化、不能 deopt → 必须保守

Go 1.20+ 的 PGO 是个绝佳例子——它让 Go 这种"纯 AOT"语言获得了 90% JIT 的好处:

# 第一次:跑一次"代表性负载"
go build -o app
./app -cpuprofile=default.pgo

# 第二次:用 profile 重编
go build -pgo=default.pgo -o app
# → 自动做更激进的内联(10%+ 性能提升)

所以 JIT 和 PGO 是"同一件事的两种实现"——前者在运行期持续做,后者在编译期一次性做。深入理解 JIT 后,你回头看 PGO 会有顿悟感。

📌 跨语言总结:后面 §2 ~ §6 主要用 JVM/V8 当例子,但每个机制(热点检测、内联、逃逸分析、去优化)你都可以问自己"AOT 语言怎么做同一件事"——这才是真正打通语言界限的姿势。

# 1.1 解释执行的三个固有开销

要理解 JIT 为什么能加速,先理解解释器为什么慢。

每条字节码在解释器中执行的开销可以拆解为三部分:

开销 1:fetch-decode-dispatch 循环

while (true) {
    opcode = code[pc++];           // 1 内存读
    handler = dispatch[opcode];    // 1 间接跳转
    handler();                     // 1 函数调用 (或宏展开)
    // 循环回顶部                   // 1 jmp
}

这个循环本身就要 4-6 条机器指令,而被解释的字节码可能"语义上"只是一个 add(CPU 上 1 条指令)。

开销比:解释器执行 1 条 add 字节码 ≈ 7 条机器指令,而 native 代码只需 1 条。接近 7× 慢。

开销 2:缺乏寄存器优化

JVM 字节码:
  iload_0   → 读 LVT[0](内存)
  iload_1   → 读 LVT[1](内存)
  iadd      → 弹两个,加,压栈(内存)
  istore_2  → 存 LVT[2](内存)

每个值都在内存中倒来倒去。

而 native 代码可以让数值停留在寄存器里,减少 90% 的内存访问。

开销 3:无法做跨指令优化

解释器只能"逐条看"——它不知道下面 5 条指令是什么
所以无法做:
  - 死代码消除
  - 公共子表达式消除
  - 循环不变量外提
  - SIMD 向量化

这三个开销叠加,让解释器比 native 慢 10-15 倍——这就是 §0.1 那个"预热问题"的根源。

# 1.2 为什么不"AOT 一次编译完"

最朴素的想法:"既然 JIT 这么麻烦,我启动时就把所有字节码编译成机器码,不就完了吗?"

这就是 AOT(Ahead-Of-Time) 路线——GraalVM Native Image、.NET ReadyToRun、Android ART 都走这条路。但 AOT 有四个根本短板:

短板 1:丧失动态语言优势

// 反射、动态加载、动态代理在 AOT 下要么禁用、要么大量 hack
Class<?> c = Class.forName(userInputClassName);  // AOT 不知道有哪些类

GraalVM Native Image 必须通过"reachability metadata"提前声明所有反射使用——大型 Spring 项目这部分配置可能上千条。

短板 2:缺乏运行时类型反馈(PGO)

AOT 编译器只能"猜"——它不知道某个 if 分支有 99% 概率成立、某个虚方法 99% 调用 ConcreteA。JIT 可以"看到"真实的数据分布,做出针对性优化。

短板 3:无法去虚化所有方法

List<String> list = getList();
list.add("x");        // 是 ArrayList?LinkedList?还是别的?

AOT 不知道运行时 list 的具体类型,只能保留虚调用。JIT 在运行 100 万次后发现"99% 是 ArrayList",可以激进内联 ArrayList.add 的代码。

短板 4:编译后无法重新优化

AOT 编译的二进制是固定的——发现某段代码模式变了也无法重编。JIT 可以 deopt + 重新编译。

# 1.3 JIT核心思想:运行时换质量

JIT 的设计哲学一句话:

代码刚加载时不编译(避免无谓开销);只编译"真正热"的代码(聚焦关键路径);编译时利用"已经观察到的运行时信息"(做激进假设);假设错了就 deopt 重来(保证正确性)。

这就是 §0.3 第一题的答案——JIT 的优势不是"编译速度",而是"信息优势":

优化机会 AOT 能做吗 JIT 能做吗
内联小函数 ✅ ✅
去虚化(确定虚方法目标) ⚠️ 有限 ✅ 100% 监控
基于分支概率的代码布局 ⚠️ 需要 PGO 数据 ✅ 自动观察
类型猜测(type speculation) ❌ ✅ 核心能力
锁消除 ⚠️ 静态分析 ✅ 逃逸分析
重新优化 ❌ ✅ deopt + recompile

一个真实例子:

public int sum(List<Integer> list) {
    int s = 0;
    for (Integer i : list) s += i;
    return s;
}

AOT 编译:保留虚调用 list.iterator()、it.next(),每次循环 5-10ns。 JIT 在运行 1 万次后观察到:"这里的 list 99.9% 是 ArrayList",于是:

// JIT 内部生成的"特化代码"(伪代码)
if (list.getClass() != ArrayList.class) goto deopt;  // 守卫
ArrayList al = (ArrayList) list;
Object[] arr = al.elementData;   // 直接访问内部数组
int size = al.size;
int s = 0;
for (int i = 0; i < size; i++) {
    s += (Integer)arr[i];   // 进一步优化:标量替换 Integer 拆箱
}
return s;

最终性能:每次循环 < 1ns,比 AOT 快 5-10 倍。

# 1.4 AOT vs JIT vs Tiered 决策矩阵

到底什么场景选 AOT、什么场景选 JIT、什么场景选 Tiered?给你一张可以直接拿到设计评审会上用的决策矩阵:

五维评分(1 最差,5 最好):

维度 纯 AOT
(C/Go/Rust/Native Image)
纯 JIT
(纯 HotSpot Server)
Tiered JIT
(HotSpot 默认/V8)
AOT+JIT 混合
(.NET R2R/CoreCLR)
启动时间 ⭐⭐⭐⭐⭐ ⭐ ⭐⭐⭐ ⭐⭐⭐⭐
稳态性能 ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
内存占用 ⭐⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐ ⭐⭐⭐
反射/动态特性 ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐
跨平台分发 ⭐⭐(每平台一个二进制) ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐
可调试性/可观测 ⭐⭐ ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐

典型场景选择:

🎯 Serverless / FaaS(启动是命门)
   → 纯 AOT (GraalVM Native / Go / Rust)
   → 1 秒内启动 vs JVM 30 秒

🎯 长生命周期 RPC 后端 / 中间件(稳态性能优先)
   → Tiered JIT(HotSpot 默认)
   → 预热可接受,但要求峰值最大

🎯 桌面 / 移动 / 浏览器(启动+稳态都要)
   → 多层 Tiered(V8 4 层)或 AOT+JIT 混合(.NET R2R)
   → Sparkplug 立即可用,TurboFan 后台升级

🎯 高频交易 / 低延迟(不能容忍预热抖动)
   → Azul Falcon JIT + Replay Compilation
   → 或 AOT + 手工调优(C++)

🎯 嵌入式 / IoT(内存极少)
   → 纯 AOT(C/Rust/Zig)
   → JIT 引擎本身要占 MB 级内存,不可接受

🎯 多语言互操作 / 插件(运行时灵活)
   → Tiered JIT 或 GraalVM Polyglot
   → 必须能动态加载、可热替换

决策三句话:

  1. 启动是不是关键指标? 是 → AOT;否 → JIT
  2. 是否需要反射/热替换/动态加载? 是 → JIT;否 → AOT 可选
  3. 应用生命周期多长? 短(< 1 分钟)→ AOT;长(小时级)→ Tiered JIT 性价比最高

📌 跨语言落地建议:

  • Java:默认 Tiered JIT;Serverless 场景考虑 GraalVM Native Image(接受配置成本)
  • C#:用 R2R + Tiered Compilation(.NET 6+ 默认);高启动要求用 NativeAOT
  • JS:V8 多层架构自动处理,开发者一般无需干预
  • Python:长生命周期用 PyPy(追求性能),短生命周期用 CPython(启动快)
  • Go/Rust:默认 AOT;加上 PGO 拿额外 5-15%

# 02.热点检测与分层编译

# 2.1 不是所有代码都值得编译

关键观察:真实程序符合 80/20 法则——80% 的运行时间花在 20% 的代码上(实际比例往往更极端,95/5 甚至 99/1)。

启动一个 Spring Boot 应用:
  加载 1 万个类,包含 10 万个方法
  启动后真正"被调用"的方法 < 5000
  其中"高频调用"(每秒>1000次)< 200
  
→ 只编译那 200 个方法就够了

这就是热点检测的核心动机——不浪费 CPU 在冷代码上。

# 2.2 计数器法 vs 采样法

两种主流的热点检测策略:

策略 1:方法调用计数器(HotSpot 用)

// 每个方法有一个调用计数器
struct Method {
    int invocation_count;
    int back_edge_count;   // 循环回边计数
};

// 每次调用 invocation_count++
// 每次循环回边 back_edge_count++
// 超过阈值(默认 10000)触发 JIT

优点:精确、容易实现 缺点:每次调用都要做计数,有性能开销

策略 2:采样(V8 早期、SpiderMonkey 用)

定时器每 1ms 中断一次
检查当前正在执行的方法 → 给它 +1
统计高频出现的方法 → 标记为热点

优点:开销极低(不修改方法本体) 缺点:不够精确,可能漏检

HotSpot 选择计数器法:因为 JVM 已经为类型安全在每个方法入口做了大量工作,多一个计数器开销可以忽略。

# 2.3 OSR栈上替换机制

考虑这段代码:

public static void main(String[] args) {
    long sum = 0;
    for (long i = 0; i < 1_000_000_000L; i++) {  // 10 亿次循环
        sum += i;
    }
    System.out.println(sum);
}

问题:main 只被调用 1 次(在循环开始前还没达到 JIT 阈值),但循环里跑 10 亿次。如果只看方法调用计数器,永远不会编译这个 main——结果是程序在解释器里跑 10 亿次循环,慢得离谱。

解决方案 OSR:

JIT 也跟踪"循环回边计数器"(back-edge counter)
当循环执行 1 万次时:
  1. 暂停当前解释器执行
  2. JIT 把这个方法编译成机器码
  3. 把当前栈帧"替换"为机器码栈帧(保留所有局部变量)
  4. 从循环的当前位置继续,但用机器码执行

这个机制叫"栈上替换"——在不重启方法的前提下,从解释切换到 JIT。OSR 是 JIT 能加速"长循环"的关键。

# 2.4 分层编译(Tiered)

§0.3 第二题。HotSpot 有两个 JIT:

JIT 编译速度 编译质量 用途
C1(Client) 快(10× C2 速度) 中等(基础优化) 快速到达"机器码"状态
C2(Server) 慢 极致(激进优化) 长期高频热点

纯 C2 路线(HotSpot 老版本):

解释器(慢) → 攒够 10000 次 → C2 编译(慢)→ 机器码(快)

问题:
  C2 编译要几百毫秒
  在 C2 完成前,方法都在解释器跑(慢)

纯 C1 路线:

解释器 → C1 → 机器码

问题:
  C1 编译质量不够,比不上 native 性能

分层编译(Java 7 引入,Java 8 默认):

Level 0: 解释器
Level 1: C1(无 profiling)—— 完全编译,无运行时信息收集
Level 2: C1(有限 profiling)—— 收集调用次数和回边
Level 3: C1(完全 profiling)—— 收集类型反馈、分支概率
Level 4: C2(用 Level 3 的反馈做激进优化)

执行流程:
  方法被调用 → 解释器执行 + 计数
  达到阈值 → Level 3 编译(C1 完整 profiling)
  Level 3 收集足够数据 → Level 4 编译(C2 激进优化)
  完成 → 切换到最高级机器码

这是一种"渐进加速"策略——每一级都立即可用,每一级都比上一级快。

# 2.5 编译队列与并行 JIT

JIT 编译本身要消耗 CPU。HotSpot 的策略:

编译队列(Compilation Queue):
  应用线程把"热点方法"加入队列
  独立的 JIT 编译线程从队列取出,编译完成后替换

线程数:
  -XX:CICompilerCount=N   (默认根据 CPU 核数自动设置)
  
优先级:
  C1 编译队列优先于 C2(先快速到达 Level 1,再慢慢到 Level 4)

这意味着 JIT 编译不会阻塞业务线程——它在后台异步进行,编译完成后用新机器码"替换"旧的解释执行。


# 03.内联:JIT 的"原子优化"

如果说 JIT 只能保留一个优化,那一定是——内联(inlining)。

# 3.1 内联是性能的原子操作

观察这两段代码:

public int compute(int x) {
    return helper(x) + 1;
}

private int helper(int x) {
    return x * 2;
}

没内联时:

compute:
    push    rbp
    mov     rbp, rsp
    mov     edi, [arg_x]
    call    helper             ; 调用开销 ~5-10ns
    add     eax, 1
    pop     rbp
    ret

helper:
    push    rbp
    mov     rbp, rsp
    mov     eax, [arg_x]
    shl     eax, 1
    pop     rbp
    ret

内联后:

compute:
    mov     eax, [arg_x]
    shl     eax, 1                ; helper 体被嵌入
    add     eax, 1
    ret

性能差异:内联节省了函数调用的全部开销(push/pop、寄存器保存、跳转、ret)。但更重要的是——

# 3.2 内联触发的"连锁优化"

这才是内联的真正威力——它把"调用方上下文"和"被调方实现"合并,让其他优化变得可能:

public int outer() {
    Point p = new Point(3, 4);
    return p.x + p.y;
}

public class Point {
    final int x, y;
    Point(int x, int y) { this.x = x; this.y = y; }
}

没内联时:

1. 在堆上分配 Point 对象
2. 调用构造函数(写入 x, y)
3. 读取 p.x(堆访问)
4. 读取 p.y(堆访问)
5. 相加
6. GC 回收

内联后 + 标量替换:

JIT 内联了构造函数:x = 3, y = 4
JIT 看到:"这个 Point 没有逃逸到方法外"
JIT 做"标量替换":把 Point 对象拆成两个寄存器
最终代码:
    mov eax, 7   ; 编译期常量折叠:3 + 4 = 7
    ret

结果:从"分配对象+构造+两次堆访问+加法+GC"变成"一条 mov 指令"。这才是 JIT 的恐怖之处。

# 3.3 多态调用去虚化

考虑:

public int sum(Animal a) {
    return a.weight() + a.age();
}

abstract class Animal {
    abstract int weight();
    abstract int age();
}

虚方法的代价:每次调用都要查 vtable,无法内联。

JIT 的"类型反馈":

观察 1 万次调用,发现 99% 时间 a 的运行时类型是 Cat
JIT 编译为:
    if (a.getClass() != Cat.class) goto deopt;   // 守卫
    // 内联 Cat.weight() 和 Cat.age()
    return cat_weight + cat_age;

这就是"单态内联缓存"(monomorphic inline cache)——99% 路径是 1 条比较 + 内联代码,1% 路径退回去虚化。性能从"每次 2 次 vtable 查询"变成"1 次类型比较",速度提升 5-10 倍。

多态情况(运行时 a 可能是 Cat 或 Dog):

"双态内联缓存"(bimorphic IC):
    if (a.class == Cat) inline_cat();
    else if (a.class == Dog) inline_dog();
    else goto deopt;

更多种类型 → 退回 vtable 查询。

# 3.4 内联预算:为何不能无限内联

理想情况下,JIT 应该内联一切——但实际不能:

极端例子:递归内联 fact(10) 会展开成 10 层
      内联 fact(10000) 直接爆字节码尺寸

代码膨胀(code bloat)的代价:
  机器码区域变大 → I-cache miss 增加 → 反而变慢
  编译时间暴涨

HotSpot 的内联策略(默认值):

-XX:MaxInlineSize=35       字节码 <= 35 字节的方法总是内联
-XX:FreqInlineSize=325     热点方法字节码 <= 325 字节内联
-XX:MaxInlineLevel=15      递归内联深度 <= 15
-XX:InlineSmallCode=2000   被内联调用方编译后 <= 2000 字节

这些数值是 Sun/Oracle 多年实测调出来的"经验最优"——再大就开始看到 I-cache 退化。

# 3.5 final 为什么能加速 30%

§0.5 第二题。看这段代码:

class Config {
    public final int maxRetries = 3;        // 注意 final
    public int unsafeFlag = 1;              // 没 final
}

void process(Config cfg) {
    for (int i = 0; i < cfg.maxRetries; i++) { ... }
}

final 字段的优化:

没 final:
  JIT 不知道 cfg.maxRetries 会不会变
  → 每次循环条件都要重新读 cfg.maxRetries(堆访问)

有 final:
  JIT 假设 cfg.maxRetries 永不变(除非 deopt)
  → 把 maxRetries 当作"3"——常量传播、循环展开都能做
  → 最终代码可能直接展开成 3 次执行

实测:在循环条件、数组访问中,final 字段能带来 20-40% 的加速。

这背后是 JIT 的强假设:所有 final 字段的值在初始化后不变。但反射可以打破这个假设(Field.setAccessible(true) + setInt),所以 JIT 编译这种代码时会保留 deopt 守卫——一旦反射改了 final 字段,立刻 deopt。

# 3.6 五语言内联机制对照

"内联"是所有编译器的祖传优化——但触发方式、决策位置、能内联的范围在五种语言里截然不同:

语言 内联决策位置 程序员显式提示 默认策略 跨模块内联 多态去虚化
C / C++ 编译期 inline / __forceinline / [[gnu::always_inline]] 编译器启发式(基于函数大小、调用频率) LTO 开启后可跨 .o 静态分发可内联 / virtual 一般不可
Java HotSpot 运行期 JIT 无关键字(final 间接提示) MaxInlineSize=35/FreqInlineSize=325 总是跨 class(一个 JIT 单元) ✅ 基于 type profile 去虚化(核心能力)
C# RyuJIT 运行期 JIT [MethodImpl(MethodImplOptions.AggressiveInlining)] 类似 HotSpot 总是跨 assembly ✅ Tier 1 类型反馈
JavaScript V8 运行期 JIT 无关键字 Maglev/TurboFan 基于 feedback 跨脚本 ✅ inline cache + map check
Go 编译期 无关键字(-gcflags="-l" 关闭) 极度保守(预算很小) 默认仅同包;PGO 可改善 ❌ 接口调用难以内联(Go 1.20+ 有 devirtualization)
Rust 编译期 #[inline]/#[inline(always)]/#[inline(never)] LLVM 启发式 LTO 开启后可跨 crate 单态化天然内联(trait 静态分发)
Swift 编译期 @inline(__always) / @inlinable LLVM 模块边界需 @inlinable 静态分发可内联

几个关键差异:

① 内联预算大小

Go 编译期内联预算 < HotSpot JIT 内联预算(约 1/4)
原因:Go 强调编译速度(10 秒编译 100 万行)
后果:很多看起来该内联的小函数被拒绝
查看:go build -gcflags="-m -m" main.go

② "运行期 vs 编译期"决策的本质区别

JVM/V8 JIT:能基于真实类型反馈做去虚化(动态多态杀手锏)
C/Go/Rust AOT:只能基于静态可见的具体类型内联

举例:
  Java:List<String> list = getList(); list.add(x);
       JIT:99% 是 ArrayList → 直接内联 ArrayList.add
  
  Go:var w io.Writer = getWriter(); w.Write(x)
       AOT:不知道运行时 w 是谁 → 必须走接口表
       Go 1.20+:PGO + devirtualization 可改善,但远不如 JIT 灵活

③ 跨模块内联

C/C++:默认 .o 之间不能内联 → 必须用 LTO(链接期重新优化)
Rust:默认 crate 之间不能内联 → #[inline] 或 LTO 才行
JVM:所有类都是一个 JIT 单元 → 无障碍跨 class 内联(这是 JVM 的隐藏优势)
V8:所有 JS 都在一个 isolate 里 → 同理

📌 关键洞察:

  1. JVM/V8 的 JIT 不需要 inline 关键字——因为 JIT 在运行时看到所有调用,不需要程序员提示
  2. C/C++/Rust 必须用关键字 + LTO——因为编译期看到的信息不全
  3. Go 设计哲学最特别:选择"快编译 + 简单决策",牺牲峰值性能,但用 PGO 找补回来
  4. 如果你写 Rust/Swift:不要乱加 #[inline(always)]——LLVM 通常比你聪明,过度提示反而 bloat

实战建议:

我用的语言 我应该怎么做
Java 让 JIT 自己决定。重要的:保持方法 < 8KB 字节码;用 final 给提示;保持类型单态
C# 同 Java;热点路径上必要时加 [AggressiveInlining]
JS 写 monomorphic 代码(固定字段顺序),让 V8 形成稳定 hidden class
C/C++ 默认让编译器决定;只对极热的小函数用 always_inline;开 LTO
Go 用 go build -gcflags="-m -m" 看哪些函数被拒;开 PGO;不要写大函数
Rust 默认让编译器决定;跨 crate 热点用 #[inline];考虑 fat LTO

# 04.逃逸分析与栈上分配

# 4.1 逃逸分析的核心问题

public int compute() {
    StringBuilder sb = new StringBuilder();
    sb.append("hello").append("world");
    return sb.length();
}

问题:sb 这个对象有必要在堆上分配吗?

逃逸分析的判断:

sb 被赋值给方法外的变量了吗?  没有
sb 被传给可能保存它的方法了吗?没有(append 不保存)
sb 被作为返回值返回了吗?      没有
sb 被存到全局/类成员了吗?     没有

→ sb "没有逃逸"出 compute 方法 → 可以栈上分配

栈上分配的好处:

1. 无需 GC:方法返回时随栈帧销毁
2. 无堆分配开销:不调 malloc
3. cache 友好:栈数据热

# 4.2 标量替换:更激进的优化

比栈上分配更进一步——直接把对象拆成几个标量(基本类型):

Point p = new Point(3, 4);
int sum = p.x + p.y;

标量替换后:

int p_x = 3;       // 直接是寄存器变量
int p_y = 4;
int sum = p_x + p_y;

Point 对象消失了——它被拆成两个 int 变量,全部用寄存器存放。没有任何堆/栈内存占用,没有任何 GC 压力。

这就是为什么很多"看起来分配大量临时对象"的 Java 代码,实际 GC 压力极小——逃逸分析+标量替换把它们都消除了。

# 4.3 锁消除(Lock Elision)

逃逸分析的另一个应用——消除单线程访问的锁:

public String foo() {
    StringBuffer sb = new StringBuffer();   // 内部用 synchronized
    sb.append("a").append("b");
    return sb.toString();
}

StringBuffer 每个 append 都要加锁——但这个 sb 没逃逸出方法,只有当前线程能访问它。

JIT 看到这一点后:

sb 没逃逸 → 不可能有其他线程访问 → 锁完全没必要 → 删除

实测:StringBuffer 在 JIT 锁消除后,性能与 StringBuilder 几乎相同。

# 4.4 Go编译器更彻底逃逸分析

Go 没有 JVM 那种 JIT,但Go 编译器在编译期就做激进的逃逸分析:

func foo() *int {
    x := 42
    return &x   // x 的地址逃逸出去 → 编译器自动改到堆上
}

func bar() int {
    x := 42
    return x   // x 没逃逸 → 栈上分配
}
$ go build -gcflags="-m" main.go
./main.go:3:5: moved to heap: x   # foo 中的 x 逃逸
                                  # bar 中的 x 没提示,留在栈上

Go 的逃逸分析是"语义层面"的——程序员可以通过 -gcflags="-m" 看到每个变量的命运,主动写出"不逃逸"的代码:

// ❌ 触发堆分配
func badAppend(s []int) []int {
    return append(s, 1)
}

// ✅ 留在栈上(如果调用方传入足够 cap 的 slice)
func goodAppend(s []int) []int {
    if cap(s) > len(s) {
        s = s[:len(s)+1]
        s[len(s)-1] = 1
        return s
    }
    return append(s, 1)
}

Go 把"是否堆分配"暴露给程序员——这是性能控制力的源泉。

# 4.5 五语言逃逸分析全景

"逃逸分析"是一个比 Java/Go 程序员想象的更普遍的话题——几乎每种现代语言都做了这件事,只是机制和暴露程度不同:

语言 逃逸分析时机 默认分配位置 程序员能看到吗 优化结果
Java HotSpot C2 JIT 运行期 堆 看 -XX:+PrintEscapeAnalysis 不逃逸 → 栈上 / 标量替换 / 锁消除
Java Graal Graal 编译期 + 部分求值 堆 Truffle PE 时显式 比 C2 更激进
C# RyuJIT 有限分析 引用类型→堆,值类型→栈 部分 [StackOnly] 提示 主要靠程序员用 struct 来避免逃逸
JavaScript V8 TurboFan 运行期 堆(GC) 不可见 标量替换(escape-analyzed allocation)
Go 编译期(静态分析) 编译器决定(堆/栈) go build -gcflags="-m" 编译期决定,程序员可以主动写"不逃逸"代码
Rust 编译期 + 借用检查器 栈优先(默认所有局部都在栈) 类型层面就能看出(Box<T> 才上堆) 天然零逃逸(unless 显式 Box::new)
Swift 编译期 值类型栈,引用类型堆 不直接可见 类似 C#
C / C++ N/A 程序员手动决定 程序员 100% 控制 malloc vs 栈数组

五种典型代码的逃逸命运对比:

场景:return new Point(3, 4)   // 工厂方法返回对象
语言 实际行为 原因
Java 堆分配(即使有 EA,跨方法逃逸难分析) C2 视野有限
Java + Graal 调用方内联后可能栈分配 / 标量替换 部分求值更强
Go &Point{3,4} 逃逸到堆(编译期判定) 静态分析可见返回值
Rust Point { x: 3, y: 4 } 栈返回(值类型) 默认值语义
Rust(用 Box) 堆分配 程序员显式选堆
C++ return Point(3,4) 栈返回 + RVO/NRVO 编译器返回值优化
C++(用 new) 堆分配 程序员显式选堆

📌 三种逃逸分析哲学:

A. 编译期 + 显式语言(Rust/C++/Swift)
   程序员通过类型系统精确控制
   优点:可预测、零运行时开销
   缺点:心智负担、有些场景表达不出

B. 编译期 + 静态分析(Go)
   编译器尽量分析,结果暴露给程序员
   优点:自动化 + 可观测
   缺点:跨函数分析有限

C. 运行期 + JIT(Java/V8)
   JIT 在内联展开后做整体分析
   优点:能跨方法做整体分析(内联 + EA 联动)
   缺点:开发者无法直接控制;预热前不生效

最值得说的是 Rust:它用类型系统取代了运行时 EA——T 是栈,Box<T> 是堆,Rc<T> 是引用计数堆,Arc<T> 是原子引用计数堆。程序员"显式"标注内存位置,编译器只需要相信你即可。这就是为什么 Rust 不需要 GC 也能跑 system-level 代码。

实战建议:

语言 怎么写出"逃逸友好"代码
Java 局部 StringBuilder 不要返回;避免把对象塞 List/Map;用 record(HotSpot 对 record 的 EA 更激进)
C# 用 struct 代替 class;用 Span<T>/Memory<T>;用 stackalloc
JS 不要把临时对象 push 到全局;保持对象 monomorphic 让 V8 能做 EA
Go go build -gcflags="-m" 检查;避免 interface 装箱;不要随便 &local
Rust 默认栈语义就是最优;只在必要时用 Box

# 05.去优化:当假设被打破

# 5.1 JIT 的"乐观假设"

我们看到 JIT 做了大量"假设":

  • 假设虚方法 99% 调用同一个目标(去虚化)
  • 假设 final 字段永不变(常量传播)
  • 假设某 if 分支几乎总是成立(不编译另一分支)
  • 假设没有 null(消除 null 检查)
  • 假设数组下标在范围内(消除越界检查)

问题:这些假设可能被打破:

1. 反射改了 final 字段
2. 加载了一个新的子类,原来的"单态"变成"多态"
3. 输入数据分布变了,原来 1% 的分支变成 50%
4. 调试器附加上来

JIT 必须有"撤回"机制——这就是 去优化(Deoptimization)。

# 5.2 守卫指令与 deopt 触发

JIT 编译的代码里,几乎到处都是隐式的"守卫":

; JIT 编译的去虚化代码
mov rax, [rdi]               ; 读对象的类指针
cmp rax, [Cat_class_ptr]     ; 比较是不是 Cat
jne deopt_handler            ; 不是 → 跳转到 deopt
... 内联的 Cat 方法体 ...
ret

deopt_handler:
    ; 1. 恢复字节码状态
    ; 2. 跳回解释器对应位置继续执行
    ; 3. 把这段机器码标记为 "made not entrant"

deopt 触发后的处理:

1. 当前栈帧中"激进优化"的状态被还原成"解释器状态"
   - 标量替换的对象重新分配到堆
   - 寄存器值写回栈帧的 LVT
   - 设置正确的 PC 到字节码的对应位置
   
2. 控制权转回解释器
3. 这段优化代码被废弃,方法重新进入计数器累计 → 可能重新编译

这是 JIT 的"魔术"——在用户完全感觉不到的情况下,从机器码无缝切回字节码。

# 5.3 反复Deopt引发性能悬崖

一个真实陷阱:

List<?> list;
if (cond1) list = new ArrayList<>();
else if (cond2) list = new LinkedList<>();
else list = new CopyOnWriteArrayList<>();

for (int i = 0; i < 1000000; i++) {
    list.add(item);   // 这个 add 调用是哪个?
}

如果 list 三种类型都被使用过(比如配置变化导致每次启动 list 类型不同):

JIT 第一次编译:观察到 99% 是 ArrayList → 单态内联
某次启动用了 LinkedList → deopt → 重编为双态内联
某次启动用了 CopyOnWrite → deopt → 编为三态
更多类型 → deopt → 退回 vtable 查询(失去去虚化)

这就是"性能悬崖"——某些代码模式让 JIT 反复 deopt + 重编,永远到达不了最优状态。

修复:保持类型单一。如果业务确实需要多种实现,分别写不同的方法(让 JIT 各自编译为单态)。

# 5.4 PrintCompilation看到deopt

打开 JVM 参数:

-XX:+PrintCompilation

会看到大量输出:

123  45     3       Foo::bar (12 bytes)
124  46  s   3       Foo::sync (5 bytes)
   125  45       3       Foo::bar (12 bytes)   made not entrant   ★
   126  47        4       Foo::bar (12 bytes)

made not entrant 就是 deopt 的标志——某次 JIT 编译的版本被废弃了。

频繁的 made not entrant:意味着 JIT 反复 deopt → 性能问题严重,要排查。

# 5.5 CHA类层次分析:JIT单态宣言

很多人疑惑:JIT 怎么知道"现在世界上只有一个 Cat 子类"?

答案是 CHA——JVM 在类加载时维护一个"全局类层次":

Animal
├── Cat (currently the only subclass loaded)
└── ?

JIT 编译 Animal.weight() 调用时:

  • CHA 报告:"当前只有 Cat 一个子类" → JIT 直接内联 Cat.weight,不需要任何守卫
  • 后来加载 Dog extends Animal → JVM 主动让所有"单态优化的 Animal 调用"deopt
  • 重新编译为带守卫的版本

这是 JVM 类加载和 JIT 紧密协作的产物——AOT 编译器没有这个能力。

# 5.6 去优化的通用模型(所有"赌徒型 JIT"都必须有)

把"去优化"从 JVM 视角抽象出来——任何敢做激进假设的运行时都必须有一套兜底机制。这是 JIT 系统设计的"必选项":

运行时 去优化机制 触发原因 后续行为
HotSpot JVM Deoptimization 类型守卫失败 / CHA 假设失效 / uncommon trap "made not entrant" → 回解释器 → 重新 profiling → 可能重编
JavaScript V8 Bailout hidden class miss / 类型反馈失效 / overflow 从 TurboFan/Maglev → Sparkplug → Ignition
C# RyuJIT Tier downgrade rejit / 类型反馈失效 Tier 1 失效 → 回 Tier 0
GraalVM Speculation log + bailout speculation 假设失败 重写 speculation log,下次编译不再赌
PyPy/LuaJIT Trace guard failure trace 上某守卫失败 当前 trace 中止 → 新建 side trace
Go/Rust/C++ (AOT) ❌ 无 N/A 没有 deopt → 所以不敢做激进假设

通用去优化流程(所有 JIT 都遵循):

为什么 AOT 语言(Go/Rust/C++)不能做这件事?

AOT 编译完成后 → 二进制锁死 → 没有"回退点"
所以 AOT 必须保守:
  Go:接口方法默认走 vtable(即使 99% 都是同一类型)
  Rust:dyn Trait 也走 vtable
  C++:virtual 调用一律走 vtable

补救:
  Go 1.20+ PGO devirtualization:编译期看到 profile → 提前做去虚化 + 保留 vtable 兜底
  C++ LLVM ICP(间接调用提升):类似机制
  → 这些是"编译期模拟 JIT 去虚化",但不能动态调整

📌 设计上的深层结论:

  1. "激进优化能力 ∝ deopt 机制完备度"——没有 deopt 就不敢赌
  2. JIT 是"运行时观察 + 假设 + 兜底"三件套——少一样都不行
  3. AOT 想接近 JIT 性能必须有 PGO,但永远到达不了 JIT 上限(因为不能根据运行时变化调整)
  4. "反复 deopt"是 JIT 系统最大的性能事故源——监控 made not entrant / bailout 比看 CPU 重要

实战监控命令对照:

# JVM
-XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining

# V8(Node.js)
node --trace-opt --trace-deopt app.js

# .NET
dotnet-counters monitor --counters System.Runtime
# 看 IL Bytes Jitted, Methods Jitted Count

# PyPy
PYPYLOG=jit-log-opt:logfile python app.py

# Go(PGO 时代)
go build -pgo=auto -gcflags="-m=2" 2>&1 | grep "devirtualizing"

# 06.跨语言 JIT 设计对比

§0.3 第三题。不同语言的 JIT 哲学差异巨大。

# 6.1 主流语言 JIT 对比表

语言 JIT 实现 设计哲学
Java HotSpot C1 + C2 分层编译,重量级、深度优化
Java GraalVM Graal Java 写的 JIT,更激进的部分求值
JavaScript V8 Ignition + Sparkplug + Maglev + TurboFan(4 层) 极端分层,启动至关重要
JavaScript JSC LLInt + Baseline + DFG + FTL(4 层) 类似 V8
PyPy Tracing JIT 跟踪热路径,不是基于方法
LuaJIT Tracing JIT 单作者作品,性能逼近 C
.NET RyuJIT AOT + JIT 混合(R2R)
Lua 没有官方 JIT 解释器极致优化

# 6.2 V8 的 4 层架构(2024 年)

V8 是当今最复杂的 JIT 系统:

Level 1: Ignition(解释器)
  - 注册式字节码
  - 极快启动
  - 收集类型反馈

Level 2: Sparkplug(基线 JIT)   [2021 引入]
  - 直接从字节码生成机器码
  - 不做激进优化,但比解释快 ~5×
  - 编译速度极快(<1ms/方法)

Level 3: Maglev(中级 JIT)       [2023 引入]
  - 中等优化,性能介于 Sparkplug 和 TurboFan 之间
  - 编译速度 10× TurboFan

Level 4: TurboFan(顶级 JIT)
  - 类似 HotSpot C2 的深度优化
  - 几十 ms 编译时间
  - 4-10× 解释器速度

为什么要 4 层? 因为 JS 在浏览器里启动极其关键:

首屏加载 1 秒延迟 → 用户流失 5%
TurboFan 编译 10 个热点函数要 1 秒
→ 必须先有"中间产物"(Sparkplug)
→ Sparkplug 牺牲优化质量换"立即可用"
→ 后台 Maglev → TurboFan 慢慢追加

这就是 §0.3 第三题的答案——V8 不是"放弃 JIT",而是回归"分层"以解决纯 JIT 启动慢的问题。Crankshaft(V8 老 JIT,2010 年代)在编译完成前用户体验极差。

# 6.3 Tracing JIT vs Method JIT

两种 JIT 编译单位的根本差异:

维度 Method JIT (HotSpot, V8) Tracing JIT (PyPy, LuaJIT)
编译单位 方法 热路径(trace)
跟踪范围 单个方法体 跨方法、跨循环边界的实际路径
优势 简单、模块化 能跨越方法边界做整体优化
劣势 方法间优化有限 路径多样会爆炸

Tracing JIT 的天才之处:

def hot_loop():
    for x in items:           # 热路径开始
        result = process(x)   # 内联 process 进 trace
        if result.valid:      # 99% 走这条
            buf.append(result)
        else:
            log(result)       # 1% 走这条,不进 trace

Tracing JIT 把"99% 的实际执行路径"作为一个整体编译。哪怕这条路径跨越 10 个方法、3 层循环——整体作为"一段直线代码"优化。

LuaJIT 用 trace JIT 把 Lua 跑到 C 的 80% 性能——这是动态语言性能的标杆。

# 6.4 GraalVM:用 Java 写 JIT

Graal 是一个用 Java 写的 JIT——这本身就是一个壮举。

优势:

1. 比 C2(C++ 写的)易于扩展和维护
2. 部分求值(Partial Evaluation):能把"解释器"自动变成"JIT"
3. 多语言:同一个 JIT 能编译 JavaScript、Python、Ruby、R、WASM

Truffle 框架:你写一个 AST 解释器,Graal 自动给它生成 JIT 编译器——大幅降低实现新语言的成本。这是过去 10 年 VM 研究的最大突破之一。


# 07.经典陷阱与生产级反模式

# 7.1 陷阱一:JIT预热不足导致雪崩

铁律:所有"启动后立即承接大流量"的服务,必须有预热阶段。

预热方案:

@PostConstruct
public void warmup() {
    // 模拟 1 万次典型业务调用
    for (int i = 0; i < 10000; i++) {
        for (BusinessOperation op : keyOperations) {
            try { op.execute(WARMUP_DATA); } catch (Exception e) {}
        }
    }
}

或用 JVM CDS(Class Data Sharing)+ AppCDS 缩短启动时间,或用 GraalVM Native Image 完全 AOT。

# 7.2 陷阱二:微基准的 JIT 误差

§0.5 第一题。看这段代码:

long start = System.nanoTime();
for (int i = 0; i < 1_000_000_000; i++) {
    int x = i * 2 + 1;   // 看似在测乘法
}
long end = System.nanoTime();

JIT 的"恶意"优化:

JIT 看到 x 没被使用 → 死代码消除 → 删除整个表达式
JIT 看到循环没副作用 → 循环消除 → 删除整个循环
最终代码:long start = ...; long end = ...; (什么都没做)

结果是 0ns,但什么也没测到。

修复:用 JMH(Java Microbenchmark Harness):

@Benchmark
public int benchmark() {
    int x = 0;
    for (int i = 0; i < 100; i++) x = x * 2 + 1;
    return x;   // ★ 必须 return 或 Blackhole.consume,防止 DCE
}

JMH 处理了所有 JIT 陷阱(DCE、循环展开、cache 状态、warmup)。永远不要用 main 函数 + System.nanoTime 做微基准。

# 7.3 陷阱三:堵塞代码路径

public int dispatch(int type) {
    switch (type) {
        case 1: return handle1();
        case 2: return handle2();
        case 3: return handle3();
        // ... 100 个 case
    }
}

问题:单个方法太大(字节码 > 8KB),JIT 拒绝编译。

修复:拆分成多个小方法。

# 7.4 陷阱四:反复deopt导致性能悬崖

排查方法:

-XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation -XX:+PrintInlining

看到大量 "made not entrant" → 类型不稳定 → 拆分代码路径。

# 7.5 陷阱五:Lambda+反射的JIT失效

Method m = getMethod();
list.forEach(x -> {
    try { m.invoke(target, x); } catch (Exception e) {}
});

Method.invoke 是 native 调用 + 反射安全检查 + 参数装箱——JIT 几乎完全失效。

修复:用 MethodHandle(Java 7+)或 LambdaMetafactory 把反射变成"和直接调用一样快"的代码。

# 7.6 陷阱六:字节码增强阻碍JIT内联

Spring AOP、CGLIB 大量生成动态字节码:

原方法 foo()
    ↓ AOP 增强
代理类 foo$proxy()
    → ProxyFactoryBean.intercept()
        → AdviceChain.proceed()
            → 原方法 foo()

5 层调用 + 大量 try/catch + invokedynamic——JIT 难以内联整条链。

优化:用 Java Agent 在加载时直接修改字节码(Byte Buddy / ASM),生成扁平的目标代码。

# 7.7 陷阱七:Class.forName在热路径上

public Object create(String name) {
    return Class.forName(name).newInstance();   // 每次调用都查类
}

forName 内部要遍历 ClassLoader 链——是 native 调用、有同步、JIT 内联无效。

修复:缓存 Class 对象。

# 7.8 跨语言性能陷阱速查表

前面 7 个陷阱大部分是 JVM 视角——但几乎每个都能在 V8/Go/Rust/.NET 找到等价陷阱。下面这张表是这两章最实用的产出物:

陷阱类别 Java/JVM JavaScript/V8 C# / .NET Go Rust Python
预热不足 JIT 没编译 → 性能差 50× TurboFan 没编译 → 慢 5–10× Tiered Comp Tier 0 慢 AOT 无此问题 无 PyPy 需预热,CPython 无
类型不稳定 / 多态退化 "made not entrant" hidden class miss → 走 megamorphic IC Tier 1 失效 接口断言走 itab 慢 dyn Trait 类似 duck typing 天生慢
微基准误差(DCE) 必须用 JMH 必须用 benchmark.js / tinybench BenchmarkDotNet testing.B + 防 DCE criterion crate timeit + 防优化
反射 / 元编程 Method.invoke 慢 100× obj[dynamicKey] 破 hidden class MethodInfo.Invoke 慢;用 expression trees reflect.Value.Call 慢 50× Any downcast;过程宏编译慢 getattr 在热路径
字节码 / 二进制增强 Spring AOP/CGLIB 阻碍内联 Proxy / Reflect.set 破坏 IC Castle DynamicProxy 同 Spring go generate(编译期,无运行时代价) 过程宏(编译期) 装饰器嵌套深
方法 / 函数过大 > 8KB 字节码拒 JIT > 一定大小拒 Maglev 类似 内联预算极小 LLVM 启发式(默认不太苛刻) bytecode 大不影响
AOT 元数据缺失 Native Image 反射 metadata 配置不足 N/A NativeAOT trimming 切断反射 不适用 不适用 不适用
PGO 数据过期 C2 deopt 重学 TurboFan 持续学习 Tiered Comp 持续 Go PGO 数据陈旧 → 误优化 LLVM PGO 数据陈旧 N/A
内联预算超限 MaxInlineSize 阻断 类似 类似 Go 预算最小 LLVM 自动 N/A
GC 干扰 JIT Full GC 暂停 → STW Mark-Compact 暂停 GC 类似 Go GC 短暂停(无大问题) 无 GC 无(引用计数)

两条跨语言"性能元规律":

  1. 任何"运行时优化"的语言都有"预热 + 类型稳定性 + 反射代价"三大陷阱——只是名字不同
  2. 任何"AOT 优化"的语言都有"PGO 数据时效性 + 内联预算 + 跨模块边界"三大陷阱——只是表现形式不同

如果你只能记住一条:写出 monomorphic(单态)、小函数、可观测的代码 —— 所有语言的 JIT/AOT 都会奖励你。


# 08.经典案例串讲

把 §00 抛出的"双 11 雪崩"故事走完整条优化路径——这是我亲历的、用本章每一个知识点(热点检测、分层编译、内联、逃逸分析、去虚化、deopt)才把订单服务从崩溃救回来的真实工程案例。

# 8.1 案例背景:双11雪崩的完整复盘

业务背景:订单网关,日常 50000 QPS,双 11 峰值 200000 QPS。Java 21 + Spring Boot + 200 个 K8s Pod。

事故时间线:

T+0s     12:00:00  运维切流,新启动 50 Pod 接管全部流量
T+5s     CPU 100%, P99 800ms, GC 频繁
T+8s     上游 LB 健康检查超时, 50 Pod 被踢出
T+30s    保留 150 Pod 流量翻倍, 同样开始抖动
T+60s    全集群雪崩, 订单服务跌 0
T+180s   人工回滚 + 限流, 业务恢复

直接损失:3 分钟订单服务全挂 → ~4000 万元 GMV 损失。根因不是代码 bug,而是没有理解 JIT 的"预热必然性"——这就是本章 §1.3 / §2 / §7.1 反复强调的主题。下面用案例把每一节的知识对回。

# 8.2 第一现场:JIT 预热不足的具体表象

事故后我们用 -XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining 抓 JIT 行为,看到新 Pod 启动后的 30 秒里:

启动 T+0s  : 0 个方法编译过, 100% 字节码解释执行
启动 T+5s  : C1 编译了 ~200 个方法 (Tier 3, 带 profile)
启动 T+15s : C2 开始编译热点 (Tier 4, 优化激进)
启动 T+25s : 99% 热点方法已 Tier 4
启动 T+30s : 进入稳态, P99 5ms

性能差距(命中 §1.1 解释执行三个固有开销):

阶段 单次请求 CPU 指令数 内存分配 P99
解释执行(T+0-5s) ~150,000 频繁逃逸 800ms
C1(T+5-15s) ~30,000 部分栈分配 80ms
C2(T+15s+) ~3,000 几乎全栈 5ms

解释执行慢 50 倍的来源(§1.1):

  • ① dispatch 开销——每条字节码都要 fetch-decode-dispatch(§4.2 跳表分支预测失败率高)
  • ② 没有内联——get/set 也走完整方法调用栈
  • ③ 没有逃逸分析——所有对象都在堆上分配,GC 压力大

这就是为什么"新 Pod 接全流量"必崩——同样的代码,CPU 消耗差 50 倍,而集群按"稳态 CPU"扩容,预热期实际能扛的流量只有规划的 2%。

# 8.3 阶段一:分层编译 + OSR 救火

修复方案 v1——改切流策略(命中 §2.1-2.5 分层编译):

# 旧策略(崩溃版)
切流速度: 0% → 100%,瞬间

# 新策略(预热版)
切流速度: 
  T+0s:   1% 流量(让 JIT 看够样本数 ≥ 10000 次)
  T+30s:  10%(C1 已编译完热点)
  T+60s:  50%(C2 也编译完了)
  T+90s:  100%(完全稳态)

为什么能 work?正是因为 JVM 的 Tiered Compilation 在背后默默工作(§2.4):

Tier 0  : 解释器 (interpret)
Tier 1  : C1 简单编译(无 profile)
Tier 2  : C1 编译 + 调用计数 profile
Tier 3  : C1 编译 + 完整 profile(默认从这里开始)
Tier 4  : C2 优化编译

升级规则(命中 §2.2 计数器法):
  方法调用次数 ≥ 10000 → 进 C2 队列
  循环回边次数 ≥ 13700 → OSR (§2.3) 立即编译

OSR(On-Stack Replacement)的妙用:双 11 那种"还在跑的长循环"——比如 for (Order o : pendingOrders)——不需要等下一次方法调用,OSR 直接在栈上把解释器帧替换成 C2 帧。这是 §2.3 解决"长方法预热慢"的关键武器。

收益:P99 从冷启动 800ms → 平滑过渡到 50ms → 5ms。雪崩消失。

# 8.4 阶段二:内联 + 去虚化 + 逃逸分析三连击

预热问题修了,但稳态 P99 还有 5ms。性能团队再压一波——这次靠 JIT 的三大主力(§3 内联 + §4 逃逸分析 + §5.5 CHA)。

热点函数 OrderService.process(Order o):

public OrderResult process(Order o) {
    Validator v = getValidator(o.type());   // 多态调用
    if (!v.validate(o)) return reject(o);   // 又一个多态
    Pricing p = calcPrice(o);                // 返回小对象
    Audit a = new Audit(o, p);               // 创建对象
    return new OrderResult(o, p, a);         // 又创建对象
}

三连击优化(命中 §3.1 + §3.3 + §4.1 + §4.2):

① 内联(§3.1):JIT 把 getValidator / validate / calcPrice 整段内联进 process——从 4 次方法调用变成 1 个大方法。光内联本身就把 P99 从 5ms 降到 3ms。

② 去虚化(§3.3 + §5.5 CHA):

  • Validator 接口有 3 个实现,但 profile 显示 99% 走 LimitOrderValidator
  • C2 通过 类层次分析(CHA) 证明"当前类加载器里只有这一个具体实现"
  • → 去虚化为直接调用 + 类型守卫
  • → 进一步触发"被内联后的方法又能内联"的连锁优化(§3.2)

③ 逃逸分析 + 标量替换(§4.1 + §4.2):

  • Audit a = new Audit(...) 这个对象没有跨方法逃逸(只在 process 内用)
  • C2 把 Audit 对象完全消解——拆成 2 个标量字段直接放寄存器
  • → 零堆分配,GC 压力降 30%

④ 锁消除(§4.3):

  • OrderResult 构造时内部用了 StringBuilder(隐含 synchronized)
  • 逃逸分析证明 StringBuilder 没逃逸 → 锁消除
  • → 又省 50ns

最终效果:P99 从 5ms → 2.3ms,CPU 利用率从 30% → 12%(同流量下)。

# 8.5 阶段三:deopt 风暴与 CHA 兜底

新问题出现——业务方上线"新型订单(CryptoOrder)",意味着多了一个 Validator 实现:

Day 1: 部署新 jar → 看似一切正常
Day 2: P99 从 2.3ms 飙到 80ms (35×!)
Day 3: 排查发现 deopt 日志爆炸

抓 -XX:+PrintCompilation 看到(命中 §5 整章 + §7.4):

[...code...] made not entrant         ← C2 编译的代码被宣告失效
[...code...] made zombie               ← 准备回收
[Tier3 entry] OrderService::process   ← 退回 C1

根因:

  1. CryptoOrder 加载后,CHA 假设破裂(Validator 不再只有一个实现)
  2. C2 编译的"去虚化版本"全部失效
  3. 触发 deopt(§5.1 + §5.2 守卫指令)
  4. 退回 Tier 3 → 重新走完 C2 编译 → CHA 又一次失败 → 死循环 deopt(§5.3 性能悬崖)

修复手段(命中 §5.5 + §7.4 + §3.5):

① 把 Validator 改成 sealed interface(Java 17+):

public sealed interface Validator permits LimitValidator, CryptoValidator { ... }

明确告诉 CHA:"只有这两个实现,永远不会有第三个"——C2 可以生成 2-way switch 而不是退化到 vtable(§3.3 多态去虚化的中间形态)。

② 把热点 validate 标记 final(§3.5 final 为什么加速 30%): JIT 看到 final 等于得到"这个方法签名永远稳定"的承诺,敢于做更激进的内联。

③ 监控 deopt 计数(§7.4 + §8.3 七字真言⑦):

jcmd <pid> JFR.start filename=jit.jfr settings=default
# 看 Compilation Failure / Deoptimization 事件

设置告警:单分钟 deopt > 100 次立即触发。

最终稳态:P99 锁死在 2.5ms,deopt 风暴在生产环境一年没再发生。

# 8.6 案例知识点回归

事故阶段 用到的本章知识点 对应小节
切流 0→100% 雪崩 解释执行的固有开销、JIT 预热必然性 §1.1 / §1.3 / §7.1
阶梯切流 1%→10%→100% 分层编译 Tier 0-4、热点阈值 §2.1-2.5
长循环预热 OSR 栈上替换 §2.3
process 函数 P99 5→2.3ms 内联、去虚化、逃逸分析、锁消除 §3.1-3.3 / §4.1-4.3
CHA 让接口去虚化 类层次分析、单态宣言 §5.5
新 Validator 上线引发 deopt 风暴 deopt 触发、性能悬崖 §5.1-5.3
sealed + final 修复 给 JIT 强假设、稳定承诺 §3.5 / §5.5
监控 made not entrant 关注退化信号 §7.4
整篇决策(JIT vs AOT) AOT/JIT/Tiered 决策矩阵 §1.4

一句话提炼:JIT 是一场"赌博"——预热是它的入场费、单态是它的胜率、deopt 是它的兜底。本章前 7 节讲的所有原理,最终都在这次双 11 事故里得到了血淋淋的验证。

📌 学习提示:面试时如果有人问"Java 服务上线为什么要预热?JVM 预热到底在等什么?",你能不能从①解释执行 vs JIT②分层编译③CHA 假设④deopt 兜底四个角度组合回答?能,本章就吃透了。


# 09.一句话总结

# 9.1 三层认知阶梯

第一层(知其然):知道 JIT 能加速、知道有热点编译
  ↓
第二层(知其所以然):理解分层编译、内联、逃逸分析、deopt 机制
  ↓
第三层(知其将所以然):能编写 JIT-friendly 代码、能诊断性能悬崖、能根据场景选择 JIT 还是 AOT

读完本章后,你应该能回答开头 §0.3 提出的三个问题:

  1. JIT 凭什么比 AOT 更好? → JIT 拥有运行时信息(类型分布、分支概率、热点路径),能做"乐观假设 + 兜底回退"的激进优化,AOT 只能做保守的静态分析。
  2. 为什么"在线编译"划算? → 分层编译让方法立即可用(解释器/Sparkplug),后台慢慢加深优化(C1→C2/Maglev→TurboFan),编译开销摊到长期收益上。
  3. V8 为什么回归"解释器+JIT"? → 纯 JIT 启动慢、内存大,对网页致命。Ignition 让代码立即可用,TurboFan 在后台优化热点,达到"启动快+稳态高性能"双赢。

# 9.2 JIT 设计的决策树

# 9.3 七字真言总结

  1. JIT 的核心是"赌博"——大胆假设、谨慎回退。
  2. 预热不可省略——切流量前必须等 JIT 编译完成。
  3. 保持类型单态——多态会让 JIT 退化。
  4. final 是性能关键字——给 JIT 强假设。
  5. 方法不要太大——> 8KB 字节码会被 JIT 拒绝。
  6. 微基准用 JMH——main + nanoTime 一定测不准。
  7. 关注 made not entrant——deopt 反复发生 = 性能悬崖。

七字真言的六语言映射(验证它对所有语言都成立):

真言 Java/JVM JavaScript/V8 C# / .NET Go Rust Python
① 大胆假设、谨慎回退 type profile + deopt feedback vector + bailout Tier 1 + downgrade ❌ AOT 不能 deopt → 保守 ❌ 同上 PyPy guard + side trace
② 预热不可省略 warmup + CDS code cache + Sparkplug R2R 缓解 不需要 不需要 PyPy 需,CPython 不需
③ 保持类型单态 单一具体类型 固定字段顺序(hidden class) 单 interface impl 单一 interface 实现 静态分发 + 单态化 类型注解 + mypyc
④ 用关键字给提示 final const+无 delete sealed / readonly 无(PGO 代替) #[inline] / const fn type hints + @final
⑤ 函数不要太大 < 8KB 字节码 < TurboFan 限额 类似 Go 预算最小(< 80 节点) LLVM 较宽松 bytecode 大小无关
⑥ 微基准要专用框架 JMH tinybench / benchmark.js BenchmarkDotNet testing.B + Blackhole criterion crate timeit + 防优化
⑦ 关注退化信号 made not entrant --trace-deopt 输出 dotnet-counters -gcflags="-m" 看 inline 决策 rustc 没直接对应 jit-log-opt

📌 跨语言重述:

所有"想又快又灵活"的运行时都遵守同一组规则:
  - 给编译器/JIT "稳定的模式" → 它就奖励你
  - 给它"动态混乱的模式" → 它就退化、deopt、走慢路径
  
这条规律不分 JIT/AOT。
  JIT:性能在"运行期"由 profile 决定
  AOT:性能在"编译期 + PGO 数据"决定
  
两者最终都在惩罚同一类"反优化"代码:
  ① 类型不稳定(多态/dynamic dispatch)
  ② 函数过大(不能内联)
  ③ 反射/动态分派在热路径
  ④ 微基准不严谨导致误判

📢 语言无关声明:本章讨论的所有"运行时优化"思路——它们对所有支持优化的语言运行时都成立。Java 程序员、JS 程序员、Go 程序员、C++ 程序员看完这章,应该都能写出更友好的代码——只是各语言把"友好"翻译成了不同的具体技巧。

# 9.4 与下篇的承接

本篇我们看到了 JIT 如何把字节码变得"比 native 还快"。但 JIT 的所有激进优化都建立在一个假设上——程序员代码遵循"静态、可预测"的模式。

那么——程序员能不能在运行时"改变代码"?能不能动态创建新类、动态调用未知方法、动态生成新逻辑?

这就是 2.7 反射元编程核心设计 (opens new window) 要回答的——当程序需要在运行时操纵自身的结构,会发生什么、付出什么代价。


# 🔗 延伸阅读

  • 同卷上篇:2.5 字节码虚拟机执行 (opens new window)
  • 同卷下篇:2.7 反射元编程核心设计 (opens new window) | 2.8 异常机制核心设计 (opens new window)
  • 内存视角:4.x 缓存局部性原理 | 4.x 内存模型技术设计
  • 经典文献:
    • The Java HotSpot Performance Engine Architecture(Oracle 官方白皮书)
    • Trace-based Just-in-Time Type Specialization for Dynamic Languages(Andreas Gal, PLDI 2009)
    • V8 Sparkplug: Maglev: A New Compiler(Google V8 团队博客)
    • Self: The Power of Simplicity(David Ungar,1987 年 Self 语言论文,奠定了现代 JIT 基础)
    • The LuaJIT Architecture(Mike Pall 的设计文档)
上次更新: 2026/06/28, 17:55:19
5.字节码虚拟机执行
7.反射元编程核心设计

← 5.字节码虚拟机执行 7.反射元编程核心设计→

最近更新
01
科学方法实践论法
06-28
02
辩证思维矛盾论法
06-28
03
毛选中的调查观念
06-28
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 鄂ICP备2024073355号-1 | 鄂ICP备2024073355号
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式