内存测试工具介绍

背景

  • 在对内存的测试过程中,需要用到一些测试工具,但是需要搞清楚测试工具的数值代表什么意思及工具的工作原理。
  • 这里只介绍经常用到的工具。

    基础概念

  • 进程的内存空间:或者叫虚拟内存、逻辑内存,每个进程都独享自己的内存空间。对于32位的机器,进程的地址空间是0到4GB。其中前1GB被内核空间使用,被称为内核空间,剩下3GB供用户使用,被称为用户空间,我们接下来讨论的内存相关概念理论上都是在用户空间。如下图所示:

    Android进程内存

  • RAM:物理内存,是设备真实的内存大小,可以通过 adb shell cat /proc/meminfo 查看RAM的使用情况。需要注意的是RAM被所有的进程共享。

  • low-memory-killer:在linux中,由于虚拟地址空间比物理的地址空间要大的多。在较多进程同时运行时,物理地址空间有可能不够,操作系统会将一部物理地址空间的内容交换到磁盘,从而腾挪出一部分物理地址空间来。磁盘上的交换区,在linux上叫swap area,但是Android中并没有swap area的概念,而是当RAM不够用的时候,杀死优先级比较低的进程,从而达到释放内存的目的。
  • page:虚拟地址空间与物理地址空间的都是以page为最小管理单元,page的大小因系统而异,一般都是4KB。
  • stack:Stack空间(进栈和出栈)由操作系统控制,其中主要存储函数地址、函数参数、局部变量等等,所以Stack空间不需要很大,一般几MB大小。
  • Heap:Heap空间的使用由程序员控制,程序员可以使用malloc、new、free、delete等函数调用来操作这片地址空间。Heap为程序完成各种复杂任务提供内存空间,所以空间比较大,一般为几百MB到几GB。
  • Private RAM (Unique Set Size):私有内存,被app单独占用(没有与其他进程共享)的内存。如果app进程退出的话,系统可以回收这么多内存。
  • Shared RAM:两个或多个进程共享的地址空间。虽然两个进程的虚拟地址空间是独立的,但都对应同一份物理地址空间。
  • Clean RAM:内存在分配时,用磁盘的镜像或资源文件,或者是用0,对内存进行了填充,对于这部分内存一直没有修改,就是Clean RAM。可在必要时进行回收,没有影响,不会造成数据丢失。
  • Dirty RAM:相反,如果内存的内容被修改过,就是Dirty RAM,这部分内存会常驻内存,因为Android没有swap功能。当进程退出时,系统可以回收这部分内存。(我们的代码等都属于这部分内存,java/C/c++ new 申请的内存)。

adb shell dumpsys meminfo [pid / packageName]

  • 我们直接在命令行运行命令,得到结果,如下图所示:

    dumpsys memifo

  • 首先横着一排的标题含义:

    • pss total(Proportional set Size 按比例分配占用内存):实际使用内存,计算方式是private RAM + 按比例计算的共享内存页(比如,两个进程共享了某一内存页,那么共享内存= 1/2内存)。
    • private Dirty & Clean:(参考上面Dirty & Clean RAM)指的是进程独占内存。通常我们更关注private dirty内存,因为这部分内存是在应用退出之后,可以被回收的内存。换句话说,这部分内存是应用 new 出来的对象实例。
    • Swapped Dirty:Some Android devices do use swap, but they swap to RAM rather than flash. Linux has a feature called ZRAM that compressed pages and then swaps them to a special RAM area, and decompresses them again when needed。这里的swapped Dirty我也有些疑问,之前提到过Android是不支持swap的。所以直接从网上找了一段来解释这个字段的含义,大意是某些Android设备是使用swap的,但是被直接swap到RAM,而不是高速缓存中,类似于Linux中的ZRAM机制。
    • Heap size:虚拟机Heap 的大小。
    • Heap Alloc : 统计的是虚拟机分配的所有应用实的内存大小,会将从zygote共享的部分也计算进去,因此Heap Alloc的值比实际物理内存值要大(主要是我们程序使用的内存大小)。
    • Heap Free: 虚拟机当前可用的Heap大小。
  • 接下来再看竖着一排的含义:

    • Native Heap:就是上面提到的Heap部分,由C/C++申请的内存空间在native heap。
    • Dalvik Heap:由java对象申请的对象,全部都在Dalvik Heap,我们通常关注一个app的内存是Dalvik Heap。
    • Dalvik Other:包括:LinearAlloc(JVM存储载入类的方法信息区域)、Accounting(主要做标记和指针表使用,随着Dalvik Heap的增大而增大)、Code_cache(jit编译代码后的缓存,随着代码负责度的增加变大)。
    • Stack:栈区域。(上面介绍过)、
    • Other dev:这个可以这么理解,Heap也是被映射到某特殊的dev上,这里是映射到其他dev的内存。比如Android Binder通信机制中就内存映射到了/dev上。
    • xx.mmap: 以mmap结尾表示。将对应的文件(dex代码、so等)映射到内存中。即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。
    • Graphics:UI渲染占据的内存空间,每个Activity都有一个Surface,Surface会引用绘制缓存。
    • unknown:无法归类到上面提到的内存类型中,除此之外的内存。
    • TOTAL:各个项占用的内存的总值。
    • Objects:表示各个类别中包含的对象个数。比如 viewRootImpl(表示跟视图对象的个数,每个根视图与一个窗口关联)、AppContexts和Activity(应用程序内Context和Activity的对象个数),这里的数据可以用来分析内存泄露,比如退出某个页面之后,Activity对象个数并没有减少。
    • SQL & DataBase:是app操作过程中数据库相关的内存情况。
    • Asset Allocations : 这里是将asset下面文件解压之后,放到内存中。

smaps文件

  • 在命令行执行 adb shell cat /proc/pid/smaps > /sdcard/smaps.txt 可以将smaps对应信息写入到文件中,然后再将文件拉取到本地,就可以对文件进行分析。不过需要注意,读取smaps文件需要root权限。
  • 我获取了一份文件,里面内容大致如下(我截取其中一部分):

    smaps_dalvik.png

  • 可以看到上图中是/dev/ashmem/dalvik-Heap的地址范围。其实smaps就是app在虚拟地址空间内存分配的详细情况。

  • 上面dumpsys meminfo时,pss Total / private dirty 的值其实就是对/proc/pid/smaps进行归并分析统计得来的:

    • /dev/ashmem/dalvik-heap和/dev/ashmem/dalvik-zygote归为Dalvik Heap。
    • 其他以/dev/ashmem/dalvik-开头的内存区域归为Dalvik Other。
    • Ashmem对应/dev/ashmem/下所有不以dalvik-开头的内存区域。
    • Other dev对应的是以/dev下其他的内存区域。
  • 使用smaps文件,我们能够对内存问题进行更细致的分析,有时候从meminfo中得到整体内存值时可能看不出什么问题,但是通过smaps能够查看更细分的内存分配情况。

LeakCanary

  • LeakCanary现在是我们测试内存泄露的主要工具,因此搞清楚它的工作原理是很有必要的,方便我们处理使用过程中的问题。
  • LeakCanary的github地址。里面有详细的使用方法。

LeakCanary原理

  • 想要知道工作原理是咋样的,最好的办法是fack the source code。我们从 LeakCanary.install(this);开始来分析。

    1
    2
    3
    4
    5
    public final class LeakCanary {
    public static RefWatcher install(Application application) {
    return ((AndroidRefWatcherBuilder)refWatcher(application).listenerServiceClass(DisplayLeakService.class).excludedRefs(AndroidExcludedRefs.createAppDefaults().build())).buildAndInstall();
    }
    }
  • 其主要的意图就是构造了一个AndroidRefWatcherBuilder对象,然后设置了展示用的DisplayLeakService,以及设置排除一些reference。最关键的方法是buildAndInstall。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public RefWatcher buildAndInstall() {
    // 构造一个RefWatcher,对LeakCanary工作环境进行初始化
    RefWatcher refWatcher = this.build();
    if(refWatcher != RefWatcher.DISABLED) {
    // 设置可以显示检测到的LeakCanary
    LeakCanary.enableDisplayLeakActivity(this.context);
    // 接着是关键的install方法。
    ActivityRefWatcher.install((Application)this.context, refWatcher);
    }
    return refWatcher;
    }
  • 上面跟踪到install方法,我们一步步跟踪下去(中间代码简单略去),最终调用到 watchActivities方法。

    1
    2
    3
    4
    5
    6
    public void watchActivities() {
    // 取消上一次注册的Activity回调
    this.stopWatchingActivities();
    // 注册一个新的Activity生命周期的监听回调。
    this.application.registerActivityLifecycleCallbacks(this.lifecycleCallbacks);
    }
  • 其实watchActivities方法的核心意思就是去注册了一个监听,用于对所有Activity的生命周期进行监听,这也好理解,我们判断内存泄露的手段就是反复进出某个Activity(页面)。当Activity被销毁之后,就会回调到方法:

    1
    2
    3
    void onActivityDestroyed(Activity activity) {
    this.refWatcher.watch(activity);
    }
  • 这里其实开始了去判断Activity退出之后,是否存在泄露。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    /**
    * Watches the provided references and checks if it can be GCed. This method is non blocking,
    * the check is done on the {@link WatchExecutor} this {@link RefWatcher} has been constructed
    * with.
    *
    * @param referenceName An logical identifier for the watched object.
    */
    public void watch(Object watchedReference, String referenceName) {
    if (this == DISABLED) {
    return;
    }
    checkNotNull(watchedReference, "watchedReference");
    checkNotNull(referenceName, "referenceName");
    final long watchStartNanoTime = System.nanoTime();
    // UUID是生成一个唯一的标识
    String key = UUID.randomUUID().toString();
    retainedKeys.add(key);
    // 将key 和 我们监控的Activity关联起来。
    final KeyedWeakReference reference =
    new KeyedWeakReference(watchedReference, key, referenceName, queue);
    // 执行GC
    ensureGoneAsync(watchStartNanoTime, reference);
    }
  • 上面的方法先生成一个唯一标识,然后放到retainedKeys中,方便后面比对使用。然后通过生成KeyedWeakReference对象将key和reference结合起来。接下来看下ensureGoneAsync的代码。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    private void ensureGoneAsync(final long watchStartNanoTime, final KeyedWeakReference reference) {
    // 这里的Retryable是一个接口,可以用来稍后重试之前的操作。这里我没有查看它具体的实现代码
    watchExecutor.execute(new Retryable() {
    @Override public Retryable.Result run() {
    // 最终调用到ensureGone方法。
    return ensureGone(reference, watchStartNanoTime);
    }
    });
    }
    @SuppressWarnings("ReferenceEquality") // Explicitly checking for named null.
    Retryable.Result ensureGone(final KeyedWeakReference reference, final long watchStartNanoTime) {
    long gcStartNanoTime = System.nanoTime();
    long watchDurationMs = NANOSECONDS.toMillis(gcStartNanoTime - watchStartNanoTime);
    // 移除弱引用
    removeWeaklyReachableReferences();
    if (debuggerControl.isDebuggerAttached()) {
    // The debugger can create false leaks.
    return RETRY;
    }
    if (gone(reference)) {
    return DONE;
    }
    // 这一步是执行GC操作
    gcTrigger.runGc();
    // 再次移除弱引用
    removeWeaklyReachableReferences();
    // 这里判断如果retainsKeys中依然还有当前的reference 对应的key,那么说明当前的reference并不存在ReferenceQueue中,这也说明当前的reference没有被回收。
    if (!gone(reference)) {
    long startDumpHeap = System.nanoTime();
    long gcDurationMs = NANOSECONDS.toMillis(startDumpHeap - gcStartNanoTime);
    // dump hprof文件。
    File heapDumpFile = heapDumper.dumpHeap();
    if (heapDumpFile == RETRY_LATER) {
    // Could not dump the heap.
    return RETRY;
    }
    long heapDumpDurationMs = NANOSECONDS.toMillis(System.nanoTime() - startDumpHeap);
    heapdumpListener.analyze(
    new HeapDump(heapDumpFile, reference.key, reference.name, excludedRefs, watchDurationMs,
    gcDurationMs, heapDumpDurationMs));
    }
    return DONE;
    }
  • 那么上面的代码简言之就是利用WeakReference和ReferenceQueue的关系,让GC之后,对象被回收之后会被放到ReferenceQueue中,所以可以通过判断ReferenceQueue 在GC之后是否存在对应的对象,以此来判断被观察对象是否被回收。

  • dumpHeap以及分析hprof文件的代码在哪里呢?经过查看导入LeakCanary jar包名称,就一目了然了:

    LeakCanary_jars.png

  • 我们上面分析的代码主要在LeakCanary-watcher和LeakCanary-android中。dump hprof文件的实现代码在LeakCanary-android的AndroidHeapDumper类中:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    public File dumpHeap() {
    File heapDumpFile = this.leakDirectoryProvider.newHeapDumpFile();
    if(heapDumpFile == RETRY_LATER) {
    return RETRY_LATER;
    } else {
    FutureResult waitingForToast = new FutureResult();
    this.showToast(waitingForToast);
    if(!waitingForToast.wait(5L, TimeUnit.SECONDS)) {
    CanaryLog.d("Did not dump heap, too much time waiting for Toast.", new Object[0]);
    return RETRY_LATER;
    } else {
    Toast toast = (Toast)waitingForToast.get();
    try {
    Debug.dumpHprofData(heapDumpFile.getAbsolutePath());
    this.cancelToast(toast);
    return heapDumpFile;
    } catch (Exception var5) {
    CanaryLog.d(var5, "Could not dump heap", new Object[0]);
    return RETRY_LATER;
    }
    }
    }
    }
  • 从上面可以看出,主要是调用了Debug.dumpHprofData(path)来获取dump文件。分析dump文件的代码主要在LeakCanary-analyzer中,代码来自于haha项目。这里就不展开讲解它的源码了。感兴趣的同学可以自行查看。

小结

  • LeakCanary对于检测内存泄露比较灵敏,相对于我们用MAT自己分析内存发现问题会更全面,因此建议用LeakCanary来测试内存泄露问题。

MAT

  • 提到内存测试,MAT是最强大的分析工具,只是我们平时用到的功能比较简单,所以还没意识到它的强大,包括我自己,也仅仅是测下内存泄露。
  • 关于MAT的使用这里就不做过多介绍了,之前写过一篇文章:MAT的使用。感兴趣的可以查看参考。
  • MAT还有很多更高级的用法,大家可自行查阅。