内存泄漏与排查流程——安卓性能优化

7,260 阅读16分钟

前言

内存泄漏可以说是安卓开发中常遇到的问题,追溯和排查其问题根源是进阶的程序猿必须具备的一项技能。小盆友今天便与大家分享一下这方面的一些见解,如有理解错误或是不同见解,可以于评论区留言我们进行讨论,如果喜欢给个赞鼓励下吧。

篇幅较长,可以通过目录寻找自己所需了解的吧

目录

1、JAVA内存解析
2、JAVA回收机制
3、四种引用
4、小结
5、安卓内存泄漏排查工具
6、内存泄漏检查与解决流程
7、常见的内存泄漏原因

1、JAVA内存解析

要想知道内存泄漏,需要先了解java中运行时内存是怎么构成的,才能知道是哪个地方导致。话不多说,先上图 java内存模型 运行时的java内存分为两大块:线程私有(蓝色区域)、共享数据区(黄色区域)
线程私有:主要用于存储各个线程私有的一些信息,包括:程序计数器、虚拟机栈、本地方法栈
共享数据区:主要用于存储公用的一些信息,包括:方法区(内含常量池)、堆

  1. 程序计数器:让程序中各个线程知道自己接下来需要执行哪一行。在java中多线程为抢占式(因为cpu在某一时刻只会执行一条线程),当线程切换时,需要继续哪一行便由程序计数器告知。

举个例子:A、B两条线程,此时CPU执行从A切换至B,过了段时间从B切换回A,此时A需要从上次暂停的地方继续执行,此时从哪一行执行就是由程序计数器来提供。

值得一提
(1)若执行java函数时,程序计数器记录的是虚拟机字节码的地址;
(2)若执行native方法时,程序计数器便置为了null。
(3)在java虚拟机规范中,程序计数器是唯一没有定义OutOfMemoryError。

  1. 虚拟机栈:描述的是java方法的内存模型,平时说的“栈”其实就是虚拟机栈,其生命周期与线程相同。每个方法(不包含native方法)执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

值得一提:在java虚拟机规范中,此处定义了两个异常
(1)StackOverFlowError (在递归中常看到,递归层级过深)
(2)OutOfMemoryError

  1. 本地方法栈:是为虚拟机使用到的Native方法提供内存空间。 有些虚拟机的实现直接把本地方法栈和虚拟机栈合二为一,比如主流的HotSpot虚拟机。

值得一提:在java虚拟机规范中,此处定义了两个异常
(1)StackOverFlowError (在递归中常看到,递归层级过深)
(2)OutOfMemoryError

  1. 方法区:主要存储已加载是类信息(由ClassLoader加载)、常量、静态变量、编译后的代码的一些信息。 GC在这里比较少出现在这块区域。

  2. 堆:存放的是几乎所有的对象实例和数组数据。 是虚拟机管理的最大的一块内存,是GC的主战场,所以也叫“GC堆”、“垃圾堆” 。

    值得一提:在java虚拟机规范中,此处定义了一个异常
    (1)OutOfMemoryError

  3. 运行时常量池:属于“方法区”的一部分,用于存放编译器生成的各种字面量和符号引用。
    字面量:与Java语言层面的常量概念相近,包含文本字符串、声明为final的常量值等。
    符号引用:编译语言层面的概念,包括以下3类:
    (1) 类和接口的全限定名
    (2)字段的名称和描述符
    (3)方法的名称和描述符

2、JAVA回收机制

java中是通过GC(Garbage Collection)来进行回收内存,那jvm是如何确定一个对象能否被回收的呢?这里就需讲到其回收使用的算法

(1) 引用计数算法

引用计数是垃圾收集器中的早期策略。在这种方法中,堆中每个对象实例都有一个引用计数。当一个对象被创建时,且将该对象实例分配给一个变量,该变量计数设置为1。当任何其它变量被赋值为这个对象的引用时,计数加1(a = b,则b引用的对象实例的计数器+1),当一个对象实例的某个引用超过了生命周期或者被设置为一个新值时,对象实例的引用计数器减1。任何引用计数器为0的对象实例可以被当作垃圾收集。当一个对象实例被垃圾收集时,它引用的任何对象实例的引用计数器减1。

优点:
  引用计数收集器可以很快的执行,交织在程序运行中。对程序需要不被长时间打断的实时环境比较有利。

缺点:
  无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0。例如下面代码片段中,最后的Object实例已经不在我们的代码可控范围内,但其引用仍为1,此时内存便产生泄漏

/**举个例子**/
Object o1 = new Object()      //Object的引用+1,此时计数器为1
Object o2;
o2.o  = o1;   			      //Object的引用+1,此时计数器为2
o2 = null;
o1 = null;				      //Object的引用-1,此时计数器为1

(2) 可达性分析算法

可达性分析算法

可达性分析算法是现在java的主流方法,通过一系列的GC ROOT为起始点,从一个GC ROOT开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点(即图中的ObjD、ObjE、ObjF)。由此可知,即时引用成环也不会导致泄漏。

java中可作为GC Root的对象有:
1、方法区中静态属性引用的对象
2、方法区中常量引用的对象
3、本地方法栈JNI中引用的对象(Native对象)
4、虚拟机栈(本地变量表)中正在运行使用的引用

但是,可达性分析算法中不可达的对象,也并非一定要被回收。当GC第一次扫过这些对象的时候,他们处于“死缓”的阶段。要真正执行死刑,至少需要经过两次标记过程。 如果对象经过可达性分析之后发现没有与GC Roots相关联的引用链,那他会被第一次标记,并经历一次筛选,这个对象的finalize方法会被执行。如果对象没有覆盖finalize或者已经被执行过了。虚拟机也不会去执行finalize方法。Finalize是对象逃狱的最后一次机会。

3、四种引用

说到底,内存泄漏是因为引用的处理不正当导致的。所以,我们接下来需要老生常谈一下java中四种引用,即:强软弱虚(引用强度依次减弱)。

(1)强引用(Strong reference): 一般我们使用的都是强引用,例如:Object o = new Object();只要强引用还在,垃圾收集器就不会回收被引用的对象。

(2)软引用(Soft Reference): 用来定义一些还有用但并非必须的对象。对于软引用关联着的对象,在系统将要内存溢出之前,会将这些对象列入回收范围进行第二次回收,如果回收后还是内存不足,才会抛出内存溢出。(即在内存紧张时,会对其软引用回收)

(3)弱引用(Weak Reference): 用来描述非必须对象。被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器回收时,无论内存是否足够,都会回收掉被弱引用关联的对象。(即GC扫过时,便将弱引用带走)

(4)虚引用(Phantom Reference): 也称为幽灵引用或者幻影引用,是最弱的引用关系。一个对象的虚引用根本不影响其生存时间,也不能通过虚引用获得一个对象实例。 虚引用的唯一作用就是这个对象被GC时可以收到一条系统通知。

软引用与弱引用的抉择
如果只是想避免OutOfMemory异常的发生,则可以使用软引用。如果对于应用的性能更在意,想尽快回收一些占用内存比较大的对象,则可以使用弱引用。另外可以根据对象是否经常使用来判断选择软引用还是弱引用。如果该对象可能会经常使用的,就尽量用软引用。如果该对象不被使用的可能性更大些,就可以用弱引用。

4、小结

至此,我们知道内存泄漏是因为堆内存中的长生命周期的对象持有短生命周期对象的引用,尽管短生命周期对象已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收。

5、安卓内存泄漏排查工具

所谓工欲善其事必先利其器,这一小节先简述下所需借用到的内存泄漏排查工具,如果已经熟悉的话可以跳过。

(1) Android Profiler

这一工具是Android Studio自带,可以查看cpu、内存使用、网络使用情况,Android Studio3.0中用于替代Android Monitor Android Profiler功能简介 ① 强制执行垃圾收集事件的按钮。
② 捕获堆转储的按钮。
③ 记录内存分配的按钮。
④ 放大时间线的按钮。
⑤ 跳转到实时内存数据的按钮。
⑥ 事件时间线显示活动状态、用户输入事件和屏幕旋转事件。
⑦ 内存使用时间表,其中包括以下内容:
• 每个内存类别使用多少内存的堆栈图,如左边的y轴和顶部的颜色键所示。
• 虚线表示已分配对象的数量,如右侧y轴所示。
• 每个垃圾收集事件的图标。

(2) MAT(Memory Analyzer Tool)

MAT用于锁定哪里泄漏。因为从Android Profiler中,知道了泄漏,但比较难锁定具体哪个地方导致了泄漏,所以借助MAT来锁定,具体使用待会会借助一个例子配合Android Profiler来介绍,稍安勿躁。

下载地址:www.eclipse.org/mat/downloa…

6、内存泄漏检查与解决流程

经过前面的一段理论,可能很多小伙伴都有些不耐烦了,现在便来真正的操作。

温馨提示:理论是进阶中必要的支持,否则只是知其然而不知其所以然

(1)第一步:对待检测功能扫雷式操作

当我们需要检查一块模块,或是整个app哪个地方有内存泄漏时,有时会比较茫然,有些大海捞针的感觉,毕竟泄漏不是每个页面都会有,而且有时是一个功能才会导致泄漏,所以我们可以采取“扫雷式操作”,也就是在需要检查的页面和功能中随便先使用一番,举个例子:假设检查MainActivity泄漏情况,可以登录进入后,此时来到了MainActivity,后又登出,再次登录进入MainActivity。

(2)第二步:借助 Android Profiler获得内存快照

使用Android Profiler的GC功能,强制进行垃圾回收,再dump下内存("Android Profiler功能简介"图的②按钮)。然后等待一段时间,会出现图中红色框部分: 在这里得到的页面,其实比较难直观获得内存分析的数据,最多只是选择“Arrange by package”按照包进行排序,然后进到自己的包下,查看应用内的activity的引用数是否正常,来判断其是否有正常回收

图中列的说明
Alloc Cout : 对象数
Shallow Size : 对象占用内存大小
Retained Set : 对象引用组占用内存大小(包含了这个对象引用的其他对象)

(3)第三步:借助Android Studio分析

至此,我们还是没得到直观的内存分析数据,我们需要借助更专业的工具。我们现将通过下图中红框内的按钮,将刚才的内存快照保存为hprof文件。

将保存好的hprof文件拖进AS中,勾选“Detect Leaked Activities”,然后点击绿色按钮进行分析。 如果有内存泄漏的话,会出现如下图的情况。图中很清晰的可以看到,这里出现了MainActivity的泄漏。并且观察到这个MainActivity可能不止一个对象存在,可能是我们上次退出程序的时候发生了泄漏,导致它不能回收。而在此打开app,系统会创建新的MainActivity。但至此我们只是知道MainActivity泄漏了,不知具体是哪里导致了MainActivity泄漏,所以需要借助MAT来进一步分析。 (4)第四步:hprof文件转换

在使用MAT打开hprof文件前先要对刚才保存的hprof文件进行转换。通过终端,借助转换工具hprof-conv(在sdk/platform-tools/hprof-conv),使用命令行:

hprof-conv -z src dst

-z:排除不是app的内存,比如Zygote
src:需要进行转换的hprof的文件路径
dst:转换后的文件路径(文件后缀还是.hprof)

(5)第五步:通过MAT进行具体分析 在MAT中打开转换了的hprof文件,如下图 打开后会看到如下图 我们需要进入到"Histogram"来分析,点击下图中的按钮 打开"Histogram"后,会看到下图,在红框中输入在AS中观察到的泄漏的类,例如上面得知的MainActivity 然后将搜索得到的结果进行合并,排除“软”、“弱”、“虚”引用对象,右键点击搜索到的结果,选择如下图的选项 得到合并结果如下 从分析结果可知,MainActivity是因为com.netease.nimlib.g.e中的一个hashMap持有导致,这里的e类是第三方库的类,显然已被混淆,造成泄漏无非两种可能,一种是第三方库的bug,一种是自己使用不当,例如忘记解绑操作等。具体的打断这个持有需要按照自己的代码进行分析,实例中的问题是因为使用第三方库注册后,在退出页面没有进行注销导致的。

当我们解决完后,可以再次进行一轮内存快照,直到没有内存泄漏,过程会比较枯燥,但一点点的解决泄漏最终会给app一个质的飞跃。

7、常见的内存泄漏原因

(1)集合类

集合类如果仅仅有添加元素的方法,而没有相应的删除机制,导致内存被占用。如果这个集合类是全局性的变量 (比如类中的静态属性,全局性的 map 等即有静态引用或 final 一直指向它),那么没有相应的删除机制,很可能导致集合所占用的内存只增不减。

(2)单例模式

不正确使用单例模式是引起内存泄露的一个常见问题,单例对象在被初始化后将在 JVM 的整个生命周期中存在(以静态变量的方式),如果单例对象持有外部对象的引用,那么这个外部对象将不能被 JVM 正常回收,导致内存泄露。

public class SingleTest{
      private static SingleTest instance;
      private Context context;
      private SingleTest(Context context){
          this.context = context;
      }
      public static SingleTest getInstance(Context context){
          if(instance != null){
                instance = new SingleTest(context);
          }
          return instance;
      }
}

这里如果传递Activity作为Context来获得单例对象,那么单例持有Activity的引用,导致Activity不能被释放。 不要直接对 Activity 进行直接引用作为成员变量,如果允许可以使用Application。 如果不得不需要Activity作为Context,可以使用弱引用WeakReference,相同的,对于Service 等其他有自己生命周期的对象来说,直接引用都需要谨慎考虑是否会存在内存泄露的可能。

(3)未关闭或释放资源

BroadcastReceiver,ContentObserver,FileObserver,Cursor,Callback等在 Activity onDestroy 或者某类生命周期结束之后一定要 unregister 或者 close 掉,否则这个 Activity 类会被 system 强引用,不会被内存回收。值得注意的是,关闭的语句必须在finally中进行关闭,否则有可能因为异常未关闭资源,致使activity泄漏

(4)Handler

只要 Handler 发送的 Message 尚未被处理,则该 Message 及发送它的 Handler 对象将被线程 MessageQueue 一直持有。特别是handler执行延迟任务。所以,Handler 的使用要尤为小心,否则将很容易导致内存泄露的发生。

public class MainActivity extends AppCompatActivity {
    private Handler mHandler = new Handler(){
        @Override
        public void handleMessage(Message msg) {
            //do something
        }
    };
    private void loadData(){
        //do request
        Message message = Message.obtain();
        mHandler.sendMessage(message);
    }
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        loadData();
    }
}

这种创建Handler的方式会造成内存泄漏,由于mHandler是Handler的非静态匿名内部类的实例,所以它持有外部类Activity的引用,我们知道消息队列是在一个Looper线程中不断轮询处理消息,那么当这个Activity退出时消息队列中还有未处理的消息或者正在处理消息,而消息队列中的Message持有mHandler实例的引用,mHandler又持有Activity的引用,所以导致该Activity的内存资源无法及时回收,引发内存泄漏,所以另外一种做法为:

public class MainActivity extends AppCompatActivity {
    private MyHandler mHandler = new MyHandler(this);
    private void loadData() {
        //do request
        Message message = Message.obtain();
        mHandler.sendMessage(message);
    }
    private static class MyHandler extends Handler {
        private WeakReference<Context> reference;
        public MyHandler(Context context) {
            reference = new WeakReference<Context>(context);
        }
        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);
            MainActivity mainActivity = (MainActivity) reference.get();
            if (mainActivity != null) {
                //do something to update UI via mainActivity
            }
        }
    }
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        loadData();
    }
}

创建一个静态Handler内部类,然后对Handler持有的对象使用弱引用,这样在回收时也可以回收Handler持有的对象,这样虽然避免了Activity泄漏,不过Looper线程的消息队列中还是可能会有待处理的消息,所以我们在Activity的Destroy时或者Stop时应该移除消息队列中的消息,

@Override
protected void onDestroy() {
    super.onDestroy();
    mHandler.removeCallbacksAndMessages(null);
}

使用mHandler.removeCallbacksAndMessages(null);是移除消息队列中所有消息和所有的Runnable。当然也可以使用mHandler.removeCallbacks();或mHandler.removeMessages();来移除指定的Runnable和Message。

(5)Thread

和handler一样,线程也是造成内存泄露的一个重要的源头。线程产生内存泄露的主要原因在于线程生命周期的不可控。比如线程是 Activity 的内部类,则线程对象中保存了 Activity 的一个引用,当线程的 run 函数耗时较长没有结束时,线程对象是不会被销毁的,因此它所引用的老的 Activity 也不会被销毁,因此就出现了内存泄露的问题。

(6)系统bug

比如InputMethodManager,会持有activity而没释放,导致泄漏,需要通过反射进行打断。