Kotlin 协程 面试知识点

1. 协程的原理

一句话:协程是编译器把异步代码变换成状态机 + 运行时用线程池调度执行的组合,用同步的写法实现了非阻塞的异步效果。

分三个层次:

编译器层 — suspend 函数的 CPS 变换
- suspend 函数编译后会被加一个额外参数 Continuation<T>,本质是一个回调
- 函数被拆成多个状态机片段,每次遇到挂起点,保存当前状态(局部变量 + label)到 Continuation 对象
- 返回 COROUTINE_SUSPENDED 标志,等异步结果回来后通过 resumeWith 恢复执行

运行时层 — 调度器
- 协程本身不绑定线程,执行由 CoroutineDispatcher 决定
- Dispatchers.Main → post 到主线程 Handler
- Dispatchers.IO → 共享线程池,适合阻塞 IO
- Dispatchers.Default → CPU 密集线程池,大小默认等于 CPU 核数
- 挂起的协程不占线程,这是它"轻量"的核心原因

结构化并发层 — CoroutineScope + Job
- 每个协程关联一个 Job,以树状结构组织
- 父 Job 取消会传播到所有子 Job,避免泄漏


2. CoroutineScope 是什么

一句话CoroutineScope 不是线程池,也不是协程本身,它本质是一个协程上下文容器,负责约束协程启动时的上下文和生命周期。

可以把它理解成“协程运行的作用域”:
- 规定这批协程挂在哪个父 Job
- 规定默认运行在哪个 Dispatcher
- 规定取消时机和异常传播边界

它的接口非常简单:

public interface CoroutineScope {
    public val coroutineContext: CoroutineContext
}

所以 CoroutineScope 的本质不是“做事情”,而是持有 coroutineContext,然后让 launch/async 等构建器基于这个 context 去创建新协程


3. CoroutineScope 的作用

3.1 生命周期管理

这是 CoroutineScope 最核心的价值。

如果没有 scope,协程就容易变成“到处 launch 的后台任务”,生命周期没人管,页面退出了还在跑,就会出现:
- 内存泄漏
- 页面销毁后回调 UI 崩溃
- 请求结果回来了,但宿主对象已经没了

而有了 CoroutineScope,协程就会和宿主生命周期绑定:
- viewModelScope 绑定 ViewModel
- lifecycleScope 绑定 LifecycleOwner
- coroutineScope {} 绑定当前 suspend 调用链

宿主结束时,scope 对应的父 Job 会被取消,子协程也会一起取消。

3.2 建立父子协程关系

launch/async 并不是凭空创建协程,而是:
1. 读取当前 CoroutineScopecoroutineContext
2. 从里面拿到父 JobDispatcher、异常处理器等元素
3. 创建一个新的子 Job
4. 把新协程挂到父 Job 下面

所以 scope 决定了这批协程是不是同一个“家族”。

3.3 统一上下文继承

协程启动时默认会继承 scope 里的上下文:
- Job:决定取消关系
- CoroutineDispatcher:决定运行线程
- CoroutineName:便于调试
- CoroutineExceptionHandler:决定顶层异常处理方式

比如:

viewModelScope.launch {
    // 默认继承 Main dispatcher + ViewModel 的 Job
}

如果启动时额外传了 context,本质是和 scope 里的 context 做 + 合并,相同 key 后者覆盖前者

3.4 CoroutineScope 可以用 + 组合多个配置吗

可以,但要注意:不是多个 CoroutineScope 相加,而是多个 CoroutineContext.Element+ 合并后,再构造一个 CoroutineScope

例如:

val scope = CoroutineScope(
    SupervisorJob() +
    Dispatchers.Main +
    CoroutineName("MainScope")
)

这里合并进去的本质是:
- Job
- Dispatcher
- CoroutineName

最终一起组成这个 scope 的 coroutineContext

+ 的规则本质是合并 CoroutineContext
- 不同 key 的元素可以共存
- 相同 key 的元素,后者覆盖前者

比如:

val context = Dispatchers.IO + Dispatchers.Main

最终保留的是 Dispatchers.Main,因为它们都属于 CoroutineDispatcher,key 相同。

再比如:

val context = CoroutineName("A") + CoroutineName("B")

最终保留的是 CoroutineName("B")

Job 也一样,不能两个一起生效:

val context = Job() + SupervisorJob()

最终只会保留后面的 SupervisorJob(),不会同时存在两个父 Job

所以面试里可以直接说:
CoroutineScope 常见写法就是 CoroutineScope(Job() + Dispatchers.Main + CoroutineName(...))+ 本质是合并 CoroutineContext,不是把多个 scope 拼起来;相同 key 的元素以后者为准。

3.5 CoroutineContext 的组成和合并规则

一句话CoroutineContext 是协程运行时携带的一组配置集合,本质上是一个按 Key 存储的元素集合。

它常见包含这些元素:
- Job:管理取消和父子关系
- CoroutineDispatcher:决定线程调度
- CoroutineName:方便日志和调试
- CoroutineExceptionHandler:处理顶层异常

所以一个协程跑起来时,真正随身带着的“运行环境”其实就是 CoroutineContext

例如:

val context =
    SupervisorJob() +
    Dispatchers.IO +
    CoroutineName("UploadTask")

这个 context 表达的含义就是:
- 这是一个可监督的任务
- 默认在 IO 线程池运行
- 调试名字叫 UploadTask

它为什么能用 + 合并?

因为 CoroutineContext 内部不是 List,也不是 Map 的普通实现,而是一个支持按 Key 查找和覆盖的上下文结构。

你可以把它理解成:
- 每个元素都有自己的 Key
- 合并时按 Key 去重
- key 不同就共存,key 相同就覆盖

常见合并规则:

  1. Dispatcher + Dispatcher
val context = Dispatchers.IO + Dispatchers.Main

最终是 Dispatchers.Main,因为同一个 key,后者覆盖前者。

  1. Name + Name
val context = CoroutineName("A") + CoroutineName("B")

最终是 CoroutineName("B")

  1. Job + Job
val context = Job() + SupervisorJob()

最终只保留后面的 SupervisorJob()

  1. Dispatcher + Name + Job
val context = Dispatchers.IO + CoroutineName("Sync") + Job()

这三个 key 不同,所以可以同时存在。

launch 里也是一样的规则:

viewModelScope.launch(Dispatchers.IO + CoroutineName("load")) {
    // 会覆盖 scope 默认的 dispatcher
    // 但仍然继承 scope 的父 Job
}

这里可以这样理解:
- Dispatcher 被新的 Dispatchers.IO 覆盖
- CoroutineName 新增进去
- 如果没显式传 Job,父 Job 继续沿用 scope 里的

所以 launch(context) 不是完全替换原 scope,而是先继承 scope,再和新 context 做一次合并

面试里常见一句话总结:
CoroutineContext 是协程的运行上下文,里面常见有 JobDispatcherCoroutineName、异常处理器;多个 context 用 + 合并时,本质是按 key 合并,同类元素后者覆盖前者,不同元素可以共存。


4. 协程里的异常处理

4.1 协程异常的核心规则

一句话:协程异常不是“谁抛谁处理”这么简单,它还和协程构建器类型、父子关系、是否 await、是否结构化并发有关。**

最核心的几条规则:
- launch 里的异常默认会立即向父协程传播
- async 里的异常会先存起来,等 await() 时再抛给调用方
- 子协程抛异常,默认会取消父协程,再由父协程取消其他子协程
- CancellationException 属于取消信号,通常不当成业务失败处理

4.2 launchasync 的异常区别

launch:异常是“直接冒泡型”

viewModelScope.launch {
    throw RuntimeException("error")
}

launch 返回的是 Job,没有结果可取,所以异常不会等你“取值”时再抛,而是会立刻沿父子关系向上传播。

async:异常是“延迟暴露型”

val deferred = viewModelScope.async {
    throw RuntimeException("error")
}

deferred.await()

async 返回的是 Deferred<T>,异常会先被封装在里面,等到 await() 时才真正抛出来。

所以面试里常见表述可以说:
- launch 更像 fire-and-forget,异常立即传播
- async 更像 future/promise,异常在 await() 时暴露

4.3 普通 JobSupervisorJobsupervisorScope 的区别

这三个经常一起考,核心区别就是:子协程失败后,会不会把父协程和兄弟协程一起带挂。

普通 Job
- 默认父子关系
- 任一子协程失败,会向上取消父协程
- 父协程再取消其他兄弟子协程
- 特点是“一损俱损”

例如:

coroutineScope {
    launch { task1() }
    launch { error("task2 failed") }
}

这里 task2 失败后,整个 coroutineScope 会失败,task1 也会被取消。

SupervisorJob
- 是一种“监督型父 Job”
- 子协程失败,不会自动把父 scope 取消
- 其他兄弟协程可以继续执行
- 适合多个并行任务彼此独立的场景

例如:

val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

scope.launch { error("task1 failed") }
scope.launch { loadBanner() } // 仍可继续

这里第一个子协程失败,不会影响第二个子协程。

supervisorScope
- 是一个 suspend 作用域构建器
- 作用和 SupervisorJob 类似,都是隔离子协程失败
- 但它更适合在一次 suspend 调用链内部临时创建“监督作用域”

例如:

suspend fun loadPage() = supervisorScope {
    launch { loadCardA() }
    launch { loadCardB() }
}

这里 loadCardA() 失败,不会自动取消 loadCardB()

它们的使用场景区别:
- Job() / 普通 coroutineScope {}:希望失败能整体收敛,适合强一致任务
- SupervisorJob():适合构建长期存在的监督型 scope,比如 ViewModel 内部某些独立任务组
- supervisorScope {}:适合在某个 suspend 函数里临时做“局部失败隔离”

一个表格记忆:

普通 Job / coroutineScope SupervisorJob / supervisorScope
子失败是否取消父 不会
子失败是否影响兄弟 不会自动影响
父取消是否影响子
适合场景 强依赖、整体成败一致 并行模块相互独立

可以这样理解:
- 普通 Job:团队里一个人出问题,整个小组停工
- SupervisorJob:组长知道有人失败了,但不会让其他人一起停工

面试里一句话总结:
普通 Job 是默认父子失败联动;SupervisorJobsupervisorScope 会隔离子协程失败,不让一个子任务把整个作用域和其他兄弟任务一起拖垮,但父作用域如果主动取消,子协程仍然都会被取消。

4.4 supervisorScope 里子协程异常后,外层函数会不会失败

结论先说:supervisorScope 只负责“失败隔离”,不负责“自动吞异常”。

也就是说:
- 子协程失败,不会自动取消其他兄弟协程
- 但这个异常如果最终没人处理,外层函数仍然可能失败

所以 supervisorScope 解决的是“不要连坐”,不是“不要报错”。

情况一:launch 子协程抛异常

suspend fun load() = supervisorScope {
    launch {
        error("taskA failed")
    }
    launch {
        loadB()
    }
}

这里 taskA 失败后:
- loadB() 不会被自动取消
- 但 taskA 的异常本身依然存在
- 如果没有处理,它仍然会进入异常处理链

情况二:async 子协程抛异常

suspend fun load() = supervisorScope {
    val a = async { error("taskA failed") }
    val b = async { loadB() }

    b.await()
    a.await()
}

这里 a 的失败不会自动取消 b,但当执行 a.await() 时,异常还是会抛出来,外层函数最终仍然会失败。

所以 supervisorScope 的本质是:
- 保住兄弟协程继续执行
- 但不替你消费异常

4.5 什么时候需要手动 try-catch

当你的业务目标是“允许局部失败,但整体流程继续”时,就需要手动处理这个子任务的异常。

典型场景:
- 首页多个卡片并行加载,一个卡片失败不影响整页
- 聚合多个接口,一个接口失败时降级展示默认值
- 某个埋点、预加载、推荐流失败,只记日志,不中断主流程

例如在子协程内部处理:

suspend fun load() = supervisorScope {
    launch {
        try {
            loadCardA()
        } catch (e: Exception) {
            log(e)
        }
    }

    launch {
        loadCardB()
    }
}

或者对 asyncawait() 时处理:

suspend fun load() = supervisorScope {
    val a = async { loadCardA() }
    val b = async { loadCardB() }

    val resultA = try {
        a.await()
    } catch (e: Exception) {
        null
    }

    val resultB = b.await()
}

如果你不想让外层函数失败,就要把这个异常:
- 在子协程内部 try-catch
- 或者在 await() 时显式处理
- 或者转成 Result / null / 默认值

4.6 面试里怎么回答这个追问

可以直接这样答:

supervisorScope 只能隔离子协程失败,不会让一个子任务失败把其他兄弟协程一起取消,但异常本身并不会自动消失。如果这个异常最终没有被消费,外层函数还是可能失败。所以当业务上允许局部失败、整体继续时,需要在子协程内部手动 try-catch,或者在 await 时把异常转成可控结果。

4.7 try-catch 在协程里能不能用

能用,而且是处理业务异常最直接的方式。

例如:

viewModelScope.launch {
    try {
        val data = repository.loadData()
        render(data)
    } catch (e: Exception) {
        showError(e)
    }
}

这里的 try-catch 和普通 Kotlin 代码本质一样,只要异常是在当前协程这条执行链里抛出来的,就能接住。

比如:
- suspend 函数内部抛出的异常
- withContext 块里的异常
- await() 时抛出的异常

4.8 协程异常处理和普通 try-catch 的区别

try-catch局部、同步语义上的异常捕获工具,而协程异常处理还多了两层:
- 异步边界:异常可能在另一个协程里发生
- 结构化并发边界:异常会沿 Job 树传播和取消

这就是为什么下面这种写法经常接不住:

try {
    viewModelScope.launch {
        throw RuntimeException("error")
    }
} catch (e: Exception) {
    // 接不到
}

原因是:
- 外层 try-catch 只包住了“启动协程”这一步
- 真正抛异常发生在新协程里
- 它已经不在当前同步调用栈中了

所以这个例子和普通代码最大的区别就是:协程启动本身是同步的,但协程体执行是异步的。

4.9 正确使用 try-catch 的位置

原则是:谁的协程里可能抛异常,就在谁的协程执行体内部捕获。

正确示例:

viewModelScope.launch {
    try {
        request()
    } catch (e: Exception) {
        handleError(e)
    }
}

如果是 async

viewModelScope.launch {
    try {
        val result = async { request() }.await()
        render(result)
    } catch (e: Exception) {
        handleError(e)
    }
}

4.10 CoroutineExceptionHandler 是干什么的

CoroutineExceptionHandler 更像兜底的顶层异常处理器,不是替代 try-catch 的业务异常处理工具。

例如:

val handler = CoroutineExceptionHandler { _, throwable ->
    logError(throwable)
}

scope.launch(handler) {
    throw RuntimeException("error")
}

它更适合做:
- 统一日志上报
- 崩溃监控
- 顶层兜底

不适合做:
- 普通业务分支处理
- 替代页面状态更新
- 替代局部 try-catch

因为它拿到异常时,往往已经到了“这个协程失败了”的阶段。

4.11 try-catchCoroutineExceptionHandler 怎么对比

try-catch CoroutineExceptionHandler
处理粒度 局部代码块 顶层协程兜底
适合场景 业务异常处理、状态回传 日志、监控、统一上报
是否能恢复流程 可以,捕获后继续走分支 通常不用于恢复业务流程
是否能跨协程接异常 不能 只处理命中它的顶层未捕获异常

一句话理解:
- try-catch 是“我知道这里可能失败,我要自己处理”
- CoroutineExceptionHandler 是“这类未捕获异常最后统一交给我收口”

4.12 为什么有时候 CoroutineExceptionHandler 不生效

这是面试高频坑点。

async 来说,CoroutineExceptionHandler 往往不是你第一反应看到异常的地方。

因为 async 会把异常存进 Deferred,等 await() 时再抛。

也就是说,async 的异常通常应由:
- await() 外层的 try-catch 处理

而不是单纯依赖 CoroutineExceptionHandler

4.13 取消异常要特殊对待

协程取消时抛的是 CancellationException,它的语义不是“任务失败”,而是“任务被正常取消”。

所以一般要注意两件事:
- 不要把它当普通错误提示给用户
- 不要在宽泛的 catch (Exception) 里把它吞掉

推荐写法:

try {
    doWork()
} catch (e: CancellationException) {
    throw e
} catch (e: Exception) {
    showError(e)
}

如果把取消异常吞掉,可能会导致:
- 协程无法及时结束
- 结构化并发的取消链条失效
- 资源释放时机混乱

4.14 面试里怎么回答“协程异常和 try-catch 有什么区别”

可以直接这么答:

try-catch 当然还能用,而且是处理协程内部业务异常最常用的方式。但协程异常比普通代码多了一层异步边界和父子协程传播关系。比如 launch 的异常会立刻向父协程传播,async 的异常会在 await() 时抛出;外层 try-catch 不能捕获另一个新协程里的异常。CoroutineExceptionHandler 更适合做顶层兜底和日志上报,不是替代业务里的 try-catch。另外 CancellationException 要单独处理,不能随便吞掉。


5. CoroutineScope 的底层原理

4.1 Scope 本身很轻

CoroutineScope 本体几乎没什么复杂逻辑,它只是一个带 coroutineContext 的接口。

真正关键的是它里面装的几个上下文元素,尤其是:
- Job
- CoroutineDispatcher

所以面试里可以直接说:CoroutineScope 本身不负责调度,它只是把协程组织起来;真正决定线程切换的是 Dispatcher,真正决定生命周期的是 Job

4.2 launch/async 如何依赖 Scope

launch 为例,流程可以理解为:

  1. scope.coroutineContext 取出上下文
  2. 和调用时传入的 context 合并
  3. 创建新的协程对象,比如 StandaloneCoroutine
  4. 新协程内部持有自己的 Job
  5. 这个 Job 会挂到父 Job 下,形成树状结构
  6. 再由 Dispatcher 决定是立即执行还是投递到对应线程池

也就是说,CoroutineScope 提供的是“组织关系”,不是“执行能力”。

4.3 为什么说 Scope 是结构化并发的入口

结构化并发要求:子任务不能脱离父任务独立失控地存在

CoroutineScope 正是这个约束的入口,因为所有正规创建的协程都应该从某个 scope 发起:

suspend fun load() = coroutineScope {
    launch { task1() }
    launch { task2() }
}

这里两个子协程都属于 coroutineScope 创建出来的作用域:
- 任一子协程失败,整个 scope 感知并取消其他子协程
- scope 不会提前结束,会等所有子协程结束

所以 scope 的底层意义是:把并发任务收口到一个明确的生命周期边界里。


6. CoroutineScope 在 Android 里的典型使用

viewModelScope
- 适合界面数据请求、状态流转
- ViewModel.onCleared() 时自动取消
- 避免页面重建后旧请求继续持有 ViewModel

lifecycleScope
- 适合和 Activity/Fragment 生命周期直接绑定的任务
- 页面销毁时自动取消

rememberCoroutineScope(Compose)
- 适合由组合节点触发的事件协程,比如点击事件、动画、Snackbar
- 组合离开时自动取消

GlobalScope
- 不推荐业务里直接使用
- 生命周期几乎等于进程级,容易造成任务失控、异常难收敛、资源泄漏

面试里如果被问“为什么不推荐 GlobalScope”,可以直接答:
因为它破坏了结构化并发,协程脱离了页面、ViewModel、调用链的生命周期管理。


7. CoroutineScope 最佳实践

6.1 优先使用系统提供的生命周期作用域

Android 里优先选这些现成 scope:
- UI 状态和数据请求用 viewModelScope
- 页面级任务用 lifecycleScope
- Compose 事件协程用 rememberCoroutineScope

这样做的好处是生命周期天然对齐,取消时机明确,不需要自己维护 Job

6.2 不要在业务代码里随手 new 一个独立 Scope

比如下面这种写法通常不推荐:

val scope = CoroutineScope(Dispatchers.IO)
scope.launch { fetchData() }

问题在于:
- 没有明确宿主,生命周期不好管理
- 容易忘记取消
- 异常边界不清晰

如果确实需要自定义 scope,至少要显式带上 SupervisorJob()Job(),并且明确谁负责 cancel()

private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

fun clear() {
    scope.cancel()
}

6.3 有 suspend 调用链时,优先用 coroutineScope,不要额外起野生协程

如果当前已经在 suspend 函数里,优先这样写:

suspend fun load() = coroutineScope {
    val user = async { api.loadUser() }
    val profile = async { api.loadProfile() }
    merge(user.await(), profile.await())
}

这样所有子任务都收敛在当前调用链里:
- 调用方取消,内部子任务自动取消
- 子任务异常会回传给调用方
- 不会出现函数返回了但内部任务还在偷偷跑

6.4 UI 层只负责启动协程,耗时逻辑下沉到数据层

最佳实践不是把所有代码都写在 viewModelScope.launch 里,而是:
- UI 层负责触发
- Repository / UseCase 提供 suspend API
- 线程切换在合适的位置通过 withContext 完成

例如:

viewModelScope.launch {
    val data = repository.loadData()
    _uiState.value = Success(data)
}

而不是把网络、磁盘、转换逻辑全部堆在 ViewModel 里。

6.5 withContext 负责切线程,launch 负责开任务,不要混用职责

常见原则:
- 需要结果,用 withContext
- 只想并发启动任务,用 launch / async

不要为了切线程额外 launch(Dispatchers.IO),否则容易:
- 丢失结构化结果返回
- 异常处理分散
- 调用方无法自然等待完成

更推荐:

suspend fun query() = withContext(Dispatchers.IO) {
    dao.query()
}

6.6 不要滥用 GlobalScope

GlobalScope 最大的问题不是“能不能跑”,而是“跑出去以后谁负责收尾”。

它通常会带来:
- 页面退出后任务还在执行
- 异常难集中处理
- 测试时行为不可控

只有极少数和进程同生命周期的任务才可能考虑它,比如非常底层的全局守护逻辑,但业务层一般都不应该直接使用。

6.7 区分普通父子关系和监督关系

默认情况下,父子协程是一损俱损
- 一个子协程失败,父 scope 会取消其他子协程

如果场景是“局部失败不影响整体”,要显式使用:
- SupervisorJob()
- supervisorScope {}

比如并行加载多个卡片模块时,一个模块失败不应该让整个页面全部失败,这时就适合监督关系。

6.8 及时响应取消,不要吞掉取消异常

协程取消本质上依赖 CancellationException 传播,所以要注意:
- 不要随便 catch (Exception) 后把取消也吞掉
- 长任务中要检查 isActive
- 确实要兜底时,CancellationException 要继续抛出

例如:

try {
    doWork()
} catch (e: CancellationException) {
    throw e
} catch (e: Exception) {
    log(e)
}

6.9 避免在同一个 Scope 里堆太多职责

一个 scope 最好服务于一个明确生命周期边界。

比如:
- viewModelScope 管页面状态
- 应用级 scope 管进程级任务

不要把页面请求、全局上报、长期轮询全部塞进同一个 scope,否则取消策略和异常策略会互相污染。

6.10 面试里可以总结成这几条


8. 协程是如何挂起的

本质:suspend 函数返回 COROUTINE_SUSPENDED,线程退出;外部事件调用 continuation.resumeWith(),状态机从上次的 label 继续执行。

delay(1000) 为例:

  1. 编译器生成状态机,label 记录执行位置
  2. 调用 delay 时传入当前 Continuation
  3. delayContinuation 包装成 Runnable 扔给延迟队列,返回 COROUTINE_SUSPENDED
  4. 调用方收到标志后直接 return,线程释放
  5. 1000ms 后调度器触发,调用 continuation.resumeWith(),状态机从 label=1 继续

挂起不阻塞线程,阻塞才阻塞线程,这是协程和 Thread.sleep 的本质区别。


9. withContext 切换线程的原理

本质:挂起当前协程 + 把任务 dispatch 到新线程池 + 完成后 dispatch 回原调度器恢复。

三步流程:
1. withContext 是 suspend 函数,调用后挂起当前协程,当前线程释放
2. 创建子协程,调用新 Dispatcher.dispatch() 把任务 post 到目标线程池执行
3. 子协程执行完毕,用原来的 Dispatcher dispatch 恢复操作,切回原线程继续

Dispatcher 切线程的底层:
- Dispatchers.MainHandler(Looper.getMainLooper()).post(block)
- Dispatchers.IO/Defaultexecutor.execute(block)

"切线程"本质就是把 Continuation 的恢复操作作为 Runnable post 到目标线程。

launch 的区别:

withContext launch
返回值 有,可以拿结果 无(返回 Job)
执行方式 串行,等待完成 并行,新建子协程
执行完后 自动切回原调度器 不切回

10. 协程的并发问题

协程不等于线程安全,并发问题依然存在。

什么情况下有问题: 多个协程在 Dispatchers.IO/Default 上并发修改共享可变状态时。

var count = 0
repeat(1000) {
    launch(Dispatchers.Default) { count++ } // 非原子,结果不一定是 1000
}

StateFlow 的坑: .value 单次读写线程安全,但"读-改-写"组合非原子,多协程下仍需 Mutex


11. 并发问题解决方案

一、原子操作 — 适合简单计数/标志位,无锁性能最好

val count = AtomicInteger(0)
count.incrementAndGet()

二、锁
- synchronized / ReentrantLock — 阻塞线程,协程里慎用
- Mutex — 协程专用,挂起等待,不阻塞线程(推荐)
- ReadWriteLock — 读多写少场景

三、限制单线程访问 — 从根本上消除竞态

val single = newSingleThreadContext("state")
launch(single) { count++ }

四、Channel — 通过通信共享数据,消费者单协程串行处理

五、不可变数据data class + copy(),配合 StateFlow 使用

六、线程安全集合ConcurrentHashMapCopyOnWriteArrayList

选型总结:

场景 推荐方案
简单计数/标志位 AtomicXxx
通用共享状态(协程) Mutex
读多写少 ReadWriteLock
状态集中管理 单线程 / 主线程
生产消费解耦 Channel
复杂共享集合 ConcurrentHashMap

12. Java 锁在协程里的使用

机制 能用吗 问题
synchronized 阻塞线程;块内不能调用 suspend 函数(编译报错)
ReentrantLock 阻塞线程
volatile 只保证可见性,不保证原子性
AtomicXxx 推荐 适合简单原子操作
Mutex 推荐 挂起等锁,协程友好

关键坑: synchronized 块内不能调用 suspend 函数,因为协程挂起后可能在另一个线程恢复,而 synchronized 锁是线程绑定的,编译器直接禁止。


13. Mutex 的原理

核心数据结构: 内部维护一个原子状态值 _stateUNLOCKED / LOCKED)+ 等待队列(链表)

加锁过程:
1. CAS 原子尝试把 UNLOCKED 换成 LOCKED,成功直接返回
2. 失败则把当前协程的 Continuation 加入等待队列,挂起协程,线程释放

解锁过程:
1. 队列为空 → CAS 改回 UNLOCKED
2. 队列不为空 → 取出队头 Continuation,直接 resume(锁的所有权直接移交,FIFO 公平)

synchronized 的本质区别:

synchronized Mutex
等待方式 阻塞线程(OS 级别) 挂起协程(用户态)
等待期间 线程被占用 线程释放,可跑其他协程
底层 JVM monitorenter/monitorexit CAS + 协程等待队列
suspend 块内 不能用 可以用

14. 协程并行分解子任务

基本模式:async + awaitAll

suspend fun fetchAll() = coroutineScope {
    val t1 = async { fetchFromNetwork() }
    val t2 = async { fetchFromDisk() }
    awaitAll(t1, t2)
}

总耗时取决于最慢的任务,不是相加。

任务量不固定:

ids.map { async { fetchItem(it) } }.awaitAll()

限制并发数:Semaphore

val semaphore = Semaphore(5)
ids.map { async { semaphore.withPermit { fetchItem(it) } } }.awaitAll()

流式处理:Flow + flatMapMerge

ids.asFlow()
    .flatMapMerge(concurrency = 5) { flow { emit(fetchItem(it)) } }
    .collect { process(it) }

15. 结构化并发

核心约束: 协程必须在一个 scope 内启动,生命周期不能超过这个 scope。

三个关键保证:
1. 父取消,子全取消 — 不会有游离协程
2. 子失败,父感知 — 异常不会静默丢失,自动向上传播
3. 父等所有子完成才结束coroutineScope {} 块结束时所有子任务已完成

Android 里的体现:
- viewModelScope — 绑定 ViewModel 生命周期,ViewModel clear 时自动取消
- lifecycleScope — 绑定 Activity/Fragment 生命周期

与 RxJava 对比:

RxJava 协程结构化并发
生命周期管理 手动 CompositeDisposable 自动,scope 对齐
异常处理 需每个流单独处理 自动向上传播
泄漏风险 忘记 dispose 就泄漏 scope 结束自动取消

16. 启动协程的方法

方法 返回值 异常时机 适合场景
launch Job 立即传播到父 触发任务,不关心结果
async Deferred<T> await() 时抛出 并行任务,需要结果
runBlocking 阻塞线程 立即抛出 测试 / main 函数

补充:scope 构建器
- coroutineScope — 任一子协程失败,全部取消
- supervisorScope — 子协程失败互不影响

一句话:有返回值用 async,没返回值用 launch,测试用 runBlocking


17. 面试里怎么回答 CoroutineScope

可以用这套表达:

第一层,定义:
CoroutineScope 是协程的作用域,本质是持有 CoroutineContext 的容器,不负责执行任务,本身也不是线程池。

第二层,作用:
它主要解决三个问题:
- 统一协程上下文
- 建立父子 Job 关系
- 管理生命周期,保证结构化并发

第三层,底层原理:
launch/async 启动协程时,会从 scope 里拿到父 JobDispatcher,创建子协程并挂到父 Job 下,所以取消、异常传播、线程调度都能统一管理。

第四层,Android 实战:
在 Android 里通常用 viewModelScopelifecycleScope,避免协程跑飞;一般不建议直接用 GlobalScope,因为它脱离生命周期,容易泄漏和失控。