Android内存泄漏排查及实战

320 阅读9分钟

内存泄漏实战

前段时间面试了腾讯android岗,问到内存优化的时候问我如何排查内存泄漏情况,具体的措施,涉及到了我的盲区,于是本篇文章应运而生,目的是让刚入行的android开发学会内存泄漏的排查。

前提要求是对gc和内存泄漏的常见情况有了解,此处简单介绍内存泄漏的原因及常见情况。

内存泄漏原因

所谓内存泄漏,就是堆申请了一块内存,在这个引用的生命周期之后内存这一块未能被及时回收或长期无法被回收,此时认为内存泄漏。

gc机制概括如下,gc会从gc root结点遍历对象树,当一个对象允许被回收时(包括几种情况,涉及java四种引用的知识)会释放这一块的堆内存。

内存泄漏的原因就是生命周期长的对象持有生命周期短的对象,导致生命周期短的对象在离开作用区域时仍然被引用,gc认为这个对象无法被回收,而实际上应该被回收,此时就引发了内存泄漏。值得一提的是,虽然称之为”引发“,但是只是从我们的角度来看叫做内存泄漏发生了,实际上内存一直在堆里面,它的状态没有发生变化,只是它的工作并不如我们的预期(在不被使用时应该销毁)所以称之为内存泄漏。

常见内存泄漏情况

这篇文章最后一节讲了常见泄露情况,挺全的,不必再啰嗦了,下面的内容是参考这篇文章,不过给出了最新的Profiler操作,因为时间过去挺久了所以这些工具的ui变化比较大,相关操作和信息也有变化,故才重写下一部分的内容。

内存泄漏与排查流程——安卓性能优化内存泄漏可以说是安卓开发中常遇到的问题,追溯和排查其问题根源是进阶的程序猿必须具备的一 - 掘金

内存泄漏排查

Profiler + Mat方案

profiler获取堆内存快照并导出

profiler可以分析当前进程的内存使用情况,并导出当前堆内存的快照,打开profiler,运行app触发可能内存泄露的操作后,依次点击Analyze Memory Usage => StartProfiler Task

持续数秒后展示当前堆内存的分析页面。

image-20250216213608651.png

临时保存分析记录

上方的Tabs,Past Recordings展示了最近调用的Profiler分析操作,在Android Studio一次运行过程中会保存下来所有Profiler操作。左下角Export Recording可以把记录导出为文件,下次可以通过Import Recording导入,或者直接把记录文件拖入Android Studio的Profiler界面。

有了记录后点击右下角Open profiler task就可以恢复分析页面。

image-20250216213737923.png

内存分析页面

Analyze Memory Usage页面如下,展示了Heap Dump,包括当前堆内所有类,及其分类的数量和大小。左上角Leak展示了检测到内存泄漏的数量,单击Leaks会只显示当前泄漏的Activity/Fragment。

应该是通过activity/fragment触发onDetroy后是否有对象持有它们的引用来判断是否内存泄漏。需要注意的是,即使没有对象持有引用,由于gc的特性,它们可能仍在堆中未得到释放,因此可能存在关闭activity后其实例仍存在内存中的情况,此时并没有被检测到泄漏。

顶部可以筛选或查找对象。

image-20250216213525687.png 单击某一个对象,底部Instance List展示了该对象的fields和references:

  1. fields表示对象的字段引用情况,表示该对象还有哪些字段是存在堆中的
  2. references表示该对象被引用的引用树情况,表示该对象被哪些对象引用了,会一直递归到Application为止。

image-20250217014046159.png

下载Mat

到官网下载mat(memory analyze tool),是由Eclipse开发的内存分析工具,可以用于分析java堆内存快照,直接解压即可。

Downloads | The Eclipse Foundation

问题1:打开Mat提示

打开提示java版本不对是因为没有设置本地的java路径,打开配置文件设置-vm为本地的java可执行文件路径即可,没有-vm需要自行添加。

image-20250216215546228.png

问题2:mat导入.hprof快照提示格式不对

android profiler把内存快照导出为.hprof文件后,它的格式跟普通java有一些区别,官方提供了工具hprof-conv用于转换,在sdk的platform-tools目录下。

命令行输入后出来提示,告诉我们这个工具的使用方法

hprof-conf [-z] infile outfile

如果加了-z的意思是排除非app的堆的内存情况,infile表示你的.hprof路径,outfile表示转换后的输出路径

转换后即可在Mat中导入.hprof文件分析

image-20250216220134951.png

Mat的具体操作参考掘金博主,Mat的主要功能是进行一些分析和进一步的筛选,比如可以筛选出强引用,排除弱引用和虚引用,根据Profiler中提示内存泄漏的类来查看哪个引用导致的泄漏。

内存泄漏与排查流程——安卓性能优化内存泄漏可以说是安卓开发中常遇到的问题,追溯和排查其问题根源是进阶的程序猿必须具备的一 - 掘金

image-20250217000052239.png

构造内存泄漏实例

下面通过一个实例来构造内存泄漏的实例,本案例通过广播来构造内存泄漏的实例。

动态广播的级别追朔

前提:我们知道在activity动态注册广播是Application或activity级别的:

  1. 通过LocalBroadcastManager注册的广播是app级别的
  2. 通过activity注册的广播的广播是activity级别的
  3. 通过application注册的广播是app级别的。

下面对以上3个观点一一佐证:

  1. 但是通过LocalBroadcastManager可以侧面看出,它的instance是application级别的,因此LocalBroadcastManager的实例是Application级别的,如下代码所示。
@NonNull
    public static LocalBroadcastManager getInstance(@NonNull Context context) {
        synchronized (mLock) {
            if (mInstance == null) {
                mInstance = new LocalBroadcastManager(context.getApplicationContext());
            }
            return mInstance;
        }
    }

再看它的注册,LocalBroadcastManager$registerReceiver,这个方法把receiver放到map中,从以下代码可以看出:mReceivers.put(receiver, filters),如下代码所示,而mReceivers是成员变量,它的级别跟instance相同,故可以下结论,第1点和第3点是一个结论,LocalBroadcastManager的广播级别等同于app级别。

这意味着什么呢?这意味着LocalBroadcastManager注册的广播短时间内没有解注册不会引起所谓的”内存泄漏“,在app的视角来说,即使你在activity中通过LocalBroadcastManager注册了广播,但是这个广播实际上是app级别的,你还没有关闭app自然也说不上这个广播接收者对象泄漏了。

public void registerReceiver(@NonNull BroadcastReceiver receiver,
            @NonNull IntentFilter filter) {
        synchronized (mReceivers) {
            ReceiverRecord entry = new ReceiverRecord(filter, receiver);
            ArrayList<ReceiverRecord> filters = mReceivers.get(receiver);
            if (filters == null) {
                filters = new ArrayList<>(1);
                mReceivers.put(receiver, filters);
            }
            filters.add(entry);
            for (int i=0; i<filter.countActions(); i++) {
                String action = filter.getAction(i);
                ArrayList<ReceiverRecord> entries = mActions.get(action);
                if (entries == null) {
                    entries = new ArrayList<ReceiverRecord>(1);
                    mActions.put(action, entries);
                }
                entries.add(entry);
            }
        }
    }

虽然在android studio点击进入context的registerReceiver无法查看到源码,也无法打断点,但是可以通过BroadcastReceiver的回调处打断点,通过传入的context对象判断。

  1. activity注册

    this@MainActivity.registerReceiver(receiver, filter, RECEIVER_EXPORTED)
    

image-20250217004624318.png 如上,通过activity注册的广播传入的是activity对象,此时通过profiler查看,可以发现内存泄漏。

image-20250217004753551.png 0. application注册

```
application.registerReceiver(receiver, filter, RECEIVER_EXPORTED)
```

image-20250217004507245.png 如上,通过activity注册的广播传入的是application对象,此时通过profiler查看,发现Broadcast引用仍存在,但内存没有泄漏。

image-20250217013901305.png 为什么会这样呢?查看reference

这是泄露的activity的引用

image-20250217014046159.png

这是没泄露的Receiver的引用

image-20250217014412476.png

发现都和LoadedApk有关,前去查看对应代码,LoadedApk有一个字段mReceivers,发现广播跟这个map有关,map的key是广播注册的context引用,当通过activity注册的时候是activity级别的context,application注册的则是app级别。

private final ArrayMap<Context, ArrayMap<BroadcastReceiver, ReceiverDispatcher>> mReceivers
    = new ArrayMap<>();

故前提的3个点都得到了佐证,每一个BroadcastReceiver跟注册时的context绑定,由App的LoadedApk持有context的引用。

通过广播构造内存泄漏实例

上面提到了不同级别的广播会引起内存泄漏,下面分析具体原因。

下面假设这样一种场景,我设置了两个activity:StartActivity、MainActivity,启动的activity是StartActivity,点击按钮进入MainActivity。在MainActivity中设置了回调:onResume阶段注册了广播接收器,onPause阶段取消注册。启动StartActivity后启动MainActivity,然后马上返回。 代码如下:

            override fun onActivityResumed(activity: Activity) {
                receiver = receiver ?: MyBroadcastReceiver(activity, TestProfiler()).apply { this.testProfiler.onCreate() }
                val filter = IntentFilter()
                filter.addAction("android.media.VOLUME_CHANGED_ACTION")
                // 注意这里VOLUME_CHANGED_ACTION是系统级别广播,必须设置为RECEIVER_EXPORTED才能接收到外部广播,
                // 而通过LocalBroadcastManager注册的是本地广播无法接收到
                application.registerReceiver(receiver, filter, RECEIVER_EXPORTED)  // app 级别
                // this@MainActivity.registerReceiver(receiver, filter, RECEIVER_EXPORTED)  // activity级别
                Log.e(DEBUG_TAG, "register")
            }
​
            override fun onActivityPaused(activity: Activity) {
                if (receiver != null) {
                    LocalBroadcastManager.getInstance(activity.applicationContext).unregisterReceiver(
                        receiver!!
                    )
                    Log.e(DEBUG_TAG, "unregister")
                    receiver = null
                }
            }

假设我们注释掉onActivityPaused的代码,不再取消注册,此时按寻常理解,应该总是发生了内存泄漏,但是结果却跟上面测试的一样,只有activity的广播未注销会引起内存泄漏。

因此不注销广播并不是android认为的内存泄漏,而是对广播生命周期的错误理解:

  1. 当广播注册为App级别,activity的引用没有被loadedApk持有,在activity触发onDestroy回调时activity的资源可以被回收。
  2. 当广播注册为activity级别,activity的引用被loadedApk持有,会被检测为内存泄漏,因为activity的强引用的存在,不会释放其资源。

经过实际测试,即使注册为application级别,在退出MainActivity的时候,资源也不会被马上回收,原因是gc。刚退出activity时,没有马上触发gc,多次触发gc后才会被回收掉。由于application的loadedApk持有receiver对象,activity内部的引用也被回收,转移到app上了。