内存泄漏:是什么、典型场景与诊断

作者: IT Sectr 发布日期: 2026-07-29 阅读时间: 10 分钟

内存泄漏 (memory leak) — 应用程序未能释放不再需要的对象所占用的内存的情况。在移动开发中,这尤其关键:有限的堆和缺乏交换导致 OutOfMemoryError 和应用程序崩溃。根据 Purdue University(2022年)的数据,Google Play 中 35% 的 Android 应用程序至少包含一个内存泄漏。我们将分析典型场景、诊断工具和修复方法。

要点

  • GC Root — 垃圾回收器确定活动对象的入口点
  • Context 泄漏 — 将 Activity Context 传递给单例会导致整个 View 层级被持有
  • Handler 与 postDelayed — 如果 Activity 被销毁,Handler 不允许它进入 GC
  • Heap dump — 通过 MAT 或 Android Profiler 分析泄漏的主要方法
  • SoftReference — WeakReference 的替代方案,用于在内存不足时自动清理的缓存

移动应用中的内存泄漏是什么?

内存泄漏 — 是指在对象不再被程序需要后,已分配的内存未能归还给系统的情况。垃圾回收器认为这样的对象是活动的,因为从 GC Root 到该对象有一条活跃的引用链。

Java/Kotlin 中,垃圾回收器自动工作,但如果存在技术上的引用,它无法确定对象在逻辑上不再需要。开发人员必须显式中断不需要的连接。在 Swift/Objective-C 中,ARC 自动计数引用,但保留循环会阻止计数器归零。

泄漏的主要危险是 累积效应。每次泄漏消耗少量内存,但在多次屏幕切换(屏幕旋转、打开/关闭 Activity)时,泄漏会不断累积,直到耗尽堆的限制。

泄漏与膨胀有何不同?

泄漏 — 对象对代码不可访问,但未被 GC 删除。膨胀 — 对象在逻辑上是需要的,但以过大的数量存储。膨胀的例子:100 MB 的图像缓存,工作集为 30 MB。两个问题都会导致 OOM,但原因和处理方法不同。

垃圾回收器如何工作以及为什么会产生泄漏?

ART(Android Runtime)使用带并发压缩的分代垃圾回收。内存分为新生代(Young)、老年代(Old)和大对象(Large)。经历了多次 GC 周期的对象被移动到老年代,在该区域回收频率较低 — 这加快了常规周期。

达到一定的占用阈值(通常为 75-85%)时,GC 启动。在 GC 期间,应用程序的所有线程都会被暂停(STW — Stop The World)。活动对象越多,暂停时间越长。泄漏增加了活动对象的数量,从而延长了 GC 暂停时间。

回收器通过从 GC Roots 遍历图来确定活动对象:静态字段、活动线程的栈变量、JNI 引用。从这些根节点通过引用可达的任何对象都被视为活动的 — 即使开发人员知道它不再需要。

kotlin
// 示例:作为 GC Root 的静态集合 — 永久泄漏
object GlobalHolder {
    val listeners = mutableListOf<WeakReference<Any>>()
}

class LeakingFragment : Fragment() {
    override fun onCreate(savedInstanceState: Bundle ?= null) {
        super.onCreate(savedInstanceState)
        GlobalHolder.listeners.add(WeakReference(this))
        // WeakReference 不阻止 GC — 正确行为
    }
}

WeakReference 解决了这个问题:GC 在确定活动对象时忽略弱引用。如果只剩下弱引用指向一个对象,它将在下一个 GC 周期中被回收。

Android 和 iOS 中的典型泄漏场景

Activity Context — Android 中最常见的泄漏场景。如果单例、静态字段或长生命周期服务持有 Activity Context 的引用,则整个 Activity 及其所有 View 都无法被 GC 回收。解决方案:对于长生命周期对象使用 Application Context。

Handler 和已发送的消息 — Handler.postDelayed(runnable, delay) 将消息放入 Main Looper 的队列中。如果 Activity 在延迟到期前被销毁,消息仍在队列中,并通过 Runnable → 匿名类 → 外部类(Activity)持有引用。

kotlin
class SafeActivity : AppCompatActivity() {
    private val mainHandler = Handler(Looper.getMainLooper())
    private val callback = Runnable { /* update UI */ }

    override fun onResume() {
        super.onResume()
        mainHandler.postDelayed(callback, 5000)
    }

    override fun onPause() {
        mainHandler.removeCallbacks(callback) // 必需:清除队列
        super.onPause()
    }
}

内部类 — 非静态内部类持有对外部类实例的隐式引用。如果外部类是 Activity,且内部类被传递到外部(例如,传递给 RecyclerView.Adapter),则 Activity 无法被回收。

  • TimerTask 和 ScheduledExecutorService — 在 Activity 销毁前计划的任务
  • BroadcastReceiver — 未在 onPause/onDestroy 中取消注册的接收器继续持有 Context
  • ViewModel 持有 View 引用 — ViewModel 比 Activity 存活更久,持有 View 的引用会导致泄漏
  • Retrofit Call — 如果 Call 未被取消,响应会发送到已销毁的 Fragment

内存泄漏的诊断工具

Android Studio Memory Profiler — 用于实时监控堆的内置工具。显示已用内存的图表、按类型分配的数目和对象数量。允许记录堆转储并导出为 HPROF 格式,以便在 MAT 中进行分析。

Eclipse MAT(Memory Analyzer Tool)— 桌面堆转储分析器。自动构建泄漏嫌疑人报告,突出显示保留集最大的对象,并为每个可疑对象建议可能的 GC 根链。

Xcode Memory Graph Debugger — 用于 iOS。暂停应用程序并可视化对象图。保留循环以红色突出显示,可以点击任何对象查看其保留计数和引用。

工具功能复杂度
Memory Profiler实时图表、堆转储、对象分配跟踪
Eclipse MAT支配树、泄漏嫌疑人、OQL 查询
LeakCanary自动检测、通知中的泄漏跟踪最低
Xcode Memory Graph保留循环的可视化图、活动对象列表

根据 Uber Engineering Blog,在 CI/CD 流水线中引入自动内存性能分析(LeakCanary + 堆转储分析)可在 3 个月内将生产环境中的内存相关事故减少 60%。

修复泄漏的方法

替换 Context — 如果对象比 Activity 存活更久,请使用 applicationContext。所有长生命周期对象(单例、存储库、数据库辅助类)应接收 Application Context,而不是 Activity Context。例外:需要访问 Activity 特定主题或资源的 UI 组件。

Lifecycle-aware 组件 — 使用 LifecycleObserver、DefaultLifecycleObserver 或 reactivex 会在 onDestroy 时自动取消订阅。Android Jetpack 提供了 lifecycleScope 和 viewModelScope,它们会在相应事件发生时被清理。

静态内部类 — 如果内部类不需要访问外部类的字段,请将其设为 static。静态内部类不持有对外部类的隐式引用。如果需要访问,请使用 WeakReference 进行显式引用。

kotlin
class MyActivity : AppCompatActivity() {

    // ❌ 非静态内部类 — 对 MyActivity 的隐式引用
    inner class BadListener : SomeListener {
        override fun onEvent() { /*...*/ }
    }

    // ✅ 静态内部类 — 无隐式引用
    class GoodListener(private val activityRef: WeakReference<MyActivity>) : SomeListener {
        override fun onEvent() { /*...*/ }
    }
}

iOS 中,在闭包中使用捕获列表:[weak self],用于可能比创建者存活更久的闭包。对于委托,使用弱引用(weak var delegate)。对于保证只在 self 生命周期内被调用的闭包,可以使用 [unowned self],但要小心 — 访问已释放的对象会导致崩溃。

常见问题

如何在没有特殊工具的情况下找到泄漏?

Android 中,多次在屏幕之间切换(Activity A → B → A → B),然后检查 adb shell dumpsys meminfo package_name。如果 Total PSS 持续增长且不返回到初始值 — 说明存在泄漏。在 iOS 中类似:在 Xcode 中使用 Debug Memory Graph 进行可视化检查。

Kotlin 协程会导致泄漏吗?

会,如果 CoroutineScope 在组件销毁时没有被取消。在 GlobalScope 中启动的协程即使在 Activity 的 finish() 之后也会继续执行。解决方案:使用 viewModelScope(在 onCleared 中取消)或 lifecycleScope(在 onDestroy 中取消)。对于自定义 Scope,通过 LifecycleOwner 创建生命周期感知的作用域。

Bitmap 如何影响泄漏?

Bitmap 将像素数据存储在原生堆中,而不是 Java 堆中。这意味着 Java GC 看不到 Bitmap 的实际大小。如果不调用 Bitmap 的 recycle() 或不将引用置空,原生内存将不会被释放。使用带有 inSampleSize 的 BitmapFactory 加载缩略图,并使用 Glide/Coil 进行自动缓存管理。

什么是通过静态字段的泄漏?

静态字段 — 是一个 GC Root。它只要类被加载就存在(在 Android 中 — 只要 Process 存活)。如果静态字段引用了 Activity、Bitmap、View 或任何其他重量级对象,该对象将永远不会被 GC 回收。静态字段 — 永久的引用。解决方案:只存储 WeakReference 或在 onDestroy 中将静态字段置空。

如何在 iOS 中使用 ARC 避免泄漏?

ARC 在强引用计数降至零时自动释放对象。保留循环是 ARC 中唯一的泄漏方式。始终对 parent→child 引用使用 weak,其中 child 应该比 parent 存活更久(委托、数据源)。对于闭包,使用捕获列表 [weak self],并在闭包内部检查 self 是否为 nil。

总结

  • 内存泄漏 — 对象对代码不可访问,但由于存在来自 GC Root 的活动引用,未被 GC 删除
  • GC Roots 包括静态字段、栈变量和 JNI 引用;任何从它们可达的对象都是活动的
  • Context 泄漏 — Android 中最普遍的问题:将 Activity Context 传递给单例或静态字段
  • Handler 和内部类 — 第二常见的原因为:Looper 队列中未取消的消息持有对 Activity 的引用
  • LeakCanary — 标准自动检测工具;进行堆转储并显示精确的 GC 根链
  • lifecycleScope 和 viewModelScope 解决了通过协程泄漏的问题 — 在 destroy 时自动取消
  • 在 CI/CD 中分析内存:debug 中的 LeakCanary + 测试运行中的堆转储分析应在有新泄漏时阻止合并

我们将开发一款交钥匙移动应用程序

IT Sectr自2017年以来为初创企业和企业打造iOS和Android应用程序。我们将为您提供咨询并提出最佳解决方案。

讨论项目

另请阅读