任何一个敢说自己懂 Kotlin 协程的人,都应该能回答一个问题:挂起函数到底是怎么挂起的?多数人被问到这里,都会搬出“CPS 变换”这个词,但再往深问一层,CPS 变换之后字节码长什么样,状态机是怎么生成出来的,就支支吾吾了。这篇文章就做一件事——把挂起函数的字节码彻底扒开,从签名变换开始,一路看到状态机的每个分支、每个字段,让你在面试和实战里都真正有底气。
这篇内容适合两类人:一类是已经被协程的 suspend 关键字折磨过、想搞懂原理的 Kotlin/Android 开发者;另一类是看了很多协程源码分析但始终觉得“底层是黑盒”的进阶学习者。我会配合可运行的代码、IDEA 字节码面板和 javap 命令,把整个 CPS 变换过程走一遍。看完你会有一种感觉:协程没有魔法,只有编译期很朴素的体力活。
1. 挂起函数到底被编译成了什么
1.1 从一个最简单的挂起函数说起
先写下这段代码:
suspend fun fetchUser(id: Int): User { val cache = loadCache(id) val user = api.fetch(id) return user }别小看这个函数。它在 Kotlin 源码层面写着suspend,但从 JVM 的角度看,编译器必须把它改造成一个“可挂起、可恢复”的结构。这里面的核心动作就是 CPS 变换,全称 Continuation-Passing Style,翻译过来是“续延传递风格”。
什么叫续延传递?典型的过程式写法是“我调用一个函数,等它返回结果,我继续往下走”。但 CPS 风格是“我调用一个函数,除参数外,我还告诉它‘你处理完了该去哪儿找我继续’”。这个“去哪儿找我继续”的东西,在 Kotlin 协程里就是Continuation。
那编译器具体改了什么?我用 IDE 自带的反编译工具看过后,发现这个挂起函数实际签名约等于:
fun fetchUser(id: Int, completion: Continuation<super User?>): Any?是不是很惊讶?源码里你只写了一个参数id,编译完却多出一个completion。这不是 Kotlin 编译器闲得没事,而是因为挂起函数必须把“恢复点”交给调用链。所有调用这个挂起函数的上层函数,都得收到这个completion参数,再往下传。所以 CPS 变换的第一个特征非常纯粹:多一个 Continuation 参数,返回值变成 Object/Any?。
有朋友会问,为什么返回值不是User而是Any??这个问题问到了点子上。因为挂起函数执行到一半可能真的挂起了,比如在等网络请求、等磁盘 IO。这个时候函数不能返回User,因为它还没拿到结果。它得返回一个特殊标记,告诉调用者“我挂起了,先别等我”。Kotlin 编译器定义的标记就是COROUTINE_SUSPENDED。
1.2 返回值改成 Any? 的真正含义
我们先看一眼反编译后的方法签名(以 Java 视角展示):
@Nullable public final Object fetchUser(int id, @NotNull Continuation<? super User> $completion) { // 状态机入口,内部实现省略 }返回类型是Object(对应 Kotlin 的Any?),这里头有两个可能:要么直接返回最终结果User,要么返回COROUTINE_SUSPENDED这个单例标记。这个设计非常关键,它让调用方可以做判断:
- 如果返回
COROUTINE_SUSPENDED,说明被调用的挂起函数让出了线程,当前函数也不能继续往下执行“拿结果”的逻辑。 - 如果返回的是正常值,说明这个挂起函数其实没真正挂起(比如数据已经缓存,直接拿到了结果),那就可以立刻继续执行。
所以“挂起”并不是 suspend 关键字触发的魔法,而是由函数内部“真的遇到耗时操作,并且这个操作需要恢复回调”来决定的。这也是面试经常借题发挥的点:suspend 函数不一定会挂起,它可能是同步完成的,只是给了你一个可以挂起的机会。
我最初学协程时有个误区:以为编译器会把函数拆成两个方法,一个执行前半段,一个执行后半段。实际看字节码才知道,编译器根本没那么“优雅”,它用的是一张状态机大表,用一个 int 字段标记当前执行到第几个状态,然后在大 switch 里来回跳转。这就是下面要重点拆的部分。
2. 状态机:一张 switch-case 看清挂起的全部秘密
2.1 编译后的类结构长什么样
打开 IDEA 的 Tools -> Kotlin -> Show Kotlin Bytecode,找到刚才的fetchUser函数,会看到编译器在函数里创建了一个匿名内部类,这个匿名类继承自ContinuationImpl。这是一个常规操作:每进入一个挂起函数,编译器会检查你传入的continuation参数是不是自己这个状态机实例。
如果是第一次进来(不是恢复执行),就把外部传进来的Continuation包装成一个新的ContinuationImpl,后续所有状态都记录在这个新实例里。我反编译时见过类似这样的结构(简化后):
final class FetchUser$fetchUser$1 extends ContinuationImpl { int label; Object L$0; Object L$1; /* synthetic */ Object result; /* synthetic */ Object L$2; final /* synthetic */ FetchUser this$0; FetchUser$fetchUser$1(FetchUser this$0, Continuation $completion) { super($completion); this.this$0 = this$0; } @Nullable public final Object invokeSuspend(@NotNull Object $result) { // 这里就是状态机的主方法 } }注意几个字段的含义:
label:记录当前执行到哪个挂起点,状态机跳转的核心。L$0、L$1:把跨挂起点存活的局部变量提升为字段,比如循环变量、中间计算结果。result:保存上一次挂起调用的返回值,用于恢复后继续处理。this$0:指向外部类的引用,因为匿名内部类要访外部类的成员方法。
这段字节码看的次数多了,我发现一个规律:编译器永远只保存那些“跨过挂起点还需要用”的局部变量。如果某个变量只在同一个挂起点内部使用,它就不会被提升成字段。这和人脑记忆的机制挺像——只有需要带上下次继续的信息才会特意记到本子上,一次性用完的随手就丢。
2.2 核心的 invokeSuspend 与 label 跳转
invokeSuspend是整个状态机的“总指挥”。它接收一个$result参数,这个参数就是上一次挂起调用返回的结果。函数体里最常见的样子就是一段巨大的switch (this.label)。我拿到过一个反编译示例,大致长这样:
public final Object invokeSuspend(@NotNull Object $result) { Object var10000; User user; Object value; switch (this.label) { case 0: // 初始状态:执行 loadCache(id) this.label = 1; value = loadCache(id); // 如果 loadCache 是挂起函数且确实挂起了 if (value == COROUTINE_SUSPENDED) { return COROUTINE_SUSPENDED; } // 没有挂起就直接跳到 case 1 继续处理 // 编译器用 fall-through 的方式来模拟“立刻恢复” case 1: // 恢复状态:拿到 loadCache 的结果 user = (User) $result; // 继续执行 api.fetch(id) this.label = 2; value = api.fetch(id); if (value == COROUTINE_SUSPENDED) { return COROUTINE_SUSPENDED; } case 2: // 最后一个状态:返回数据 return $result; } }这里有个细节值得琢磨:如果挂起函数并没有真的挂起,比如loadCache只是从内存缓存里读数据,天然是同步完成的,编译器也不会傻傻地等下一次恢复。它会把返回值拿过来,直接顺着 switch 的 fall-through 逻辑落到对应的下一个 case。这种设计让“同步完成”和“异步挂起”共用一套代码,不需要额外分支。通常我们读协程源码时那个熟悉的if (value == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED;判断,学名就是“挂起检查”。
多挂起点的情况就是 case 数量变多,逻辑一模一样。比如一个函数有 5 个挂起点,switch 就会分 5 个 case。所以状态机的规模和函数里的挂起点数量正相关。这也是为什么说协程本质是“编译器帮你把回调式的代码,转化成了可由状态变量驱动跳转的判断结构”。
3. 挂起函数与函数调用链的变形
3.1 为什么每层都要传 Continuation
再看一个稍微复杂的场景:一个挂起函数调用另一个挂起函数。比如:
suspend fun loadUserProfile(): Profile { val user = fetchUser(userId()) // 挂起函数 A val posts = fetchPosts(user.id) // 挂起函数 B return Profile(user, posts) }编译之后,loadUserProfile会把自己的Continuation往下传,传给fetchUser和fetchPosts。这一层一层的传递,最终构成一条完整的“续延链”。这个链条的本质就是:每个挂起函数记录了“我执行到哪了”和“我的调用者是谁”,而调用者的信息又保存在它的continuation里。
所以你在崩溃栈里经常能看见一大串ContinuationImpl的子类:LoadUserProfile$loadUserProfile$1、FetchUser$fetchUser$1……它们就是从最外层一路嵌套下来的。理解了这一点,以后排查协程相关崩溃栈时就不会再一脸懵。
这里我还想补充一个观察:每次调用挂起函数时,编译器不是直接把当前方法自己的continuation原样传给被调函数,而是包装成一个新的ContinuationImpl子类。这个子类的label值,记录的是“被调函数完成后,我应该从哪个 case 继续”。本质上,它就是一张写了“回头在哪继续”的小纸条。
3.2 循环、分支、异常这些控制流怎么被折叠
很多教程讲到状态机就停了,仿佛协程里只有顺序调用。但实际业务里到处都是循环、try-catch、when 分支,这些控制流怎么折叠成状态机和 label?我看字节码时发现,编译器处理起来非常直接粗暴。
先看循环,这是协程最常踩坑的地方之一。比如:
suspend fun retryFetch(times: Int): User { var lastException: Exception? = null for (i in 0 until times) { try { return fetchUserInner(i) } catch (e: Exception) { lastException = e } } throw lastException ?: IllegalStateException("never") }编译器首先把循环变量i和lastException都提升成状态机类的字段。然后在invokeSuspend里维护一个“当前节点”的概念。每次循环体执行到fetchUserInner(i)这个挂起点时,会把当前的i值通过 label 记录下来。恢复后,编译器会先判断循环是否继续,如果继续,就把i加 1,再走一遍状态机的某个 case。
也就是说,循环本质上被拆成了“检查条件 - 执行循环体 - 更新计数器 - 跳回检查条件”这样的格子,每个格子对应一个 label 阶段。我印象里看到的最极端场景是一个嵌套了三层循环的挂起函数,反编译出来的invokeSuspend里为了管理不同层级的循环,编译器额外声明了多个字段,分别标记外层和内层的当前位置。
再说异常。状态机里处理异常也不是玄学,它会把 try-catch 区域也映射到 label 区间。例如 label 从 1 到 2 属于 try 块,如果这段时间抛出了异常,恢复时$result就不是正常返回值,而是一个Failure包装对象,里面包着异常。编译器通过判断$result是Result.Failure还是普通值,来决定是跳到 catch 分支还是继续正常执行。到这一步,本质上已经没有了源码里“嵌套 try”的直觉,所有东西都变成平铺的 switch 分支。
4. 对照实验:Kotlin 协程与另一套机制的差异
4.1 Kotlin 为什么选 CPS 而不是直接靠线程
我们常拿 Kotlin 协程和 Python 协程比。Python 的async/await底层是生成器,它靠yield from把执行权交给事件循环,状态保存在生成器栈帧里;而 Kotlin 选择在编译期做 CPS 变换,核心原因很实际:JVM 线程太重了。
JVM 里每个线程默认栈大小动辄 512KB 甚至 1MB,如果像 Java 当年那样“一个并发任务一个线程”,几万个连接就能把内存打爆。协程希望做到的是“少量线程承载海量任务”。要做到这一点,就必须把任务从线程栈里“摘”出来。CPS 变换的价值就在这里:函数被改造成不占用线程栈的闭包 + 状态机,挂起时线程可以直接跑别的任务。
所以我在各种分享里强调过:Kotlin 协程不是“更轻的线程”,也不是“线程池的封装”,它本质上是编译期把代码结构改了,让挂着大量任务的线程可以随时换班。
4.2 一个简单例子看两种形态的差别
写一个假想的例子。在 Kotlin 里:
suspend fun readFile(): String { val content = fileIo.read() // 挂起点 return content.uppercase() }编译后的近似形态是:
public final Object readFile(Continuation<? super String> $completion) { // 状态机里有个 label if (this.label == 0) { this.label = 1; Object result = fileIo.read(this); if (result == COROUTINE_SUSPENDED) { return COROUTINE_SUSPENDED; } } return ((String) $result).uppercase(); }如果用 Python 的生成器思想去写同样的逻辑,会是这样:
def read_file(): content = yield from file_io_read() # 挂起 return content.upper()两者的差异一句话总结:Kotlin 把“挂起点”翻译成状态机的 case,Python 保留了一个真实生成器帧来暂停和恢复。前者更依赖编译器,后者更依赖运行时。Kotlin 的做法让它能在不修改 JVM、不依赖虚拟机协程支持的情况下,跑出类似协程的效果。这也是为什么你在 Java 世界里看不到这种“同步代码风格异步执行”的体验。
理解这个差异有个实际好处:你会明白 Kotlin 协程并不能天然优于所有基于线程池的方案,它适合的是“任务多但每个任务大部分时间都在等待”的场景,比如 IO 密集的网络服务、Android 主线程的异步操作。如果是纯 CPU 密集计算,状态机再多也省不下线程切换的开销,反而可能多了一些字节码额外成本。
5. 对日常开发和面试的隐藏影响
5.1 崩溃栈变难看,但你会翻译了就没事
协程崩了以后,异常栈总是一长串BaseContinuationImpl、DispatchedContinuation、ContinuationImpl之类的类名,新手一看就头大。但读完上面的字节码分析,再看这种东西就很清晰了:DispatchedContinuation是协程切换调度器时包的一层壳,ContinuationImpl则是状态机执行的核心,崩溃信息底下那串Caused by才是业务错误的本体。
还有个小技巧:Kotlin 协程在 debug 模式通常会打印增强栈信息,如果项目开了kotlinx.coroutines.debug系统属性,会在堆栈里看到如CoroutineId(1)的信息,这对定位是哪个协程崩的很有帮助。之前我排查过一次线上偶现崩溃,就是靠协程 ID 过滤日志才找到真正的业务入口。
5.2 性能开销不是玄学,是有据可查的
很多人担心状态机性能差,但你要意识到编译器生成的代码是固定结构的。每次挂起函数调用都会创建新的ContinuationImpl匿名实例,如果函数用了闭包捕获,还会多一层包装。这个分配是有成本的,尤其在启动阶段大量调用挂起函数时。不过现代 JVM 对短生命周期对象的分配做了优化,加上协程框架自己做了很多复用,实际影响通常可以接受。
真正需要警惕的是滥用withContext和suspendCancellableCoroutine。withContext每次切换调度器可能涉及线程切换和状态保存,如果在一个大循环里频繁调用withContext(Dispatchers.IO),会产生不少额外开销。我在代码评审时就见过把网络请求放在循环里、每次都用 withContext 切线程的写法,那性能能好才怪。最好的做法是先把数据批量准备好,再一次性切线程或异步处理。
5.3 高频面试题,其实都能用字节码回答
基于 CPS 变换,我整理几个经常被问的问题,可以直接从字节码层面作答:
- suspend 函数能不能调用普通函数?可以,普通函数没有 Continuation 参数,直接调用即可。
- 普通函数能不能调用 suspend 函数?不行,因为没有 Continuation 可以传。你只能在自己的 suspend 上下文里调用,或者启动新的协程。
- suspend 函数一定异步吗?不一定,返回
COROUTINE_SUSPENDED才是真挂起,否则就是同步执行。 - 协程的挂起会不会阻塞线程?不会,挂起时线程直接回归调度池去执行其他任务。
- 为什么用
stateFlow.collect要挂起函数?因为它内部也是依赖 CPS 变换的长生命周期调用,常常通过挂起保持收集状态。
这些问题在 IDEA 里对着字节码看一遍,基本就不会再忘。
6. 亲手验证这套机制,5 分钟跑一个实验
6.1 IDEA 里查看 Kotlin 字节码的操作路径
想验证上面所有结论,最省事的方法是用 IntelliJ IDEA 或 Android Studio 自带功能:
- 新建一个 Kotlin 文件,随便写一个挂起函数。
- 菜单栏依次点击 Tools -> Kotlin -> Show Kotlin Bytecode。
- 右侧会弹出字节码面板,里面就是 class 文件的反汇编文本。
- 如果想看 Java 形态,点击面板上方的 Decompile 按钮,就会看到反编译后的 Java 代码,状态机的结构和字段清晰得多。
我第一次点开的时候,说实话是有点震撼的:源码里明明是一段顺序代码,反编译出来全是一个个大括号里套 switch。IDEA 的反编译结果虽然不是 100% 等价于原始生成代码,但结构上足以看清 label、字段、状态机这些核心要素。
6.2 用 javap 从命令行层面验证
如果不想开 IDE,也可以编译出 class 文件后用 javap 查看,命令差不多是这样:
javap -p -c com/example/CoroutineSampleKt-p参数显示私有成员,能看到编译器生成的匿名内部类,-c参数显示字节码指令。通过 javap 你能确认几件事:方法签名里多出的Continuation参数、invokeSuspend方法、合成的label字段。如果配合-v还能看到访问标志和内部类表,后期排查编译器版本差异时特别有用。
6.3 一个完整的实验模板
我自己常做的实验模板是这样:在同一个文件里放一个线性调用、一个含循环的挂起函数、一个含 try-catch 的挂起函数,然后分别用 Decompile 查看。重点标出三个东西:
- label 的初始值和每次跳转的新值。
- 所有被提升为字段的局部变量名称。
$result在 case 之间如何传递。
做完这三步,你对协程的实现模型基本就有“肌肉记忆”了,以后看到任何协程优化、proguard 混淆、崩溃栈分析都不会再犯怵。
再提醒一个容易被忽略的小坑:协程编译器插件的版本,以及 Kotlin 编译器本身,在不同版本之间生成的字节码结构会有细节差异。比如老版本可能在挂起点多生成一个intrinsics判断,新版本可能引入 invokedynamic 优化。所以当你和同事讨论“协程字节码”时,最好先确认双方 Kotlin 版本一致,否则会出现明明看的是同一个函数,反编译结果却不一样的情况。