前言
合体境中阶已过,你已开启天眼,能看穿协程的堆栈迷雾,能操纵虚拟时间让测试瞬间完成。你的协程代码不仅功能完备,而且可调试、可测试。
然而,当应用规模膨胀,当数百个协程同时运行,当网络请求与数据库操作交织,新的挑战悄然降临:
“我的应用在网络差时疯狂创建协程,
Dispatchers.IO的线程池会不会爆炸?” “为什么用viewModelScope.launch启动的任务,在页面快速切换时会有短暂的内存泄漏?” “limitedParallelism是什么?它能帮我限制某个接口的并发数吗?” “如何用 Android Studio Profiler 定位协程相关的内存泄漏?”
这些问题指向协程开发的最高境界——性能调优与资源管控。不懂得线程池的弹性机制,你的应用可能在极端场景下 OOM;不了解协程泄漏的排查方法,你的代码可能暗藏隐患。
本讲是合体境的最终章。你将万法归宗,彻底掌控协程的性能命脉:
- 深入理解
Dispatchers.IO与Dispatchers.Default的线程池弹性机制。 - 掌握
limitedParallelism限流器的用法,防止资源被耗尽。 - 学会用
Mutex和Semaphore实现协程级的并发控制。 - 掌握协程泄漏的排查技巧——从 Profiler 到
WeakReference的追踪。 - 理解
viewModelScope与lifecycleScope的取消时机差异。
准备好万法归宗,将协程的性能与资源掌控于股掌之间了吗?我们开始。
操千曲而后晓声,观千剑而后识器。虐它千百遍方能通晓其真意。
Dispatchers 线程池的深层秘密
Dispatchers.IO 与 Default 共享线程池
在筑基境后阶我们学过,Dispatchers.IO 和 Dispatchers.Default 共享同一个底层线程池——CoroutineScheduler。但它们的任务调度策略截然不同。
| 特性 | Dispatchers.Default | Dispatchers.IO |
|---|---|---|
| 线程数上限 | CPU 核心数(最小 2) | 64(或通过系统参数配置) |
| 任务类型标记 | TaskType.CPU_BOUND | TaskType.IO_BOUND |
| 线程创建策略 | 固定,不扩容 | 弹性:当线程阻塞时自动创建新线程 |
| 适用场景 | 纯 CPU 计算 | 网络、文件、数据库等可能阻塞的操作 |
flowchart LR
subgraph Scheduler[CoroutineScheduler 共享线程池]
direction TB
CPUPool[CPU 核心线程]
IOPool[IO 弹性线程]
end
Default[Dispatchers.Default] --> CPUPool
IO[Dispatchers.IO] --> IOPool
style Scheduler fill:#e8eaf6,stroke:#3949ab,stroke-width:2px
style Default fill:#c5e1a5,stroke:#558b2f
style IO fill:#a5d6a7,stroke:#1b5e20
style CPUPool fill:#90caf9
style IOPool fill:#81c784
弹性机制的代价与配置
Dispatchers.IO 的弹性机制保证了即使有线程因 I/O 阻塞,其他任务仍能被及时执行。但这种弹性也有代价:
- 如果误将 CPU 密集型任务放到
IO调度器,线程池可能膨胀到 64 个线程,导致严重的上下文切换开销。 - 如果并发请求过多,64 个线程可能仍然不够,任务会在队列中等待。
你可以通过 JVM 参数调整 Dispatchers.IO 的最大线程数:
// 在 Application 或测试中设置
System.setProperty("kotlinx.coroutines.io.parallelism", "128")
最佳实践:
- 永远不要在
Dispatchers.IO中执行纯 CPU 计算。 - 监控线程数:使用
Thread.activeCount()或 Profiler 观察线程池膨胀情况。 - 对于高并发的 I/O 任务,考虑使用
limitedParallelism限流。
limitedParallelism:精细化并发控制
什么是 limitedParallelism?
limitedParallelism 是 CoroutineDispatcher 的扩展函数,它创建一个新的调度器,该调度器将并发任务数限制在指定的数量内。它底层共享原始调度器的线程池,但通过信号量控制同时执行的任务数。
val limitedIO = Dispatchers.IO.limitedParallelism(4)
repeat(100) {
launch(limitedIO) {
// 最多同时运行 4 个协程
downloadFile(it)
}
}
flowchart LR
subgraph Unlimited[无限制 100 个协程]
U1[协程1] --> U2[协程2] --> U3[协程3] --> U4[...]
end
subgraph Limited[limitedParallelism=4]
direction TB
L1[协程1]
L2[协程2]
L3[协程3]
L4[协程4]
Queue[其余 96 个排队等待]
end
style Unlimited fill:#ffcdd2,stroke:#b71c1c,stroke-width:2px
style Limited fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
style Queue fill:#ffb74d
实战:限流下载器
假设你需要下载 100 个文件,但服务器限制单 IP 最多 5 个并发连接。使用 limitedParallelism 可轻松实现。
class DownloadManager {
private val downloadDispatcher = Dispatchers.IO.limitedParallelism(5)
suspend fun downloadAll(urls: List<String>) = coroutineScope {
urls.map { url ->
async(downloadDispatcher) {
downloadFile(url)
}
}.awaitAll()
}
}
与 Semaphore 的区别
| 对比维度 | limitedParallelism | Semaphore |
|---|---|---|
| 作用层级 | 调度器级别,对所有使用该调度器的协程生效 | 协程代码块级别,需手动 acquire/release |
| 使用复杂度 | 简单,声明式 | 需手动管理,易出错 |
| 适用场景 | 全局并发限制 | 局部临界区保护 |
对于限制某类操作的全局并发数,limitedParallelism 是更优雅的选择。
协程泄漏的排查与根治
什么是协程泄漏?
协程泄漏是指协程持有的引用(如 Activity、View)在协程完成或取消前无法被 GC 回收的现象。它不像内存泄漏那样直接导致 OOM,但会浪费资源,且可能导致已被销毁的 UI 被错误更新。
常见泄漏场景与解决方案
| 泄漏场景 | 原因 | 解决方案 |
|---|---|---|
在 GlobalScope 中持有 Activity 引用 | GlobalScope 生命周期与进程相同 | 使用 lifecycleScope 或 viewModelScope |
在 ViewModel 的 init 中启动无限循环协程,未在 onCleared 取消 | viewModelScope 虽会取消,但无限循环中的挂起点可能未及时响应 | 保存 Job 引用,在 onCleared 中显式 cancel |
在 suspend 函数中直接捕获外部 Context 或 View | 协程可能在 Activity 销毁后仍执行 | 使用 lifecycleScope.launchWhenStarted 或在访问 UI 前检查 isActive |
用 Profiler 与 WeakReference 排查泄漏
方法一:Android Studio Profiler
- 打开 Profiler,选择 Memory。
- 触发可疑操作(如旋转屏幕)。
- 手动 GC 后,查看内存中是否仍有该 Activity 的实例。
- 若有,点击实例查看 GC Roots,追踪到持有它的协程引用链。
方法二:WeakReference 追踪
class LeakDetector {
private val trackedObjects = mutableMapOf<String, WeakReference<Any>>()
fun track(tag: String, obj: Any) {
trackedObjects[tag] = WeakReference(obj)
}
fun checkLeaks() {
trackedObjects.forEach { (tag, ref) ->
if (ref.get() != null) {
Log.w("LeakDetector", "Potential leak: $tag is still referenced")
}
}
}
}
在可疑对象的 onDestroy 或 onCleared 中调用 track,然后定期 checkLeaks。
viewModelScope vs lifecycleScope:取消时机的微妙差异
生命周期对比
| 作用域 | 绑定对象 | 取消时机 | 典型场景 |
|---|---|---|---|
viewModelScope | ViewModel | ViewModel.onCleared() | 与 UI 状态相关的数据加载 |
lifecycleScope | LifecycleOwner | Lifecycle.Event.ON_DESTROY | 与 Activity/Fragment 生命周期绑定的 UI 操作 |
何时该用哪个?
- 数据加载、状态管理 →
viewModelScope。即使 Activity 因配置变更重建,数据仍在 ViewModel 中。 - 动画、
Toast、导航 →lifecycleScope。这些操作直接依赖 UI 的存在,Activity 销毁后不应执行。
class MyViewModel : ViewModel() {
fun loadData() {
viewModelScope.launch {
val data = repository.fetch()
_state.value = data // 即使 Activity 重建,StateFlow 仍会保留数据
}
}
}
class MyActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
delay(1000)
showToast() // Activity 销毁后,Toast 不应显示
}
}
}
实战:高并发图片加载的调优
场景:一个图片墙,需要并发加载 200 张缩略图。直接使用 Dispatchers.IO 可能导致线程池膨胀和 OOM。
class ImageLoader {
// 限制最大并发数为 8,防止线程爆炸
private val loadDispatcher = Dispatchers.IO.limitedParallelism(8)
suspend fun loadImages(urls: List<String>): List<Bitmap> = coroutineScope {
urls.map { url ->
async(loadDispatcher) {
// 模拟网络加载
delay(500)
Bitmap.createBitmap(100, 100, Bitmap.Config.ARGB_8888)
}
}.awaitAll()
}
}
配合内存缓存:
class CachedImageLoader {
private val cache = LruCache<String, Bitmap>(1024 * 1024 * 10) // 10MB
private val loadDispatcher = Dispatchers.IO.limitedParallelism(8)
suspend fun loadImage(url: String): Bitmap = withContext(loadDispatcher) {
cache.get(url) ?: downloadImage(url).also { cache.put(url, it) }
}
}
flowchart LR
subgraph Request[请求图片]
R1[url1]
R2[url2]
R3[...]
end
subgraph Limiter[限流器 limitedParallelism=8]
L[最多 8 个并发]
end
subgraph Cache[内存缓存]
C[LruCache]
end
subgraph Network[网络下载]
N[OkHttp]
end
Request --> Limiter --> Cache
Cache -->|未命中| Network
Cache -->|命中| Result[返回 Bitmap]
Network --> Result
style Request fill:#e3f2fd,stroke:#1976d2
style Limiter fill:#c8e6c9,stroke:#2e7d32
style Cache fill:#fff3e0,stroke:#f57c00
style Network fill:#ffcdd2,stroke:#b71c1c
常见错误与避坑指南
错误 1:滥用 limitedParallelism 导致死锁
val single = Dispatchers.IO.limitedParallelism(1)
launch(single) {
launch(single) {
// 永远等不到执行,因为外层协程占用了唯一槽位
}
}
正确:避免在同一个受限调度器上嵌套启动协程,或确保不会形成循环等待。
错误 2:忘记 viewModelScope 在 onCleared 后才取消
viewModelScope.launch {
while (true) {
delay(1000)
updateState() // 在 ViewModel 被清除后,可能仍有最后一次更新
}
}
正确:在循环中检查 isActive 或使用 repeatOnLifecycle(UI 层)。
错误 3:在 Dispatchers.Main 中使用 limitedParallelism
val limitedMain = Dispatchers.Main.limitedParallelism(2) // 无意义,Main 只有一个线程
limitedParallelism 对单线程调度器无效。
最佳实践
- I/O 密集用
IO,CPU 密集用Default:牢记这一铁律。 - 对外部服务(API、数据库)设置
limitedParallelism:防止单个服务拖垮整个线程池。 - 使用 Profiler 定期检查协程泄漏:将内存分析纳入日常开发流程。
- 在 ViewModel 中管理长期运行的协程引用:必要时在
onCleared中显式取消。 - 优先使用
viewModelScope加载数据:配置变更时自动保留。
总结与下回预告
恭喜,你已万法归宗,将协程的性能与资源掌控于股掌之间!合体境大圆满!
本讲核心收获:
Dispatchers.IO线程池弹性机制,最大 64 线程,需避免误用。limitedParallelism精细控制并发数,防止资源耗尽。- 协程泄漏排查:Profiler 与
WeakReference双管齐下。 viewModelScope与lifecycleScope的取消时机差异。- 实战:高并发图片加载的调优与缓存。
在下一境——大乘境·初阶——中,我们将超越 Android 平台,俯瞰协程在 Kotlin Multiplatform 与后端开发中的广袤天地。届时你会明白:
- 协程如何在 KMM 中统一 iOS 与 Android 的异步逻辑?
- 协程在后端框架(如 Ktor)中如何支撑高并发?
- 协程的设计哲学如何影响未来的异步编程范式?
准备好踏入大乘,一览众山小了吗?
【当前境界修为面板】
| 当前境界 | 修炼技能 | 修炼进度 | 修炼心得 |
|---|---|---|---|
| 合体境 · 后阶 | 1、线程池弹性真解 2、 limitedParallelism 限流诀3、泄漏排查天眼术 | 当前进度:100%修为: 1000/1000下一突破: [大乘境 · 初阶] (需领悟:KMM 协程统一、Ktor 后端协程、异步编程范式) | limitedParallelism是线程池的调速阀。泄漏不在协程本身,而在协程持有的View引用。 |
【本讲思考题】
-
表象题:
Dispatchers.IO的最大线程数默认是多少?如何修改? -
场景题:你有一个 API 接口,后端限制同一账号最多 3 个并发请求。如何在协程中优雅地实现这个限制?写出核心代码。
-
原理题:
limitedParallelism底层是如何实现并发数限制的?它是否创建了新的线程池?请结合源码简述。
道友,合体四境已全部通关。你的协程修为已臻至 Android 开发的巅峰。下一境,我们将跳出平台限制,在更广阔的天地中领悟协程的大道真意。大乘境·初阶见。
欢迎一键四连(
关注+点赞+收藏+评论)