第2章:GlobalScope在Android/Kotlin中的典型滥用场景——静态引用、匿名内部类、协程未取消
第1章建立了内存泄漏的基本认知,本章聚焦于Android/Kotlin开发中最容易被忽视的三个GlobalScope滥用场景。这三个场景覆盖了90%以上的线上泄漏案例,彼此之间常有叠加效应。本章按照“现象识别→数据采集→原因定位→修复动作→复测→预防”的流程,逐一拆解每个场景的泄漏根因。
2.1 现象识别
当GlobalScope被滥用时,内存泄漏的现场通常有以下共性特征:
- Activity/Fragment销毁后,对应实例数量持续增长:使用
dumpsys meminfo或Profiler查看Activity对象计数不会下降。 - Heap dump中大量匿名内部类实例:例如
GlobalScope$1、MyActivity$1对象占据内存。 - GC Root路径显示协程Continuation对象持有外部引用:常见Root为
GlobalScope或DefaultExecutor。
快速判断规则:如果发生内存泄漏的Activity/Fragment中使用了
GlobalScope.launch 且未手动取消,那么它99%是泄漏元凶。使用LeakCanary可以直接给出协程Continuation的引用链。2.2 数据采集
针对每个场景,推荐以下采集手段:
| 场景 | 推荐工具 | 关键采集指标 |
|---|---|---|
| 静态引用 + GlobalScope | Memory Profiler + MAT | 静态变量(如 Companion)中持有的Activity引用 |
| 匿名内部类 + GlobalScope | LeakCanary | 泄漏分析报告中的 GlobalScope$launch → this$0 引用 |
| 协程未取消 + 延迟任务 | Android Studio CPU Profiler + 代码日志 | GlobalScope协程数量(Thread.activeCount 与协程上下文数量) |
采集时务必保证进入泄漏页面后再退出,等待至少一次GC(点击Profiler中的“垃圾桶”按钮),然后抓取heap dump。
2.3 原因定位
场景一:静态引用 + GlobalScope
开发者常为了便捷,将GlobalScope的Job或返回的Flow存储在伴生对象(静态域)中:
// ❌ 错误示例
class MyActivity : AppCompatActivity() {
companion object {
lateinit var globalJob: Job
}
fun startTask() {
globalJob = GlobalScope.launch {
// 使用外部Activity引用
showToast("done")
}
}
}
当Activity退出后,globalJob 仍然持有Activity的隐式引用(通过协程的Continuation),导致Activity无法被回收。
场景二:匿名内部类 + GlobalScope
在Android中,Lambda表达式(本质是匿名内部类)会隐式捕获外部类的引用:
// ❌ 错误示例
class MyFragment : Fragment() {
fun loadData() {
GlobalScope.launch(Dispatchers.IO) {
val result = api.fetch()
// 这里的launch内部类隐式持有MyFragment.this
withContext(Dispatchers.Main) {
textView.text = result
}
}
}
}
即使Fragment被移除,GlobalScope.launch创建的协程仍然在运行,Continuation中保存了Fragment引用,导致Fragment泄漏。
场景三:协程未取消 + 延迟任务
使用 GlobalScope.launch 启动定时任务或网络轮询,且未在生命周期回调中取消:
// ❌ 错误示例
class MainActivity : AppCompatActivity() {
private fun pollStatus() {
GlobalScope.launch {
while (true) {
delay(5000)
updateUI() // 使用Activity引用
}
}
}
override fun onDestroy() {
super.onDestroy()
// 没有取消协程!pollStatus的协程继续持有Activity
}
}
该协程被 GlobalScope 持有,且协程内部不断延时,Activity永远不会被GC。
2.4 修复动作
修复策略分为两层:替换作用域 和 主动取消。以下给出针对三个场景的修复对照:
| 场景 | 修复前 | 修复后 |
|---|---|---|
| 静态引用 | GlobalScope + 静态Job | 使用 lifecycleScope 或 viewModelScope,移除静态域 |
| 匿名内部类 | GlobalScope.launch{ ... } | 改为 lifecycleScope.launch 或显式传递弱引用 |
| 协程未取消 | GlobalScope + while(true) | 存入 CoroutineScope 实例,在 onDestroy 中调用 scope.cancel() |
具体代码示例(针对场景三):
// ✅ 正确修复
class MainActivity : AppCompatActivity() {
// 使用Activity生命周期感知的CoroutineScope
private val activityScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private fun pollStatus() {
activityScope.launch {
while (isActive) {
delay(5000)
updateUI()
}
}
}
override fun onDestroy() {
super.onDestroy()
activityScope.cancel() // 自动取消所有子协程
}
}
核心原则:协程的作用域应与其绑定的组件生命周期一致。短生命周期组件使用
lifecycleScope/viewModelScope,不要使用 GlobalScope 除非你明确知道该任务是全局单例且不持有任何外部引用。2.5 复测
修复后必须通过以下检查点确认泄漏已消除:
- 重复进出场景5~10次,每次退出后手动触发GC,检查heap中Activity/Fragment实例数是否保持在1~2个(通常残留一个被
onRestoreInstanceState短暂持有)。 - LeakCanary:如果检测报告没有再出现泄漏,且引用链中不再有GlobalScope节点,则修复成功。
- 协程数量稳定:使用
AsyncProfiler或日志观察GlobalScope的协程池线程数,修复后不应出现协程数量无限增长。
注意:如果修复后仍然存在泄漏,请检查是否还有其他全局单例(如
object、companion object)间接持有了Activity引用。可以使用MAT的“GC Root”路径分析确认。2.6 预防
长期工程层面建议建立如下防线:
- 代码审查规则:禁止在Activity/Fragment内部使用
GlobalScope。统一使用LifecycleScope或ViewModelScope。可以在Detekt或自定义lint规则中配置。 - 静态分析:利用LeakCanary自动检测的CI管道,每次PR自动触发泄漏检查。
- 协程取消训练:团队约定所有协程启动后必须与某个作用域绑定,并在组件销毁时取消。
CoroutineScope应作为构造函数参数传递,而非硬编码GlobalScope。 - 知识固化:使用本章的故障树(SVG图)作为新成员培训材料,强化“GlobalScope ≠ 全局作用域,而是应用级作用域”的认知。