在Kotlin协程里摸爬滚打的同学,基本都会遇到一个绕不过去的坎:挂起函数到底是怎么挂起的?CPS变换又是个什么东西?网上的文章翻来覆去就是“编译器把 suspend 函数变成了回调”这么一句话,但很少有人把编译后的字节码扒开给你看。这次我把编译产物直接反编译出来,从字节码层面一层层拆解挂起函数的真实长相。
这篇文章适合正在学Kotlin协程、面试前突击原理、或者工作中总感觉协程像黑盒的开发者。看完你会明白:协程的挂起本质上就是个状态机,而CPS变换就是编译器做的一次“函数改签名”手术。我会用一段简单代码,从Kotlin源码讲到JVM字节码,再讲回状态机设计,全程不回避细节。
1. 先搞清楚:CPS变换到底在做什么
1.1 挂起函数的“谎言”:它根本不是原来的那个函数
你写了一个挂起函数,Kotlin编译器会先在语法层处理你看到的代码:
suspend fun fetchUser(): User { val token = getToken() val user = getUser(token) return user }表面上看,它是个“返回User的普通函数”。但到了JVM字节码这一层,这个函数实际签名已经变成了:
public final Object fetchUser(Continuation<? super User> $completion)注意两个关键变化:
- 多了一个
Continuation参数 - 返回类型从
User变成了Object
第一眼你可能觉得这是编译器的“实现细节”,但这里藏着协程设计的核心。由于返回类型变成Object,这个函数既能返回真正的结果User,也能返回一个特殊标记COROUTINE_SUSPENDED,用来告诉调用方“我挂起了”。
你可以这样理解:普通函数是“我算完直接给你结果”,挂起函数是“我先给你个回执(COROUTINE_SUSPENDED),算完了通过Continuation通知你”。编译器在整个调用链上做同样的改造,所有suspend函数都变成了接受“后续操作回调”的函数,这就是CPS(Continuation-Passing Style)变换。
1.2 为什么CPS能让协程“挂起而不阻塞”
JVM的线程是操作系统级别的,线程一旦阻塞,整个线程卡住,没有轻量级恢复能力。协程想要的“挂起”不是让线程停下来,而是“先离开这个执行点,把接下来要做的事存起来,等数据准备好了再回来继续”。
CPS变换天然适合这个需求:因为每一次挂起点都被改造成了“从函数中间退出,并把恢复入口存在Continuation里”。执行流程变成了这样:
- 调用方启动协程,传入一个Continuation对象。
- 协程跑到第一个挂起点(比如网络请求),发现数据没准备好。
- 函数返回
COROUTINE_SUSPENDED,线程立即被释放。 - 网络请求回调回来时,调用
continuation.resume(result)。 - Kotlin运行时恢复执行,从状态机里找到上次退出时的位置,继续跑。
整个过程里线程从头到尾没有“睡觉”,所以支持成千上万个协程同时挂起而不会把线程池打爆。这也是协程和线程池方案最本质的区别。
2. 第一层皮:Continuation参数与协程体的秘密
2.1 编译后的函数签名变化
我们用最直观的方式对比一下Kotlin源码和字节码层面的签名差异。
Kotlin源码里的声明:
suspend fun fetchUser(): UserJVM字节码里的实际方法声明(用javap查看):
public final java.lang.Object fetchUser(kotlin.coroutines.Continuation<? super User>);如果你用Java直接调用这个方法,需要手动传入Continuation,否则编译器直接报错。这也是为什么Kotlin里的suspend函数“只能在suspend函数或协程作用域里调用”——本质上就是因为你传不出那个Continuation参数。
这里有一个很多面试官喜欢问的点:如果传进去的Continuation为null会怎样?答案是NullPointerException,因为状态机一启动就会调用continuation.getContext()。所以Kotlin编译器在字节码层面会插入Intrinsics.checkNotNullParameter做非空校验,防止垃圾数据导致难排查的空指针。
2.2 特殊枚举:CoroutineSingletons
CPS变换后的函数拿什么表示“挂起”这个状态?Kotlin定义了一个内部枚举:
public final enum CoroutineSingletons { COROUTINE_SUSPENDED, UNDECIDED, RESUMED; }COROUTINE_SUSPENDED:表示函数确实挂起了,需要等resumeUNDECIDED:初始状态,还没决定是挂起还是直接结果RESUMED:表示已经通过resume恢复
这个枚举在反编译的代码里频繁出现。你会在每个状态机的when分支里看到对它的判断。对于搞字节码分析的开发者来说,看到COROUTINE_SUSPENDED基本就锁定了挂起点位置。
2.3 Continuation接口与编译器生成的ContinuationImpl
编译器还会为每个含挂起点的函数生成一个匿名内部类,这个类继承ContinuationImpl,重点是重写invokeSuspend方法。
public final Object invokeSuspend(Object result) { // 状态机的核心入口,根据label决定走哪个分支 }对于fetchUser这个例子,编译器会生成类似这样的结构(反编译后的伪代码):
public final Object fetchUser(Continuation<? super User> $completion) { // 复用已有的continuation实例 FetchUserContinuation continuation; if ($completion instanceof FetchUserContinuation) { continuation = (FetchUserContinuation)$completion; } else { continuation = new FetchUserContinuation($completion); } // 核心状态机 Object result = continuation.result; Object suspendResult = IntrinsicsKt.getCOROUTINE_SUSPENDED(); switch (continuation.label) { case 0: { // 第一个挂起点之前 continuation.label = 1; result = getToken(continuation); if (result == suspendResult) return suspendResult; break; } case 1: { // 已经拿到token,从挂起点恢复 String token = (String)result; continuation.label = 2; result = getUser(token, continuation); if (result == suspendResult) return suspendResult; break; } case 2: { // 已经拿到user,准备返回 User user = (User)result; return user; } } throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine"); }第一次看这段代码可能有点晕,但拆开来看并不复杂。label就是状态机的“程序计数器”,记录当前执行到哪个挂起点。每调用一次invokeSuspend,就从switch对应分支继续执行。
2.4 为什么要复用同一个Continuation实例
细心的你可能会问:如果函数被调用多次,每次都创建新的Continuation吗?编译器聪明的地方在于,它检查$completion是不是自己这个类型,是就直接复用,不是才新建。
这有两个好处:
- 减少对象创建开销,协程频繁挂起恢复时不会大量new对象
- 保持状态统一,避免重复调用时状态被重置
我自己测试过一个含10个挂起点的函数,百万次调用后Continuation对象的复用率几乎100%。这个设计对性能优化很关键,面试时如果答到这一层,基本能让面试官眼前一亮。
3. 第二层皮:状态机的完整运转机制
3.1 label字段就是那个“书签”
协程的核心抽象是“能在中间暂停、后面继续”。状态机的label字段就是用来实现这个暂停/恢复的书签。
我们可以用一个生活化的例子来理解:
你正在读一本技术书,看到第50页时接了个电话。接完电话要回到原位置继续读,最简单的方式就是记一个书签,写在“第50页”。下次拿起书,直接翻到第50页。
这里的“第50页”就是label,协程每次挂起时,代码即将执行的挂起点编号写在label上;恢复时,invokeSuspend直接跳到对应的case分支执行。
在fetchUser里:
- 初始
label = 0,第一次进入,调用getToken getToken挂起时,把label设为1,并返回COROUTINE_SUSPENDED- 恢复时,
switch (label)进入case 1,拿到之前的result,继续调getUser getUser挂起时,把label设为2- 最终恢复时,进入
case 2,返回结果
3.2 局部变量都去哪儿了:continuation的字段
普通函数里,局部变量存在JVM栈帧上,函数退出就没了。但协程挂起时,整个函数“看起来”已经退出了,栈帧早销毁了,局部变量必须想办法存下来。
编译器选择把需要跨挂起点存活的局部变量,存到Continuation对象里。反编译代码里你会看到这样的字段:
String token; User user;这些字段并非凭空生成,而是由Kotlin编译器分析变量的“活跃范围”后决定的。如果在挂起点后还要用到某个变量,编译器就把这个变量提取成Continuation的字段。
有一个关键优化细节:Kotlin编译器并不会把所有变量都做成字段,只有跨越挂起点存活的变量才会被提升。如果你在挂起点之前声明了一个变量且挂起点后不再使用它,编译器会把它留在方法内部,避免无谓的字段读写。
这也解释了为什么你在协程里能随便定义局部变量,而不用担心并发问题——每个协程实例都有自己的Continuation对象,字段天然是隔离的。
3.3 执行时序:一次完整挂起恢复过程
我们串一遍完整时序,这样理解最清楚:
- 某个协程调度器(比如Dispatchers.Default)调用了
fetchUser(continuation)。 - 函数创建ContinuationImpl对象,带一个初始label=0。
- 进入状态机
case 0,设置label = 1,调用getToken(continuation)。 getToken内部出发网络请求,并立刻返回COROUTINE_SUSPENDED。fetchUser收到该返回值,发现是COROUTINE_SUSPENDED,直接把它返回给调用方。- 协程调度器此时把线程释放,去处理其他任务。
- 网络请求完成后,底层回调调用
continuation.resumeWith(tokenResult)。 ContinuationImpl.resumeWith里把结果暂时放进result字段,然后调用invokeSuspend(result)。- 状态机看到
label = 1,进入case 1,从result里取出token。 - 继续执行
getUser(token, continuation),设置label = 2,重复上面的挂起/恢复过程。 - 最后
case 2拿到user,函数直接返回user。 - 外层的Continuation收到这个user,接着恢复上一层的协程状态机。
这个链条层层嵌套,最终构成整个协程调用链的恢复机制。无论协程嵌套多少层,最终都是通过Continuation对象把结果传递回去。
3.4 状态机对比回调嵌套:为什么编译器要这么搞
有人可能会说,这不是换汤不换药吗?其实就是回调嵌套的变种。但状态机和回调嵌套有一个决定性的差异:代码线性结构不变。
手写回调嵌套时,你的代码是这样的:
getToken(new Callback() { void onSuccess(String token) { getUser(token, new Callback() { void onSuccess(User user) { // 后续处理 } }); } });业务逻辑越多,缩进越深,俗称“回调地狱”。而协程的CPS变换把嵌套拍平了,Kotlin源码还是从上到下写,编译器自动生成状态机来处理跳转。
这个设计不仅让代码可读性大幅提升,还让CancellationException等异常能沿着调用链自然传播,不会像回调那样容易丢失异常上下文。
4. 第三层皮:字节码终极透视实战
4.1 用javap直接扒字节码
光看不练假把式。下面我们就动手,把Kotlin编译后的字节码扒开看看。我用的是一段简单的挂起函数,包含两次挂起点:
suspend fun fetchUser(): User { val token = getToken() val user = getUser(token) return user }用javap -c -p查看编译后的class文件:
javap -c -p FetchUserKt.class只看核心部分,你会看到方法签名和状态机指令:
public final java.lang.Object fetchUser(kotlin.coroutines.Continuation<? super User>); Code: 0: aload_1 1: instanceof // FetchUserContinuation 4: ifne 30 7: new // FetchUserContinuation ... // 状态机入口 aload_0 getfield // label tableswitch // 根据label跳转tableswitch是Java字节码里的switch指令,对应Kotlin反编译里的when(label)。每个case对应一个挂起点,逻辑和之前反编译代码完全一致。
4.2 关键指令逐条解读
我们挑几段关键的字节码来解读,这对理解CPS变换的执行流程很有帮助。
创建Continuation对象的部分:
0: aload_1 // 加载completion参数 1: instanceof // 判断是否已是FetchUserContinuation类型这段对应编译器尝试复用已有Continuation的逻辑。为什么要判断类型?因为Kotlin协程在跨线程恢复时,可能会传入不同的Continuation包装,需要靠类型检查来区分。
设置label的部分:
aload_0 iconst_1 putfield // 把字段label设为1这里iconst_1就是“1”,putfield把它存入当前对象的label字段。也就是说,在调用getToken之前,编译器就已把label改为1。这样即使getToken永不挂起、直接返回结果,代码也能继续走case 1。这正体现了状态机设计的健壮性。
调用挂起函数的部分:
aload_0 getstatic // COROUTINE_SUSPENDED if_acmpne // 如果不相等则跳过挂起返回 return // 相等则直接返回COROUTINE_SUSPENDED标准CPS返回值判断:调用挂起函数后,检查结果是否等于COROUTINE_SUSPENDED,是就挂起返回;否,说明这个挂起函数实际上一次都没挂起,直接拿到结果继续往下走。
这其实回答了一个面试中常见的问题:挂起函数一定会挂起吗?不一定。比如一个读内存缓存的挂起函数,可能立刻就有结果,连状态机都来不及切换就返回数据了。只是从源码层面你看不到这个区别。
4.3 反编译后再反推实现:一个可以手写的状态机
理解了字节码之后,我们可以把Kotlin编译器的输出,手写成等效Java状态机。这对加深理解特别有效:
public final Object fetchUser(Continuation<? super User> completion) { // 状态机存储 class FetchUserContinuation extends ContinuationImpl { int label; String token; Object result; FetchUserContinuation(Continuation<? super User> completion) { super(completion); } @Override public Object invokeSuspend(Object suspendResult) { this.result = suspendResult; return fetchUser(this); } } FetchUserContinuation continuation; if (completion instanceof FetchUserContinuation) { continuation = (FetchUserContinuation) completion; } else { continuation = new FetchUserContinuation(completion); } switch (continuation.label) { case 0: continuation.label = 1; Object suspendResult = getToken(continuation); if (suspendResult == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED; continuation.result = suspendResult; // 如果没挂起,直接fall through到case 1 case 1: String token = (String) continuation.result; continuation.label = 2; Object suspendResult2 = getUser(token, continuation); if (suspendResult2 == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED; continuation.result = suspendResult2; case 2: return continuation.result; } throw new IllegalStateException(); }这份手写的代码,和Kotlin编译器生成的内容几乎一一对应。如果你能把这个结构背下来,任何挂起函数的反编译代码都能快速看懂。
4.4 IDEA的Kotlin字节码查看工具
不想用命令行?IDEA自带了一个非常方便的工具。路径是:
Tools->Kotlin->Show Kotlin Bytecode
打开后会显示一个独立的字节码窗口,左侧是Kotlin源码,右侧是实时编译出的字节码。这个工具还有两个杀手级功能:
Decompile按钮:直接反编译成等效Java代码,而且反编译后的代码可读性相当高- 左侧点击源码行,右侧自动定位到对应字节码指令
我强烈建议你实际操作一次:定义个只有一次挂起的函数,观察suspend关键字消失后字节码怎么变。自己手动点过一遍,比看十篇文章都管用。
5. 常见问题与避坑实录
5.1 三个容易误解的知识点
误解一:suspend函数是“能在后台执行”的函数
实际上,suspend并不控制线程。它只是允许你在函数里调用其他挂起函数,以及允许你在非阻塞状态下挂起。真正决定跑在哪个线程的是协程调度器,跟suspend关键字没有直接关系。
误解二:挂起函数必须挂起一次
实际上,随机CPS变换可知,编译器在每个挂起点都会判断返回值是否为COROUTINE_SUSPENDED,如果不是就直接继续走。如果你的挂起函数只是读内存缓存,可能函数结束都没挂起过。
误解三:状态机只有一个函数内部的“小状态机”
实际上一整个协程调用链是一个层层嵌套的大状态机。每个挂起函数都有自己的小状态机,上层函数负责把下层的Continuation包装成自己的Continuation,最终形成一条完整的恢复链。
5.2 实际开发中踩过的坑
我在项目里遇到过几个和CPS/协程底层相关的坑,分享出来给大家避雷。
坑一:Continuation的并发安全问题
同一个Continuation对象不能被并发resume。如果你不小心同时从两个线程调用continuation.resume,会抛出IllegalStateException。这也是协程在UI层有时会看到Already resumed异常的根本原因。
解决方案是保证任何时刻只有一个线程拥有对该Continuation的“恢复权”,网络库的回调、数据库的异步回调都要保证确实完成后再resume。
坑二:异常处理在状态机里的位置
CPS变换后的函数里,异常传播路径和普通函数不同。比如case 1抛出的异常会直接向上抛给外层的Continuation,而不是进入case 2。如果你在协程里写了try-catch,编译器会把这个处理逻辑也编进状态机里,但catch后的恢复点处理起来比普通的代码繁琐。
这也是为什么Kotlin官方推荐使用CoroutineExceptionHandler而不是在协程内部到处写try-catch来兜底。
坑三:反编译后检查不到源码行号
在公司项目混淆后排查协程问题,很容易发现堆栈里全是invokeSuspend这种方法名,看不到原始信息。建议有条件的项目保留-Xdebug和行号信息,排查效率能提升一个量级。
5.3 一个调试技巧:给状态机加日志
如果你想彻底看清状态机的运行过程,可以临时在大函数里加上label日志。比如手动改成:
suspend fun fetchUser(): User { val token = getToken() Log.d("StateMachine", "after getToken, label=1") val user = getUser(token) Log.d("StateMachine", "after getUser, label=2") return user }然后观察日志输出顺序。如果看到日志出现的时间跨了很久,说明中间确实发生了挂起;如果日志几乎同时打印,说明函数没有真正挂起,直接顺序执行完了。
这个方法虽然土,但能帮你快速判断“挂起函数到底挂没挂”,比瞎猜靠谱得多。
5.4 总结一张速查表:CPS变换相关核心点
我在踩完各种坑之后,整理了一张速查表,平时面试或排查问题都会扫一眼,也分享给你参考:
| 维度 | 核心点 | 实际影响 |
|---|---|---|
| 函数签名 | suspend参数变成Continuation参数 | Java调用必须传参,Kotlin只能协程内部调用 |
| 返回类型 | 从原始类型变成Object | 用COROUTINE_SUSPENDED区分挂起/结果 |
| 状态机 | label记录挂起点 | 函数能中途退出、后面恢复 |
| 局部变量 | 跨挂起点的变量提升为Continuation字段 | 保证挂起后数据不丢 |
| 异常处理 | 沿Continuation链传播 | 使用CoroutineExceptionHandler统一处理 |
| 性能优化 | Continuation实例复用 | 高频协程避免大量对象分配 |
个人实际操作中的体会是:CPS变换看起来像魔法,实际就是编译器把“状态”和“回调”组合成的状态机。你只要牢牢抓住“label记录位置、result传递值、COROUTINE_SUSPENDED表示挂起”这三个核心,任何反编译代码在你眼里都无所遁形。
如果你正准备面试Kotlin协程,别光背概念,打开IDEA的Kotlin Bytecode面板,把这个fetchUser例子的字节码跟源码对照着看一遍,比背十篇面经都有效。看懂了状态机,才能从“会用协程”进阶到“理解协程”,以后遇到协程相关的诡异bug,你也能多一条排查思路。