Kotlin与Java虚拟机四:一段代码的完整旅程

第 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 的语法树)上做类型检查。这带来三个结构性问题:

  1. IDE 和编译器行为不一致 :IDE 里的错误标记有时和 kotlinc 报的不一样(因为 IDE 用增量/局部解析,编译器用全量解析)。
  2. 无法复用 :类型信息散落在 PSI 节点的 BindingContext(一个大 Map)里,多平台后端(JS/Native)要各自重新解析一遍。
  3. 性能与可维护性差:解析与检查耦合,增量编译难以做对。

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 陷阱:

  1. 只比较主构造器里的属性 。类体中声明的属性不参与 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 ------ 可能不是你想要的
  1. 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
  1. 继承时不看父类字段:
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);           // 重新进入函数
    }
}

🔬 五个关键点:

  1. CPS(Continuation-Passing Style)变换 :每个 suspend 函数都被改写成"接收一个 Continuation 参数,结果通过 Continuation.resumeWith 回调"的形式。
  2. 无栈协程 :状态机的字段(label、局部变量提升为字段)保存在堆上 ,所以挂起时不需要保留线程栈。这就是协程比线程轻的根本原因。
  3. COROUTINE_SUSPENDED 哨兵值:一个特殊的单例对象。函数返回它就意味着"我挂起了,别再等我了"。
  4. label 字段是状态编号:有几个挂起点,就有几个状态。挂起点越多,状态机越大。
  5. 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 本章小结

  1. Kotlin 的编译管线是 5 段 :源码 → PSI → FIR(K2 前端) → Kotlin IR → JVM 降低 → ASM 生成 class。降低(Lowering)是所有"魔法"消失的地方。
  2. K2/FIR 的意义不只是快,而是让 IDE 与编译器共享同一套语义分析,并让新语言特性(未使用返回值检查、数据流穷尽性、上下文相关解析)能以 checker 的形式独立添加。
  3. 四大魔法的真相 :
    • data class → 编译器生成 7 类方法;只比较主构造器属性;copy 是浅拷贝;
    • inline → 函数体复制到调用点,Lambda 对象消失,代价是代码膨胀 + 二进制兼容;
    • suspend → CPS 变换 + 状态机 + Continuation 参数 + COROUTINE_SUSPENDED 哨兵;
    • 字符串模板 → StringBuilder(不用 indy,为了多平台一致性)。
  4. class 文件是 Kotlin 与 JVM 的契约 。Kotlin 额外塞了一个 kotlin.Metadata 注解来携带 JVM 表达不了的信息(可空性、型变、suspend、internal)。
  5. 类加载 5 阶段与执行引擎分层编译对 Java/Kotlin 完全一样------这就是"换语言不换虚拟机"。
  6. Leyden AOT 缓存正在重塑这条流水线:把"加载 + 链接 + Profile 收集"提前到构建期,同时保留 JIT 的峰值性能。这是 2026 年 JVM 最重要的架构变化。

4.10 练习题

4-1. 为什么 suspend 函数的返回类型在字节码里变成了 Object 而不是原本的 List<Point>?
答案要点

因为 CPS 变换后,函数有两种可能的"返回":

  1. 正常完成 → 返回真实结果(List<Point>);
  2. 挂起 → 返回 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 的编译过程。

答题框架(五段式):

  1. 词法/语法分析 → PSI 树(与 IDE 共享);
  2. K2 前端:PSI → FIR(Frontend IR),依次完成导入解析、超类型解析、类型推断、函数体解析、数百条 checker 检查,输出诊断;📌 Kotlin 2.0 起 K2 稳定;
  3. FIR2IR → Kotlin IR(平台无关的后端 IR);
  4. JVM 后端降低:把 data class / 默认参数 / 属性 / suspend / inline / tailrec / when / 字符串模板等降解为 JVM 可表达的构造;
  5. ASM 生成字节码 → .class,并附加 kotlin.Metadata 注解携带 Kotlin 专有语义。

加分点:提到 -Xphases-to-dump 可以观察每个阶段;提到 JVM IR 后端在 1.5 成为默认;提到 Compose Compiler 在 2.0 并入主仓。

Q2:suspend 函数是怎么实现的?

答题框架:

  1. CPS 变换 :编译期给每个 suspend 函数追加一个 Continuation 参数,返回类型改为 Object;
  2. 状态机生成 :函数体按挂起点切分成多个状态(label 字段),局部变量提升为状态机字段;
  3. 挂起协议 :调用被挂起函数时把状态机传下去;若返回 COROUTINE_SUSPENDED 哨兵,则当前函数也返回该哨兵,线程被释放;
  4. 恢复 :异步结果就绪后调用 Continuation.resumeWith(result) → invokeSuspend → 重新进入函数,label 决定跳到哪一段;
  5. 无栈协程:状态保存在堆上的状态机对象里,不占用线程栈 → 一个线程可以承载百万协程;
  6. 加分:提到 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 事件。
  • 启示 :
    1. 稳定的类型分布比"少一次虚调用"更重要------频繁切换实现类会导致反复去优化;
    2. 动态代理/CGLIB 会破坏 CHA,大量使用有隐性 JIT 成本;
    3. final 有利于去虚化(Kotlin 默认 final 是性能优势);
    4. 预热很重要:压测前必须充分预热,否则测到的是"编译+去优化"的抖动;
    5. 📌 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 章的代码都能直接跑。

相关推荐
Kapaseker8 小时前
Kotlin 2.5 Value Class 终于能放多个属性了
android·kotlin
传奇开心果编程21 小时前
【Jetpack Compose娓娓道来】第29课:状态容器与UI事件建模——把“散装状态”变成“可穷举的契约”
android·kotlin·移动开发·android jetpack·ui框架·开发范式·声明式ui
我是唐青枫2 天前
Kotlin Inheritance(继承)详解:语法、原理、实战 Demo
开发语言·kotlin
Goway_Hui3 天前
【KMP for OpenHarmony】-- 在鸿蒙工程中使用 Apollo-Kotlin
华为·kotlin·harmonyos
释厄6233 天前
AI 军备竞赛悖论——为 AI 卖游戏但 AI 需要游戏母场景
人工智能·游戏·kotlin
D e re k ZH3 天前
保序回归:从 PAVA 到可并堆的单调约束优化
c++·算法·数据挖掘·回归·kotlin
Goway_Hui3 天前
【KMP for OpenHarmony】Cache4k 0.14.0 鸿蒙适配
华为·kotlin·harmonyos
YB13753 天前
kotlin与函数式编程
kotlin
我是唐青枫4 天前
Kotlin Delegation 委托详解
开发语言·kotlin