Kotlin suspend 的编译器黑盒:从 CPS 变换到状态机字节码的完整推演

用了两年协程,有天我突然想搞清楚一件事:一个 suspend fun fetchUser() 在 JVM 上究竟长什么样?IDE 里看起来就是普通函数,但它能挂起、能恢复,还能在不同线程间跳来跳去——编译器到底做了什么?

带着这个问题,我用 javap 反编译了一堆协程代码,把整个推演过程整理成这篇文章。

一切从 CPS 变换说起

suspend 函数的底层实现基于「续体传递风格(Continuation-Passing Style,CPS)」。这个概念来自函数式编程,核心思想是:把”接下来要做什么”作为参数显式传递,而不是靠调用栈隐式维护。

Kotlin 编译器对每个 suspend fun 做的第一件事,是在参数列表末尾注入一个 Continuation 参数:

// 你写的
suspend fun fetchUser(id: String): User

// 编译器看到的
fun fetchUser(id: String, continuation: Continuation<User>): Any?

返回值从 User 变成了 Any?。这个 Any? 可能是真正的 User 对象(同步完成时),也可能是 COROUTINE_SUSPENDED 这个标记值(需要挂起时)。调用方通过判断返回值决定是继续执行还是放弃当前线程。

Continuation<T> 的接口定义极简:

interface Continuation<in T> {
    val context: CoroutineContext
    fun resumeWith(result: Result<T>)
}

它本质上是类型安全的回调抽象——只不过这个回调由编译器自动生成和传递,不需要手写。

多个挂起点:状态机登场

只有一个挂起点时,CPS 变换还算直观。但现实代码往往像这样:

suspend fun loadProfile(userId: String): Profile {
    val user = fetchUser(userId)      // 挂起点 1
    val avatar = fetchAvatar(user.id) // 挂起点 2
    return Profile(user, avatar)
}

两个 suspend 调用意味着两次可能的挂起和恢复。编译器不能简单地把两次 CPS 变换嵌套起来——那会退化成回调地狱。它选择的方案是:将函数体编译为有限状态机

每个挂起点之间的代码段对应一个状态。编译器为这个函数生成一个匿名的 Continuation 子类,内部包含:

  • 一个 label 字段,记录当前执行到哪个状态
  • 若干字段保存跨挂起点的局部变量
  • 一个 invokeSuspend 方法,包含状态机的 when 分支

反编译后大致结构如下(伪代码,已简化):

// 编译器生成的匿名状态机类
final class LoadProfileContinuation extends ContinuationImpl {
    int label = 0;
    Object result;
    
    // 跨挂起点存活的局部变量
    String userId;
    User user;
    
    @Override
    public Object invokeSuspend(Object result) {
        this.result = result;
        return loadProfile(COROUTINE_SUSPENDED_MARKER, this);
    }
}
// 变换后的 loadProfile 主体
static Object loadProfile(String userId, Continuation cont) {
    LoadProfileContinuation sm = (LoadProfileContinuation) cont;
    
    switch (sm.label) {
        case 0:
            sm.userId = userId;
            sm.label = 1;
            Object r1 = fetchUser(userId, sm); // 调用挂起函数
            if (r1 == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED;
            // 未挂起则直接落穿到 case 1
            sm.result = r1;
        case 1:
            User user = (User) sm.result;
            sm.user = user;
            sm.label = 2;
            Object r2 = fetchAvatar(user.id, sm);
            if (r2 == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED;
            sm.result = r2;
        case 2:
            Avatar avatar = (Avatar) sm.result;
            return new Profile(sm.user, avatar);
    }
}

label 就是状态机的”当前状态”。每次协程恢复时,invokeSuspend 被调用,函数重新进入 switch,直接跳到上次挂起的位置继续执行。整个函数在 JVM 上是一个普通的循环状态机,没有任何魔法,只有朴素的 switch-case

局部变量的生死边界

并非所有局部变量都会被保存到状态机对象里——这个细节容易被忽略。

编译器做了一次「活跃性分析(Liveness Analysis)」,只有跨越挂起点仍然存活的变量才会被提升为状态机字段:

suspend fun example() {
    val a = compute()        // a 在第一个挂起点之前就用完了
    val b = a * 2
    delay(100)               // 挂起点
    val c = fetchData()      // c 跨越了第二个挂起点
    delay(50)                // 挂起点
    println(c)
}

变量 abdelay(100) 之后不再使用,编译器不会把它们存入状态机对象,GC 可以正常回收。c 需要在第二个 delay 之后使用,所以会被提升为字段。

这里有个实际踩过的坑:如果你在挂起点之前持有一个大对象的引用,而该引用跨越了挂起点,它会被状态机持有,整个协程挂起期间都无法被回收

suspend fun processLargeData() {
    val data = loadHugeBitmap()  // 大对象
    process(data)
    delay(5000)                  // 挂起 5 秒,data 仍被状态机持有!
    println("done")
}

解法是在挂起点之前主动将不再需要的引用置 null,或者重构代码让大对象不跨越挂起点。

异常处理与 Result 包装

resumeWith(result: Result<T>) 里用了 Result 类型,这不是偶然。协程的恢复有两条路径:正常恢复(携带值)和异常恢复(携带 Throwable)。

状态机里的 result 字段在每个 case 入口都要先做一次检查:

case 1: {
    // 每次恢复时先检查是否是异常
    ResultKt.throwOnFailure(sm.result);
    User user = (User) sm.result;
    // ...
}

throwOnFailure 会在 result 是失败状态时重新抛出异常。这就是为什么协程里的异常能像同步代码一样用 try-catch 捕获——编译器把异常传播路径也编进了状态机的控制流

如果你在协程里写了跨越挂起点的 try-catch

suspend fun safeLoad(): User? {
    return try {
        fetchUser("123")  // 挂起点
    } catch (e: IOException) {
        null
    }
}

编译器会在对应的 case 分支里插入异常检测逻辑,将 catch 块的范围映射到状态机的特定状态区间。生成的字节码在这里比普通 try-catch 复杂一些,但对调用方完全透明。

用 javap 亲手验证

光看分析不够踏实,可以自己动手反编译:

# 编译 Kotlin 文件
kotlinc Example.kt -include-runtime -d example.jar

# 查看生成的 class 文件列表(注意那个带 $ 的内部类)
jar tf example.jar | grep "\.class"

# 反编译状态机类
javap -c -p ExampleKt\$loadProfile\$1.class

你会看到一个继承自 SuspendLambdaContinuationImpl 的类,里面的 invokeSuspend 方法就是状态机本体。字段名可能被混淆,但 label 字段通常能认出来。

更方便的方式是用 IntelliJ 的「Tools → Kotlin → Show Kotlin Bytecode」,再点「Decompile」得到近似 Java 代码,可读性更高。

性能影响与实践建议

理解了状态机实现之后,几个常见的性能问题就有了解释。

协程本身很轻,但状态机对象有分配开销。 每次调用一个 suspend fun 并真正挂起时,堆上都会分配一个状态机对象。在热路径上频繁调用会挂起的函数,GC 压力会上来。对于明确知道不会挂起的代码路径,可以考虑用 withContext 包裹后一次性切换,减少状态机的嵌套层数。

跨挂起点的局部变量要有意识地管控。 不必要的大对象引用跨越挂起点是内存泄漏的常见路径。LeakCanary 报告里如果出现协程相关的持有链,多半是这个原因。

inline suspend fun 会展开状态机,而不是嵌套。 标准库里的 withContextcoroutineScopesuspend 函数,框架层对它们有特殊处理。理解这一点,在分析字节码时才不会被嵌套的状态机搞晕。

我更倾向于把协程理解为”编译器帮你写的回调 + 调度器”,而不是”轻量级线程”。“轻量级线程”这个比喻在解释挂起/恢复行为时是准确的,但会让你忽视状态机分配的成本。前者的视角更贴近 JVM 上实际发生的事情,在做性能分析时更有用。