跳到主要内容

Android 内存泄漏

Android 托管堆中的内存泄漏,是业务已经不需要的对象仍被强引用链保留。Activity 执行了 onDestroy(),只代表它的组件生命周期结束,不代表所有指向它的引用都已消失。排查的重点是找出那个活得更久的持有者,再让引用的存续与业务责任一致。

这里聚焦 Android 的页面、回调与资源。可达性的共同原理见 GC Roots,ART 的具体执行与回收边界见 ART。

1. 面试核心​

先记住这几条,再读正文

2. 业务结束与对象可达性​

强引用链是从 GC Roots 出发,通过普通对象引用能够到达目标对象的路径。假如一个应用级监听集合仍持有 Activity 的回调,而这个回调捕获了 Activity,那么页面退出后仍然存在这样的持有关系:

长期可达的事件源 → 监听集合 → 回调 → 已退出的 Activity → View

GC 能看见引用,无法推断“用户已经返回上一页,所以这个回调没有业务价值”。只要该强引用链仍然存在,Activity 及其可达对象就可能继续保留。

相反,两个已经不可达的对象互相引用,不会只因为形成环就无法被追踪式 GC 回收。把 Android 的托管堆泄漏解释成“所有循环引用都无法回收”,混淆了可达性分析与引用计数。

长期持有短期对象也不一定就是缺陷。导航返回栈可能合理保留页面状态,缓存也可能在容量限制内保留对象。是否泄漏要回答两个问题:这个对象现在还应不应该存在;如果不应存在,哪条引用链阻止它释放。

延迟任务还可能造成暂时保留:60 秒后的回调会在执行或取消前保留相关对象,即使最终能够释放,也可能让大量已经退出的页面在这段时间里占用内存。因此需要区分永久增长与超出业务需要的延迟释放。

3. 持有关系与生命周期责任​

3.1 页面之外的持有者​

排查时先找谁比页面活得更久,再检查它是否有必要继续持有页面。

持有者可能保留什么释放或缩小范围的方向
静态字段、单例Activity、View、带 UI 的 Context仅需应用环境时使用合适的 applicationContext;不要缓存页面对象
Handler 的待执行消息Runnable、Handler 或其捕获对象取消不再需要的回调,防止旧任务继续重新入队
事件源、监听注册表匿名对象、lambda 和订阅者与注册范围匹配地解绑,清除保存的订阅句柄
协程、线程或长网络任务任务闭包、页面与视图使用合适作用域与取消传播,长期任务脱离 UI 对象
缓存、集合已过期的图片、页面或业务对象定义容量、过期和移除条件,避免无界增长
JNI global referenceJava 对象在对应原生资源责任结束时释放,不能只等待 Java 字段清空

Handler 的机制是消息队列持有尚未完成的工作,再通过工作对象找到页面;具体路径见 Android 消息机制。把 Handler 类改成静态嵌套类,只处理其中一种持有方式,不能消除 Runnable 自己捕获 Activity 的路径。

同样,Service 本身不是泄漏的同义词。应该检查它是否仍按业务要求运行,或者结束后是否被其他对象、线程、监听和绑定关系不必要地保留。

3.2 注册与解绑的范围​

下面使用一个只在主线程访问的简单事件源,展示监听注册为什么需要成对释放。它只说明持有关系,不承担跨线程消息调度。

object UiEvents {
private val listeners = mutableSetOf<(String) -> Unit>()

fun subscribe(listener: (String) -> Unit): AutoCloseable {
listeners += listener
return AutoCloseable { listeners -= listener }
}

fun emit(text: String) {
listeners.toList().forEach { it(text) }
}
}

listeners 强引用每个回调。若回调读取或修改 Activity 的成员,就可能捕获 Activity。订阅句柄也会保留用于解绑的回调,所以解绑后不应再无意义地长期缓存句柄。

import android.app.Activity

class EventActivity : Activity() {
private var subscription: AutoCloseable? = null

override fun onStart() {
super.onStart()
subscription = UiEvents.subscribe { text -> title = text }
}

override fun onStop() {
subscription?.close()
subscription = null
super.onStop()
}
}

这里选择 onStart() 到 onStop(),因为示例只在页面处于相应可见生命周期时接收事件。退出该范围后,既从事件源移除监听,也清空自己的订阅句柄。若业务需要更短或更长的监听范围,应重新选择匹配的注册点与解除点。

示例中所有操作在同一线程完成;异步消息源还可能有已经发出或正在执行的回调。解除注册不一定撤销那些在途工作,仍需取消、有效性检查和清晰的结果归属。

3.3 Fragment 的 View 与协程​

Fragment 仍在返回栈中时,它的旧 View 已经可能结束生命周期。因此 binding、adapter 中指向旧 View 的关系,以及界面观察者,应跟随 View 的生命周期清理;相关规则见 Fragment 生命周期。

使用生命周期协程作用域,可以让任务在所属生命周期结束时取消,但前提是任务遵守取消。把页面闭包交给不受该作用域管理的全局对象,或者吞掉取消异常,都可能让任务继续保留页面。

ViewModel 可以比一次 Activity 或 Fragment View 实例活得更久,因此不适合直接保存 Activity、View 或 binding。状态应描述界面需要什么,而不是持有旧界面本身,见 Android ViewModel。

4. 静态类与弱引用的作用范围​

Java 非静态内部类可能带有指向外部实例的隐式引用;静态嵌套类不带这条隐式关系。但如果静态类显式保存 activity 字段,或者它提交的 Runnable 捕获了页面,仍然可以形成同样的保留路径。

Kotlin 的嵌套类默认不持有外部实例,inner 类才具有对应外部引用。lambda 是否保留页面,要看实际捕获内容,不能只看有没有匿名类的语法。

弱引用允许 GC 在对象不再强可达时清除该引用,适合表达“能找到就使用,但不承担保持存活责任”的关系。不过它不能替代业务上的任务取消:

  • 页面仍可能因另一条强引用路径活着,改掉某一个字段并不能证明泄漏已经修复。
  • get() 可能返回 null,调用方必须处理对象已经消失的情况。
  • 旧任务即使不再保留页面,也可能继续消耗网络、CPU 或持有其他资源。
  • 页面对象尚在内存中,不表示它仍处于可以更新 UI 的生命周期。

所以修复顺序通常是明确责任、取消或解绑不再需要的工作,再选择合适的引用方式。不能看到泄漏就把所有字段统一换成 WeakReference。

5. LeakCanary 与堆证据​

5.1 从预期释放到持有路径​

LeakCanary 是用于发现 Android 对象意外保留并分析持有关系的工具。它会跟踪 Activity、Fragment、Fragment View 等生命周期,在对象应当不再被使用时交给 ObjectWatcher 观察;具体自动观察对象随版本与配置有所不同。

观察器通过弱引用跟踪对象,避免检测工具自己成为强持有者。等待并触发相应 GC 检查后,仍未释放的对象会成为 retained object 线索。达到相应分析条件时,工具生成堆转储,并用堆分析寻找 GC Root 到目标对象的强引用路径。

Leak trace 展示一条导致对象被保留的引用路径。读报告时,要逐段判断哪个对象应长期存在、哪个对象已经结束责任,以及从哪里开始出现不该保留的引用。

例如事件源应当全局存在,不代表它的每一个 Activity 监听也应当全局存在。真正要修复的通常是漏掉的解绑,不能因为 trace 中出现事件源就把整个事件系统删除。

工具观察到保留,不等于已经自动判定所有业务条件。正在进行的必要操作、测试环境额外持有、库或平台实现问题,都需要结合现场分析;没有 LeakCanary 报告也不能证明不存在 native 内存泄漏或未被观察对象的泄漏。

5.2 堆转储里的几个量​

堆转储是一个时刻的对象和引用关系快照。基本分析方法与 堆转储 相通,但 Android 应使用相应工具与 ART 环境,不能直接套用 HotSpot 的诊断命令。

观察项能帮助回答什么不能直接推出什么
某类实例数量退出页面后是否仍积累多份 Activity 或 View数量多一定是泄漏
Shallow size对象自身在对应堆中的占用它连带保留的全部内存
Retained size若解除某个支配对象的保留,预计可一并释放的对象范围任意两个对象的 retained size 可以直接相加
到 GC Roots 的路径谁在保留目标对象这条路径一定没有业务必要性
Native size 或相关分配记录某些对象关联的原生分配成本Java 堆快照覆盖了所有图形、native 和系统分配

分析 retained size 时需要理解共享:两个对象都引用同一张图片,并不代表删除其中任何一个就能释放图片。只有相关持有关系都解除,且不存在其他必要引用,图片才可能回收。

5.3 验证修复​

可靠的排查过程包含可重复操作与前后对照:

  1. 固定构建、设备与场景,例如连续进入并退出同一详情页,记录是否发生旋转或异步请求。
  2. 在页面责任已结束、必要工作已完成后观察存活数量和趋势,再采集相关堆证据。
  3. 找到对象到根的路径,定位未解绑监听、队列任务或无界集合等具体持有者。
  4. 修改负责注册或持有的代码,并重复同一操作序列,确认旧实例不再持续积累。
  5. 同时验证页面返回、取消、重新进入等功能,避免只通过提前置空把正常回调弄丢。

一次主动 GC 后占用降低,只能说明一部分内存得到回收。它不能单独证明修复,也不能强行回收仍有强引用的页面。Java 堆空闲空间与进程实际驻留内存也不同,因此不要求每次页面退出后 RSS 都立即回到初始值。

6. 内存压力、OOM 与资源​

6.1 OOM 的不同来源​

OutOfMemoryError 表示某次内存分配无法满足。泄漏会逐步减少可用空间,但没有泄漏的程序也可能因为一次分配过大、并发解码过多或正常数据规模超过预算而失败。

现象优先验证的假设
重复进出页面后,旧页面实例持续累积生命周期或外部持有泄漏
打开超大图片时瞬间失败解码尺寸、像素格式和同时存活的缓冲
大量临时对象伴随频繁 GC分配速率和短时压力,不必然是泄漏
缓存随内容种类不断增长容量与淘汰策略是否有界
Java 堆相对稳定但进程内存持续增大native、图形或其他分配,需要对应证据

图像压缩文件的体积不等于解码后像素内存。例如常见的 4 字节每像素表示下,4000 × 3000 像素仅像素数据就约 48 MB,还没有计算额外缓冲和对象成本。控制显示所需尺寸、并发和缓存容量,比无条件扩大堆更能对应问题。

6.2 显式资源与 Bitmap​

文件、Cursor、流和某些 native 句柄需要及时关闭或释放。即使包装对象最终可由 GC 回收,也不能依赖不可预测的回收时机承担稀缺资源释放;应按 API 约定使用 use、close 或对应释放方法。

Bitmap 的像素存储位置与 Android 版本有关:API 11—25 位于托管堆,API 26 起像素数据位于 native heap。Bitmap 包装对象仍然有托管引用,因此一个泄漏的 ImageView 也可能连带保留大量像素资源。

不能把 recycle() 当作所有版本、所有场景的通用泄漏修复。如果其他 View、绘制工作或缓存仍在使用同一 Bitmap,提前回收会破坏使用者;需要依据实际库与平台的生命周期管理资源。

对 native 资源应检查对应分配与释放;对 JNI global reference 应检查删除责任。只给 Java 字段赋 null,不保证另一侧保存的引用和资源已经消失。

7 · 面试题卡片​

本页题目与参考回答

7.1 Android 中的内存泄漏是什么?​

考察重点:业务生命周期、可达性与循环引用。

相关内容:业务结束与对象可达性。

参考回答

托管堆泄漏是业务已不需要的对象仍被从 GC Roots 出发的强引用链保留。 Activity 生命周期结束不会自动清除监听、队列或单例中的引用。

对象之间仅有内部循环、整体已不可达时,追踪式 GC 可以回收。是否泄漏需要结合预期生命周期和实际引用路径判断。

7.2 Android 中有哪些内存泄漏场景,怎样处理?​

考察重点:长期持有者、释放责任和生命周期匹配。

相关内容:持有关系与生命周期责任。

参考回答

重点检查单例、队列、监听、长期任务和缓存是否保留已经退出的页面。 例如全局监听集合捕获 Activity,延迟 Runnable 捕获旧 View,或 Fragment 在 View 销毁后仍保存 binding。

按责任修复:缩小 Context 和对象持有范围,匹配注册与解绑,取消无用任务,清除旧视图关系,并给缓存设置合理边界。之后沿原路径复查,不能只把某个字段置空就认定全部释放。

7.3 把内部类改成 static 就能完全避免内存泄漏吗?​

考察重点:隐式外部引用、显式字段与回调捕获。

相关内容:静态类与弱引用的作用范围。

参考回答

不能。静态嵌套类去掉的是隐式外部实例引用,显式保存 Activity 或提交捕获页面的回调仍然可能泄漏。 还要检查类中的字段、任务与长期注册关系。

弱引用也只能改变一条引用的保留语义,不能替代取消、解绑和生命周期校验。

7.4 LeakCanary 怎样检测内存泄漏?​

考察重点:弱引用观察、预期生命周期与堆路径分析。

相关内容:从预期释放到持有路径。

参考回答

它在对象应当不再使用时通过弱引用观察是否仍被保留,再通过堆分析寻找 GC Root 到目标的强引用路径。 观察器不能自己强持有目标,否则会制造被检测的问题。

报告提供保留路径与判断线索,开发者仍需结合业务生命周期找到不该存在的引用并修复。它不等于自动证明所有 native 或未观察对象都没有泄漏。

7.5 怎样排查 Android 内存泄漏?​

考察重点:复现、堆证据、持有者与修复对照。

相关内容:堆转储里的几个量、验证修复。

参考回答

先用固定操作复现存活对象持续积累,再沿堆中的引用路径定位持有者。 结合实例数量、shallow/retained size 与生命周期判断,不只看总内存或一次 GC。

在负责注册、排队或缓存的代码处解除无用持有,重复同样的进出页面、旋转和异步场景,确认增长消失,同时验证回调与恢复功能仍正确。

7.6 OOM 是否一定意味着存在内存泄漏?​

考察重点:分配失败、峰值需求和长期保留的区别。

相关内容:OOM 的不同来源。

参考回答

不一定。OOM 是分配失败,泄漏只是可能原因之一。 超大图片、多份同时解码的缓冲、正常数据过大或短时分配峰值,都可能耗尽可用空间。

先看失败分配、同时存活对象与多轮操作后的趋势,再决定是解除持有、限制并发、降低解码尺寸还是调整容量。不能仅靠扩大堆或主动 GC 代替原因分析。

7.7 为什么清空 Java 引用后,仍需检查 Bitmap 和 native 资源?​

考察重点:包装对象、底层资源、版本与共享使用者。

相关内容:显式资源与 Bitmap。

参考回答

包装对象与底层资源有各自的持有和释放责任,清空一个字段不会解除所有引用。 API 26 起 Bitmap 像素在 native heap,泄漏的 View 仍可能通过 Bitmap 保留大量内存。

文件和 native 句柄应按 API 约定及时关闭,JNI global reference 也需要对应释放。不能对仍被其他消费者使用的 Bitmap 随意调用 recycle()。

8. 参考资料​

来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明