3、内存泄漏检测:LeakCanary原理、鸿蒙内存泄漏检测工具、常见泄漏场景分析
内存泄漏,说白了就是对象用完了,但垃圾回收器收不回来。你想想看,一个对象明明不再需要了,却因为被某个生命周期更长的对象引用着,导致它一直占着内存不放。时间一长,内存越吃越多,最后就是OOM,应用直接闪退。
我在项目中遇到过好几次线上OOM问题,查到最后都是内存泄漏。嗯,这玩意儿不提前预防,后期排查成本极高。所以今天我们就来聊聊内存泄漏检测这件事。
3.1 LeakCanary的原理与启示
Android开发者对LeakCanary应该不陌生。它是个非常经典的内存泄漏检测工具。它的核心思路其实很简单:
- 监听对象生命周期:比如Activity的onDestroy()执行后,这个Activity就应该被回收了。
- 触发GC:在对象应该被回收的时间点,主动触发一次垃圾回收。
- 检查对象是否存活:通过弱引用(WeakReference)来观察目标对象。如果GC后弱引用还能获取到对象,说明有泄漏。
- 生成泄漏路径:如果确认泄漏,就dump堆内存,分析出GC Root到泄漏对象的引用链。
核心原理一句话:在对象本该死亡的时间点,检查它是否还活着。活着,就是泄漏。
LeakCanary在Android上用的是Application.ActivityLifecycleCallbacks来监听Activity销毁。鸿蒙这边呢?其实思路完全一样,只是API不同。
我个人习惯:在项目初期就把LeakCanary类似的机制集成进去。不要等到线上出问题了再补,那时候成本就高了。
3.2 鸿蒙内存泄漏检测工具
鸿蒙系统提供了几款官方工具,我挑几个最常用的说说。
3.2.1 DevEco Studio的Profiler
DevEco Studio自带的Profiler工具,功能很全。你可以用它抓取内存快照,分析对象引用关系。操作路径:
- 打开DevEco Studio,连接设备或模拟器
- 点击底部的“Profiler”标签
- 选择“Memory”面板
- 点击“Dump Java Heap”抓取快照
抓取后,你可以看到所有对象实例。按“Retained Size”排序,最大的那些往往就是泄漏嫌疑对象。
注意:Profiler抓快照时,应用会短暂卡顿。线上环境慎用,最好在测试阶段做。
3.2.2 鸿蒙自带的LeakDetector
鸿蒙从API 9开始,内置了一个轻量级的泄漏检测器——LeakDetector。它不需要你额外引入第三方库,直接就能用。
用法很简单:
import ohos.hiviewdfx.LeakDetector;
// 在Ability的onStop()中注册检测
@Override
protected void onStop() {
super.onStop();
LeakDetector.getInstance().detectLeak(this);
}
它的原理和LeakCanary类似:在Ability销毁时,通过弱引用持有Ability实例,然后延迟几秒触发GC,检查弱引用是否被回收。如果没回收,就输出警告日志。
我建议:在开发阶段,给所有Ability的基类加上这个检测。成本极低,收益很高。
3.2.3 命令行工具:hdc shell
有时候UI工具不方便,我喜欢直接用命令行。鸿蒙的hdc工具可以配合dumpsys meminfo查看进程内存:
hdc shell dumpsys meminfo com.example.myapp
输出里能看到Java Heap、Native Heap、Code等各个区域的内存占用。如果某个Activity的实例数异常多,基本就是泄漏了。
3.3 常见泄漏场景分析
理论说完了,咱们看看实战中哪些场景最容易踩坑。我把自己踩过的和帮别人排查过的案例整理了一下。
3.3.1 静态变量持有Activity/Ability引用
这是最常见的泄漏场景。比如:
public class MyManager {
private static Context sContext;
public static void init(Context context) {
sContext = context; // 这里持有了Activity的引用
}
}
Activity销毁后,sContext还指向它,GC就回收不了。解决办法很简单:用ApplicationContext,或者用弱引用。
避坑指南:我曾经在一个项目里看到有人把Activity传给一个全局的EventBus,Activity销毁时忘了反注册。结果每次打开关闭页面,内存里就多一个Activity实例。排查时一看,好家伙,堆里有十几个MainActivity。
3.3.2 内部类持有外部类引用
非静态内部类会隐式持有外部类的引用。比如:
public class MainAbility extends Ability {
private Handler handler = new Handler() {
@Override
public void handleMessage(Message msg) {
// 这里隐式持有MainAbility的引用
}
};
}
如果handler处理的消息有延迟,或者消息队列里还有未处理的消息,MainAbility就泄漏了。解决办法:
- 把内部类改成静态内部类
- 或者在onStop()里移除所有回调:
handler.removeCallbacksAndMessages(null)
3.3.3 资源未关闭
数据库Cursor、文件流、网络连接等,用完后必须关闭。不关闭的话,这些资源占用的内存就释放不了。
// 错误示例
DatabaseHelper helper = new DatabaseHelper(this);
ResultSet rs = helper.query("SELECT * FROM users");
// 用完后没关闭rs和helper
// 正确做法
try {
// 使用资源
} finally {
if (rs != null) rs.close();
if (helper != null) helper.close();
}
注意:鸿蒙的Closeable接口和Java一样,建议用try-with-resources语法,自动关闭。
3.3.4 单例模式中的Context泄漏
单例对象生命周期等于应用进程。如果单例里持有了Activity的Context,那这个Activity就泄漏了。
public class AppManager {
private static AppManager instance;
private Context context;
private AppManager(Context context) {
this.context = context; // 危险!
}
public static AppManager getInstance(Context context) {
if (instance == null) {
instance = new AppManager(context);
}
return instance;
}
}
解决办法:单例里只存ApplicationContext,或者用弱引用包装。
3.3.5 集合类中的对象未清理
如果你用HashMap、ArrayList等集合缓存了一些对象,但对象不再需要时没有从集合中移除,这些对象就会一直存在。
我见过一个案例:某个页面用ArrayList缓存了所有网络请求的回调对象。页面关闭时,只清空了界面,忘了清空这个列表。结果回调对象一直存在,还不断被调用,导致空指针和内存泄漏双重暴击。
我个人习惯:在Ability的onStop()里,统一清理所有集合、反注册所有监听器、移除所有回调。写一个cleanup()方法,把所有清理逻辑放进去,一目了然。
3.4 总结与建议
| 场景 | 原因 | 解决方案 |
|---|---|---|
| 静态变量引用 | 生命周期不匹配 | 用ApplicationContext或弱引用 |
| 内部类 | 隐式持有外部类引用 | 改为静态内部类 |
| 资源未关闭 | 忘记调用close() | try-with-resources |
| 单例持有Context | 单例生命周期长 | 只存ApplicationContext |
| 集合未清理 | 对象移除不及时 | 在onStop()中统一清理 |
嗯,内存泄漏检测这件事,说白了就是「在正确的时间点,检查对象是否还活着」。工具只是辅助,关键还是编码习惯。你想想看,如果每个开发者都能在写代码时多问一句「这个对象什么时候被回收?」,很多泄漏根本不会出现。
最后说一句:别等到线上OOM了才想起检测工具。开发阶段就把它跑起来,省心省力。