Kotlin协程底层原理:从字节码看CPS变换、状态机与挂起函数实现
发布时间:2026/9/20 18:06:39 锦皓数字建站

我写协程写了快一年半网络请求、数据库查询、列表刷新全挂在suspend函数上。我一直觉得自己对协程已经算熟直到一次内部技术评审被人追问你说这是CPS变换那CPS到底改了哪些东西Continuation对象里藏了什么状态机是怎么流转的我当场只能背出改变签名、返回值、引入状态机的骨架细节全是模糊的。说真的那感觉就像一直在API层面打转从没真正看过底牌。这周末我把自己写过的suspend函数全部反编译了一遍对着字节码逐行看还用Java手写了一遍等价的CPS状态机。这篇文章就是这次渡劫的完整记录。如果你也想真正把Kotlin协程吃透不去官网翻一遍文档而是直接看字节码这一篇应该能帮你省下不少瞎折腾的时间。1. 挂起函数到底挂的是什么CPS变换要解决的真实问题1.1 JVM没有原生的挂起指令Kotlin只能在编译期动手脚先问一个底层问题JVM支持一个方法先暂停执行等某个条件满足后再从暂停的位置继续往下跑吗答案是不支持。JVM的方法调用模型很死板一个方法一旦开始执行要么正常返回要么抛异常要么因为阻塞而白白占用线程它没有用户态的挂起语义。Kotlin要在JVM上实现协程就只能靠编译器在字节码层面偷天换日。Kotlin的做法是把挂起转换成提前返回。一个suspend函数执行到挂起点时不是真的暂停在方法栈帧里等结果而是直接return一个特殊信号。等异步条件满足之后外界通过之前传入的continuation对象再次进入这个方法从上次离开的位置继续写。这样就能把原本需要阻塞线程的等待阶段变成线程可以先去做其他事情的自由时间。理解这个转变很重要。很多人以为协程挂起是方法被临时冻结了从字节码看完全不是。它更像你取了个号把手机号留给银行然后回家等短信而不是原地坐一下午。难点在于方法已经return了局部变量全没了编译器怎么保证你回来时还记得之前跑到哪、要用到的变量是什么这就引出了CPS变换和状态机的存在意义。1.2 suspend修饰符如何改变函数契约以及为什么普通函数不能调用它一个普通Kotlin函数fun getToken(): String { return token }调用方拿到的是一个String。但如果你在前面加上suspendsuspend fun getToken(): String { return token }看起来用法几乎一样调用方拿到的还是一个String。但Kotlin编译器会强制约束只有suspend函数或协程构建器里才能调用它。这个约束不是拍脑袋想出来的而是因为经过CPS变换后getToken的JVM签名已经彻底变了它带上了一个Continuation参数。普通函数没有这个对象自然无法完成调用。语言层面的只能这样写本质上是字节码层面的缺少一个参数。所以别再背挂起函数必须在协程上下文里调用这句话了真正原因是没有ContinuationCPS变换后的函数根本没有完整入参。这也是Kotlin编译器在你试图从普通函数调用suspend函数时报错的内在逻辑——它不只是在做语法限制而是在保证编译后的字节码能正常对上参数。1.3 CPS与普通回调的区别以及Kotlin的务实变体CPS全称Continuation-Passing Style是一种把调用后继续执行的操作显式作为参数传递的编程风格。它和回调很像但比回调更彻底回调只是我这里做完之后要通知你而CPS要求把接下来的整个计算过程打包成一个continuation对象后续所有步骤都围绕这个对象展开。Kotlin并没有做教科书级别的完整CPS。函数正常算完时仍然直接返回结果只有遇到真正挂起的操作才返回COROUTINE_SUSPENDED这个哨兵值。这种做法是性能和可读性的平衡也直接决定了你在字节码里看到的形态不是那种清一色回调嵌套而是一个可以被重复调用的状态机。下面进正题看编译器到底改了什么。2. 反编译工具与最小样本先看suspend函数在字节码里的签名变化2.1 工具选择IDEA内置反编译能力的用法在做实验之前先说工具。最方便的是IntelliJ IDEA或Android Studio打开任意Kotlin文件菜单栏选Tools - Kotlin - Show Kotlin Bytecode。右侧会打开一个字节码窗口顶部有个Decompile按钮点击后能生成将近Java源码的等价代码。我下面展示的都是反编译后的Java等价代码因为原生字节码全是aload、areturn这类助记符阅读成本太高。真实操作时我建议两个窗口对照着看左边Java反编译结果右边原始字节码。一旦Java结果里出现不理解的跳转逻辑去右边看对应的标签跳转会清楚很多。另一个小技巧是反编译之后搜索COROUTINE_SUSPENDED这一个关键词基本上能把所有挂起相关的路径都标出来。2.2 最简单的挂起函数签名和返回类型两条硬变化先看一个没有任何真实挂起的suspend函数suspend fun getToken(): String { return token }反编译后的Java等价代码已删掉元数据注解等噪音public static final Object getToken(Continuation? super String $completion) { return token; }对比原方法签名有两条硬性变化。第一条参数列表末尾多了一个Continuation? super String $completion这是整个CPS变换最直观的标志。Continuation的泛型参数代表挂起函数完成后应该恢复给调用方的结果类型。第二条返回类型从String变成了Object。为什么必须是Object因为函数可能有两种返回一种是正常完成返回String另一种是真正挂起返回一个固定的信号值COROUTINE_SUSPENDED。String这个类型装不下这个信号所以编译器把统一返回类型提升成了Object。注意即使这个函数体内没有任何真正挂起的调用只要加了suspend签名和返回类型也会发生这两条变化。第一层CPS适配是强制性的跟函数体内是否挂起无关。2.3 加入真正挂起点COROUTINE_SUSPENDED哨兵值的传递流程现在加一个真正的挂起函数delaysuspend fun getUserId(token: String): String { delay(100) return id-$token }反编译并简化后的Java等价代码如下public static final Object getUserId(String token, Continuation? super String $completion) { GetUserIdKt$getUserId$1 continuation; if ($completion instanceof GetUserIdKt$getUserId$1) { continuation (GetUserIdKt$getUserId$1) $completion; } else { continuation new GetUserIdKt$getUserId$1($completion); } Object $result continuation.result; switch (continuation.label) { case 0: { continuation.label 1; Object delayResult DelayKt.delay(100L, continuation); if (delayResult IntrinsicsKt.getCOROUTINE_SUSPENDED()) { return IntrinsicsKt.getCOROUTINE_SUSPENDED(); } $result delayResult; // 如果delay没有真正挂起继续往下执行case 1 } case 1: { return id- token; } } throw new IllegalStateException(call to resume before invoke with coroutine); }最核心的是那个if判断。当delay真的需要等待100毫秒时它返回COROUTINE_SUSPENDEDgetUserId看到这个信号后也跟着返回同一个信号。这个信号一路往调用链上层传调度器发现协程挂起了就会把线程让出来去做其他任务。等100毫秒时间到了delay内部通过continuation的resumeWith方法把结果塞回来协程状态机再根据label记录的坐标从case 1继续执行。同时也注意到token是函数参数它放在方法栈帧里方法return后栈帧销毁但恢复时需要再次传入token吗不需要。调用方在第一次调用getUserId时已经传了token而语音层的函数调用逻辑保证了后续resume调用仍然复用同一个入口方法实际上这里的状态机恢复时方法参数token之所以还能用是因为反编译代码显示case 1直接用了token但真实字节码中token会被保存到continuation字段里。我这个简化版本隐藏了存储细节下一节用更复杂的例子把局部变量跨挂起点的存储逻辑展示清楚。3. 状态机编排多个挂起点和局部变量如何被编译器管理3.1 用三个挂起点的fetchUserSummary做样本看一个更贴近业务的函数suspend fun fetchUserSummary(): String { val token getToken() val user getUserInfo(token) val score getScore(user.id) return user: $user, score: $score }这里面有三个挂起点getToken、getUserInfo、getScore。并且getUserInfo依赖tokengetScore依赖user。如果采用传统的回调写法三层嵌套已经很难看了更多挂起点会直接变成回调地狱。Kotlin编译器用一个状态机就能管住所有挂起点不管你有3个还是30个。3.2 Continuation子类的字段设计L$0、L$1和label为了跨过挂起点保存局部变量编译器会生成一个ContinuationImpl的子类。反编译后的结构大致如下final class FetchUserSummaryKt$fetchUserSummary$1 extends ContinuationImpl { Object L$0; Object L$1; int label; FetchUserSummaryKt$fetchUserSummary$1(Continuation? super String $completion) { super($completion); } Override public final Object invokeSuspend(Object $result) { // 状态机主逻辑 } }这里L$0、L$1就是编译器指定的局部变量槽位。函数第一次执行时方法栈帧里存着token、user这些局部变量一旦执行到挂起点并return整个栈帧销毁。为了让协程恢复后还能拿到这些值编译器会把可能跨挂起点的局部变量逐个存到continuation对象的字段里。label字段则记录当前执行到第几个挂起点相当于给状态机做了一个书签。另外编译器还会很聪明地判断变量的生命周期。有些局部变量只在当前case内使用后续不再用到就不会浪费一个L$槽位有些变量被多个case共用就复用同一个槽位。这层优化在源码里完全不可见只有看反编译结果才能体会。3.3 状态机执行流程拆解从case 0到case 2fetchUserSummary被反编译成Java后的核心逻辑如下我做了适度简化保留状态机骨架public static final Object fetchUserSummary(Continuation? super String $completion) { FetchUserSummaryKt$fetchUserSummary$1 continuation; if ($completion instanceof FetchUserSummaryKt$fetchUserSummary$1) { continuation (FetchUserSummaryKt$fetchUserSummary$1) $completion; } else { continuation new FetchUserSummaryKt$fetchUserSummary$1($completion); } switch (continuation.label) { case 0: { continuation.label 1; Object tokenResult getToken(continuation); if (tokenResult IntrinsicsKt.getCOROUTINE_SUSPENDED()) { return IntrinsicsKt.getCOROUTINE_SUSPENDED(); } continuation.L$0 tokenResult; // 继续执行 case 1 } case 1: { String token (String) continuation.L$0; continuation.label 2; Object userResult getUserInfo(token, continuation); if (userResult IntrinsicsKt.getCOROUTINE_SUSPENDED()) { return IntrinsicsKt.getCOROUTINE_SUSPENDED(); } continuation.L$1 userResult; continuation.L$0 null; // 手动置空帮助GC // 继续执行 case 2 } case 2: { User user (User) continuation.L$1; Object scoreResult getScore(user.id, continuation); if (scoreResult IntrinsicsKt.getCOROUTINE_SUSPENDED()) { return IntrinsicsKt.getCOROUTINE_SUSPENDED(); } return user: user , score: scoreResult; } } throw new IllegalStateException(call to resume before invoke with coroutine); }这段代码有几个设计上的讲究。第一个是case 0末尾没有return直接fall-through到case 1。这是因为如果getToken没有真实挂起说明结果已经拿到了那就不需要再绕一圈状态机直接在当前这轮调用里继续处理token。这个处理可以省掉一次方法重入的代价属于非常实在的优化。第二个是每次调用内部suspend函数之前先把label改成下一步该去哪。比如在case 0里先把label改成1然后再调getToken。这样即使getToken真的挂起了等它resume回来时状态机一看label是1就知道应该走case 1。第三个是局部变量在生命周期结束后被置为null。比如case 1用完了token马上把L$0清空case 2用完了user理论上也会清空。这是为了在长时间运行的协程里尽量让大对象不被continuation对象长期引用避免内存泄漏。这种字节码层面的洁癖业务代码里你是永远看不见的。3.4 continuation复用机制与内存优化细节每个反编译结果的开头都有一块instanceof判断if ($completion instanceof FetchUserSummaryKt$fetchUserSummary$1) { continuation (FetchUserSummaryKt$fetchUserSummary$1) $completion; } else { continuation new FetchUserSummaryKt$fetchUserSummary$1($completion); }这段代码的意思是如果外部传入的completion已经是我们这个状态机类型的实例就直接复用否则新建一个。为什么可以复用因为协程在一次完整执行周期里外部调用方拿到的continuation始终是同一个对象。第一次进入fetchUserSummary时创建状态机对象把它传给getTokengetToken如果挂起后续resume触发时又带着同一个continuation对象回到fetchUserSummary入口。既然类型匹配就无需再分配一个新对象。这个做法大幅减少了协程高频挂起时的对象分配压力也是协程性能能打的一个隐藏原因。明白了这一点你再去看协程崩溃栈里频繁出现的那些带$1、$2的类名就不会觉得它们是随机生成的一堆临时对象了。它们就是那个被一路复用的状态机只是长得丑了点。4. Continuation接口、CoroutineContext与resume字节码视角下的框架地基4.1 Continuation接口与BaseContinuationImpl的模板方法Kotlin标准库里的Continuation接口非常短public interface Continuationin T { public val context: CoroutineContext public fun resumeWith(result: ResultT) }但在字节码层面编译器生成的状态机基类并不直接实现这个接口而是继承kotlin.coroutines.jvm.internal.BaseContinuationImpl。BaseContinuationImpl把resumeWith做成一个模板方法它接收Result内部做一些状态判断然后调用abstract的invokeSuspend(result)。而invokeSuspend正是编译器给每个状态机生成的入口方法。所以外部看resumeWith是从挂起状态恢复的入口内部看调用的就是状态机里那个大switch。resumeWith并不负责计算结果只是负责拿着结果驱动状态机进入下一段逻辑。4.2 CoroutineContext在continuation对象中的实际作用再看BaseContinuationImpl或者编译器生成的子类你会发现每一个状态机对象都持有CoroutineContext。这不是摆设。CoroutineContext里装的是Job、Dispatcher、CoroutineName等元素它决定了协程的父级关系、调度方式、调试名称。在resume被调用时调度器会检查这个上下文中的ContinuationInterceptor决定要不要把恢复工作派发到另一个线程。举一个常见场景你在主线程启动协程里面调用withContext(Dispatchers.IO)做文件读取。withContext本质上就是创建了一个新的continuation把IO调度器放到它的context里。恢复时IO调度器会拦截resumeWith让状态机在IO线程池上继续执行。任务完成后切回主线程也是同样的机制在切换。CPS变换只负责打包接下来的步骤不负责决定在哪个线程上执行线程切换完全是CoroutineContext和调度器配合的结果。4.3 Dispatcher如何用resume实现线程切换而非阻塞理解了CoroutineContext之后挂起不阻塞线程这句话从字节码看就非常实在。普通线程阻塞时方法没有返回线程卡在那个栈帧上。协程挂起时状态机函数在挂起点直接return COROUTINE_SUSPENDED调用栈被释放线程可以继续执行别的任务。关键区别就在于一个占用线程一个释放线程。resumeWith被调用时Dispatcher的Interceptor会把恢复动作post到目标线程队列。状态机的invokeSuspend在新线程上重新执行看起来就像函数在另一个线程里恢复了。整个过程没有线程被无谓地长时间占住这是协程在IO密集场景下比直接起线程阻塞更省资源的根本原因。4.4 协程构建器与CPS的关系launch为什么能启动挂起函数最后把范围拉大一点。launch接收的block本身是一个suspend lambda它也会被编译成一个Continuation子类通常叫SuspendLambda。launch做的事很简单创建coroutine上下文创建一个受DispatchedContinuation包装的continuation然后调用一次resumeWith。这次resumeWith开始执行SuspendLambda状态机第一次进入block运行到第一个真实挂起点时返回COROUTINE_SUSPENDED协程进入挂起状态。所以协程构建器本质上就是构造continuation并启动它的包装器与suspend函数执行机制完全一致。在Compose项目里经常会写rememberCoroutineScope().launch { ... }背后的原理和上面一样。scope提供CoroutineContextlaunch创建/启动continuationblock里的状态机遇到挂起点就返回遇到网络回包就恢复。只是Compose的context里额外塞了Recomposer相关的拦截逻辑让协程恢复时机和重组时机能正确联动。5. 手写一遍CPS变换把Kotlin编译器的活儿还原出来5.1 一个最小可用的Java状态机实现看再多反编译结果不如自己写一遍。我用纯Java手写fetchUserSummary的等价状态机先把Continuation简化为只包含label和result的最小结构public class ContinuationT { public int label; public T result; }然后是核心的状态机方法public static Object fetchUserSummary(ContinuationString completion) { FetchUserSummaryStateMachine continuation; if (completion instanceof FetchUserSummaryStateMachine) { continuation (FetchUserSummaryStateMachine) completion; } else { continuation new FetchUserSummaryStateMachine(completion); } switch (continuation.label) { case 0: continuation.label 1; Object tokenResult getToken(continuation); if (tokenResult COROUTINE_SUSPENDED) { return COROUTINE_SUSPENDED; } continuation.savedToken (String) tokenResult; // fall through case 1: String token continuation.savedToken; continuation.label 2; Object userResult getUserInfo(token, continuation); if (userResult COROUTINE_SUSPENDED) { return COROUTINE_SUSPENDED; } continuation.savedUser (User) userResult; continuation.savedToken null; // fall through case 2: User user continuation.savedUser; Object scoreResult getScore(user.id, continuation); if (scoreResult COROUTINE_SUSPENDED) { return COROUTINE_SUSPENDED; } return user: user , score: (String) scoreResult; } throw new IllegalStateException(非法label); }配套的状态机类static final class FetchUserSummaryStateMachine extends ContinuationString { String savedToken; User savedUser; FetchUserSummaryStateMachine(ContinuationString completion) { super(); this.completion completion; } }以及三个模拟挂起函数public static Object getToken(ContinuationString completion) { return token; // 模拟直接完成 } public static Object getUserInfo(String token, ContinuationString completion) { return new User(token); // 模拟直接完成 } public static Object getScore(String userId, ContinuationString completion) { return 99; // 模拟直接完成 }实际业务里这三个函数往往都会返回COROUTINE_SUSPENDED并稍后手动resume。如果想让这个状态机真正活起来可以把getScore改成返回COROUTINE_SUSPENDED再由别的线程在合适的时候调用continuation.resumeWith(Result.success(99))然后重新调用fetchUserSummary(continuation)。这样你就能亲眼看到状态机从case 2恢复并输出最终结果。5.2 对照源码逐行复盘写完这段Java后我逐行对比过Kotlin编译器生成的反编译结果核心逻辑能对上入口方法先做continuation复用判断。switch分支与label对应。每次调用挂起函数前先改label。挂起函数返回COROUTINE_SUSPENDED时向上传递信号。局部变量存入约定好的槽位。case之间用fall-through加速直通场景。真要说差别就是我手写的版本没有Kotlin编译器那么抠内存槽位。比如token可能和user复用一个槽位编译器能根据作用域做出最优解。手写版本为了可读性用两个字段不影响对状态机运行原理的理解。5.3 最容易写错的三个点及其根因第一fall-through控制失误。如果case 0里getToken没挂起直接返回结果但你没有让它自然落入case 1而是手滑return了调用链就断了。下次带着label1进入函数时savedToken还没被赋值直接NPE。Kotlin编译器不会犯这种错因为它生成的字节码里case 0末尾就是无条件跳转到case 1的代码但手写时最容易漏。第二局部变量保存范围判断错误。判断一个变量是否需要保存标准是它会不会跨越挂起点。如果变量在挂起点之前完全被消费掉了不需要保存如果在挂起点之后还要用就必须找个字段存下来。我一开始写状态机习惯性把所有局部变量都塞字段结果又慢又乱。反过来少存一个变量恢复时直接空指针。这个尺度必须练成肌肉记忆。第三没处理continuation复用。真实Kotlin协程中每次挂起恢复之间用的是同一个状态机对象。手写时如果每次调用都new一个对象暂时看着能跑但一旦涉及真实的resumeWith和多次挂起状态必然丢。这也是为什么反编译代码里那段instanceof判断极其重要。写完这个手写版本之后我再回头看协程源码之前觉得很绕的suspendCoroutine、suspendCancellableCoroutine的源码都豁然开朗了。它们本质上都是在帮你控制COROUTINE_SUSPENDED这个信号让你能在合适的时候手动resume而不是让编译器强制你返回结果。6. 渡劫心得挂起函数调试、崩溃栈阅读和性能避坑6.1 协程崩溃栈为什么长怎么定位业务代码协程的崩溃栈确实吓人动不动几十层。但看多了会发现它是有固定骨架的。典型的协程崩溃栈从底层往上层大概长这样java.lang.ArithmeticException: divide by zero at com.example.MyRepository.fetchUser(MyRepository.kt:23) at com.example.MyRepository$fetchUser$1.invokeSuspend(MyRepository.kt:25) at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(BaseContinuationImpl.kt:33) at kotlinx.coroutines.DispatchedContinuation.run(DispatchedContinuation.kt:108) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1145) ...我一开始看到这种栈习惯性往上翻找业务代码。后来发现规律业务代码一般出现在invokeSuspend帧附近或者往上再几帧。resumeWith和DispatchedContinuation这样的类名代表这是协程恢复链路的部分并不是问题根因。真正抛异常的那一行通常在invokeSuspend帧下面第一个非协程框架类里。学会跳过框架帧定位速度会快很多。6.2 利用label与协程调试工具进行有效断点调试挂起函数时断点打进去后留意continuation对象的label字段尤其是编译器生成的FetchUserSummaryKt$fetchUserSummary$1这类状态机对象。如果label是0说明第一次进入如果是2说明前面已经走过了两个挂起点现在正在恢复后执行。这对排查为什么resume后走到了错误分支非常有用。另外2022年之后的IntelliJ/Android Studio版本对协程调试支持已经比较成熟Dap和Kotlin插件可以直接在调试页面看到当前协程的状态、创建栈和挂起历史。这些工具呈现的信息本质上就是continuation对象和CoroutineContext里各种状态元素的可视化。理解了字节码模型后再用这些功能你会觉得一切都在预料之中不是黑盒操作。6.3 基于字节码的几个性能避坑结论CPS变换是有代价的。每经过一个挂起点至少要经历一次方法返回、一次哨兵值比较如果真的挂起还涉及状态对象分配、resume回调、调度器切换。基于这个模型有几个性能避坑选项值得每次写协程前都过一遍脑子。循环里不要反复调用suspend函数。比如for循环逐条查询数据库、逐条请求接口每个循环都是一次状态机挂起和恢复开销很大能批量查就批量查。别给不需要挂起的函数加suspend。没有真实挂起逻辑的函数虽然也会生成continuation但白白多了一次方法调用和对象分配的代价。如果你的函数只是同步返回一个计算值不需要挂起就不要用suspend修饰。理解Flow本质后再优化。Flow的collect本身就是一个挂起函数collect的block里每次emit都涉及状态机流转。网上很多建议用debounce合并高频事件、用buffer分离生产和消费底层逻辑就是减少collect调用链上的挂起恢复次数减少线程调度开销。说到性能还有一个我自己踩过的坑在Android里Room和Retrofit的suspend支持虽然好用但如果是高频率的极短事务比如轮询一个小状态建议实测一下挂起恢复的开销必要时加一个buffer或者合并策略。不要为了好看把所有同步逻辑都改成suspend毕竟每个suspend函数都在字节码里多了一层状态机。最后分享一点个人习惯建议你每周抽半小时把自己最近写的三四个suspend函数反编译看看。重点看带真实挂起点、局部变量跨挂起点的那几个。看多了之后你对协程的判断会从API能不能跑通变成这几个挂起点会产生多少状态机开销、这个变量要不要存到continuation槽位里。这种视角转变才是真正把协程从黑盒变成白盒的过程。到这一步你这次对挂起函数字节码的透视就算真正渡劫成功了。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。