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)
}
变量 a 和 b 在 delay(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
你会看到一个继承自 SuspendLambda 或 ContinuationImpl 的类,里面的 invokeSuspend 方法就是状态机本体。字段名可能被混淆,但 label 字段通常能认出来。
更方便的方式是用 IntelliJ 的「Tools → Kotlin → Show Kotlin Bytecode」,再点「Decompile」得到近似 Java 代码,可读性更高。
性能影响与实践建议
理解了状态机实现之后,几个常见的性能问题就有了解释。
协程本身很轻,但状态机对象有分配开销。 每次调用一个 suspend fun 并真正挂起时,堆上都会分配一个状态机对象。在热路径上频繁调用会挂起的函数,GC 压力会上来。对于明确知道不会挂起的代码路径,可以考虑用 withContext 包裹后一次性切换,减少状态机的嵌套层数。
跨挂起点的局部变量要有意识地管控。 不必要的大对象引用跨越挂起点是内存泄漏的常见路径。LeakCanary 报告里如果出现协程相关的持有链,多半是这个原因。
inline suspend fun 会展开状态机,而不是嵌套。 标准库里的 withContext、coroutineScope 是 suspend 函数,框架层对它们有特殊处理。理解这一点,在分析字节码时才不会被嵌套的状态机搞晕。
我更倾向于把协程理解为”编译器帮你写的回调 + 调度器”,而不是”轻量级线程”。“轻量级线程”这个比喻在解释挂起/恢复行为时是准确的,但会让你忽视状态机分配的成本。前者的视角更贴近 JVM 上实际发生的事情,在做性能分析时更有用。