Android内存性能测试

1,579 阅读8分钟

阅读本文大约需要2.1分钟。

前言

Android应用大部分性能问题归根结底都会成为内存的问题,今天我们就先以Out of Memory(OOM)为起点介绍一下Android内存的原理以及排查内存问题的方法。

原理

在讲OOM之前我们先来弄清楚几个概念:内存泄漏、内存抖动、内存溢出

  • 内存泄漏:内存泄漏是指没有用的对象资源仍与GC-Root保持可达路径,导致系统无法进行回收;

  • 内存抖动:内存抖动是因为大量的对象被创建又在短时间内马上被释放,导致频繁 GC;

  • 内存溢出:我们需要一定的内存大小,但是系统无法分配给我们,满足不了我们的需求,所以会导致OOM;

既然我们知道了什么是内存溢出,那它是什么时候发生的呢?答案是在虚拟机的Heap内存使用超过堆内存最大值(Max Memory Heap)的时候,那么在这里大家需要理解的第一个概念就是Dalvik(ART)虚拟机的最大堆内存。

虚拟机的堆内存最大值

在虚拟机中,Android系统给堆(Heap)内存设置了一个最大值,可以通过runtime.getruntime().maxmemory()获取。而因为游戏消耗内存特别大的原因,Android给开通了一个绿色通道,可以在manifest里面设置LargeHeap为true。

虽然我们的手机拥有动辄8GB、16GB的内存,但系统只会分给每个应用一小部分。比如Nexus7单个应用的最大可用内存是192MB,这个值一般在Android设备出厂以后就固定下来了,分这么小内存有一个重要的原因,是Android默认没有虚拟内存。在内存资源稀缺的大背景下,为了保证在极端情况下,前台App和系统还能稳定运行,就只有靠low memory killer机制。

Low Memory Killer

下面引出另一个重要概念Low Memory Killer,也是App消耗内存过大导致的另外一个结果。在手机剩余内存低于内存警戒线的时候,就会召唤Low Memory Killer这个劫富济贫的“杀手”在后台默默干活。这里需要记住一句:App占用内存越多,被Low Memory Killer处理掉的机会就越大。

如果OOM和Low Memory Killer都没有干掉你的App,那也不代表App就没有内存问题,因为还有一类问题,会直接导致App卡顿,那就是GC。

GC

最简单的理解就是没有被GC ROOT间接或直接引用的对象的内存会被回收。在具体执行中,ART和Dalvik会有很多不同,并发GC的时候ART会比Dalvik少一个stop-the-world的阶段,因此Dalvik比ART更容易产生Jank(卡顿),当然,无论ART还是Dalvik并发GC的stop-the-world的时间并不长。然而,糟糕的情况是GC for Alloc,这个情况在内存不足以分配给新的对象时触发,它stop-the-world的时间因为GC无法并发而变得更长。

那么说到底,我们还是要避免GC FOR ALLOC,跟要避免OOM一样,关键是要管理好内存。什么是管理好内存?除了减少内存的申请回收外,更重要的是减少常驻内存和避免内存泄漏,说起内存泄漏,就必须要提Activity内存泄漏。

Activity内存泄漏

因为Activity对象会间接或者直接引用View、Bitmap等,所以一旦无法释放,会占用大量内存,如下图:

图片缓存

另外一个情况就是内存常驻了,而通常在常驻内存中最大的就是图片。 现在很多互联网产品APP中都有大量的图片,但是这些图片在内存中的存储如果不合理就会导致Crash堆栈然后是疯狂GC,接着触发我们前面说到的GC for Alloc,导致Stop-the-world的“卡”,最后的结果就是导致功能异常,有损用户体验。

既然有这么多的损害,为什么不能把图片下载来都放到磁盘(SD Card)上呢?其实答案不难猜,放在内存中,展示起来会“快”那么一些,快的原因有如下两点:

  • 硬件快(内存本身读取、存入速度快);

  • 复用快(解码成果有效保存,复用时,直接使用解码后对象,而不是再做一次图片解码);

很多同学不知道所谓“解码”的概念,可以简单地理解,Android系统要在屏幕上展示图片的时候只认“像素缓冲”,而这也是大多数操作系统的特征。我们常见的jpg、png等图片格式,都是把“像素缓冲”使用不同的手段压缩后的结果,所以相对而言,这些格式的图片,要在设备上展示,就必须经过一次“解码”,它的执行速度会受图片压缩比、尺寸等因素影响,是影响图片展示速度的一个重要因素。

因此官方建议使用LRU算法来做图片缓存,而不是之前推荐的WeekReference,因为WeekReference会导致大量GC。另外官方也建议,把从内存淘汰的图片,降低压缩比存储到本地,以备后用。这样就可以最大限度地降低以后复用时的解码开销。

现在我们来归纳一下,内存问题主要包括常驻问题(主要是图片缓存)、泄漏问题(主要是Activity泄漏)、GC问题(关键是GC For Alloc),后果会导致App Crash、闪退、后台被杀、卡顿,而且这是各种资源类性能问题积压的最后一环。因此可见其重要性,下面,我们来介绍一下如何简单快速的检测和定位内存泄漏问题。

方案

这里介绍手工和自动两种检测方案

手工检测和定位

先介绍一个命令:

$ adb shell dumpsys meminfo (pid name)

这个命令是用来查看指定进程所占用内存的具体情况,比如当前APP在手机中占用的具体的堆内存大小、View数量、Activity数量等:

其中Activities的数量是一个非常关键的信息,可以帮助我们快速找出内存泄漏的页面,我们可以反复进入待测页面,如果反复进入退出后,查询内存的占用情况,Activity数量一直在增加,那说明一定是发生内存泄漏了。

在确定了哪个页面发生内存泄漏后,用Android Studio 自带工具就可以直接分析泄漏的Activity,完全没必要再单独安装MAT了,如下图打开Android Studio 的profile进入内存模块:

点击Dump,反复进入退出发生内存泄漏的页面

勾选下面的Activity/Fragment Leaks 就可以展示出具体哪些Activity或 Fragment发生了内存泄漏,右边还有具体的引用情况

自动检测和定位

这里主要是通过leakcanary来实现的,leakcanary具体在客户端的接入就不多说了,可以参考官网的文档: https://square.github.io/leakcanary/getting_started/

这里主要想讲一下如何自动收集leakcanary检测出的内存泄漏信息,因为在日常测试和开发过程中,即便客户端接了内存泄漏检测的工具,但也只是作为一个debug工具,很难系统的看出某个版本的应用内存泄漏情况是如何的。

于是我们需要在业务和开发同学平时使用的过程中顺带将这些信息收集上来,在同一的平台上以版本和页面为维度去展示,可以直观的看到某个版本发生了多少次内存泄漏以及哪些页面的哪些调用栈。

首先新建一个LeakUploadService类,用来格式化内存泄漏详情以及上传到日志服务器便于快速定位,具体代码如下:

                                                                                                                                                                                    

public class LeakUploadService extends DisplayLeakService {

    @Override

    protected void afterDefaultHandling(HeapDump heapDump, AnalysisResult result, String leakInfo) {

        if (!result.leakFound || result.excludedLeak) {

            return;

        }

        String className = result.className;

        String pkgName = leakInfo.trim().split(":")[0].split(" ")[1];

        String pkgVer = leakInfo.trim().split(":")[1];

        String leakDetail = leakInfo.split("\n\n")[0] + "\n\n" + leakInfo.split("\n\n")[1];

        JSONObject json = new JSONObject();

        try {

            json.put("className", className);

            json.put("app", pkgName);

            json.put("appVersion", pkgVer);

            json.put("leakDetail", leakDetail);

        } catch (JSONException e) {

            e.printStackTrace();

        }

        OkHttpClient okHttpClient = new OkHttpClient();

        MediaType mediaType = MediaType.parse("application/json; charset=utf-8");

        RequestBody requestBody = RequestBody.create(mediaType, json.toString());

        Request request = new Request.Builder()

                .url("日志上传接口")

                .post(requestBody)

                .build();

        try {

            okHttpClient.newCall(request).execute();

        } catch (IOException e) {

            e.printStackTrace();

        }

    }

}

然后在manifest中注册:

<service android:name="com.squareup.leakcanary.LeakUploadService"/>

最后将Service注册到监听接口:

LeakCanary.refWatcher(application).listenerServiceClass(LeakUploadService.class)

推荐阅读:

APP集成卡口性能标准

APP耗电量测试白皮书

APP网络性能测试白皮书

APP适配测试白皮书

想要明白些道理,遇见些有趣的事 —— 离岛