Android 性能优化之内存泄漏的检测与修复

在 Android 开发中, 内存优化是APP性能优化中很重要的一个部分. 而在内存优化中, 最重要的就是修复内存泄漏问题. 本文就来介绍一下内存泄漏的基本概念以及常用的检测手段.

1. 什么是内存泄漏

简单来说, 当一个对象不再被使用时, 理应不存在任何强引用指向他从而可以让垃圾回收器(GC)在未来的某个时间点将其回收的, 但由于某些原因导致有强引用依然指向该对象, 使得该对象无法被垃圾回收器(GC)回收的现象, 我们就称该对象被泄漏到内存中了, 简称”内存泄漏”. 其实, 在这种情况下, 不仅该对象会泄漏, 而且该对象内部包含的其他引用所指向的对象也将发生泄漏. 也就是一种牵连效应.

概念总是枯燥难懂的, 那就举个例子吧.

这里写图片描述

假如一个 Activity 的布局文件中定义了多个控件, 例如: 下面的 DemoActivity

public class DemoActivity extends Activity {
    private TextView mTv;
    private ImageView mIv;
    private Button mBtn;
}
 
 
      • 1
      • 2
      • 3
      • 4
      • 5

      上面是我们通常的写法, DemoActivity 对其内部定义的所有控件都持有强引用 (请自行查阅四种引用的概念和区别). 如果我们在某个时刻需要销毁该 DemoActivity , 但是实际上因为某些原因导致它没有销毁或者没有及时销毁(即: 延迟了一段时间才销毁), 依然存在于内存中, 那么我们就说该 DemoActivity 对象发生了内存泄漏, 对于后一种情况, 就是在延迟的那段时间内发生了内存泄漏. 同时, 由于该DemoActivity 对其内部定义的各个控件都持有了强引用, 导致这些控件也没有销毁, 也发生了内存泄漏. 同理可以推测, 这些控件内部包含的一些强引用所指向的对象也将发生内存泄漏…… 这一系列泄漏的对象组成了一个强引用的链条, 一旦该链条中处于头部的某个对象发生了内存泄漏, 那么这个链条中, 被该对象所直接或间接引用的对象也将受牵连而发生内存泄漏.

      2. 为什么要检测并修复内存泄漏

      为什么要检测并修复内存泄漏问题呢? 因为内存泄漏问题轻则造成 APP 卡顿不流畅, 重则导致 OOM 而让 APP 崩溃, 所以修复内存泄漏问题是非常必要的. 更具体地说, 一款手机的可用内存空间是非常有限的, 一个应用程序可申请的最大内存空间也是有限的, 如果一个对象发生了内存泄漏, 那么它所直接或间接强引用的那些对象也将发生内存泄漏, 可能该对象本身占用的内存空间并不大, 但是由它直接或间接强引用的那些对象中, 就可能存在着占用内存较大的对象. 所以, 一个占用内存不太大的小对象的泄漏, 由上述链式连锁反应所造成的整体泄漏量也可能是非常庞大的. 例如: 我们有一个页面, 该页面中包含了一个用于展示一系列图片的 ListView 或 GridView 控件. 如果用于表示该页面的 Activity 或 Fragment 对象发生了内存泄漏, 那么, 该页面中的 ListView 或 GridView 控件也会泄漏, ListView 或 GridView 的每个条目中所包含的 ImageView对象也将发生泄漏, 而 ImageView 是用来展示图片的, 因此, 每一个 ImageView 对象所(强)引用的 Bitmap 对象也会发生泄漏. 而 Bitmap 对象的内存泄漏是非常严重的. 为什么这么说呢? 我们来具体分析一下一个 Bitmap 对象被加载到内存中所占据的内存空间大小吧. 例如: 我们要加载一张分辨率为 960 * 637 的图片, 存储方式采用 RGB565, RGB565 意味着该图片的每个像素点在内存中占用2个字节(5+6+5 = 16bit = 2Byte), 如果我们不对该图片进行压缩处理, 而是直接加载原图的话, 那么这张图片加载到内存中就会占据约 1.2MB 的空间 (960 * 637 * 2 = ‭‭1223040 Byte ≈ 1.2MB). 如果它发生了泄漏, APP 就会损失 1.2MB 的可用空间. 而一个包含有图片的 ListView GridView 通常加载的图片数量远远不止一张, 那么多个 Bitmap 对象同时泄漏, APP的可用内存空间一下子就减少了很大一部分, 泄漏多了就会让页面的滑动变得非常卡顿, 甚至会提示 APP 已经崩溃. 所以我们说, Bitmap 对象的内存泄漏是非常严重的. 上个图证明一下前边我们对 Bitmap 对象占用内存大小的计算:

      这里写图片描述

      看图中右边 Watches 窗口中计算的 bitmap 在内存中的大小 bitmap.getByteCount()的值, 确实就是我们自己计算的 1223040 Byte.

      3. 如何判断某个页面是否存在内存泄漏

      介绍完内存泄漏的基本概念及其严重性以后, 我们可能会思考, 如何判断某个页面是否存在内存泄漏? 其实, android Studio 已经为我们提供了相关的检测工具, 我们只需利用好这些工具即可进行判断了. 当然, 这里提醒一下各位朋友, 这些工具并不会直接告诉我们 “某个地方有泄漏”, “某个地方无泄漏” 等等这么直白的结论, 而只会提供给我们一些数据和图表, 至于是否有泄漏, 需要我们自己结合这些数据和图表去判断.

      这些检测工具在哪里呢? 我们看下边这张图, Android Monitor 标签中有 logcat 和 Monitors 两个子标签:

      这里写图片描述

      其中, logcat 是我们非常熟悉的用于查看 log 的地方. 而我们判断内存泄漏, 则需要用到另外一个标签, 即: Monitors. 点击 Monitors 标签, 会看到下图:

      这里写图片描述

      可以看到, Monitors 标签可以展示内存(Memory), 网络(Network), CPU, GPU这些重要指标的实时数值以及变化曲线图. 我们要判断内存泄漏, 只需关注最上方的内存(Memory)即可. 上图中有几个重要的地方需要大家了解一下:

      • Initial GC 按钮(这里写图片描述): 点击此按钮可手动触发 GC 进行垃圾回收. 每次垃圾回收都能回收掉内存中弱引用指向的对象.
      • Dump Java Heap 按钮(这里写图片描述): 点击此按钮可保存这一瞬间该进程在 Java 堆内存中对象分配情况的快照 (“快照”可以理解为 “截图”的意思). 这里请大家稍微留意一下, “Dump Java Heap” 是个动词, 意思是获取 Java 堆内存的快照, 后文将要提到的 “Heap Dump”是个名词, 意思是 Java 堆内存的快照. 总之, 只要见到这几个单词的组合, 表示的含义就基本相同.
      • Allocated 数值(这里写图片描述): 当前实时分配的堆内存数值.

      我们要判断 APP 的某个页面是否存在内存泄漏, 只需在进入该页面前, 先点击几次 Initial GC 按钮(这里写图片描述), 让垃圾回收器先进行几次回收, 然后记录 Allocated 的瞬时最小数值(一定要记录点击GC后的最小值, 因为只有最小值才是 GC 执行后的真实结果. 但如果你点击GC后过了几秒才去记录, 那么在这几秒内, 当前页面可能又会为某些弱引用所引用的对象再次分配内存空间从而使 Allocated 的数值又增长了上去, 那么这时的 Allocated 的数值就不准确了, 因为我们要记录的是无法被GC回收的内存大小), 记录完 Allocated 值以后, 进入该页面再退出回到先前的页面, 然后再次点击几次 Initial GC 按钮(这里写图片描述), 并再次记录点击GC以后那一瞬间几个 Allocated 数值中的最小值. 然后将前后两次记录的 Allocated 数值进行对比, 如果发现后一个数值要大于或者远大于前一个数值, 就说明该页面可能存在内存泄漏. 注意: 一定要在执行完GC后再记录数据, 因为执行完 GC 后的数值表示无法被回收的对象大小, 如果被测试的页面不存在内存泄漏或者只存在轻微以至于可以忽略不计的泄漏, 那么关闭该页面后再执行 GC 操作, 此时所占用的内存空间理应和进入该页面前所占据的内存空间大小基本相等. 而如果数值增长了较多, 就说明该页面可能存在内存泄漏.

      下面用一个例子来演示一下我们刚才所介绍的判断过程. 假如我们的 APP 有如下两个页面:

      这里写图片描述这里写图片描述

      点击第一个页面中红框标注的文字, 会跳转到第二个页面. 代码都是常规的控件和常规的写法, 这里就只贴出第二个页面的代码:

      public class StaticLeakActivity extends Activity {
      
          private GridView mGv;
          private ImageAdapter mAdapter;
      
          private static Context sContext;
      
          @Override
          protected void onCreate(Bundle savedInstanceState) {
              super.onCreate(savedInstanceState);
              setContentView(R.layout.activity_static_leak);
              sContext = this;
              loadImg();
          }
      
          private void loadImg() {
              mGv = (GridView) findViewById(R.id.gv);
              mAdapter = new ImageAdapter(this);
              mGv.setAdapter(mAdapter);
              mAdapter.addDatas(Arrays.asList(ImgUtils.URLS));
          }
      }
       
       
          • 1
          • 2
          • 3
          • 4
          • 5
          • 6
          • 7
          • 8
          • 9
          • 10
          • 11
          • 12
          • 13
          • 14
          • 15
          • 16
          • 17
          • 18
          • 19
          • 20
          • 21
          • 22

          如要查看该 demo 的完整代码, 请点击这里下载. 为了模拟内存泄漏的情况, 我在上述代码中定义了一个静态变量 sContext, 并让其指向当前页面对象. 如下:

          这里写图片描述

          常规开发中, 估计大家都不会这么写, 这里只是为了演示内存泄漏的效果, 以及接下来要讲到的定位内存泄漏的方法而刻意这样写的. 读者朋友不必太纠结于上述代码的写法, 只需关注后边要讲到的定位内存泄漏的步骤和方法. 好了, 我们现在就可以利用前边提到的判断方法来判断一下第二个页面是否存在内存泄漏吧.

          1. 在第一个页面时, 多次点击 Initial GC 按钮(这里写图片描述), 然后记录这几次点击GC后的瞬间 Allocated 数值中的最小值.

            这里写图片描述

            这里我点击了 3次 Initial GC 按钮(这里写图片描述), 发现每次在点击后的瞬间, Allocated 的最小值基本上维持在 0.98MB 左右.

          2. 进入第二个页面, 待该页面上的图片基本加载完毕后, 退出该页面, 回到第一个页面, 多次点击 Initial GC 按钮(这里写图片描述), 然后记录这几次点击GC后的瞬间 Allocated 数值中的最小值.

            这里写图片描述

            这里我点击了 4次 Initial GC 按钮(这里写图片描述), 发现每次在点击后的瞬间, Allocated 的最小值基本上维持在 3.82MB 左右.

          3. 将前后两次记录的数值进行对比, 发现后一个数值远大于前一个数值 (3.82MB > 0.98MB), 说明第二个页面可能存在内存泄漏. (另外补充计算一下, 从 0.98MB 增长到 3.82MB, 增长了 2.84MB)

          4. 如何定位内存泄漏

          如何定位, 也就是说, 如何找到导致内存泄漏问题的那一行或几行代码. 这里介绍三种方法.

          (1) 使用 Android Studio 内部集成的工具进行定位

          其实 Android Studio 内部已经集成了分析内存泄漏的工具, 我们就以前边的例子来演示用这种方法定位内存泄漏吧.

          这里写图片描述

          这里写图片描述

          这里写图片描述

          这里写图片描述

          根据上图图示的步骤进行操作, 即可知道, 第二个页面, 也就是 StaticLeakActivity 确实发生了内存泄漏, 并且在步骤⑧可以看出, 该泄漏是由该 Activity 中的 sContext 变量引起的. 在关闭了该页面后, 由于该页面中定义的变量 sContext 无法被 GC 回收, 而它又引用着该 Activity 对象, 导致该 Activity 对象发生泄漏. 为什么关闭该页面后, 变量 sContext 无法被回收呢? 因为它是静态变量, 只要该 APP 所在的进程还在, 那么静态变量就不会被销毁. 这样, 我们就知道了问题的根源了. 那么, 修复起来也容易, 把该变量改为非静态的, 或者将它不要引用到 Activity 对象即可. 当然如果有某个地方确实需要使用到静态变量, 并且它也需要引用一个 Context 对象作为参数, 如果允许的话, 也可以改为使用 ApplicationContext 来代替直接使用 Activity对象作为该静态变量引用的参数. 我们将原先代码中静态变量 sContext 引用当前 Activity 对象的那句代码注释掉:

          // sContext = this;
           
           
              • 1

              然后, 我们再次看看两次 Allocated 数值的对比:

              1. 第一个页面, 多次点击 Initial GC 按钮(这里写图片描述), 记录点击 Initial GC 按钮(这里写图片描述)后的 Allocated 瞬时值中的最小值, 0.98MB, 如下图:

                这里写图片描述

              2. 进入第二个页面, 然后退出回到第一个页面, 多次点击 Initial GC 按钮(这里写图片描述), 记录点击 Initial GC 按钮(这里写图片描述)后 Allocated 瞬时值中的最小值, 1.80MB, 如下图:

                这里写图片描述

              3. 对比两次记录的数值, 发现依然有增长, 从 0.98MB 增长到 1.80MB, 涨幅 0.82MB, 说明依然有少量内存泄漏, 不过这也正常, 因为 Android Studio 这个分析内存泄漏的工具只能分析 Activity 的泄漏, 不能分析其他对象的泄漏, 要想彻底分析所有对象的泄漏, 还需借助于后文将要介绍的第(3)种检测工具 —— MAT. 虽然仍有泄漏, 但和先前的代码相比, 泄漏量明显减少了许多, 先前从 0.98MB 增长到 3.82MB, 涨幅为 2.84MB, 但现在的代码只泄漏了 0.82MB, 减少了约 2MB 的泄漏量. 而且, 我们修复内存泄漏的原则是, 先修复泄漏量最大的问题, 最后有空余时间了再去修复泄漏量较小的问题. 另外, Android 系统框架自身, 以及常见的第三方优秀开源框架也都多多少少存在着内存泄漏问题, 这些都是我们无法控制和避免的, 我们只要处理好我们自己写的代码, 修复由我们自己代码所引起的内存泄漏问题就够了. 系统和第三方框架的内存泄漏如果量不大, 就忽略吧, 毕竟优秀框架是不可避免要用的, 而且现在手机配置也越来越高了, 可分配的内存空间也更大了, 所以少量泄漏影响不大.

              (2) 使用 LeakCanary 库进行定位

              Square 公司开源了一个用于检测内存泄漏的库, 叫做 LeakCanary, 具体使用方法可以参考其 GitHub 主页, 或者这篇LeakCanary 中文使用说明.

              具体到我们前边的这个例子, 该如何使用该库进行检测呢? 首先, 我们需要把如下代码取消注释, 营造一个内存泄漏的大环境.

              // sContext = this;
               
               
                  • 1

                  然后按照官方使用说明, 集成该库到我们的代码中, 包括添加依赖以及在自定义 Application 类中对该库进行注册. 
                  app module 的 build.gradle:

                  dependencies {
                      // ... 省略其他依赖
                      debugCompile 'com.squareup.leakcanary:leakcanary-android:1.4-beta2'
                      releaseCompile 'com.squareup.leakcanary:leakcanary-android-no-op:1.4-beta2'
                      testCompile 'com.squareup.leakcanary:leakcanary-android-no-op:1.4-beta2'
                  }
                   
                   
                      • 1
                      • 2
                      • 3
                      • 4
                      • 5
                      • 6

                      自定义 Application 类 (另外别忘了在 Manifest 中为 Application 标签添加 name 属性).

                      public class App extends Application {
                          @Override
                          public void onCreate() {
                              super.onCreate();
                              LeakCanary.install(this);
                              // ... 其他代码省略
                          }
                      }
                       
                       
                          • 1
                          • 2
                          • 3
                          • 4
                          • 5
                          • 6
                          • 7
                          • 8

                          这样就配置好了, 接下来我们就开始操作吧.

                          这里写图片描述

                          上图就是使用 LeakCanary 进行内存泄漏检测的全过程. 如果我们怀疑某个页面有内存泄漏, 那么就进入该页面再退出, 然后等待几秒钟时间, LeakCanary 就开始捕获这一进一出前后两次的内存快照进行对比, 捕获内存快照时还会给个下图这样的提示:

                          这里写图片描述

                          对比前后两次内存快照后, 如果发现有内存泄漏, 就会推送一条消息, 并在顶部通知栏显示一个图标 这里写图片描述, 点开推送的消息, 即可进入到如下图所示的内存泄漏详情页面:

                          这里写图片描述

                          该页面详细描述了发生泄漏的对象所在的包名, 引用链, 以及泄漏的内存大小. 上图中, 我们可以很清楚地看到, 我们这个例子中, StaticLeakActivity 对象发生了泄漏, 并且就是由于 sContext 造成的. 另外, 我们留意一下右上角的内存泄漏大小, 2.0MB, 这个数字我们很熟悉呀. 我们在介绍方法(1)时, 最后总结到, 经过修复后, 内存虽仍有少量泄漏, 但和之前相比节省了 2MB空间, 这个 2MB 不正是上图右上角的 2.0MB嘛, 看来 LeakCanary 也太智能了, 再也无需自己手动操作那一大堆按钮了, 也无需再自己手动 Dump Java Heap 了.

                          另外, 我们点击返回按钮, 可以进入到 LeakCanary 的首页, 如下:

                          这里写图片描述

                          首页还会列出该包名下的所有泄漏情况 (包括昨天, 前天…的历史泄漏情况), 每个泄漏还会列出被泄漏的对象, 泄漏量, 捕获内存快照的日期时间. 当然最下方还提供了可以清空这些记录的 “DELETE ALL” 按钮.

                          另外, 经过集成 LeakCanary 库的代码在运行后, 会额外生成一个名为 leaks 的图标: 
                          这里写图片描述

                          如果我们将导致内存泄漏的那句代码屏蔽掉, 再次按照上述步骤运行, 等待一段时间后, 我们发现这次 LeakCanary 并不会向我们推送消息, 说明该页面没有内存泄漏, 我们的修复奏效了.

                          (3) 使用 MAT(Memory Analyzer Tool) 进行定位

                          MAT (Memory Analyzer Tool) 是一个专门用于分析 Java 内存泄漏的工具, 既有 Eclipse 的插件, 也有不依赖 Eclipse 可独立运行的软件. 如果要使用 Android Studio 结合 MAT 进行内存分析, 就需要使用后者. 可在 MAT 的官方网站 https://www.eclipse.org/mat/ 进行下载. 我们这里要介绍的是使用 Android Studio + MAT 进行内存泄漏分析. 我们还是用上文中的哪个例子来做介绍吧. 既然我们已经分析出了第二个页面, 也就是 StaticLeakActivity 可能存在内存泄漏问题了, 那么, 我们如何用 MAT 来定位到具体代码呢? 其实, 使用 MAT 分析内存泄漏的核心思想, 就是对比前后两次分别经过GC处理后的内存快照, 找出二者的差异之处, 这些差异就可能是发生内存泄漏的对象. 所以, 我们需要导出两份内存快照, 一份是在第一个页面时的快照, 另一份是进入第二个页面然后又退出回到第一个页面后的快照, 注意导出内存快照前, 都需要手动触发GC, 否则可能会对我们的判断造成干扰. 另外还需注意的一点是, 点击 Android Studio 中的 Dump java Heap 按钮 (这里写图片描述) 所导出的 .hprof文件不是标准的快照文件, 如需提供给 MAT 使用, 还需转换为标准的快照文件. 下面我们还是用贴图的方式介绍操作步骤吧.

                          首先, 在第一个页面, 如下图, 先手动触发几次 GC操作(也就是点击下图中的按钮①), 边点击边观察, 当发现每次触发 GC 后的瞬间 Allocated 数值都基本维持在某一个数值附近时, 就可以在触发了 GC 后立即点击按钮②, 这时就会导出这一瞬间的内存快照:

                          这里写图片描述

                          内存快照导出完毕后, 会生成一个 .hprof 文件, 保存在 Captures 标签中 (上文已做介绍), 由于该 .hprof 文件不是标准的 .hprof 文件, 无法被 MAT 识别, 所以需要将其转换为标准文件, 转换方式如下图④所示. 当然也可以使用 hprof-conv 命令进行转换(这里不做介绍, 请自行搜索). 
                          这里写图片描述

                          将转换后的标准文件命名为 1.hprof 并保存, 命名可随意. 然后, 我们进入第二个页面再退出回到第一个页面, 再使用相同的方式导出内存快照并转换为标准文件, 命名为 2.hprof.

                          打开 MAT 软件, 同时打开刚才保存的两个 .hprof文件. 打开方式是: 
                          进入 MAT 后, 选择 File– Open Heap Dump…—同时选择刚才保存的 1.hprof 和 2.hprof 文件并打开, 打开后的效果是这样的:

                          这里写图片描述

                          要对比这两个文件, 可以通过拖拽将这两个文件左右摆放, 各自占据一半的空间, 也就是下面的效果:

                          这里写图片描述

                          我们看到, MAT 打开 .hprof 文件后, 会有多个不同的选项可供点击查看, 他们要么提供了各种图表, 要么提供了各种数据. 而我们使用 MAT 进行内存泄漏分析, 常用的是 “Histogram” 和 “Dominator Tree” 这两个选项. 我们对比两个 .hprof 文件, 通常来说也就是对比这两个选项中的数据.

                          这里写图片描述

                          无论是使用 Histogram 还是 Dominator Tree 进行内存泄漏分析, 都会遇到与内存相关的一些专用术语, 只有了解了他们的具体含义, 才能更好地分析内存问题. 下面我们就先来介绍一下这些术语吧.

                          • Heap Dump
                            前边已经介绍过, 它表示某个进程在某一时刻所占用的堆内存的快照. 我们导入到 MAT 中的 .hprof 文件保存的就是内存快照的数据. 由于快照只是一瞬间的事情,所以 Heap Dump 中无法包含一个对象在何时、何地(哪个方法中)被分配这样的信息.

                          • Shallow Heap 和 Retained Heap
                            一个对象的 Shallow Heap, 指的是该对象自身占用内存的大小. 
                            一个对象的 Retained Heap, 指的是当该对象被GC回收时, 所释放掉的内存大小. 由于该对象先前可能直接或间接持有对其他多个对象的引用, 那么当它自己被回收时, 被它所引用的其他对象有些也可能会被回收, 所以这种情况下, 该对象的 Retained Heap 既包括他自身占用内存的大小, 也包括所有被它直接或间接引用的某些对象占用内存的大小. 注意, 并不是所有被引用的对象所占用的内存大小都算作该对象的 Retained Heap. 例如: 对象B被对象A直接或间接引用, 但是当对象A被回收时, 如果对象B不会被回收, 那么对象B所占用的内存大小就不能算作对象A的 Retained Heap 的一部分. 再来看下图的例子:

                            这里写图片描述

                            如果 E 被回收, 那么 G 也会被回收, 所以 E 的 Retained Heap 就是 E, G 占用内存大小的总和. 
                            如果 C 被回收, 那么 D,E,F,G,H 也会被回收, 所以 C 的 Retained Heap 就是 C,D,E,F,G,H 占用内存大小的总和. 
                            如果 A 被回收, 那么 B,C,D,E,F,G,H 也会被回收, 所以 A 的 Retained Heap 就是 A,B,C,D,E,F,G,H 占用内存大小的总和. 
                            另外还有个与 Retained Heap 类似的但属于另一个维度的概念, Retained Set, 意思是上述这些 Retained Heap 所对应的对象的集合.

                          • Garbage Collection Root (简称 GC Root)
                            MAT 官方对于 GC root 的解释是 A garbage collection root is an object that is accessible from outside the heap. 翻译过来就是, GC root 是一个可以从堆内存以外的地方访问到的对象. 这句话不太好理解, 其实简单来说, GC root 就是我们上文提到的引用链中位于链条起点的那个对象. 例如: 没有其他对象引用 A, 而 A 引用 B, B 引用 C…., C 引用 D, 这样就形成了一条引用链: A–>B–>C–>D…, 其中 A 就是这条引用链的 GC root. 此外, MAT 官方还介绍了 GC root 的常见类型, 例如: System Class, Java Local(局部变量)等, 有兴趣的朋友可以点击这里查阅. 注意: GC执行垃圾回收时, 是不会回收 GC root 的对象.

                          • Dominator Tree
                            如果对象A引用着对象B, 我们就可以说 A 控制(dominate)着 B. 我们可以将众多引用关系绘制成一张树状图, 那么这个表示对象之间引用关系的树状图就称作 Dominator Tree (控制树). 严格来说, Dominator Tree 和我们上文所说的引用链不完全相同, Dominator Tree 中的所有对象都属于根节点对象的 Retained Set (这个概念在上文介绍 Shallow Heap 和 Retained Heap 时曾经介绍过).

                          介绍完这些概念, 我们就可以介绍使用 Histogram 和 Dominator Tree 来进行内存泄漏分析了.

                          • 使用 Histogram 分析内存泄漏

                            下面是打开 Histogram 后的效果图, 该图会列举出该内存快照中, 各个对象的数量以及各自占用的 Shallow Heap 的大小.

                            这里写图片描述

                            我们可以设置该列表中对象归类显示的具体方式, 例如: 根据包名进行归类显示, 这样具有相同包名的对象就会被放置在一起进行显示. 如下图所示. 如果我们只关心我们自己开发的代码中各个对象在内存中的数量, 那么选择 “Group by package”的选项 (根据包名归类显示) 即可.

                            这里写图片描述

                            既然我们要检测第二个页面是否存在内存泄漏, 那么我们可以在搜索框中输入该页面的 Activity 名称, 查看内存中该 Activity 对象的数量. 如果 Activity 对象没有泄漏, 那么在 2.hprof 文件中搜索出来的该 Activity 数量就应该为0, 如果搜索结果大于0, 那么就表示该 Activity 对象有泄漏. 我们对比一下两个快照文件的搜索结果:

                            这里写图片描述

                            可以看到, 1.hprof 中, 第二个页面(即: StaticLeakActivity)的对象数量为0, 而 2.hprof 中其数量为1, 说明在进入该页面再退出后, 即使执行了 GC操作, 该页面的 Activity 对象也依然无法被GC回收而存在于内存中, 说明该页面确实发生了内存泄漏, 有内存泄漏就说明该 Activity 一直被其他对象引用着. 被谁引用呢? 我们可以对该行点右键—>List objects—>with incoming references 查看.

                            这里写图片描述

                            下图是所有持有对该 Activity 引用的其他对象.

                            这里写图片描述

                            因为每个控件创建时都会传一个 Context 参数, 所以图中的 ImageView, GridView 对象引用着该 Activity也就很容易理解了, 同理还有我们定义的 ImageAdapter 等. 不过注意观察图中用红框选中的那一行, 这是该 Activity 内部定义的一个静态变量 sContext, 它也引用着该Activity对象, 这很正常啊. 但是请观察该行最左边有个小黄点, 小黄点表示该变量属于 GC root, 是无法被GC回收的, 关闭该页面时, 由于该变量不能被回收, 就导致它所引用的该 Activity 对象也不能被回收, 发生泄漏. 我们再观察一下红框中最右边的 System Class, 说明该变量是在系统启动时就被加载进内存了, 而不是该页面启动后才加载的, 这不正是静态变量的本意吗? 至于图中其他行, 由于他们不是 GC root, 所以在关闭该页面并执行 GC 操作后, 他们都会被回收. 分析到这里, 我们也就知道了, 该 Activity 的泄漏, 是由于静态变量 sContext 的缘故, 修复起来也就简单了.

                            最后要提示一句, 上图列出的只是直接引用到该 Activity 的对象列表, 而不能查看间接引用它的对象列表. 有时候某个对象被 GC root 对象间接引用, 也会导致该对象发生泄漏, 那么如果是这种间接引用, 那么我们又该如何查看呢? 其实可以在上图的基础上, 对着该对象点右键 —>Path To GC Roots —> exclude weak references, 即可查看到是哪些 GC root 对象间接引用着该对象了. 操作步骤见下图:

                            这里写图片描述

                            使用这种方法, 我们再次找到了导致该 Activity 对象发生泄漏的元凶. 而之所以要排除 weak reference (弱引用), 是因为弱引用的特点决定了他们是一定能被 GC 垃圾回收的, 虽然我们在导出内存快照前执行过一次 GC 操作, 已经清理掉大部分软引用的对象, 但是在执行完 GC 操作到我们点击 Dump Java Heap 按钮之间的一小段时间可能又会生成少量这些对象, 所以在这里将他们排除掉可以避免干扰.

                            这里写图片描述

                            前边介绍的是通过搜索来对比被搜索对象前后数量的变化, 当然, 我们也可以将这些对象按照各自所属的包进行归类摆放, 然后找出我们自己代码所属的包, 然后逐一进行数量的对比, 这样的检测也会更完整一些. 点击下图红框标注的按钮即可让对象列表按照包来归类.

                            这里写图片描述

                            我们只关注我们自己的包 com.exampel.memoryleakdemo 中的对象在前后数量上的对比, 如下图所示:

                            这里写图片描述

                            经过对比, 可以发现, 2.hprof 文件中使用红框标注的3个对象 (ImageAdapter$ViewHolder, ImageAdapter 和 StaticLeakActivity), 在数量上都比各自相应在 1.hprof 文件中的数量要多, 说明这3个变量都发生了泄漏. 而且我们还能看到他们各自被泄漏出去的具体数量. 我们也可以使用前边提到的查看 Path To GC Roots 的方法来查看是哪些 GC root 对象分别引用着他们. 查询结果如下:

                            这里写图片描述

                            这里写图片描述

                            这里写图片描述

                            由上述3张图可知, 被泄漏的这三种对象都是由于被 sContext 引用而导致泄漏的.

                            最后提示一个小技巧, 我们不是要肉眼观察对比两个文件相同对象的数量差异吗? 何必这么麻烦呢! MAT 已经集成了自动对比的功能, 我们只需点击一个按钮即可实现, 这个按钮只有在打开 Histogram 界面后才会显示出来. 如下图所示:

                            这里写图片描述

                            点击上图中任意一个文件中用红框标注的按钮, 选择将要与该文件进行对比的另一个文件, 然后就会显示出同一对象在当前文件和另一文件中的数量差了. 如果增加了, 就显示正数, 减少了就显示负数. 具体操作如下图所示:

                            这里写图片描述

                          • 使用Dominator Tree 分析内存泄漏

                            下面是打开 Dominator Tree 后的效果图, 该图会列举出该内存快照中, 各个对象占用的 Shallow Heap 和 Retained Heap 的大小以及百分比, 如下图所示.

                            这里写图片描述

                            其实, 使用 Dominator Tree 分析内存泄漏, 和使用 Histogram 进行分析, 结果是相同的, 只是二者站的角度不同而已. 上文我们介绍过, Dominator Tree 的含义其实就是引用链, 这里的 Dominator Tree 标签中所展示的是从 GC root 对象开始的一系列引用链的列表. 如果某个对象被 GC root 直接引用着, 那么不仅该对象自身无法被 GC 回收, 而且被该对象强引用的其他对象也将无法回收, 这些对象都会发生泄漏, 那么由于该对象的原因而造成的内存泄漏总量就称作该对象的 Retained Heap. Retained Heap 数值越大, 就表示因该对象而导致的泄漏量就越大. 所以我们可以将上图中两个文件的 Dominator Tree 列表按照 Retained Heap 从大到小排列. 然后逐一对比, 同样的对象, 如果在 2.hprof 中的数量要多于 1.hprof, 就表示该对象可能发生了泄漏. 我们之所以按照从大到小的顺序排列, 是因为 Retained Heap 数值越大, 就越有可能表示泄漏量越大, 我们总是要优先修复泄漏量最大的那些问题, 这些问题也是影响最大的, 紧急程度最高的. 那么我们就来张对比图吧:

                            这里写图片描述

                            按照 Retained Heap 从大到小的顺序排列后, 对比两个文件, 发现至少图中两个标注了红框的地方是 2.hprof 文件比 1.hprof 文件多出的部分, 而且差异约靠前的地方, 泄漏量就越大. 我们先看看第一个红框中的对象, 这是图片加载框架 Glide 的一个 LruResourceCache 对象发生了泄漏. 我们可以对它点右键 —> Path To GC Roots —> exclude weak references, 来查看从 GC root 到该对象除去弱引用后的引用链, 如下图所示:

                            这里写图片描述

                            原来是被 Glide 框架内部的某个变量引用所导致的. 查看源码:

                            这里写图片描述

                            原来这个 glide 也是个静态变量啊, 又是静态变量造成的内存泄漏. 不过这个泄漏不是由我们自己写的代码所造成的, 而是由第三方优秀的图片加载框架 Glide 的代码造成的. 其实当前各个优秀的第三方图片加载框架都或多或少存在着一些内存泄漏问题, 不过泄漏应该都不会太严重, 否则就不可能称作优秀的框架了. 就连 Android SDK 也会存在着一些泄漏. 所以这些泄漏我们先忽略掉, 优先修复由我们自己写的代码造成的泄漏. 所以我们再来看看上图中另外一个红框标注的内容, 这是 14 个 Bitmap 对象发生了泄漏. 还是像上边那样操作, 对任意一个 Bitmap, 找到除去弱引用后的 Path To GC Roots, 如下:

                            这里写图片描述

                            原来, 这个 Bitmap 对象就是由我们自己写的代码中的 sContext 变量所一直引用而造成的泄漏. 分析其他 Bitmap 对象的泄漏, 会发现也是由这个sContext 变量所造成的. 每个 Bitmap 的泄漏量占比 1.26%, 那么修复了这14个泄漏, 就相当于修复了 17% 的泄漏, 况且, 靠前的几行还不一定就是泄漏呢. 这样, 我们就修复了由我们自己代码所造成的最严重的泄漏问题了.

                            另外提示一下, 上图中有很多 Bitmap 对象泄漏, 有时候我们还想查看某个 Bitmap 对象到底对应的是哪张图片, 这又该如何操作呢? 其实这也不难, 只需下载一个名为 GIMP 的软件即可查看 (下载地址: http://www.gimp.org/downloads/). 具体的操作步骤如下图所示, 注意该过程中需要把 Bitmap 对象内的 byte[] 数组中的数据保存为 .data 文件.

                            这里写图片描述

                          5. 关于上述几种定位内存泄漏方法的对比

                          上文介绍了3种定位内存泄漏问题的方法. 通过介绍以及实例演示, 我们应该能对每种方式都有直观的认识, 那么我们就总结一下什么情况下该用哪种方法吧. 总结如下:

                          1. 使用 Android Studio 内置的分析工具, 和使用 LeakCanary 库的方式, 操作都相对较简单, 但是这2种方式只告诉我们哪些对象发生了泄漏以及造成该泄漏的 GC root 对象, 但却不会告诉我们其他更详细的细节信息. 二者比较起来, LeakCanary 的操作更简单而且更加智能, 它能够直接告诉我们哪个对象发生了泄漏, 而无需我们自己去分析内存快照了.
                          2. 使用 MAT 进行分析, 能够分析出更加详细的细节信息, 不仅能看到有哪些对象被泄漏了, 还能看到这些对象泄漏的数量. 我们甚至还能结合 GIMP 软件查看某个泄漏的 Bitmap 对象对应的图片内容等, 所以功能显然是三种分析方法中最强大的, 但是这些强大的功能需要我们事先掌握相关的知识才能合理地运用, 而且 MAT 也不会直接告诉我们哪些对象发生了泄漏, 所以这只能由我们自己去分析, 过程也相对更加繁琐, 需要前后导出两份 .hprof 文件并分别转换为标准格式.

                          6. 总结

                          本文讲述了内存泄漏的概念以及分析定位的几种常用手段, 至于当我们定位到造成泄漏的根源代码后, 该如何修改这段代码, 才能保证既不影响原有功能, 又能消除内存泄漏, 这需要长期实践经验的积累和总结. 但是有几种常见的容易引起内存泄漏的不良写法, 大家可以上网搜索并留意一下, 然后在今后开发过程中有意识地避免那样写就好了. 如果定位出来的问题代码块并没有这些常见的不良写法, 那就需要仔细分析业务需求和代码本身了, 这种情况通常也需要依赖平时的经验积累. 所以我们平时遇到难题要多思考, 多交流, 解决问题后也要多总结, 这样才能不断积累经验. 最后提示一句, 本文讲解时所用的 demo 代码, 可以在这里下载.

                          7. 参考资料:

                          • 0
                            点赞
                          • 1
                            收藏
                            觉得还不错? 一键收藏
                          • 0
                            评论

                          “相关推荐”对你有帮助么?

                          • 非常没帮助
                          • 没帮助
                          • 一般
                          • 有帮助
                          • 非常有帮助
                          提交
                          评论
                          添加红包

                          请填写红包祝福语或标题

                          红包个数最小为10个

                          红包金额最低5元

                          当前余额3.43前往充值 >
                          需支付:10.00
                          成就一亿技术人!
                          领取后你会自动成为博主和红包主的粉丝 规则
                          hope_wisdom
                          发出的红包
                          实付
                          使用余额支付
                          点击重新获取
                          扫码支付
                          钱包余额 0

                          抵扣说明:

                          1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
                          2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

                          余额充值