第2章:GlobalScope在Android/Kotlin中的典型滥用场景——静态引用、匿名内部类、协程未取消

第1章建立了内存泄漏的基本认知,本章聚焦于Android/Kotlin开发中最容易被忽视的三个GlobalScope滥用场景。这三个场景覆盖了90%以上的线上泄漏案例,彼此之间常有叠加效应。本章按照“现象识别→数据采集→原因定位→修复动作→复测→预防”的流程,逐一拆解每个场景的泄漏根因。

GlobalScope滥用导致的三种典型泄漏路径 GlobalScope.launch 静态引用持有Activity 匿名内部类隐式持有 协程未取消&延迟任务 Activity无法回收 Fragment泄漏 回调队列膨胀 共同点:GlobalScope的协程生命周期与Application绑定, 但匿名内部类/静态引用却持有了短生命周期的对象,形成根不可达的泄漏。

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 数据采集

针对每个场景,推荐以下采集手段:

场景推荐工具关键采集指标
静态引用 + GlobalScopeMemory Profiler + MAT静态变量(如 Companion)中持有的Activity引用
匿名内部类 + GlobalScopeLeakCanary泄漏分析报告中的 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 预防

长期工程层面建议建立如下防线:

  1. 代码审查规则:禁止在Activity/Fragment内部使用 GlobalScope。统一使用 LifecycleScope 或 ViewModelScope。可以在Detekt或自定义lint规则中配置。
  2. 静态分析:利用LeakCanary自动检测的CI管道,每次PR自动触发泄漏检查。
  3. 协程取消训练:团队约定所有协程启动后必须与某个作用域绑定,并在组件销毁时取消。CoroutineScope 应作为构造函数参数传递,而非硬编码 GlobalScope。
  4. 知识固化:使用本章的故障树(SVG图)作为新成员培训材料,强化“GlobalScope ≠ 全局作用域,而是应用级作用域”的认知。