编译器在背后做了什么?从 5 行 Kotlin 代码反编译出 50 行 Java,逐行拆解 Continuation 状态机的一切。
挂起函数的实现本质上是编译器对代码的 CPS(Continuation-Passing Style,续体传递风格)变换。在编译期,编译器会以每个挂起点为分界,将协程体拆分成若干状态机片段,每个片段对应一个回调续体。
用人话说就是:你写的一段连续代码,编译器把它切成几段,每段执行完后记录"我执行到哪了",然后挂起等待;等条件满足后,通过之前保存的"续体"恢复执行下一段。
协程执行时,在挂起点之前只是正常执行并返回挂起标记,不会触发任何回调;只有在挂起任务完成、通过续体的 resume 恢复执行时,才会触发一次回调,进入下一个状态继续执行。
要理解续体和状态机,最简单的方法就是写一个挂起函数,在里面制造一个挂起点。比如:
suspend fun myTest() {
println("A")
delay(1000)
println("B")
}
五行代码,两个打印,中间夹一个 delay 挂起点。然后使用 IntelliJ 的 Tools → Kotlin → Show Kotlin Bytecode → Decompile 就能看到编译器把它变成什么了。
上面 5 行 Kotlin 反编译成 Java 后变成了这样:
@Metadata(
mv = {2, 0, 0},
k = 2,
xi = 48,
d1 = {"..."},
d2 = {"myTest", "", "(Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", "..."}
)
public final class CoroutineKt {
@Nullable
public static final Object myTest(@NotNull Continuation $completion) {
Continuation $continuation;
label20: {
if ($completion instanceof ) {
$continuation = ()$completion;
if (($continuation.label & Integer.MIN_VALUE) != 0) {
$continuation.label -= Integer.MIN_VALUE;
break label20;
}
}
$continuation = new ContinuationImpl($completion) {
// $FF: synthetic field
Object result;
int label;
@Nullable
public final Object invokeSuspend(@NotNull Object $result) {
this.result = $result;
this.label |= Integer.MIN_VALUE;
return CoroutineKt.myTest((Continuation)this);
}
};
}
Object $result = $continuation.result;
Object var3 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch ($continuation.label) {
case 0:
ResultKt.throwOnFailure($result);
System.out.println("A");
$continuation.label = 1;
if (DelayKt.delay(1000L, $continuation) == var3) {
return var3;
}
break;
case 1:
ResultKt.throwOnFailure($result);
break;
default:
throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
}
System.out.println("B");
return Unit.INSTANCE;
}
}
短短 5 行 Kotlin,反编译后膨胀为近 50 行 Java。这些多出来的代码就是编译器自动生成的状态机骨架。
反编译代码虽然长,但可以分为三块来理解:
原来的 myTest() 变成了 myTest(Continuation $completion),接收调用者的续体作为参数。
检查是否为恢复调用。是则复用已有续体;否则创建新的 ContinuationImpl 匿名内部类。
根据 label 值跳转到对应 case,执行当前状态的代码,然后更新 label 指向下一个状态。
下面逐个深入这三块区域。
原来的 suspend fun myTest() 编译后变成了:
public static final Object myTest(@NotNull Continuation $completion)
这个 $completion 是调用者的 Continuation。它自己没有 label 等状态——它只是被当作"上下文"传入,编译器会基于它生成内部真正的状态机。
返回类型也从 Unit 变成了 Object。这是因为挂起函数有两个可能的返回值:
COROUTINE_SUSPENDED — 表示"我还没执行完,先挂起了"Unit.INSTANCE — 表示"我执行完了,这是最终结果"一句话:suspend 函数编译后等价于一个接收 Continuation、返回 Object 的回调风格函数。调用者通过传入自己的 Continuation 来接收执行结果。
编译器生成的匿名内部类 ContinuationImpl 实际上继承自 BaseContinuationImpl。我们来追踪继承链:
Continuation 是一个接口,其中定义了核心的 resumeWith() 方法。而 BaseContinuationImpl 则是所有状态机实例的公共基类,它实现了 resumeWith() 的通用逻辑。
下面是 BaseContinuationImpl.resumeWith() 的核心源码:
public final override fun resumeWith(result: Result<Any?>) {
// 通过循环展开递归,让恢复调用链的栈更短、更清晰
var current = this
var param = result
while (true) {
probeCoroutineResumed(current)
with(current) {
val completion = completion!! // 快速失败:没有 completion 直接抛
val outcome: Result<Any?> =
try {
val outcome = invokeSuspend(param)
if (outcome === COROUTINE_SUSPENDED) return
Result.success(outcome)
} catch (exception: Throwable) {
Result.failure(exception)
}
releaseIntercepted() // 当前状态机实例结束
if (completion is BaseContinuationImpl) {
// 通过循环展开递归
current = completion
param = outcome
} else {
// 到达顶层 completion,调用并返回
completion.resumeWith(outcome)
return
}
}
}
}
这个方法就是续体恢复的调度引擎。它通过 while(true) 循环和 invokeSuspend 调用不断推进状态机,直到所有 Continuation 链执行完毕。
无论是首次启动还是挂起后恢复,最终都是同一条路径:
核心结论:无论协程是首次启动还是从挂起恢复,最终都会通过 resumeWith → invokeSuspend → myTest 这条链进入状态机。区别只在于进入时 label 的值不同。
反编译代码中的 label20 是整个状态机的"门卫",负责判断当前是首次调用还是挂起后恢复:
label20: {
if ($completion instanceof ) {
$continuation = ()$completion;
if (($continuation.label & Integer.MIN_VALUE) != 0) {
$continuation.label -= Integer.MIN_VALUE; // 清除恢复标记
break label20; // 直接复用已有的 $continuation,跳过新建
}
}
// 首次调用:创建新的状态机实例
$continuation = new ContinuationImpl($completion) {
Object result;
int label;
public final Object invokeSuspend(@NotNull Object $result) {
this.result = $result;
this.label |= Integer.MIN_VALUE;
return CoroutineKt.myTest((Continuation)this);
}
};
}
这里的 <undefinedtype> 实际上是编译器生成的内部类 MyFunction$myTest$1——当前 suspend 函数专属的状态机类名。
$completion 不是本函数的状态机实例ContinuationImpl 匿名内部类$completion 是本函数的状态机实例(instanceof 通过)在恢复调用时,invokeSuspend 会设置:
this.label |= Integer.MIN_VALUE;
这是把 label 的 最高位置 1,作为"这是一个恢复调用"的暗号。
回到 myTest 入口处,label20 块检测:
if (($continuation.label & Integer.MIN_VALUE) != 0) {
$continuation.label -= Integer.MIN_VALUE; // 清除标记
break label20;
}
检测到这个标记后,清除它(恢复 label 的真实值),然后直接 break label20,跳过创建新对象的逻辑,复用已有的 $continuation。
为什么不用 boolean 标志位,而要用 int 的最高位?因为 label 字段同时承担了两个职责:低 31 位存储状态编号,最高位作为恢复标记。这在有限的字段中压缩了更多信息,也避免了引入额外的 boolean 字段。
进入状态机的核心逻辑:
Object $result = $continuation.result;
Object var3 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch ($continuation.label) {
case 0:
ResultKt.throwOnFailure($result);
System.out.println("A"); // 打印 A
$continuation.label = 1; // 更新状态为 "下一段"
if (DelayKt.delay(1000L, $continuation) == var3) {
return var3; // 返回 COROUTINE_SUSPENDED
}
break;
case 1:
ResultKt.throwOnFailure($result);
break; // 跳出 switch
default:
throw new IllegalStateException(...);
}
System.out.println("B"); // 打印 B
return Unit.INSTANCE; // 正常结束
关键观察:
$continuation.label 就是状态机的程序计数器COROUTINE_SUSPENDED,case 0 的 break 和后面的 println("B") 都不会执行理解状态机的核心:suspend 函数编译后,方法体被切成多个 case 块。label 记录"上次执行到哪了",每次进入方法时根据 label 跳到对应的位置继续。这就是 CPS 变换的底层实现。
当协程启动,第一次调用 myTest 时的完整时序:
关键细节拆解:
步骤 3:label20 块 — $completion 不是 MyFunction$myTest$1 的实例,所以 instanceof 不通过。创建一个新的匿名 ContinuationImpl,其 label 默认为 0。
步骤 4:case 0 — 打印 "A",将 $continuation.label = 1。然后调用 delay(1000, $continuation),延迟函数返回 COROUTINE_SUSPENDED(即 var3),于是 myTest 执行 return var3。case 0 的 break 和下面的 println("B") 都没有被执行到。
步骤 5-7:传递挂起信号 — invokeSuspend 把 COROUTINE_SUSPENDED 返回给 resumeWith,resumeWith 看到这个特殊值就直接 return,控制权交回事件循环,协程进入挂起状态。
1000ms 后,delay 的定时器触发,调用 $continuation.resumeWith(Unit):
步骤 5:再次进入 label20 — 这次 $completion 就是上次创建的那个状态机实例,instanceof 通过。同时 label 携带了 Integer.MIN_VALUE 标记。清除标记后,break label20 跳过创建新对象,直接复用已有实例。此时 label 的真实值为 1。
步骤 6:case 1 — 只是 break 跳出 switch,没有挂起点。然后继续执行 switch 下面的代码:打印 "B",返回 Unit.INSTANCE。
步骤 7-8:正常结束 — invokeSuspend 得到 Unit.INSTANCE(不再是 COROUTINE_SUSPENDED),resumeWith 将它包装成 Result.success,调用 releaseIntercepted(),当前状态机实例终止。
如果调用链上有父 Continuation,resumeWith 的 while 循环会继续向上"冒泡",将 result 传递给父续体。当最终到达顶层 completion(不是 BaseContinuationImpl 的实例)时,调用它的 resumeWith,整个协程链正式结束。
把两次调用放在一起对比:
精髓在于:首次调用和恢复调用执行的是同一个方法,但 label 的不同使得它们走了完全不同的分支。这就是状态机模式的威力——它把一段"看起来连续"的代码,变成了可以被任意中断和恢复的有限状态机。
把上面两次调用的关键信息浓缩成一张表:
| 步骤 | 调用入口 | label | 执行内容 | 结果 |
|---|---|---|---|---|
| 1 | myTest(调用者Cont) |
0 | 创建状态机 → case 0:打印 A,改 label=1,调 delay → 返回 COROUTINE_SUSPENDED | 挂起 |
| 2 | resumeWith(Unit) → invokeSuspend → myTest(状态机) |
1 | 复用状态机 → case 1:检查结果,break → 打印 B,返回 Unit | 完成 |
关键点:状态机的执行上下文(局部变量、label、result)全部保存在 $continuation 对象里。每次进入 myTest,不管是不是恢复,都通过同一个 $continuation.label 决定从哪个 case 开始执行。
这是理解状态机最关键的一个问题:switch 本身没有暂停能力,那为什么协程可以在 switch 中间"停住"?
答案不是 switch 有特殊能力,而是整个函数在某个 case 中通过 return 提前退出,把"下一步"记录在 label 里,下次函数重新进入时用 switch(label) 跳到紧跟着的那个位置。
第一次 case 0 做了 label = 1,然后 return 了,所以 case 0 之后的代码(包括 println("B"))没有被执行。恢复时 label 是 1,直接走 case 1,break 后恰好就落到了 println("B"),完美接续。
一句话:switch 代码块的"暂停"是假象——实质是函数在某个 case 中通过 return 提前退出,把"下一步"存在 label 里。下次函数重新进入时,switch(label) 直接跳到对应的 case,从断点之后继续执行。这就是 CPS 变换的核心魔法。
挂起函数的本质不是"线程挂起",而是编译器将你的连续代码变换为一个由 label 驱动的有限状态机——Continuation 持有状态,resumeWith 推进状态,每个 case 对应一段"从上一个挂起点之后到下一个挂起点"的代码片段。
理解续体和状态机后,很多协程行为就变得透明了:
编译器为我们做了太多太多的事情。下次写 delay 时,可以想想背后那个默默工作的 label 状态机。