24、协程与性能优化:协程的内存开销分析、协程池与调度器优化、避免协程泄漏、协程的性能基准测试

协程用起来确实爽,但爽归爽,性能问题不能忽视。我见过不少项目,协程满天飞,结果内存飙得比线程还高。说白了,协程不是银弹,用不好照样翻车。

这一章,咱们就聊聊协程的性能那些事。我会从内存开销讲起,再到调度器优化、泄漏防范,最后说说怎么做基准测试。嗯,都是我在实际项目中踩过的坑。

协程的内存开销分析

很多人觉得协程轻量,就随便创建。其实每个协程都有成本。

一个协程在内存里占什么?我拆开给你看:

  • Continuation 对象:每个挂起点都会生成一个。里面保存了局部变量和恢复点。
  • 状态机状态:编译器生成的标签,用于记录执行到哪一步。
  • 协程上下文:Job、Dispatcher、CoroutineName 等元素。
  • 捕获的变量:闭包里引用的外部变量。

我做过一次粗略测试:一个空的协程,大概占用 200-300 字节。如果协程里捕获了大对象,那就不止了。

关键点:协程比线程轻量,但比普通函数调用重。别拿它当 for 循环里的临时变量用。

举个例子,这种写法就有问题:

// ❌ 不推荐:循环里大量创建协程
for (i in 0 until 100000) {
    GlobalScope.launch {
        delay(10)
        println(i)
    }
}

10 万个协程同时挂起,内存瞬间吃掉几十 MB。你想想看,这跟开线程有啥区别?

我的建议:控制并发数。用 limitedParallelism 或者自定义协程池。

协程池与调度器优化

调度器是协程性能的核心。默认的 Dispatchers.Default 用线程池,Dispatchers.IO 用另一个。但默认不一定适合你的场景。

我曾经在一个下载器项目里,大量使用 Dispatchers.IO 做网络请求。结果发现线程数飙到 64 个,CPU 上下文切换频繁。后来我做了限制:

// 自定义 IO 调度器,限制最大线程数
val limitedIoDispatcher = Dispatchers.IO.limitedParallelism(8)

// 使用
viewModelScope.launch(limitedIoDispatcher) {
    // 网络请求
}

效果很明显。内存占用降了 40%,响应速度反而快了。为什么?因为线程少了,竞争少了。

再来说说协程池。其实协程本身没有池的概念,但我们可以通过 CoroutineScope 来管理。

调度器 默认线程数 适用场景
Dispatchers.Default CPU 核心数(最少 2) CPU 密集型任务
Dispatchers.IO 64 或更多 IO 密集型任务
Dispatchers.Main 1(主线程) UI 操作
newSingleThreadContext 1 单线程串行

注意:别在 Dispatchers.Main 里做耗时操作。也别在 Dispatchers.Default 里做大量 IO。调度器选错了,性能直接崩。

避免协程泄漏

协程泄漏,说白了就是协程结束了但资源没释放。最常见的原因:

  • 作用域没取消
  • 回调里持有协程引用
  • 生命周期没对齐

我曾经接手过一个项目,Activity 销毁后协程还在跑。结果呢?内存泄漏,还时不时崩溃。排查了半天,发现是 GlobalScope 用多了。

正确的做法:

// ✅ 推荐:使用 viewModelScope 或 lifecycleScope
class MyViewModel : ViewModel() {
    fun loadData() {
        viewModelScope.launch {
            // 网络请求
        }
    }
}

// ❌ 不推荐:GlobalScope 容易泄漏
fun loadData() {
    GlobalScope.launch {
        // 网络请求
    }
}

还有一个坑:协程的 Job 没取消。比如这样:

// 容易泄漏的写法
var job: Job? = null

fun start() {
    job = CoroutineScope(Dispatchers.IO).launch {
        while (isActive) {
            // 循环任务
        }
    }
}

fun stop() {
    job?.cancel() // 如果忘了调用,协程一直跑
}

避坑指南:用 SupervisorJob 配合 CoroutineScope,子协程失败不影响父协程。但别忘了在生命周期结束时取消。

协程的性能基准测试

光说优化不行,得用数据说话。怎么做基准测试?我一般用 measureTimeMillis 或者 System.nanoTime

但要注意:协程是异步的,测试时得等所有协程完成。

// 基准测试示例
fun benchmarkCoroutine() = runBlocking {
    val time = measureTimeMillis {
        val jobs = List(1000) {
            launch(Dispatchers.Default) {
                // 模拟计算
                var sum = 0L
                for (i in 1..1000) {
                    sum += i
                }
            }
        }
        jobs.joinAll()
    }
    println("1000 个协程耗时: $time ms")
}

我习惯对比测试:同样的任务,用线程和协程各跑一次。结果往往很直观。

任务类型 线程耗时 协程耗时 内存对比
1000 次计算 120 ms 85 ms 线程高 3 倍
1000 次 IO 2.1 s 1.8 s 线程高 5 倍
10000 次挂起 OOM 320 ms 协程稳定

你看,协程在大量并发场景下优势明显。但前提是——你得用对。

总结一下:性能优化不是玄学。先分析内存开销,再优化调度器,最后用基准测试验证。每一步都有章可循。

知识体系图

下面这张图,把协程性能优化的核心逻辑串起来了:

协程性能优化 内存开销分析 调度器优化 避免协程泄漏 性能基准测试 Continuation 状态机状态 线程池管理 limitedParallelism 作用域管理 生命周期对齐 measureTimeMillis 对比测试 协程性能优化四象限:分析 → 优化 → 防范 → 验证

嗯,这张图把四个核心方向串起来了。你写代码时,可以对照着检查:内存分析做了吗?调度器优化了吗?泄漏防范了吗?基准测试跑了吗?

做到这四点,协程性能基本不会出大问题。


公众号:蓝海资料掘金营,微信deep3321