Appearance
Android / Java 内存泄漏定位、LeakCanary 原理与 Profiler 实战
回到总览:内存、生命周期、系统约束
相关模块:JVM / ART / Dart / Flutter 内存模型总览 · Android 生命周期总览 · JVM / ART 垃圾回收算法与 GC 演进深度剖析 · Java / Android 引用类型与 Dart 弱引用对照
一句话定义
内存泄漏(Memory Leak)是指本来已经不再需要的对象仍然被某条不应该存在的强引用链持有,导致 GC 判定它仍然从 GC Roots 可达。它通常表现为托管对象无法回收,但进程整体内存问题还可能来自 Native heap、Bitmap、GPU 或外部资源,不能把所有 OOM 都直接归因于 Java 堆泄漏。
代码索引
计划补齐的实验:
labs/android/host/topics/memory-lifecycle/memory-leak-demo/— 常见内存泄漏场景重现、LeakCanary 监控与 Memory Profiler 堆转储分析
为什么需要
- 为什么 Activity 被销毁(
onDestroy已调用)后,它占用的内存不一定会立即被 GC 回收?- 一句话答:
onDestroy()只是系统给 Activity 的生命周期回调通知,如果在静态变量、单例或异步子线程中依然保留了对该 Activity (或其 Context) 的强引用,GC Roots 依然可达,它就会成为泄漏在堆中的“僵尸对象”。(详见下文 §1及复习题 2)
- 一句话答:
- 为什么 10 年 Android 工程师必须掌握 LeakCanary 与 Profiler 原理?
- 一句话答:线上 OOM 崩溃日志通常只显示内存分配失败的“受害现场”,真正原因还需要结合托管堆、Native/Bitmap/GPU 统计、引用链和分配记录判断;LeakCanary 与 Hprof/Heap Dump 能帮助定位托管对象的持有链,但不能单独覆盖所有进程内存问题。(详见下文 §2、§3及复习题 1)
底层机制
1. 常见内存泄漏五大经典场景
text
[长生命周期对象 (如 GC Root: Static / Singleton / Thread)]
| (持有强引用)
v
[短生命周期对象 (如 Activity / Fragment / View)] ---> [携带大量字节的 Bitmap / Cache]- 单例模式 / 静态变量持有 Context:单例持有 Activity 的 Context,导致 Activity 无法随 View 销毁。
- 非静态内部类 / 匿名内部类隐式持有外部类引用:
- 典型的
Handler匿名内部类发延迟消息:Message 进了 MessageQueue,Message 的target指向 Handler,Handler 隐式持有 Activity!
- 典型的
- 未注销的监听器 / 观察者模式:
EventBus.register()、RxJava订阅、系统广播未在onDestroy调用unregister()。 - 属性动画未在 View 销毁时停止:
ValueAnimator设置了INFINITE无限循环且持有 View 引用。 - WebView 资源未收口:
WebView可能通过渲染、JS、回调和 Context 持有较长生命周期资源,需要在合适的生命周期中停止加载、解除回调并调用destroy();是否使用独立进程要根据隔离、稳定性和内存风险单独评估,不是通用泄漏修复方案。
判断一次“内存没降”到底是不是泄漏,至少要分三步:
text
页面/任务结束
↓
对象是否仍被 GC Root 强引用?
├─ 是:先追引用链,可能是泄漏或业务缓存
└─ 否:对象已不可达,等待 GC,不一定是泄漏
↓
进程内存仍高?
├─ 托管堆高:看分配、晋升、缓存和 GC
├─ Native/Bitmap 高:看解码、JNI、FFI、插件资源
└─ GPU/图形高:看纹理、Surface、BufferQueue、渲染缓存onDestroy()、dispose() 和“对象不可达”是三个不同事件。页面回调结束只能说明生命周期进入某个阶段;泄漏判定还需要等待对象在合理时间后仍未被回收,并找到实际的强引用路径。
2. LeakCanary 工作原理深度拆解
LeakCanary 是 Android 社区最成熟的内存泄漏自动化检测库,其底层核心机制分为 4 步:
text
1. 监听生命周期
ActivityLifecycleCallbacks -> 捕获 onDestroy(activity)
|
v
2. 弱引用 + 引用队列 (WeakReference + ReferenceQueue)
用 KeyedWeakReference 包装 activity,并关联 ReferenceQueue
|
v
3. 延迟一段时间后请求 GC 检查(以下为原理化简化流程)
Background Handler -> request GC
判断 ReferenceQueue 中是否存在该 key:
- 若存在:本次观察中该弱引用已进入队列,说明目标不再被强引用保持
- 若不存在:可能仍有强引用,也可能只是 GC 尚未完成,需要继续观察和分析
|
v
4. Dump Hprof 并用 Shark 解析引用链
生成 .hprof 文件 -> Shark 引擎分析 GC Roots -> 最短强引用路径 (Shortest Path to GC Roots) -> 报出泄漏!弱引用 + 引用队列 (ReferenceQueue) 技巧代码
java
// LeakCanary 核心逻辑简化
ReferenceQueue<Object> queue = new ReferenceQueue<>();
String key = UUID.randomUUID().toString();
// 用弱引用包裹被销毁的 Activity,并绑定 queue
WeakReference<Object> ref = new WeakReference<>(activity, queue);
// 触发 GC 后判断
if (queue.poll() == null) {
// 未入队不等于确定泄漏:GC 可能尚未完成,也可能仍存在强引用
analyzeHeapDump();
}Android / Flutter / Web / Backend 对照
| 维 | Android (Java/Kotlin) | Flutter (Dart VM) | Web / Frontend (JS) |
|---|---|---|---|
| 内存泄漏根源 | 内部类隐式持有外部类、单例/静态变量 | 闭包持有 BuildContext、未 cancel 的 Stream | 全局变量、闭包/DOM 引用未释放 |
| 检测手段 | LeakCanary / Android Profiler | DevTools Memory Timeline / Heap Snapshot | Chrome DevTools Memory Heap Snapshot |
| 主要危害 | 引发 OOM 直接 Crash 闪退 | 引起 UI 卡顿、内存报警 | 页面变慢、浏览器 Tab 崩溃 |
| 防范工具 | WeakReference / LifecycleOwner 绑定 | dispose() 取消订阅、避免闭包强持有、DevTools Memory | 页面 Unmount 显式解绑 Listener |
常见场景与代码修法
1. 修复 Handler 导致的 Activity 内存泄漏
错误示范(匿名内部类)
java
public class LeakyActivity extends Activity {
// 隐式持有 LeakyActivity.this 强引用!
private final Handler mHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
// ...
}
};
}正确修复(静态内部类 + 弱引用)
java
public class SafeActivity extends Activity {
private static class SafeHandler extends Handler {
private final WeakReference<SafeActivity> mActivityRef;
public SafeHandler(SafeActivity activity) {
mActivityRef = new WeakReference<>(activity);
}
@Override
public void handleMessage(Message msg) {
SafeActivity activity = mActivityRef.get();
if (activity != null && !activity.isFinishing()) {
// 安全地处理 UI 逻辑
}
}
}
private final SafeHandler mHandler = new SafeHandler(this);
@Override
protected void onDestroy() {
super.onDestroy();
mHandler.removeCallbacksAndMessages(null); // 清空队列中所有未执行的消息
}
}常见误配、事故后果与排障
1. 利用 Android Studio Memory Profiler 抓取 Hprof 堆转储
当怀疑有泄漏时:
- 启动 Android Profiler → Memory 仪表盘。
- 点击 Record Heap Dump 录制当前内存快照。
- 过滤搜索被销毁的
Activity实例,查看 Depth 与 Instance Count;onDestroy后短时间仍存在只能作为线索,最终要结合保留时间和 nearest GC root 判断是否泄漏。 - 右侧展开 References 面板,勾选 Show nearest GC root,定位是哪条强引用链阻止了 GC。
与相近概念对比
| 概念 | 发生原因 | 表现结果 | 治理方法 |
|---|---|---|---|
| 内存泄漏 (Memory Leak) | 垃圾对象从 GC Roots 依然强引用可达,无法被回收 | 内存占用随使用持续攀升 | LeakCanary 找出引用链,切断强引用 |
| 内存抖动 (Memory Churn) | 短时间内频繁创建并销毁大量临时对象 | 内存图呈锯齿状,频繁 GC 引起卡顿 | 对象池复用、避免在 onDraw/循环中 new |
| 内存溢出 (OOM) | 堆空间不足以分配新对象,且无法释放出足够空间 | 直接抛出 OutOfMemoryError 导致 Crash | 优化 Bitmap 尺寸、修复泄漏、开大堆 |
Flutter 侧的泄漏与排障边界
Flutter 没有 Android Activity 那样的单一“页面对象泄漏”入口,但页面级资源仍可能被异步源、缓存或 Native 资源持有:
| 资源 | 常见持有者 | 修法/验证 |
|---|---|---|
TextEditingController、AnimationController | State、动画 ticker、输入系统 | 在 dispose() 中释放,检查 State 是否仍被回调触达 |
StreamSubscription、Timer、ChangeNotifier | Stream、Timer、Provider/业务服务 | 在页面或业务作用域结束时 cancel / dispose |
| 闭包、Future 回调 | State、BuildContext、异步任务 | 取消任务或让回调只更新仍存活的作用域;不要把弱引用当作生命周期治理替代品 |
ImageCache、大 Uint8List | 图片缓存、列表状态、业务缓存 | 检查缓存上限、淘汰策略和是否重复持有原图与缩略图 |
| FFI 指针、PlatformView、Texture | Native 插件、Engine、GPU | 显式释放 Native 资源,结合 DevTools 和宿主侧工具观察 |
最小例子:
dart
class _DetailPageState extends State<DetailPage> {
late final StreamSubscription<String> _subscription;
@override
void initState() {
super.initState();
_subscription = eventStream.listen((value) {
if (!mounted) return;
setState(() => _value = value);
});
}
@override
void dispose() {
_subscription.cancel();
super.dispose();
}
}这里的关键不是“加一个 mounted 就没有泄漏”,而是两件事同时成立:页面销毁后不再更新旧 State,并且订阅本身也被取消。mounted 主要防止晚到回调更新已销毁的 State,不能替代 cancel()。
对应实验
计划补齐的实验:
labs/android/host/topics/memory-lifecycle/memory-leak-demo/— 常见内存泄漏场景重现、LeakCanary 监控与 Memory Profiler 堆转储分析
复习检查题
LeakCanary 是如何巧妙判断一个已经被
onDestroy()的 Activity 是否真的发生内存泄漏的?答:LeakCanary 会在 Activity 销毁后记录一个带
ReferenceQueue的弱引用,并在等待和 GC 观察阶段检查对象是否仍被保留。弱引用进入队列说明本次观察中目标已不再被强引用保持;未进入队列只能说明对象仍可能存活或 GC 尚未完成,不能单独作为确定结论。随后 LeakCanary 会结合 retained object 的策略和 Shark 堆分析,寻找从 GC Roots 到目标对象的实际引用链。为什么非静态内部类(包括匿名内部类)默认会持有外部类的强引用?
答:在 Java/Kotlin 编译期,编译器会自动为非静态内部类的构造函数中添加一个隐式的外部类引用参数(即
this$0),使得内部类可以直接访问外部类的成员变量与方法。这种隐式强引用的生命周期如果比外部类长(如延迟 Handler 消息或异步子线程),就会阻止外部类被 GC 回收从而引发内存泄漏。
速记
- 泄漏本质:长生命周期强引用短生命周期对象,GC Roots 依然可达。
- LeakCanary 原理:WeakReference + ReferenceQueue 负责发现疑似 retained object,再结合等待策略和 Heap Dump 引用链分析确认原因。
- Handler 修复法则:静态内部类 + WeakReference 弱引用,onDestroy 清空消息队列。