Android面试-Kotlin Coroutines(协程)
Kotlin协程与Java线程的关系与区别
你可以记住一个最直观的生动比喻:线程是“公交车”,协程是“乘客”。
-
Java 线程(Thread): 是操作系统(OS)级别的概念,由系统内核进行调度。它是真正干活的执行者。
-
Kotlin 协程(Coroutine): 是语言级(用户态)的概念。协程本身不能脱离线程运行,协程本质上是一套运行在 Java 线程池上的“任务框架”。一个线程(公交车)可以并发搭载成百上千个协程(乘客)。
2. 核心区别:挂起(Suspend) vs 阻塞(Block)
这是它们最大的区别,也是协程被称为“轻量级线程”的灵魂所在。
-
Java 线程的阻塞(Block): 假设公交车遇到红灯(比如发起网络请求),整辆车只能停在原地死等,这期间这辆车什么也干不了。这就叫线程阻塞。
-
协程的挂起(Suspend): 同样遇到红灯,乘客(协程)会主动下车去旁边等(保存当前执行状态),把公交车(线程)让出来去接载其他乘客(执行其他协程)。等红灯变绿(网络请求返回),乘客再找一辆空闲的公交车继续坐。这就是非阻塞式挂起。
3. 资源开销与上下文切换
-
线程开销极大: 创建一个 Java 线程大约需要 1MB 的内存。且线程间的上下文切换需要进入操作系统内核态,保存寄存器状态,非常消耗 CPU 资源。
-
协程开销极小: 协程的切换纯粹是内存里的方法调用(基于状态机),不需要操作系统介入。这就是为什么你能轻松创建 10 万个协程,但绝对建不了 10 万个线程。
总结
从关系上来说,协程并不是用来取代线程的,协程本质上是运行在 Java 线程之上的一种轻量级的任务封装。线程是操作系统的底层调度单位,而协程是 Kotlin 在用户态实现的一套调度框架。一个线程可以并发执行多个协程。
它们的区别,我主要从三个维度来总结:
第一,是阻塞与挂起的机制不同。 Java 线程一旦调用 sleep 或者进行 IO 操作,就会进入阻塞状态,白白占用系统资源。而 Kotlin 协程利用 suspend 关键字实现了非阻塞式挂起。当协程遇到耗时操作时,会主动让出当前所在线程的执行权,让该线程去执行其他协程任务,等耗时操作结束再恢复执行,大大提高了线程的利用率。
第二,是资源开销和切换成本。 线程的创建非常昂贵,且线程切换需要进入系统内核态,开销很大;而协程非常轻量,它在底层的切换本质上只是普通的方法调用(基于状态机流转),不需要操作系统的干预。我们在普通机器上轻松创建十万个协程也不会 OOM,但绝不可能创建十万个线程。
第三,是编程模型上的优势。 在日常开发中,多线程处理复杂网络请求往往会导致‘回调地狱’。而协程通过结构化并发,允许我们用同步的顺序代码来写异步逻辑,代码可读性和容错性都得到了极大的提升。
综上所述,协程其实就是 Kotlin 提供给我们的一个极其好用的、自带极高并发性能的线程池封装库。
kolin协程中如何切换线程
如何使用?
在协程切换线程主要是使用withContext()挂起函数,配合dispatchers调度器来切换。
// 这是一个典型的 Android 业务场景
viewModelScope.launch(Dispatchers.Main) {
// 1. 此时在主线程,可以安全地显示 Loading UI
showLoading()
// 2. 切换到 IO 线程执行耗时操作,主线程此时被释放,不会卡顿
val result = withContext(Dispatchers.IO) {
repository.fetchDataFromNetwork() // 模拟网络请求
}
// 3. withContext 执行完后,会自动切回原来的 Dispatchers.Main
// 此时拿到结果,安全地刷新 UI
updateUI(result)
}
追问withContext是如何自动切换回来的?到底是做了什么?
为什么 withContext 跑完之后能“自动切回来”?这里面有三个最核心的概念:
-
CoroutineDispatcher(调度器): 它本质上就是对 Java 线程池的封装。比如Dispatchers.IO底层对应的是一个专为 IO 优化、可弹性伸缩的线程池;Dispatchers.Main在 Android 平台上,底层其实就是封装了大家最熟悉的Handler(Looper.getMainLooper())。 -
ContinuationInterceptor(上下文拦截器): 它是协程上下文(CoroutineContext)的一部分。这是线程切换的灵魂。 -
DispatchedContinuation(包装类): 当你调用withContext(Dispatchers.IO)时,协程框架会把当前的Continuation(也就是剩下的代码逻辑)包装成一个DispatchedContinuation。
线程切换的完整工作流
当你写下 withContext(Dispatchers.IO) 的那一刻:
-
拦截并包装: 协程发现你传入了一个新的调度器,它会利用拦截器,把当前的执行上下文包装起来。
-
派发任务(Dispatch):
Dispatchers.IO会调用它底层的dispatch方法,把你要执行的任务封装成一个Runnable,然后丢进 IO 线程池的队列里。这时候,原来的主线程就解脱了,可以继续去刷新界面。 -
恢复执行(Resume): 当 IO 线程池里的线程把任务执行完了,拿到结果后,它会调用
Continuation.resumeWith。 -
切回原线程: 在恢复的时候,原来的拦截器(比如
Dispatchers.Main)又会介入,再次调用dispatch方法,把剩下的代码通过Handler.post重新丢回主线程的 MessageQueue 里。
总结
在实际开发中,我们切换线程一般就是用 withContext 这个挂起函数,然后在里传入不同的 Dispatchers 调度器。比如切到 Dispatchers.IO 去做网络请求,跑完之后它会自动切回原来的线程,这就比以前用回调或者 RxJava 顺眼多了。
如果往底层看的话,这个自动切回来的机制……其实完全是靠协程的拦截器来实现的。
Kotlin 协程的上下文里有一个核心组件叫 ContinuationInterceptor,也就是上下文拦截器。当我们调用 withContext(Dispatchers.IO) 的时候,框架会做几件事。
首先它会把当前的协程,也就是包含剩下代码逻辑的 Continuation 对象,包装成一个特殊的 DispatchedContinuation。
然后这个 Dispatchers.IO 调度器,它本质上是对 Java 线程池的一个封装。它会调用底层的 dispatch 方法,把我们要执行的那段耗时代码,做成一个 Runnable 任务,直接丢到它的 IO 线程池队列里面去排队。这时候,原来的线程—如 Android 的主线程,就被释放出来了,它可以继续去处理用户的点击或者刷新 UI,完全不会被阻塞。等 IO 线程池里的某个线程把任务跑完了,拿到结果之后,它就会触发 Continuation 的 resumeWith 方法来恢复协程。
在这个恢复的过程中,最初启动协程的那个拦截器,比如 Dispatchers.Main,它就会重新介入。因为 Dispatchers.Main 底层封装的是 Android 的 Handler,所以它会调用 handler.post,把剩下需要更新 UI 的代码,再次当成一个 Message 扔进主线程的普通消息队列里。
所以,表面上看是一个简单的 withContext 挂起和恢复,但在底层,其实是调度器、拦截器和 Java 线程池之间,通过多次把任务封装成 Runnable 并在不同线程池里排队,来实现的这种非阻塞的线程切换。”
整体链路:使用层 -> 底层本质(线程池) -> 核心纽带(拦截器) -> 完整闭环(Dispatch与Resume)。
Dispatchers.IO 和 Dispatchers.Default 底层都是线程池,是共享同一个线程池吗?在线程数量的限制上有什么区别?
1. 它们共享同一个线程池吗?
它们底层完全共享同一个线程池。 在 kotlinx.coroutines 的底层实现中,无论是 Dispatchers.Default 还是 Dispatchers.IO,其实都是一个壳子。它们最终都会把任务提交给同一个专门为协程定制的线程池——CoroutineScheduler。
2. 既然是同一个线程池,为什么要分 Default 和 IO?
因为它们的任务属性不同,所以需要不同的“限流策略”。 Kotlin 团队使用了一种叫 LimitingDispatcher(限制性调度器)的包装类。这就好比同一个水库(CoroutineScheduler),分出了两个不同的水龙头(Default 和 IO),每个水龙头控制的水流速度(并发度限制)是不一样的。
3. 线程数量的限制有什么区别?
这里的限制,本质上是对最大并行度(Parallelism)的限制:
-
Dispatchers.Default(针对 CPU 密集型任务): 它的最大线程并发数默认等于当前机器的 CPU 核心数(如果核心数小于 2,则默认为 2)。 原因: CPU 计算任务是非常消耗 CPU 时间片的。如果盲目开更多线程,CPU 就需要频繁做上下文切换,反而降低了执行效率。让线程数等于核心数,能最大化利用多核性能。 -
Dispatchers.IO(针对 阻塞型 IO 任务): 它的最大线程并发数默认是 64,或者等于 CPU 核心数(两者取最大值)。 原因: IO 任务(网络请求、读写文件)大部分时间线程都在死等数据返回,不怎么消耗 CPU。为了不让耗时的 IO 把整个线程池占满,它限制了最多只能同时跑 64 个 IO 任务,但允许创建比 CPU 核心数多得多的线程去应对并发。
4. 底层共享的精妙之处:工作窃取(Work-Stealing)
既然共享同一个池子,如果 IO 线程被阻塞了,会不会拖垮 Default 的计算任务? CoroutineScheduler 内部做了一套非常精妙的调度算法:
-
它把池子里的线程分成了两种状态:
CPU状态和IO状态。 -
当一个线程在执行
Default任务时,它占的是 CPU 名额;当它通过withContext(Dispatchers.IO)转去执行 IO 任务时,它会释放自己的 CPU 许可,把自己标记为 IO 线程。 -
更厉害的是它支持工作窃取机制。如果某个线程自己的任务队列空了,它会去别的线程队列里“偷”任务来帮着跑,从而保证整个线程池没有闲着的执行单元。
总结
很多人以为它们是两个独立的线程池,但实际上……它们底层完全是共享同一个线程池的。
如果你去看过官方的源码,会发现 Kotlin 团队在底层设计了一个叫 CoroutineScheduler 的定制化线程池。不管是 Dispatchers.Default 还是 Dispatchers.IO,它们最终都是把任务提交给这同一个池子。
那既然池子是同一个,怎么做出区分的呢?
其实它们在外面各自包了一层限制器,也就是通过控制‘最大并行度’来做限流。
比如 Dispatchers.Default,它是专门用来处理那种比较纯粹的 CPU 计算任务的,比如复杂的算法、解压文件什么的。因为这种任务特别吃 CPU,所以它的线程并发上限默认就等于我们机器的 CPU 核心数。如果开多了,线程频繁切换,反而会把时间浪费在保存上下文上。
然后 Dispatchers.IO 呢,它是针对那种网络请求、读写数据库这种阻塞任务设计的。这种任务大部分时间线程都在死等,不怎么消耗 CPU 算力。所以它的并发上限给得比较宽,默认是 64。
我觉得这个设计最精妙的地方在于,虽然它们在一个池子里跑,但并不会互相拖垮。
因为底层这个 CoroutineScheduler 内部有一套机制,它会动态去识别线程的状态。一个线程如果跑的是 Default 任务,它就占用一个 CPU 额度。如果这个任务遇到了 withContext(Dispatchers.IO) 转去搞网络请求了,这个线程就会很聪明地把自己的 CPU 额度释放掉,把自己标记成 IO 线程。这时候空出来的 CPU 额度,就可以立马给其他做计算的协程用。
所以,它们俩的关系,表面上是各司其职,底层其实是一套非常紧密的、能够动态调整优先级的动态共生关系。
在 Dispatchers.Default 里面写了一个死循环或者调用了阻塞的 Thread.sleep(),会有什么后果?IO 任务还会正常执行吗?
1. 为什么 Dispatchers.Default 会彻底瘫痪?
当你在 Dispatchers.Default 里写了 while(true) 死循环或者 Thread.sleep() 时,你是在阻塞线程,而不是挂起协程。
-
底层
CoroutineScheduler给Default分配的“CPU 许可(Token)”是极其有限的(等于机器核心数,比如 8 个)。 -
一旦这 8 个线程进入了死循环或者 sleep,它们不会向调度器上报自己的状态(调度器以为它们还在正常做计算)。
-
此时 CPU 许可被耗尽,再提交任何
Default任务,都会被永远卡在队列里,得不到执行。
2. 为什么 Dispatchers.IO 却能“死里逃生”?
这就是 CoroutineScheduler 线程池最精妙的地方——它是一个可以弹性膨胀的池子。
-
虽然池子的“核心并发数”等于 CPU 核心数,但它的“最大容量(MaxPoolSize)”通常大得多(默认能到两三百万)。
-
当一个
IO任务被提交进来时,它走的是另一套限流器。调度器一看:哎呀,当前活着的 8 个线程全都在忙(被你的死循环卡住了),但是IO的 64 个并发许可还没用完啊! -
这时候,调度器会果断绕开被卡死的线程,直接在池子里
new出新的底层线程(Worker),专门去接管这个 IO 任务。 -
所以,从框架逻辑上讲,IO 任务完全可以顺畅执行。
3. 必须要点出的“系统级副作用” (加分项)
虽然框架层面允许 IO 新建线程去跑,但如果你的卡死是因为纯计算的 while(true) 死循环,这会导致物理 CPU 核心满载 100%。这时候操作系统层面的时间片轮转会变得极其艰难,新创建出来的 IO 线程也会因为抢不到 CPU 时间片而执行得非常缓慢。但如果是 Thread.sleep(),物理 CPU 是闲置的,IO 任务就会毫无阻碍地飞速执行。
总结
“嗯……这是一个非常好的极端场景。很多人可能觉得既然共享线程池,那池子堵死了就全盘崩溃了。但实际上并不是这样,如果真的遇到这种情况,Dispatchers.Default 确实会彻底瘫痪,但 Dispatchers.IO 大概率是能正常跑的。
里面的核心逻辑在于,协程底层的 CoroutineScheduler 遇到任务时的派发策略。
比如我们机器是 8 核,当我们在 Default 里写了 8 个死循环或者 Thread.sleep。这时候,因为没有调用 suspend 函数,这 8 个底层线程是被物理阻塞的。调度器并不知道它们卡死了,只知道‘计算许可’用光了,所以后续所有的 Default 任务都会被堵在队列里,直接饿死。
但是呢,IO 任务过来的时候情况就不一样了。
调度器一看当前的 8 个线程都在忙,但它查了一下 IO 的 64 个并发额度还有剩余。这时候,CoroutineScheduler 强大的地方就体现出来了——它不会在队列里干等,而是会直接在线程池里动态地新建出额外的线程,把这个 IO 任务接过去跑。因为底层的最大线程数限制其实是非常高的。
所以从 Kotlin 协程框架的机制来说,它们不仅不会互相锁死,框架还会拼命保住 IO 的执行。
不过在实际的操作系统层面,这里也有个细节……如果是 Thread.sleep 导致的卡死,那是最好的,因为 CPU 没满,新建的 IO 线程会跑得很顺畅。但如果是 while(true) 这种死循环,会导致物理机器的 CPU 占用率直接飙到 100%。这种时候,虽然协程框架把 IO 线程建出来了,但因为底层操作系统调度已经忙不过来了,这个 IO 线程拿到 CPU 时间片的概率会变低,表现出来就是网络请求可能会非常慢,甚至超时。
所以总结下来就是:框架层面的机制保证了 IO 能被独立派发和扩容,但最终的执行效率还得看物理 CPU 的脸色。”
协程的异常传播机制
如果一个父协程下有三个子协程,其中一个发生了异常崩溃,另外两个还能正常运行吗?怎么解决?
协程的异常和取消机制,核心围绕着一个概念:结构化并发(Structured Concurrency)与 Job 层级树。
1. 默认的连锁反应(双向传播)
在协程里,Job 是有父子关系的,像一棵树。默认情况下,普通的 Job 具有双向传播的特性:
-
向下取消: 如果父协程被取消了,它会把所有的子协程全部取消掉。(比如用户退出了 Activity,整个页面的网络请求全部停掉,这很合理)。
-
向上抛异常(致命点): 如果某一个子协程抛出了未捕获的异常(比如网络请求超时
UnknownHostException),它会把这个异常向上传递给父协程。父协程收到后,会立刻取消自己,并且取消掉所有其他的子协程。
代码示例:灾难现场
// 模拟一个常见的业务场景:同时请求用户信息和配置接口
viewModelScope.launch { // 这是父协程 (默认是普通的 SupervisorJob,但内部 launch 会生成普通 Job)
val job1 = launch {
delay(100)
throw RuntimeException("接口A挂了") // 发生异常
}
val job2 = launch {
delay(500)
println("接口B的数据加载完成") // 这里的代码永远不会执行,因为被 job1 连累了
}
}
2. 解决方法:SupervisorJob 与 supervisorScope
如何解决呢?就是 Supervisor(监督者)。 SupervisorJob 是一种特殊的 Job,它切断了异常的向上破坏。它的规则是:子协程如果发生异常,只在自己内部爆炸,绝不连累父协程,更不会连累兄弟协程。
代码示例:
viewModelScope.launch {
// 使用 supervisorScope 建立一道隔离墙
supervisorScope {
val job1 = launch {
throw RuntimeException("接口A挂了")
// 注意:这里仍然需要 try-catch 或者 CoroutineExceptionHandler 来处理异常本身,
// 否则虽然兄弟协程不受影响,但程序还是会崩溃抛出未捕获异常。
}
val job2 = launch {
delay(500)
println("接口B依然坚挺,正常加载完成") // 完美执行
}
}
}
3. 特殊的异常:CancellationException
这里有个坑。所有的异常都会导致父协程崩溃吗? 只有一种异常是例外的,那就是 CancellationException。当一个协程被正常 cancel() 的时候,底层抛出的就是这个异常。协程框架内部把这个异常当成一种“正常的消息传递”。父协程收到这个异常后,会默默忽略它,并不会触发整个树的崩溃。
总结
如果用的是默认的普通 launch 去启动子协程,那它内部是一套双向传播机制。比如我们开三个子协程去并发请求不同的接口。如果其中一个接口报错,抛了异常,这个异常会直接向上传递给父协程。
然后呢,父协程一旦收到这种普通异常,它不仅会取消掉自己,还会顺带着把另外两个正在正常请求的兄弟协程也一起强行干掉。这种连锁反应在业务上往往是灾难性的,等于一个非核心接口挂了,整个页面的数据就全白屏了。
要解决这个问题,我们在实战里通常会引入 Supervisor 机制。
比如用 supervisorScope 把并发的子协程包裹起来,或者在构建协程上下文的时候传一个 SupervisorJob。这种机制就相当于建了一道防火墙。它的特性是单向传播……也就是说,如果父协程被取消了,子协程依然会被一起停掉,这能防止内存泄漏;但是,如果某个子协程内部报错了,这个异常只会在它自己内部终结,完全不会往上抛去连累父协程和其他兄弟协程。
不过这里有个细节需要注意……就算用了 SupervisorJob 阻断了连锁崩溃,那个发生异常的子协程本身还是要把异常抛出来的。所以我们在写代码的时候,还是要配合 CoroutineExceptionHandler,或者在子协程内部老老实实写上 try-catch,去处理掉这个具体的错误状态。
另外补充一点就是,协程框架里有一个特殊的异常叫 CancellationException。平时我们主动调 cancel 方法取消任务,底层抛的就是它。这种异常被当做正常的取消信号,是不会引起刚才说的那种连锁崩溃的。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐
所有评论(0)