前言
时光飞逝,距离上次整理Kotlin面试题,一晃两年过去了,恍恍惚惚,已经隔世。这期间,依旧能看到不少同学阅读、收藏笔者之前整理的Kotlin面试题目,能够帮到大家还是挺开心的,授人以鱼不如授人以渔。转眼又快要进入金三银四了,不少人(当然包括笔者)也开始考虑换个环境,重新投入到面试准备中。已经2026年了,随着协程在实际项目中的广泛使用,面试中对Kotlin协程的考察也在不断升级——从最初的 API 使用,逐渐深入到运行机制、调度模型,以及异常与取消等边界问题。
很多时候,这些问题本身并不算多复杂,但一旦被追问“为什么”,答案就容易变得模糊。如今市场早已不缺能熟练调用API的初中级工程师了,再加上 AI 的兴起,现在越发成熟,甚至于日常工作都离不开AI的帮助,可想而知技术门槛又被进一步抬高。生活不易,想要混口饭吃,把基础打牢的同时,还得兼顾当下技术的浪潮,避免一问三不知。所谓知其然,更要知其所以然。
因此,笔者在原有内容的基础上,结合这段时间的实践经验,并参考现阶段网络资料与 AI 的整理,又梳理了 三十多道 Kotlin 协程进阶问题。 由于整体篇幅解答起来实在太过冗长,所以分为了几个篇幅。这些问题尽量覆盖了面试中高频出现、却又容易被忽略的关键点,希望通过问题本身,帮助自己,也帮助大家,在协程相关的考察中,真正做到心中有数。
以下是笔者的Kotlin面试指南系列:
下面开始我们的第一篇章的几个问题
1. Kotlin 协程与线程的本质区别是什么?为什么说协程是“轻量级线程”?
快问快答
这是一个非常经典、也非常容易被“说模糊”的问题,这里笔者先给出结论
线程是操作系统管理的执行单位,而协程是由Kotlin运行时程序管理的的可挂起计算单元,前者属于是内核态,后者是用户态 。协程并不等于线程,但它必须要跑在线程上面的,因为创建成本低、切换快、数量多,所以常常被戏称为”轻量级线程“
下面我们来分层详细补充下这个结论
线程的本质:操作系统的”打工人“
线程是操作系统级别的最小单位,由操作系统内核创建和管理,真正参与到CPU调度,可以简单理解为:线程=操作系统派给CPU的“天命打工人"
一个线程中至少包含:
- 独立的调用栈(Stack)
- 程序计数器
- 寄存器上下文
- 内核态调度信息
可以看到,创建一个新的线程,但是付出的代价相对是有点大,如下表所示
| 创建成本 | 高(分配栈内存,通常是MB级别的) |
|---|---|
| 切换成本 | 高(用户态 ↔ 内核态切换) |
| 数量 | 存在上限(几百-几千不等) |
| 阻塞 | IO/sleep会阻塞整个线程 |
一旦该线程发生了阻塞了,CPU就无法再利用它做别的事情了。
协程的本质:可挂起的”任务脚本“
对于协程来说,它不属于操作系统的概念了,而是Kotlin语言层面的东西。拿官方的话术来说,协程是一段可以被挂起(suspend)并在之后恢复(resume)的代码 。不同于线程,协程属于用户态,由Kotlin编译器+kotlinx.coroutines库实现,OS并不知道协程的存在的。
就拿最简单的,我们启动一个协程,然后在里面做某些事情
launch {
val data = fetchData()
showData(data)
}
表面上是同步的写法,但实际上:
- fetchData函数方法可以被挂起
- 在这个线程中可以同时执行的别的协程
- 等IO完成后回来再继续执行
值得注意的是,虽然我们常常把协程和线程进行比较,但要记住协程不是脱离线程运行的,它是一定要运行在线程之中的。当然,一个线程中可以存在多个协程的,每个协程的执行时间由用户端决定分配, 线程负责“真正跑”,协程负责“切换和挂起逻辑”
为啥说协程是”轻量级线程“?
这个问题相信各位同学都不陌生了,为了方便大家理解,才有了这个形象说法,可以说协程之所以”轻量“,因为它不由操作系统管理,不需要独立线程栈,切换发生在用户态,只保存极少的状态。
不够形象?首先我们明确一个点,在计算机里什么东西才叫”重“?通常体现在三点:1. 创建成本高 2. 内存占用大 3.切换代价高
好家伙,这三点线程全给占了,协程的出现刚好弥补了这些,我们从两方面说明下
-
从使用体验上来看,很像线程
scope.launch { doWork() }这个写法上非常像线程,它可以在里面并发执行,可以切换上写文,可以取消,等待等等
-
从实现成本上看,极其轻量
我们可以在一个线程中创建成百上千个协程,它的成本相对来说是比较低的
对比项 线程 协程 管理者 操作系统 Kotlin程序运行时 内存占用 MB 级 KB 级 创建数量 少 非常多 切换方式 内核态 用户态 切换成本 高 极低
因为协程的创建、切换和销毁成本都非常低,一个线程可以承载成千上万个协程,“轻”的不是功能,而是成本。此外由于协程调度发生在用户态,因此可以用更少的线程处理更多任务,这也是为什么说协程是“轻量级线程”了。
2. Kotlin协程是 stackless 还是 stackful,这种设计有什么影响?
快问快答
Kotlin协程是典型的 "无栈协程" 实现。这意味着:它本身不拥有自己的调用栈结构,而是通过编译器生成的状态机 + Continuation 保存程序状态。 不过值得思考的是,在底层JVM层面,调用普通函数同样会用JVM栈,但这并不改变Kotlin协程本质总体来说是 stackless。
此外,如果严格来说的话,Kotlin 协程是 stackless coroutine + JVM 原生栈配合的混合模型实现。但是协程的暂停和恢复不靠真实挂载的栈帧,而靠状态机与Continuation。
在这种设计下,协程并不保存真实的调用栈,而是由 编译器把 suspend 挂起函数改写成状态机, 在挂起点把必要状态封装进 Continuation对象存到堆上,恢复时再由调度器把它重新放回线程执行。这样以来的话
- 内存占用低,可以创建百万级别协程,对高并发特别友好
- 不会阻塞线程,但调用栈不会跨suspend点存在
好的,那么下面我们来详细拆解下这个问题
什么是Stackless和Stackful?
首先我们要明确一点的是,一般协程分为了两种,有栈协程和无栈协程,笔者分别解释下
有栈协程
想象它像 真正的小线程,每个协程都有自己的执行栈(类似线程栈),可以在任意深度的调用中任意位置暂停(yield) 。
一些典型的例子像是某些C/C++ 协程库 、Go Coroutine、Lua coroutine等。
-
它的优点在于:
- 可在任意调用层级暂停
- 能保存真实调用栈
-
它的缺点在于:
- 每个协程需要占用栈内存(开销大)
- 实现复杂度高
无栈协程(Stackless)
它没有自己的栈空间,而是 由编译器转换成状态机 + Continuation,再在需要挂起的地方保存状态。典型例子像是Kotlin、JavaScript async/await、C# async/await、C++20协程一样。
-
它的优点在于:
- 非常轻量,不需要为每个协程分配真实栈
- 能支持成千上万协程同时存在
- 内存占用小
-
它的缺点在于:
- 只能在特定的suspend点(编译器知道的地方)才能挂起
- 挂起点之外无法"随意暂停"
怎么说呢,是不是有点抽象难以理解,下面我们来打个比喻,想象一下
Stackful 协程就像一个电影拍摄团队
- 每个演员、工作人员都随身带着完整的道具和场景布置(对应完整栈)
- 无论拍摄到哪,都可以随时停下来继续上一秒的状态
- 停下来后,几乎可以立刻从任意一帧继续拍摄
- 缺点:道具重、场景大,搬运成本高,随便暂停/切换成本大
Stackless 协程就像剧本排练演员
- 演员不带道具,只带一个剧本和记录表(对应保存的少量状态)
- 暂停时,只要记下当前排练到哪一页、哪一行即可
- 轻量、容易随时切换任务
- 限制:只能在明确的“剧本节点”停下来(检查点)
Kotlin 协程为啥是Stackless?为啥不能是Stackful呢?
我们上面简单了解了下有栈协程和无栈协程的区别,那有同学就会有疑问了,为啥Kotlin协程说是StackLess的,而不能是Stackful的呢?
请允许我从Kotlin协程的核心机制开始说起,众所周知,Kotlin协程编译后不是传统递归,而是把每个 suspend fun 和其调用点改写成 连续状态的状态机(state machine) 。当碰到挂起点(suspend) 的时候,它不会真的把整个调用栈存起来,反而只存需要的变量状态和 Continuation对象,其中Continuation简单来说就是一个携带状态的对象,用来"标记我们要恢复执行的位置和数据"。
那么也就是说:
- 所谓挂起 = 状态保存到heap
- 所谓恢复 = 从heap按状态机器继续执行下去
这就是所谓无栈协程stackless的定义:没有用真正的栈用来保存执行状态
另外,为啥不能是Stackful呢?原因在于Kotlin还是基于JVM的,要实现真正的 stackful coroutine 其实是不太可能的,原因如下:
- JVM虚拟机是不直接暴露切换栈的机制
- 真实栈切换需要改变执行栈位置,这在JVM bytecode里面是很难做到的 。
所以Kotlin想了个聪明办法:制造状态机 + Continuation,这样无需修改JVM,也能实现挂起/恢复。
那么这种设计有什么影响?
属于是超轻协程
因为不需要实际栈,所以:
- 在有限的内存中可以创建中成千上百个协程,不会有过重的消耗
- 内存占用比线程低 10 至 100 倍
- 创建/销毁速度非常快,这对高并发特别友好
只能在挂起点挂起
这属实是stackless协程最大的局限了:
- 不能随便在非 suspend 函数里暂停
- suspend 函数调用链必须清晰
- 但是,编译器已经帮你生成好了状态机,所以你不用手写这些逻辑
一句话:要挂起,得走 suspend 或 async 的门
写起来更像"同步代码"
虽然是异步执行,但写起来就像同步一样
scope.launch {
val res = networkCall()
println(res)
}
suspend fun networkCall() {
...
}
这一点相对来说,比回调地狱或复杂的线程池好使多了
有趣的是,虽然Kotlin协程 本质stackless,但在suspend之前函数调用仍然使用JVM stack , 这叫混合模型 —— 状态机 + JVM 栈混合执行。 状态机负责存储挂起位置,而JVM栈负责函数内部的局部执行。
就说到这了,现在用个表格简单总结下:
| Kotlin 协程 stackless 还是 stackful? | Stackless(无栈) (混合模型) |
| 怎么实现挂起? | Continuation + 状态机 |
| 为什么不用 stackful? | JVM 无栈切换支持 + 内存效率更好 |
| 有什么影响? | 轻量、性能好、只能 suspend 点挂起 |
3. suspend 函数的底层实现原理是什么?它如何通过 Continuation 实现挂起与恢复?
快问快答
还是老规矩,笔者先将该问题总结下。
suspend函数本质实现原理主要是,编译器把它改写成「带 Continuation参数的普通函数 + 状态机」,它在挂起时保存执行状态并返回 COROUTINE_SUSPENDED,恢复时通过 Continuation.resumeWith() 从上次挂起点继续执行,在这个期间不阻塞线程。
下面笔者来详细展开说说
什么是 suspend函数?
这里先用一句话简单说明下,suspend函数就是可以“暂停自己执行,然后等合适的时候再唤醒继续执行的特殊函数。”
啥意思呢,它和普通函数有什么区别呢?
- 普通函数调用是 同步阻塞 → 执行完才返回结果;
- 而suspend函数想做的是 “不阻塞主线程的异步逻辑写成同步风格” 。
是不是比较抽象,下面笔者打个比方把,比如说我们点外卖
- 普通函数 → 你就默默地等在门口(当然现实中很少这种),看着外卖小哥路上走了半小时 → 等到送到了再继续吃饭,中间不会去干任何事情
- suspend 函数 → 你先去打游戏 或者去干些别的事情,外卖到了小哥喊一声你回来取 → 继续吃饭,美滋滋
值得注意的是挂起函数不是新的线程,它只是把当前执行状态 “保存起来”,到点再恢复。
suspend的底层核心秘密武器:Continuation
经过剖析,我们知道suspend函数的底层其实 被编译成带 Continuation 参数的普通函数
编译后的情况
我们像这样写一个挂起函数
suspend fun foo(): Int { … }
经过编译后,编译器帮我们变成如下函数
fun foo(continuation: Continuation<Int>): Any?
发现了没?这里的返回值不是 Int,而是 Any? ,因为它可能返回两种情况,如下表所示
| 情况 | 返回值 |
|---|---|
| 正常立即完成 | int(装箱成 Any) |
| 挂起中 | 特殊标记 —— COROUTINE_SUSPENDED |
那么Continuation到底是啥东东?
我们把Continuation理解成一个 “执行状态的存钱罐 + 回复回调” 。下面结合源码来简单看下
可以看到,它内部有两个重要东西:
interface Continuation<T> {
val context: CoroutineContext
fun resumeWith(result: Result<T>)
}
我们可以想象Continuation像下图这样
那么suspend 具体如何运作的呢?
下面我们还是用一个简单例子来模拟它的执行:
suspend fun doWork(): String {
println("start")
val result = fetchFromNetwork() // 假装这个是挂起点
println("got $result")
return result
}
底层被编译成类似以下这样的代码:
fun doWork(continuation: Continuation<String>): Any? {
println("start")
val r = fetchFromNetwork(object : Continuation<String> {
override fun resumeWith(result: Result<String>) {
// 网络返回时回来执行
}
})
if (r == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
println("got $r")
return r
}
具体的流程图所下所示:
整体流程已经非常清晰明了了,此外,我们再思考下,为什么挂起后不阻塞线程?
因为编译器在遇到挂起点时会把当前栈帧展开成状态机,每个挂起点变成“状态编号”,类似这样子:
state = 0 // start
...
state = 1 // next
...
于是乎,函数不会真正阻塞线程,只会 保存状态,返回 COROUTINE_SUSPENDED,线程继续做别的。等到异步结果回来后,调用Continuation.resume ,然后再继续执行后面的状态!
其次就是它的关键魔法:状态机转换,可以看到编译后的状态机看起来大概像这样,具体的后面问题会详细说明的
fun doWork(cont: Continuation<String>): Any {
when (cont.label) {
0 -> {
println("start")
cont.label = 1
val tmp = fetchFromNetwork(cont)
if (tmp == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
}
1 -> {
val result = cont.result // 网络回来
println("got $result")
return result
}
}
}
其中Continuation就是相当于“挂起 & 复活的钥匙”,它做了三件事情,上文也提了一嘴,做了一个表格简单总结下:
| 功能 | 解释 |
|---|---|
| 保存现场 | 保存当前执行点 & 局部变量 |
| 提供 resumeWith() | 等异步完成后继续 |
| 持有 CoroutineContext | 控制调度(主线程/IO/默认) |
下面我们简单总结下这个问题
- suspend函数底层是编译成带 Continuation 的普通函数
- 挂起本质上是:保存状态 & 返回特殊标记
- 恢复则由 Continuation.resumeWith() 继续执行状态机
- 执行过程中不会阻塞线程,是 协程轻量非阻塞 的核心
4. 协程启动后真正执行挂起/恢复的流程(状态机如何工作)?
快问快答
这个问题相对来说真的有点复杂了,一时半会儿也没法详细展开说明,这里笔者只是将大致流程梳理一下,通过不断问问题的方式,让大家层层递进,另外具体详细的内容同学们可自行扩展。老样子,还是先上总结。
协程启动后,suspend 函数经过 CPS(Continuation Passing Style)转换,它被编译成一个状态机类, 每个挂起点等于一个 label,它真正的执行入口是 invokeSuspend() , 如果此时挂起了,那么返回 COROUTINE_SUSPENDED,如果恢复了会调用 invokeSuspend() 。
其实简单来说,协程启动后代码是不会一次性跑完的,而是通过 CPS 转换成状态机,由 invokeSuspend 驱动执行, 每次挂起和恢复本质都是状态机(State Matchine)的一次“跳转”。
下面笔者简单展开说说
协程从“启动”开始发生了什么?
首先,我们从最常见最初的代码开始说起:
GlobalScope.launch {
val data = loadData()
show(data)
}
乍一看,这段代码很容易让人产生一种错觉:是不是launch一调用,代码就立刻开始执行,等跑完就结束了。
但真实发生的,其实完全不是这样的,协程的执行过程远比这看起来要“拐弯抹角”得多。
launch并不是直接执行你的代码
它干的第一件事就是:创建了一个协程对象,更确切的来说,launch会创建一个实现了 Continuation的协程对象,用来承载后续整个协程的执行状态。
这个对象呢,可以理解为状态机的载体,或者说是之后挂起与恢复的核心对象,大概长这样(简化伪代码):
class XxxCoroutine(
completion: Continuation<Unit>
) : SuspendLambda(completion) {
override fun invokeSuspend(result: Any?): Any? {
// 状态机在这里
}
}
可以看到,我们具体的协程代码,全都被塞进了invokeSuspend()里面了。
为什么说 invokeSuspend是协程的“心脏”?
看到网上有人这么说,invokeSuspend 是协程的“心脏”,这个说法其实非常形象,而且并不夸张。
我们只需要记住一句话就行:invokeSuspend() 是协程真正执行业务逻辑的唯一入口。
无论协程处于哪种阶段:
- 第一次启动协程
- 在某个挂起点被恢复
- 甚至是异常恢复
最终,都会回到 invokeSuspend() 方法中继续执行。
既然协程代码都跑在 invokeSuspend() 里,那么有同学就会问了,为什么代码可以在中途“暂停”,而不是一次性执行完?
这就要引出协程中的CPS(Continuation Passing Style) 。
CPS:Continuation Passing Style到底干了啥?
普通函数的执行方式
我们先来看下普通函数是怎么执行的
fun foo(): Int {
return bar()
}
可以看到,普通函数依赖 返回值 把结果交给调用方,一旦函数开始执行,就必须一路跑到结束。
CPS核心思想
我们同样写个伪代码看下
fun foo(continuation: Continuation<Int>) {
bar { result ->
continuation.resume(result)
}
}
由于源码相对较多,这里就不一一贴出来了,只要知道CPS核心思想就足够了,它不再通过 return 返回结果,而是把“下一步怎么执行”交给Continuation。
suspend函数里的CPS转换
在 suspend 函数中,CPS主要干了两件事:
- 给函数多加了一个 Continuation参数
- 把后续逻辑拆解出来,交给Continuation继续执行
但问题也随之而来:一个suspend函数里可能有多个挂起点,恢复时,Continuation是怎么知道该从哪一行继续?
答案呼之欲出:那就是状态机
状态机:协程“不死”的秘密
接下来看一个最简单的suspend函数:
suspend fun demo() {
println("A")
delay(1000)
println("B")
}
但在编译器眼里,这一句不再是一个普通的挂起函数了,而是一个状态机类,编译器生成的伪代码如下所示
class DemoContinuation(
completion: Continuation<Unit>
) : ContinuationImpl(completion) {
var label = 0
override fun invokeSuspend(result: Any?): Any? {
when (label) {
0 -> {
println("A")
label = 1
if (delay(1000, this) == COROUTINE_SUSPENDED) {
return COROUTINE_SUSPENDED
}
}
1 -> {
println("B")
return Unit
}
}
return Unit
}
}
这里的核心点只有两个
- label:记录当前执行到哪一步
- invokesuspend() : 根据label标志决定执行哪一段逻辑
到这里,协程已经具备了“记住执行位置”的能力。接下来,我们看看 挂起到底是在什么时候发生的。
真正的“挂起”是怎么发生的?
首先,有一个必须先澄清的误区:挂起 ≠ 阻塞线程。 挂起发生时,线程并不会被卡住。
以最常用的delay() 为例子,我们来看下挂起的真实流程
- invokeSuspend() 执行到delay
- delay注册一个定时器
- 返回 COROUTINE_SUSPENDED
- invokeSuspend() 立刻结束
- 线程被释放,去干别的活
所谓“挂起”,本质就是一次提前返回。就像协程老哥说:“我先暂停一下,你把我现在的状态记好,一会儿有人喊我,我再接着演。”
那“恢复”又是怎么发生的?
既既然协程已经提前返回了,那它是怎么继续执行的?
还是以delay为例子,当我们异步任务完成的时候,也就是这个延时定时器到点后会调用
continuation.resume(Unit)
接着我们来看看resume背后发生了什么事
- resume() → resumeWith()
- 调用到原来的Continuation
- 再次调用invokeSuspend()
- 此时将label = 1
- 从上次挂起点继续执行
值得注意的是,恢复 ≠ 回到原线程;也就是恢复在哪个线程,取决于CoroutineDispatcher,这个后续文章中会说到,这里就不作过多解释。
invokeSuspend为什么能反复调用?
现在回到之前的一个关键问题:为什么多次调用 invokeSuspend() ,不会把代码从头重新执行一遍?
答案就在它的设计上。
invokeSuspend() 并不是一个普通函数,而是一个“可重入的状态机执行函数”。
每次调用的时候,根据当前的label标志位,决定从哪一段逻辑继续执行
我们可以把它理解为:一个自带“执行记忆”的函数。
总的来说,协程启动后并不会“一口气跑完”,而是被编译成一个由 invokeSuspend() 驱动的状态机;每一次挂起和恢复,都是状态机的一次跳转;遇到挂起点时提前返回并保存状态,恢复时再次进入同一个状态机,依靠 label从上一次中断的位置继续执行。整个过程不阻塞线程,只是在用户态保存和恢复执行状态。
以上就是协程挂起 / 恢复真正发生的全过程。
参考资料
- scaler.com - Stackless vs Stackful Coroutines
- Stack Overflow - Are Kotlin coroutines stackless?
- bennyhuo.com - Kotlin 协程原理
- 51CTO博客 - 协程详解
- Kotlin 官方文档
- Stack Overflow - Kotlin coroutines implementation
- geeksforgeeks.org - Kotlin Coroutines
我们下一篇见
祝你早安午安晚安
顺颂时祺,秋绥冬禧