第 4 章 一段代码的完整旅程:.kt → FIR → JVM IR → .class → 类加载 → 机器码
本章目标 这是全书的脊柱章 。我们跟着一段真实代码,走完从"你敲下回车"到"CPU 执行机器指令"的全部七个阶段。 走完这一趟,后面所有章节都有了坐标系:讲 GC 时你知道对象在哪一步被分配;讲 JIT 时你知道它接手的是谁;讲协程时你知道状态机是在哪一步生成的。
4.0 我们的实验对象
一段刻意设计的代码,它同时触碰 Kotlin 的四大"魔法":数据类、内联函数、挂起函数、字符串模板。
kotlin
// book/ch04/Journey.kt
package book.ch04
import kotlin.math.sqrt
import kotlinx.coroutines.delay
data class Point(val x: Int, val y: Int) {
fun norm(): Double = sqrt((x * x + y * y).toDouble())
}
inline fun <R> measured(name: String, block: () -> R): R {
val start = System.nanoTime()
val result = block()
println("$name took ${System.nanoTime() - start} ns")
return result
}
suspend fun loadPoints(n: Int): List<Point> {
delay(1) // ← 挂起点
return (1..n).map { Point(it, it * 2) }
}
fun main() {
val p = Point(3, 4)
val n = measured("norm") { p.norm() } // ← 内联点
println(n)
}
编译:
bash
kotlinc Journey.kt -jvm-target 27 -d out -verbose
产物(注意有三个 class 文件,不是一个):
bash
ls out/book/ch04/
# JourneyKt.class ← 顶层函数 main / measured / loadPoints 所在
# Point.class ← data class
# Point$$ExternalSyntheticLambda0.class 或类似(取决于是否内联)
🔬 第一个惊喜 :loadPoints 是 suspend 函数,但它编译出来的 JourneyKt.class 里,loadPoints 变成了 loadPoints(ILkotlin/coroutines/Continuation;)Ljava/lang/Object;------多了一个 Continuation 参数,返回类型变成了 Object 。而 main 里调用 measured 的地方,完全没有 Lambda 对象。
这两个现象分别来自第 4.3 节的 协程降低(Coroutine Lowering) 和 内联降低(Inline Lowering)。
现在,我们从头走一遍。
4.1 全景图:七个阶段
kotlin
┌─ 编译期(在你的机器上,构建时)────────────────────────────────────┐
│ │
│ ① 源码 .kt │
│ │ 词法分析(Lexer)→ Token 流 │
│ │ 语法分析(Parser)→ PSI 树(KtFile / KtClass / KtFunction) │
│ ▼ │
│ ② K2 前端:FIR(Frontend IR) │
│ │ PSI → Raw FIR → 解析导入/超类型/类型/函数体 → 类型检查 │
│ │ 输出:语义完整的 FIR 树 + 诊断信息(错误/警告) │
│ ▼ │
│ ③ FIR2IR → Kotlin IR(后端 IR,与平台无关) │
│ │ 统一表示,供 JVM/JS/Wasm/Native 四个后端共用 │
│ ▼ │
│ ④ JVM 后端:降低(Lowering) │
│ │ 把"JVM 表达不了的高级特性"逐一降解成"JVM 能表达的东西" │
│ │ · data class → 生成 equals/hashCode/toString/copy/componentN│
│ │ · 默认参数 → 生成 xxx$default 合成方法 + 位掩码 │
│ │ · 属性 → 生成 getter/setter + backing field │
│ │ · suspend → CPS 变换,生成状态机 │
│ │ · inline → 把函数体复制到调用点,消除 Lambda 对象 │
│ │ · tailrec → 改写成循环 │
│ │ · when → tableswitch / lookupswitch / if-else 链 │
│ │ · 字符串模板 → StringBuilder(或常量折叠) │
│ ▼ │
│ ⑤ 字节码生成(ASM)→ .class 文件 │
│ │
└────────────────────────────────────────────────────────────────────┘
│
▼ jar / classpath / AOT 缓存
┌─ 运行期(在 JVM 里)──────────────────────────────────────────────┐
│ │
│ ⑥ 类加载与链接 │
│ │ 加载 → 验证 → 准备 → 解析 → 初始化(<clinit>) │
│ │ (📌 JDK 24+ 可从 AOT 缓存直接 mmap,跳过大部分工作) │
│ ▼ │
│ ⑦ 执行引擎 │
│ │ Level 0:模板解释器逐条执行字节码 │
│ │ Level 1--3:C1 编译(收集 Profile) │
│ │ Level 4:C2 基于 Profile 激进优化 → 机器码 │
│ │ 假设失败 → 去优化(Deoptimization)回到解释器 │
│ │
└────────────────────────────────────────────────────────────────────┘
⚠️ 一个关键认知 :阶段 ①--⑤ 是 Kotlin 特有的;阶段 ⑥--⑦ 与 Java 完全相同。
Java 的管线是:.java → javac(词法/语法/语义分析/流分析/脱糖 desugar)→ .class → 同样进入 ⑥⑦。
📌 JDK 25 起 Java 侧也有"编译器之外的新入口":
- JEP 512 紧凑源文件与实例 main :
void main() { IO.println("hi"); }直接java Hello.java就能跑,隐式类 + 实例 main + 自动导入java.base; - JEP 511 模块导入声明 :
import module java.base;一行导入整个模块的公共 API; - JEP 513 灵活的构造器体 :
super()之前可以有语句(如参数校验、this.field = ...的早期赋值)。
这些改变的是语言层,不改变 ⑥⑦。
4.2 阶段 ①②:从源码到 FIR ------ K2 编译器为什么要重写前端
旧前端的问题(Kotlin 1.x)
Kotlin 1.x 的编译器前端直接在 PSI(IDE 的语法树)上做类型检查。这带来三个结构性问题:
- IDE 和编译器行为不一致 :IDE 里的错误标记有时和
kotlinc报的不一样(因为 IDE 用增量/局部解析,编译器用全量解析)。 - 无法复用 :类型信息散落在 PSI 节点的
BindingContext(一个大 Map)里,多平台后端(JS/Native)要各自重新解析一遍。 - 性能与可维护性差:解析与检查耦合,增量编译难以做对。
K2 的解法:FIR(Frontend IR)
vbnet
PSI(纯语法树,与 IDE 共享)
│ FirBuilder
▼
Raw FIR(未解析:类型是占位符,引用是符号名)
│ ResolveImports
▼
FIR(imports 已解析)
│ ResolveSupertypes
▼
FIR(继承关系已建立)
│ ResolveImplicitTypes
▼
FIR(属性/返回类型推断完成)
│ ResolveTypes + ResolveBodies
▼
FIR(完整类型信息)
│ Checkers(数百条规则:类型检查、穷尽性、空安全、契约验证...)
▼
已检查的 FIR + Diagnostics
│ Fir2Ir
▼
Kotlin IR(后端统一 IR)
🔬 FIR 的核心价值 :它是一棵与平台无关、语义完整、可增量更新的树。所有前端诊断(错误/警告)都在 FIR 上产生,因此:
- IDE 和编译器共享同一套检查逻辑(这就是为什么 Kotlin 2.0 之后 IDE 的报错准确多了);
- 未使用返回值检查器(2.3.0 实验) 、数据流驱动的 when 穷尽性检查(2.2.20) 、上下文相关解析(2.2 预览) 都是 FIR 上的新 checker,加一个特性不需要动后端;
- 编译器插件(Compose Compiler、kotlinx.serialization、Parcelize)有了稳定的 K2 插件 API,不再 hack 内部类。
🧪 实验 4-1:观察前端诊断
kotlin
enum class Level { LOW, HIGH }
fun describe(l: Level): String = when (l) {
Level.LOW -> "low"
// 漏掉 HIGH
}
bash
kotlinc Diag.kt -d out
# error: 'when' expression must be exhaustive, add necessary 'HIGH' branch
📌 2.2.20 起还有更强的版本------数据流穷尽性检查:
kotlin
fun f(x: Any?): String = when {
x is String -> x // 智能转换生效
x == null -> "null"
else -> x.toString()
}
编译器能基于数据流分析(而非仅类型)判断分支完备性,2.3.0 转稳定。
一个有意思的细节:-Xphases-to-dump
Kotlin 编译器允许你把每个阶段的 IR dump 出来看:
bash
kotlinc Journey.kt -d out \
-Xphases-to-dump=./dump \
-Xphases-to-dump-after=org.jetbrains.kotlin.backend.jvm.lower.<PhaseName>
🚀 这是理解 Kotlin 编译器行为的终极手段:当某个特性的字节码产物让你困惑时,dump 出降低前后的 IR 对比,一目了然。第 50 章(进阶路线)会教你怎么读 Kotlin 编译器源码。
4.3 阶段 ④:降低(Lowering)------ Kotlin 魔法消失的地方
"降低"是编译器术语:把高层语言构造翻译成低层等价构造。 JVM 后端有几十道降低工序,每一道都对应 Kotlin 的一个"魔法特性"。
下面逐一拆解我们实验代码里的四个魔法。
4.3.1 data class 降低:编译器替你写了 100 行
你写的:
kotlin
data class Point(val x: Int, val y: Int) {
fun norm(): Double = sqrt((x * x + y * y).toDouble())
}
编译器生成的(javap -p Point):
text
public final class book.ch04.Point {
public final int getX();
public final int getY();
public final double norm();
public final int component1(); // 解构用
public final int component2();
public final book.ch04.Point copy(int, int);
public boolean equals(java.lang.Object);
public int hashCode();
public java.lang.String toString();
public book.ch04.Point(int, int);
// 合成方法:用于默认参数(这里没有默认参数所以可能不生成)
}
equals 的字节码逻辑(用 Kotlin 表示等价语义):
kotlin
override fun equals(other: Any?): Boolean {
if (this === other) return true // ① 引用快速路径
if (other !is Point) return false // ② 类型检查(instanceof)
other as Point
if (x != other.x) return false // ③ 逐字段比较
if (y != other.y) return false
return true
}
⚠️ 三个必须知道的 data class 陷阱:
- 只比较主构造器里的属性 。类体中声明的属性不参与
equals/hashCode:
kotlin
data class User(val id: String) {
var loginCount: Int = 0 // ← 不参与 equals!
}
val a = User("1").also { it.loginCount = 5 }
val b = User("1").also { it.loginCount = 9 }
println(a == b) // true ------ 可能不是你想要的
copy()是浅拷贝,且只复制主构造器参数:
kotlin
data class Order(val id: String, val items: MutableList<Item>)
val o1 = Order("1", mutableListOf(Item("a")))
val o2 = o1.copy()
o2.items.add(Item("b"))
println(o1.items) // [a, b] ← o1 也被改了!共享同一个 MutableList
- 继承时不看父类字段:
kotlin
open class Entity(val id: String)
data class Account(val name: String, id: String) : Entity(id)
// equals 只比较 name 和 Account 主构造器里的 id
// 但父类的 id 属性也是主构造器参数......需要小心 open 类 + data class 的组合
🚀 规则 :不要把 data class 用于有继承关系的实体 ,尤其不要用于 JPA 实体(第 43 章详解懒加载代理与 equals 的冲突)。
4.3.2 inline 降低:Lambda 凭空消失
你写的:
kotlin
val n = measured("norm") { p.norm() }
编译后 main 的字节码里没有任何 Lambda 类、没有 invokedynamic、没有 Function0 对象:
text
public static final void main();
Code:
0: new #14 // class book/ch04/Point
3: dup
4: iconst_3
5: iconst_4
6: invokespecial #16 // Method book/ch04/Point."<init>":(II)V
9: astore_0 // p
10: invokestatic #22 // Method java/lang/System.nanoTime:()J ← measured 的函数体被复制进来了!
13: lstore_1 // start
14: aload_0
15: invokevirtual #25 // Method book/ch04/Point.norm:()D ← block() 被直接展开
18: dstore_3 // result
19: ... // println 拼接
...
46: dload_3
47: invokestatic #31 // Method java/lang/Double.valueOf:(D)Ljava/lang/Double; ← 装箱给 println(Object)
50: astore 5
52: return
🔍 注意第 47 行 :println(n) 中 n 是 Double,而 println 的重载解析选择了 println(Any?),于是装箱发生了 。这是内联也救不了的开销------第 6 章会讲如何用 println(n.toString()) 或专门的打印方式避免。
对比:如果不加 inline
kotlin
fun <R> measured(name: String, block: () -> R): R { ... } // 去掉 inline
字节码里会多出:
text
invokedynamic #xx, 0 // InvokeDynamic #0:invoke:()Lkotlin/jvm/functions/Function0;
以及一个 Lambda 类(JDK 8+ 用 invokedynamic + LambdaMetafactory 在运行时生成隐藏类)。每次调用可能 分配一个 Function0 对象(捕获了 p,所以不能是无状态单例)。
📌 inline 的收益与代价:
| 收益 | 代价 |
|---|---|
| 消除 Lambda 对象分配 | 代码膨胀:函数体在每个调用点复制一份,CodeCache 压力增大 |
| 消除间接调用,JIT 更容易优化 | 二进制兼容问题 :修改 inline 函数体,已编译的调用方不会自动更新 |
支持 reified 类型参数 |
不能递归调用自己(除非 @Suppress) |
支持非局部返回、break/continue |
不能作为对象引用传递(除非用 :: 引用,且有限制) |
支持 contract 契约 |
public inline 函数不能访问 private 成员(会破坏封装,编译器报 PUBLIC-API_INLINE_ERROR) |
🚀 使用准则:
- 只对"接受 Lambda 参数的小函数"使用
inline(let、run、apply、use、measure这类); - 不要对大的、不含 Lambda 参数的函数使用(纯代码膨胀,无收益);
- 库的 public API 中的 inline 函数,函数体视为公开 API 的一部分,修改需要走完整的兼容性评审(第 47 章的二进制兼容验证器会帮你)。
4.3.3 suspend 降低:CPS 变换与状态机
这是全书技术密度最高的地方之一,第 37 章会完整展开。这里先看到"发生了什么"。
你写的:
kotlin
suspend fun loadPoints(n: Int): List<Point> {
delay(1)
return (1..n).map { Point(it, it * 2) }
}
编译后(简化):
text
public static final java.lang.Object loadPoints(int n, kotlin.coroutines.Continuation<?> $completion);
签名变了 :多了 Continuation 参数,返回 Object。
生成的等价 Java 代码(IDEA 反编译 + 人工整理):
java
public static final Object loadPoints(int n, Continuation<? super List<Point>> $completion) {
// ① 生成一个状态机对象(继承 ContinuationImpl)
Object $result;
label20: {
if ($completion instanceof L) {
L l = (L) $completion;
if (l.label & Integer.MIN_VALUE) != 0) {
l.label -= Integer.MIN_VALUE;
break label20; // 恢复执行:这是从挂起点回来的
}
}
l = new L($completion); // 首次调用:创建状态机
}
Object r = l.L$0;
int i = l.label;
if (i != 0) {
if (i != 1) throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
l.label = -1;
break label20;
}
l.label = 1;
// ② 调用挂起函数 delay,把自己(状态机)作为 Continuation 传下去
r = DelayKt.delay(1L, l);
if (r == COROUTINE_SUSPENDED) {
return COROUTINE_SUSPENDED; // ③ 挂起!直接返回哨兵值,线程被释放
}
// ④ 恢复执行:走到这里说明 delay 已完成
List<Point> list = /* (1..n).map { Point(it, it*2) } */ ;
return list;
}
// ⑤ 状态机类
static final class L extends ContinuationImpl {
int label;
Object L$0;
L(Continuation c) { super(c); }
public final Object invokeSuspend(Object result) {
this.L$0 = result;
this.label |= Integer.MIN_VALUE; // 打上"这是恢复调用"的标记
return loadPoints(0, this); // 重新进入函数
}
}
🔬 五个关键点:
- CPS(Continuation-Passing Style)变换 :每个
suspend函数都被改写成"接收一个Continuation参数,结果通过Continuation.resumeWith回调"的形式。 - 无栈协程 :状态机的字段(
label、局部变量提升为字段)保存在堆上 ,所以挂起时不需要保留线程栈。这就是协程比线程轻的根本原因。 COROUTINE_SUSPENDED哨兵值:一个特殊的单例对象。函数返回它就意味着"我挂起了,别再等我了"。label字段是状态编号:有几个挂起点,就有几个状态。挂起点越多,状态机越大。label |= Integer.MIN_VALUE:用最高位标记"这是一次恢复调用",避免与正常状态编号冲突。
⚠️ 由此推出三条实践规则:
- 挂起点越少的
suspend函数越轻。一个有 20 个挂起点的函数会生成一个有 20 个状态、20+ 字段的状态机类。 - 不要在循环里创建大量小
suspend函数调用链------每个都可能分配状态机对象(不过 JIT 的逃逸分析通常能标量替换掉,第 29 章详谈)。 suspend会传染 :调用suspend函数的函数必须也是suspend。这就是"函数颜色"问题(What Color is Your Function?)。第 37 章讨论应对策略。
🧪 实验 4-2:亲眼看到状态机
bash
kotlinc Journey.kt -d out -jvm-target 27
javap -p -c out/book/ch04/JourneyKt.class | grep -A3 "loadPoints"
# 用 ls 找出状态机类
ls out/book/ch04/ | grep -i journey
# JourneyKt.class
# JourneyKt$loadPoints$1.class ← 状态机!
bash
javap -p out/book/ch04/JourneyKt\$loadPoints\$1.class
text
final class book.ch04.JourneyKt$loadPoints$1 extends kotlin.coroutines.jvm.internal.ContinuationImpl {
int I$0; // 提升的局部变量 n
java.lang.Object L$0; // 挂起函数的返回值暂存
int label; // 状态编号
public java.lang.Object invokeSuspend(java.lang.Object);
}
看到 int I$0 了吗?那就是参数 n 被提升成了字段------因为挂起后需要跨"函数返回"保留它。
4.3.4 字符串模板降低
kotlin
println("$name took ${System.nanoTime() - start} ns")
编译成:
text
new StringBuilder → dup → invokespecial <init> → aload name → invokevirtual append(String)
→ ldc " took " → invokevirtual append → invokestatic System.nanoTime → lsub
→ invokevirtual append(J) → ldc " ns" → invokevirtual append → invokevirtual toString
→ getstatic System.out → swap → invokevirtual println(String)
📌 值得注意 :Kotlin 不使用 JDK 9+ 的 invokedynamic 字符串拼接(JEP 280),仍走 StringBuilder。原因是多平台一致性------同一份 Kotlin IR 要能降到 JS/Wasm/Native,而 StringBuilder 是最通用的抽象。
代价:JVM 上比 Java 的 indy 拼接多分配一个 StringBuilder 对象(虽然 JIT 的逃逸分析通常能消除它)。
🧪 实验 4-3:验证 JIT 是否消除了 StringBuilder
bash
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintEliminateAllocations -cp out:stdlib.jar book.ch04.JourneyKt
📌 2.4.0 的"编译期常量改进"(实验) 让部分字符串拼接能在编译期折叠,进一步减少运行期开销。
4.3.5 其他常见降低一览
| Kotlin 特性 | 降低结果 |
|---|---|
属性 var x |
private T x 字段 + getX() + setX(T) |
val x |
private final T x 字段 + getX() |
默认参数 fun f(a: Int = 1) |
f(I)V + 合成 f$default(IIILjava/lang/Object;)V(第 3 个参数是位掩码) |
object Foo |
public final class Foo { public static final Foo INSTANCE; static { INSTANCE = new Foo(); } } |
companion object |
静态嵌套类 Companion + public static final Companion Companion 字段 |
扩展函数 fun String.foo() |
静态方法 foo(Ljava/lang/String;)V,接收者是第一个参数 |
| 顶层函数 | 文件名 + Kt 的类(JourneyKt),可用 @file:JvmName 改名 |
tailrec fun |
改写成 goto 循环,消除递归栈帧 |
lazy { } |
Lazy 接口实现(SynchronizedLazyImpl 等) |
运算符 a + b |
a.plus(b) 调用 |
解构 val (a,b) = p |
p.component1() / p.component2() |
when on Int |
tableswitch(连续)或 lookupswitch(稀疏) |
when on String |
hashCode() → lookupswitch → equals 二次确认 |
when on sealed |
instanceof 链 + else 抛异常(或 lookupswitch on hashCode,取决于实现) |
可空基本类型 Int? |
装箱为 java.lang.Integer |
IntArray |
int[](原生,无装箱) |
协程 launch{} |
创建 StandaloneCoroutine + 状态机 |
👉 这张表是第 20 章的目录预告。
4.4 阶段 ⑤:class 文件长什么样
ini
ClassFile {
u4 magic; // 0xCAFEBABE
u2 minor_version;
u2 major_version; // JDK 27 = 71
u2 constant_pool_count;
cp_info constant_pool[]; // 常量池:类/方法/字段引用、字面量
u2 access_flags; // public/final/super/interface/abstract/...
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[];
u2 fields_count;
field_info fields[];
u2 methods_count;
method_info methods[];
u2 attributes_count;
attribute_info attributes[]; // Code、LineNumberTable、LocalVariableTable、
// Signature、Exceptions、SourceFile、
// NestMembers/NestHost、Record、
// 📌 kotlin.Metadata ...
}
常量池的 17 种 tag
| tag | 名称 | 用途 |
|---|---|---|
| 1 | CONSTANT_Utf8 |
字符串(类名、方法名、描述符、源码字面量) |
| 3 | CONSTANT_Integer |
int 常量 |
| 4 | CONSTANT_Float |
float |
| 5 | CONSTANT_Long |
long(占两个槽位) |
| 6 | CONSTANT_Double |
double(占两个槽位) |
| 7 | CONSTANT_Class |
类/接口引用 |
| 8 | CONSTANT_String |
字符串字面量(指向 Utf8) |
| 9 | CONSTANT_Fieldref |
字段引用 |
| 10 | CONSTANT_Methodref |
类方法引用 |
| 11 | CONSTANT_InterfaceMethodref |
接口方法引用 |
| 12 | CONSTANT_NameAndType |
名字 + 描述符 |
| 15 | CONSTANT_MethodHandle |
方法句柄(JDK 7) |
| 16 | CONSTANT_MethodType |
方法类型(JDK 7) |
| 17 | CONSTANT_Dynamic |
动态常量(JDK 11) |
| 18 | CONSTANT_InvokeDynamic |
invokedynamic 调用点(JDK 7) |
| 19 | CONSTANT_Module |
模块(JDK 9) |
| 20 | CONSTANT_Package |
包(JDK 9) |
⚠️ long/double 占两个槽位 是常量池索引计算的经典陷阱,也是"为什么 Long/Double 数组的常量池看起来索引不连续"的原因。
Kotlin 特有:kotlin.Metadata 属性
🧪 实验 4-4:看看 Kotlin 在 class 里藏了什么
bash
javap -v out/book/ch04/Point.class | grep -A20 "RuntimeVisibleAnnotations"
text
RuntimeVisibleAnnotations:
0: #21()
kotlin.Metadata(
mv={2, 4, 0} // metadataVersion
k=1 // kind:1=class, 2=file facade, 3=synthetic class, 5=multi-file facade
xi=0 // extraInt
d1={...} // data1:protobuf 序列化的 Kotlin 元信息(二进制)
d2={ // data2:可读的字符串表
"Lbook/ch04/Point;",
"x", "I", "y",
"constructor", "", "(II)V",
"getX", "()I", "getY",
"norm", "()D",
"component1", "component2", "copy",
"equals", "hashCode", "toString",
"kotlin.jvm.internal.DataClassComponentKind", ...
}
)
🔬 这就是 Kotlin 反射与 IDE 的信息来源 。d2 是人类可读的字符串表(类名、成员名、签名描述),d1 是 protobuf 编码的详细结构(含空安全信息、泛型型变、data/sealed/inline 等修饰符、默认参数值等)。
为什么需要它? 因为 JVM 的 Signature 属性只能表达 Java 泛型,无法表达 Kotlin 独有的信息 (可空性、internal 可见性、型变注解、suspend)。所以 Kotlin 把这些塞进自定义注解。
⚠️ 代价 :每个 Kotlin 类都多一个 Metadata 注解,增大 jar 体积(大型项目可达数 MB)。可以用 ProGuard/R8 移除(如果不需要 Kotlin 反射)。
📌 Kotlin 2.4.0 起"注解写入元数据"默认启用(annotations in metadata),让 Kotlin 反射能看到注解信息,代价是元数据略增。
第 24 章会做逐字节的 class 文件解析实战。
4.5 阶段 ⑥:类加载与链接(JVM 侧,Java/Kotlin 完全相同)
arduino
① 加载 Loading
通过全限定名获取二进制字节流 → 转为方法区的运行时数据结构 → 在堆中生成 java.lang.Class 对象
(📌 JDK 24+ 若命中 AOT 缓存,此步几乎为零成本:直接 mmap)
② 验证 Verification
文件格式验证(魔数、版本号)
元数据验证(语义:是否有父类、是否继承了 final 类、抽象方法是否实现)
字节码验证(数据流/类型分析:操作数栈类型是否匹配、跳转是否合法)
符号引用验证(解析时:目标类/方法/字段是否存在且可访问)
③ 准备 Preparation
为**静态变量**分配内存并设置**零值**(不是代码里写的初始值!)
static int x = 5; → 准备阶段 x = 0;初始化阶段(<clinit>)x = 5
static final int C = 5; → 若为编译期常量,准备阶段直接赋 5(ConstantValue 属性)
④ 解析 Resolution
把常量池中的**符号引用**替换为**直接引用**(内存地址/偏移)
可以是"延迟解析"(invokedynamic、某些方法引用)
⑤ 初始化 Initialization
执行 <clinit>():静态变量赋值 + 静态初始化块,按源码文本顺序
🔒 JVM 保证线程安全(对 <clinit> 加锁),这是"静态内部类单例"的基础
类加载器层次(JDK 9+)
csharp
Bootstrap ClassLoader(C++ 实现,加载 java.base 等核心模块)
▲
Platform ClassLoader(原 Extension,加载 java.sql / java.xml 等平台模块)
▲
Application ClassLoader(加载 classpath / 应用模块)
▲
自定义 ClassLoader(Tomcat WebappClassLoader、OSGi、热部署框架)
📌 JDK 9 之后,双亲委派的语义被 JPMS 重构 :不再是"逐个 ClassLoader 向上问",而是先做模块解析(哪个模块导出哪个包,由谁加载),再走委派。
第 25 章会用一个自研 ClassLoader 做完整实验,包括"如何打破双亲委派实现热部署"和"AOT 缓存如何绕过常规加载流程"。
4.6 阶段 ⑦:执行引擎与分层编译
4.6.1 模板解释器(Template Interpreter)
HotSpot 不是"逐条解释字节码"的天真实现,而是模板解释器 :启动时为每条字节码指令动态生成一段机器码模板,解释执行就是"跳转到对应模板"。这比朴素解释器快 3--5 倍。
字节码 iload_1 → 查模板表 → 跳转到已生成的机器码 → 执行 → 跳转到下一条指令的模板
🔬 代价 :解释器为每条指令生成了机器码,占 CodeCache;且没有跨指令优化(不做常量传播、不做内联、每个操作都走操作数栈)。
4.6.2 什么时候触发 JIT?
HotSpot 用两个计数器:
- 方法调用计数器(invocation counter):方法被调用的次数;
- 回边计数器(backedge counter):循环体回跳的次数(用于 OSR)。
超过阈值 → 提交编译请求到编译队列 → 编译线程异步编译 → 编译完成后把方法入口替换为机器码。
🔬 OSR(On-Stack Replacement,栈上替换) :一个方法只调用一次但里面有超长循环,等它返回再编译就太晚了。OSR 允许在循环执行中途 把解释器栈帧替换成编译后的栈帧。这就是为什么 -XX:+PrintCompilation 里会看到方法名后面带 %:
text
164 3 book.ch04.JourneyKt::main % (interpreted by C1 with OSR)
4.6.3 分层编译(Tiered Compilation)
JDK 8 起默认开启,JDK 27 依旧:
| Level | 名称 | 说明 |
|---|---|---|
| 0 | 解释执行 | 模板解释器 |
| 1 | C1,无 Profile | 简单方法(无循环、无分支),直接 C1 编译,不收集数据 |
| 2 | C1,有限 Profile | 只收集方法/回边计数(C1 队列满时的降级) |
| 3 | C1,完整 Profile | 收集类型 Profile、分支概率 ------ 为 C2 做准备 |
| 4 | C2 | 基于 Profile 的激进优化:内联、逃逸分析、标量替换、循环展开、去虚化 |
典型路径:0 → 3 → 4(大多数方法);简单方法:0 → 1。
🧪 实验 4-5:观察分层编译
bash
java -XX:+PrintCompilation -cp out:$STDLIB book.ch04.JourneyKt
text
63 1 3 java.lang.String::hashCode (55 bytes)
64 2 3 java.lang.StringLatin1::hashCode (42 bytes)
78 15 4 java.lang.String::hashCode (55 bytes) ← 从 L3 升到 L4
85 22 s 4 java.io.PrintStream::println (16 bytes) ← s = synchronized
91 31 1 book.ch04.Point::getX (5 bytes) ← L1:太简单,直接 C1
99 45 4 book.ch04.Point::norm (32 bytes) ← L4:热点,C2 优化
120 52 4 book.ch04.Point::equals (60 bytes)
140 58 ! 4 kotlin.jvm.internal.Intrinsics::checkNotNullParameter ← ! = 有异常处理
标志位含义:% = OSR,s = synchronized,! = 有异常处理器,b = 阻塞式编译,n = 非标准调用包装。
🧪 实验 4-6:看 C2 到底优化了什么
bash
# 内联决策(需要 diagnostic 选项)
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining -cp out:$STDLIB book.ch04.JourneyKt 2>&1 | head -50
# 反汇编(需要 hsdis 库,放到 $JAVA_HOME/lib)
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly \
-XX:CompileCommand=print,book.ch04.Point::norm -cp out:$STDLIB book.ch04.JourneyKt
PrintInlining 输出示例:
text
@ 12 book.ch04.Point::getX (5 bytes) inline (hot)
@ 15 java.lang.Math::sqrt (5 bytes) intrinsic
@ 22 kotlin.jvm.internal.Intrinsics::checkNotNullParameter (hot) inline
🔬 intrinsic 是个重要概念 :某些方法(Math.sqrt、System.arraycopy、Object.hashCode、String.equals、Unsafe 操作)C2 不看字节码,直接用手工优化过的机器码替换。这是 JIT 能超过 AOT 的原因之一------它可以针对具体 CPU 型号生成 AVX-512 指令。
4.6.4 去优化(Deoptimization):JIT 的"后悔药"
C2 的激进优化建立在假设 之上。假设错了怎么办?回退。
| 去优化原因 | 触发场景 |
|---|---|
unstable_if |
从未走过的分支被走到了(C2 把冷分支编译成 trap) |
class_check |
类型假设失败(如单态内联缓存遇到第二个类型) |
null_check |
假定非空的值变成了 null |
made_not_entrant |
方法被标记为"不再进入"(有新版本了) |
unloaded |
引用了尚未加载的类 |
constraint |
类层次假设被破坏(新子类被加载) |
🧪 实验 4-7:制造一次去优化
kotlin
interface Shape { fun area(): Double }
class Circle(val r: Double) : Shape { override fun area() = Math.PI * r * r }
class Square(val s: Double) : Shape { override fun area() = s * s }
fun total(shapes: List<Shape>): Double = shapes.sumOf { it.area() }
fun main() {
// 第一阶段:只有 Circle ------ C2 会做"单态内联",直接内联 Circle.area
val circles = List(1_000_000) { Circle(it.toDouble()) }
repeat(50) { println(total(circles)) }
// 第二阶段:混入 Square ------ 单态假设被打破 → 去优化 → 重新编译为双态/虚调用
val mixed = circles + List(1_000_000) { Square(it.toDouble()) }
repeat(50) { println(total(mixed)) }
}
bash
java -XX:+PrintCompilation -XX:+TraceDeoptimization -cp out:$STDLIB book.ch04.DeoptKt 2>&1 | grep -i deopt
🔬 这对 Kotlin 的启示:
final类/方法对 JIT 友好 ------去虚化(devirtualization)更容易,不需要类型 Profile。Kotlin 默认final,这是一个被低估的性能优势。- 单态调用点性能最好。如果你的多态调用点长期只见一种类型,JIT 会内联它;一旦第二种类型出现,会有一次去优化 + 重编译(之后变成双态内联缓存,性能略降但稳定)。
inline函数天然避免了调用开销,但会增大 CodeCache。
4.6.5 AOT:Leyden 如何改变这条流水线
📌 JDK 24--26 的 Project Leyden 引入了三样东西:
ruby
传统流程:
启动 → 类加载(解析 class 文件)→ 解释执行 → 收集 Profile → C1 → C2
└─────── 数百毫秒到数秒的"预热期" ───────┘
Leyden AOT 缓存流程:
① 训练运行(--enable-full-aot 或正常跑一遍)
java -XX:CacheDataStore=app.aot -jar app.jar ← JEP 514(JDK 25)
│
缓存里存了:
├── 已加载 + 已链接的类元数据(跳过解析/验证) ← JEP 483(JDK 24)
├── 堆里的"活对象"(如 Class 对象、字符串常量)
└── 📌 方法 Profile! ← JEP 515(JDK 25)
│
② 正式运行
java -XX:AOTCache=app.aot -jar app.jar
│
├── mmap 缓存 → 类"瞬间"就绪
└── 用缓存里的 Profile → JVM 启动后立刻能做 C2 级优化
(不需要重新预热!)
📌 JEP 516(JDK 26) 解决了最后一个障碍:之前缓存里的堆对象布局与 GC 实现绑定(压缩指针、ZGC 染色指针、紧凑对象头都会改变布局),导致换 GC 就要重新生成缓存。516 之后任意 GC 都能用同一份缓存。
🔬 AOT 缓存 vs Native Image 的本质区别:
| AOT 缓存(Leyden) | Native Image(GraalVM) | |
|---|---|---|
| 编译时机 | 类加载/链接提前;JIT 仍在 | 全部 AOT 编译成机器码;无 JIT |
| 峰值性能 | 与纯 JIT 相同(甚至更好,因为省了预热) | 通常低于充分预热的 JIT |
| 启动时间 | 大幅改善(几十%~数倍) | 数量级改善(毫秒级) |
| 动态性 | 完整保留(反射、动态类加载、代理) | 受限(封闭世界假设) |
| 内存占用 | 与 HotSpot 相同 | 显著更低 |
| 构建复杂度 | 低(跑一遍生成缓存) | 高(需 reachability metadata) |
🚀 2026 年的选型建议:
- 长驻服务(Web/微服务) → AOT 缓存 + JIT(Spring Boot 4 已内置
-XX:AOTCache支持); - Serverless / CLI / Sidecar → Native Image;
- 两者都想要 → Spring Boot 4 的 AOT 处理 + CDS + Leyden 组合,或 CRaC(快照恢复,第 36 章)。
第 25 章和第 29 章会做完整实测。
4.7 完整时间线:一次运行的真实数据
🧪 实验 4-8:给整条流水线打上时间戳
bash
# 编译期
time kotlinc Journey.kt -d out -jvm-target 27
# real 0m3.2s ← Kotlin 编译器启动(JVM)+ 前端 + 后端
# 运行期(无 AOT)
java -Xlog:class+load,safepoint:file=load.log:time,uptime -XX:+PrintCompilation \
-cp out:$STDLIB book.ch04.JourneyKt
# 运行期(有 AOT 缓存)
java -XX:CacheDataStore=journey.aot -cp out:$STDLIB book.ch04.JourneyKt
java -XX:AOTCache=journey.aot -XX:+PrintCompilation -cp out:$STDLIB book.ch04.JourneyKt
典型观察(量级参考,非精确值):
| 阶段 | 无 AOT | 有 AOT 缓存 |
|---|---|---|
| JVM 初始化 | ~40 ms | ~40 ms |
| 核心类加载(java.base) | ~60 ms | ~15 ms(CDS/缓存) |
| 应用类加载 + 链接 | ~25 ms | ~5 ms |
<clinit> + main 解释执行 |
~10 ms | ~10 ms |
| 首次 C2 编译完成 | 需要数千次调用后 | 启动后立刻可用 Profile |
| 总启动时间 | ~135 ms | ~70 ms |
对 Spring Boot 应用,这个差距会被放大到秒级 vs 亚秒级(因为要加载数万个类)。
4.8 观测工具箱:把每个阶段变得可见
| 阶段 | 观测手段 |
|---|---|
| ① 语法 | kotlinc -Xallow-any-scripts-in-source-roots;IDEA 的 PSI Viewer(Tools → View PSI Structure) |
| ② FIR | -Xphases-to-dump;-Xrender-internal-diagnostic-names |
| ③④ IR / 降低 | -Xphases-to-dump=./dump;IDEA Show Kotlin Bytecode → Decompile |
| ⑤ 字节码 | javap -c -p -v;jclasslib;ASM 的 CheckClassAdapter |
| ⑥ 类加载 | -Xlog:class+load=info、-Xlog:class+init、jcmd <pid> VM.classloader_stats、jcmd <pid> GC.class_histogram |
| ⑦ 编译 | -XX:+PrintCompilation、-XX:+PrintInlining、-XX:+PrintAssembly(需 hsdis)、jcmd <pid> Compiler.codelist、Compiler.codecache |
| ⑦ 去优化 | -XX:+TraceDeoptimization、JFR 事件 jdk.Deoptimization |
| 全程 | JFR:-XX:StartFlightRecording=settings=profile,用 JMC 打开看 Class Loading / Compilation / GC / Method Profiling 时间线 |
🚀 一个值得养成的习惯 :遇到"这段 Kotlin 代码性能不对",第一反应是反编译成 Java 看 (IDEA 一键),第二反应是 -XX:+PrintCompilation 看它有没有被 C2 编译、有没有去优化。
4.9 本章小结
- Kotlin 的编译管线是 5 段 :源码 → PSI → FIR(K2 前端) → Kotlin IR → JVM 降低 → ASM 生成 class。降低(Lowering)是所有"魔法"消失的地方。
- K2/FIR 的意义不只是快,而是让 IDE 与编译器共享同一套语义分析,并让新语言特性(未使用返回值检查、数据流穷尽性、上下文相关解析)能以 checker 的形式独立添加。
- 四大魔法的真相 :
data class→ 编译器生成 7 类方法;只比较主构造器属性;copy是浅拷贝;inline→ 函数体复制到调用点,Lambda 对象消失,代价是代码膨胀 + 二进制兼容;suspend→ CPS 变换 + 状态机 +Continuation参数 +COROUTINE_SUSPENDED哨兵;- 字符串模板 →
StringBuilder(不用 indy,为了多平台一致性)。
- class 文件是 Kotlin 与 JVM 的契约 。Kotlin 额外塞了一个
kotlin.Metadata注解来携带 JVM 表达不了的信息(可空性、型变、suspend、internal)。 - 类加载 5 阶段与执行引擎分层编译对 Java/Kotlin 完全一样------这就是"换语言不换虚拟机"。
- Leyden AOT 缓存正在重塑这条流水线:把"加载 + 链接 + Profile 收集"提前到构建期,同时保留 JIT 的峰值性能。这是 2026 年 JVM 最重要的架构变化。
4.10 练习题
4-1. 为什么 suspend 函数的返回类型在字节码里变成了 Object 而不是原本的 List<Point>?
答案要点
因为 CPS 变换后,函数有两种可能的"返回":
- 正常完成 → 返回真实结果(
List<Point>); - 挂起 → 返回
COROUTINE_SUSPENDED哨兵对象(kotlin.coroutines.intrinsics.CoroutineSingletons枚举的一个值)。
两种返回类型不同,JVM 的方法签名只能有一个返回类型,所以取公共上界 Object。调用方(协程库)拿到 Object 后先判断是否为 COROUTINE_SUSPENDED,是则直接返回(继续挂起),否则强转为真实类型。
延伸 :这也是为什么 suspend 函数不能直接从 Java 调用 ------Java 侧拿到的是 Object,且必须自己实现 Continuation 回调。Kotlin 提供了 kotlinx.coroutines.future.await / CompletableFuture 桥接来解决。
4-2. 下面三个 inline 使用场景,哪些是合理的?说明理由。 (a) inline fun log(msg: String)(无 Lambda 参数) (b) inline fun <T> T.also(block: (T) -> Unit): T (c) inline fun computeBigMatrix(a: Matrix, b: Matrix): Matrix(200 行实现)
答案要点
(a) ❌ 不合理。没有 Lambda 参数,内联不会消除任何对象分配,只会造成代码膨胀(每个调用点复制一遍字符串拼接逻辑)。 (b) ✅ 合理。经典场景:消除 Function1 对象分配 + 一次间接调用;函数体极小。 (c) ❌ 不合理。200 行的函数体在每个调用点复制 → CodeCache 暴涨、I-cache 命中率下降,反而变慢;而且矩阵运算的热点在循环内部,C2 会自己处理。
准则 :inline 的价值 = 消除的 Lambda 分配 × 调用频率 − 代码膨胀成本。只对"小的、接受 Lambda 的"函数使用。
4-3. (🧪 动手)写一个 Kotlin 程序,用 -XX:+PrintCompilation 观察它从 Level 0 到 Level 4 的过程。要求程序中包含一个能被 OSR 编译的长循环。
答案要点
kotlin
fun hotLoop(n: Int): Long {
var sum = 0L
for (i in 0 until n) sum += i * i // 长循环 → 可能触发 OSR
return sum
}
fun main() { println(hotLoop(100_000_000)) }
bash
java -XX:+PrintCompilation -cp out:$STDLIB MainKt | grep hotLoop
期望看到:先 hotLoop 的 L3 编译,然后是带 % 的 OSR 编译(hotLoop %),最后可能是 L4 的普通编译。若循环足够长且只调用一次,OSR 版本会先完成。
观察点 :% 标志、编译层级数字、时间戳(ms)、字节码大小。
4-4. 解释为什么 Kotlin 默认 final 对 JIT 是优势而不是劣势。
答案要点
JIT 的**去虚化(devirtualization)**依赖类层次分析(CHA):
- 若方法的目标类是
final,或整个类层次中只有一个实现(单态),C2 可以把invokevirtual/invokeinterface直接替换为invokestatic式的直接调用,进而内联; - 若类是
open且有多个子类,只能生成虚调用(vtable/itable 查表),无法内联,也无法做逃逸分析。
Kotlin 默认 final → 类层次天然扁平 → CHA 更容易成功 → 更多内联机会 → 更好的优化。
代价 :当运行时加载了新子类(如 Spring CGLIB 代理),CHA 假设失效 → 去优化 + 重新编译 (constraint 或 class_check)。所以框架大量使用动态代理的项目,会看到更多去优化事件。这也是 kotlin("plugin.spring")(all-open)在性能上的隐性成本。
4-5. AOT 缓存能替代 Native Image 吗?给出你的判断与依据。
答案要点
不能完全替代,两者目标不同。
AOT 缓存(Leyden)解决的是启动期的类加载/链接/Profile 收集 开销,但JIT 仍然存在,所以:
- ✅ 保留完整动态性(反射、动态类加载、字节码增强、Agent);
- ✅ 保留峰值性能(长跑后与纯 JIT 一致甚至更好);
- ❌ 仍需要 JVM 进程(几十 MB 基础内存占用);
- ❌ 启动时间改善是"倍数级"而非"数量级"(除非配合 CRaC)。
Native Image 解决的是根本不需要 JVM:
- ✅ 启动毫秒级、内存 30MB 级、单个静态可执行文件、镜像极小;
- ❌ 无 JIT → 峰值性能通常低 10--30%;
- ❌ 封闭世界 → 反射/资源/代理需配置元数据;
- ❌ 构建慢、构建期内存大。
结论:
- Serverless/CLI/边缘 → Native Image 仍是首选;
- 长驻服务 → AOT 缓存 + JIT 是更优解(2026 年的主流选择);
- 极端冷启动要求 + 长驻 → 考虑 CRaC(Coordinated Restore at Checkpoint),从内存快照恢复,毫秒级"启动"且保留 JIT。
4.11 面试题精选
Q1:说说 Kotlin 的编译过程。
答题框架(五段式):
- 词法/语法分析 → PSI 树(与 IDE 共享);
- K2 前端:PSI → FIR(Frontend IR),依次完成导入解析、超类型解析、类型推断、函数体解析、数百条 checker 检查,输出诊断;📌 Kotlin 2.0 起 K2 稳定;
- FIR2IR → Kotlin IR(平台无关的后端 IR);
- JVM 后端降低:把 data class / 默认参数 / 属性 / suspend / inline / tailrec / when / 字符串模板等降解为 JVM 可表达的构造;
- ASM 生成字节码 →
.class,并附加kotlin.Metadata注解携带 Kotlin 专有语义。
加分点:提到 -Xphases-to-dump 可以观察每个阶段;提到 JVM IR 后端在 1.5 成为默认;提到 Compose Compiler 在 2.0 并入主仓。
Q2:suspend 函数是怎么实现的?
答题框架:
- CPS 变换 :编译期给每个
suspend函数追加一个Continuation参数,返回类型改为Object; - 状态机生成 :函数体按挂起点切分成多个状态(
label字段),局部变量提升为状态机字段; - 挂起协议 :调用被挂起函数时把状态机传下去;若返回
COROUTINE_SUSPENDED哨兵,则当前函数也返回该哨兵,线程被释放; - 恢复 :异步结果就绪后调用
Continuation.resumeWith(result)→invokeSuspend→ 重新进入函数,label决定跳到哪一段; - 无栈协程:状态保存在堆上的状态机对象里,不占用线程栈 → 一个线程可以承载百万协程;
- 加分:提到
ContinuationImpl、label |= Int.MIN_VALUE标记恢复调用、挂起点越多状态机越大、逃逸分析可能消除状态机分配、与虚拟线程的有栈协程对比。
Q3:什么是 JIT 的去优化?为什么会发生?对开发有什么启示?
答题框架:
- 定义:C2 的优化基于 Profile 假设(类型单态、分支不走、类层次封闭、值非空等),当假设在运行时被打破,JVM 丢弃编译后的机器码,把执行退回到解释器(或较低层级),并可能重新编译。
- 常见原因 :
unstable_if(冷分支被执行)、class_check(新类型出现)、null_check、constraint(CHA 假设失效,新子类加载)、unloaded、made_not_entrant。 - 观察 :
-XX:+TraceDeoptimization、-XX:+PrintCompilation中的made not entrant/made zombie、JFR 的jdk.Deoptimization事件。 - 启示 :
- 稳定的类型分布比"少一次虚调用"更重要------频繁切换实现类会导致反复去优化;
- 动态代理/CGLIB 会破坏 CHA,大量使用有隐性 JIT 成本;
final有利于去虚化(Kotlin 默认 final 是性能优势);- 预热很重要:压测前必须充分预热,否则测到的是"编译+去优化"的抖动;
- 📌 AOT Method Profiling(JEP 515) 可以把预热阶段的 Profile 持久化,减少生产环境的去优化抖动。
Q4:类加载的过程?哪些情况会触发类初始化?
见第 1 章 1.11 与第 25 章。答题要点:五阶段(加载/验证/准备/解析/初始化)+ 六种主动引用 + 双亲委派及其"合法破坏"(TCCL、Tomcat、OSGi、JPMS、AOT 缓存)。
下一章预告 :第 5 章《工具链与工程骨架》------把全书的实验环境固化成一个规范的 Gradle 9 多模块工程:版本目录、jvmToolchain、KSP、Detekt、二进制兼容验证、JMH 基准测试、javap/反编译工作流。搭好之后,后面 45 章的代码都能直接跑。