Android 的 Context 泄露分析和解决方案

3,259 阅读6分钟

前言

在Android中可能会有多种情况的内存泄露,其中比较常见的就是Context泄露,即上下文泄露。这个问题不容忽视,因为Activity、Service、Application都是Context的子类,所以一个Context对象有时会比较庞大,所以如果Context对象无法释放那么很容易造成OOM。

我们都知道,造成内存泄露的根本原因是某个已经无用的对象还被其他对象引用着,所以GC无法释放。Context泄露也是这样,那么在Android中有什么样的场景会导致Context泄露呢?经笔者总结,有以下两个场景(也许还有其他的,笔者目前暂时没有碰到,欢迎大神指正):

1. Java语言的机制造成的泄露。

2. Android生命周期造成的泄露。

有时候二者没有明显的区分,甚至有时候是两种原因一起造成了Context泄露。本文我们就从上面两点进行阐述。

检测内存泄露的工具

因为本文要讨论内存泄露,所以我们需要可以检测内存泄露的工具,笔者总结了三种方式:

1. Dump Java Heap生成hprof文件,用DDMS或者MAT工具分析。

2. StrictMode严格模式:

3. LeakCanary。

第一种较为繁琐,不适用于今天的场景。严格模式和LeakCanary见效比较快,所以本文就用第二种和第三种来检测内存泄露。

严格模式的相关文章可以参考《Android性能调优利器StrictMode》

LeakCanary网上的教程很多,这里就不一一列举了。

说点题外话,严格模式和LeakCanary检测Activity泄露,二者的检查时机是一样的,都是在某个Activity onDestroy时检测,但二者的判断方式是不一样的。

严格模式会检测应用中某一个Activity是否存在多个实例,默认是一个,如果存在多个就会给出提示。所以如果一个Activity存在内存泄露,第一次是检测不出来的,因为第一次及时内存泄露也还是有一个实例,除非再次进到这个Activity然后退出,那么严格模式才会给出提示。

LeakCanary则在Activity销毁时检查该Activity是否还存在,如果存在就发起一次GC再检测,如果无法销毁就打印内存快照然后分析给出Notification提示。

Java语言的机制造成的泄露

这标题有点唬人,这里不是说Java语言有bug。。。请听我细细道来。让我们先来看一段代码

package com.example;

public class MyClass {


    public static void main(String[] args) throws Throwable {

    }

    public class A {
        public void methed1(){

        }
    }

    public static class B {
        public void methed1(){

        }
    }
}

新建一个类MyClass,其中有两个内部类,非静态内部类A和静态内部类B。如果我们把上面代码编译再反编译,可以看到非静态内部类A自动生成的构造方法里,默认的参数是外部类,因此使用内部类的时候会保存一个外部类的引用。而静态内部类B呢,没有生产默认的构造方法。这里只是一个结论,具体探究的过程可以参考《Java内部类的实现原理与可能的内存泄漏》。

再重述一遍结论,非静态内部类会持有外部类的引用,而静态内部类则不会持有外部类的应用。再加上一点,匿名内部类也会持有外部类的引用。

举个Android的例子,看下面的代码,

public class LeakActivity extends AppCompatActivity {

    private static final String TAG = "LeakActivity";

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_leak);
        MyThread myThread = new MyThread();
        myThread.start();
    }

    class MyThread extends Thread {

        @Override
        public void run() {
            while (true) {
                Log.e(TAG, "run: ");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

在LeakActivity中声明一个非静态内部类MyThread每个一秒打印log。因为这是一个非静态内部类,持有了一个LeakActivity的引用。当我们从这个Activity退出时,从理论上说,LeakActivity内存泄露了。让我们看看检测工具,LeakCanary会给出提示

再次进入LeakActivity然后退出,StrictMode会给出提示(至于为什么要再次进入然后退出,请参考上文)

E/StrictMode: class com.gnepux.sdkusage.activity.LeakActivity; instances=2; limit=1
android.os.StrictMode$InstanceCountViolation: class com.gnepux.sdkusage.activity.LeakActivity; instances=2; limit=1 at 
android.os.StrictMode.setClassInstanceLimit(StrictMode.java:1)

从以上可以看出,MyThread这个类确实引用了LeakActivity导致了LeakActivity的内存泄露。

其实非静态内部类会导致内存泄露,Google已经老早给出了提醒,如果我们在Activity中创建一个非静态内部类继承Handler,Android Studio会给出这样的提示

发现问题就要解决问题,那我们该如何应对这种情况的内存泄露呢。回头再看看Google的那段话,我们可以找到解决方案:

1. 将非静态内部类声明为静态内部类;

2. 如果在静态内部类中需要引用外部类,我们需要用WeakReference进行引用。因为Android系统对WeakReference的回收是相当积极的,所以在使用前一定要记得判空!

现在让我们就修改一下LeakActivity的代码来消灭内存泄露的问题吧

public class LeakActivity extends AppCompatActivity {

    private static final String TAG = "LeakActivity";

    // 创建一个非静态变量, 让静态内部类访问
    private String mStr = "LeakActivity还没释放...";

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_leak);
        MyThread myThread = new MyThread(this);
        myThread.start();
    }

    static class MyThread extends Thread {

        // 使用WeakReference引用LeakActivity
        private WeakReference<LeakActivity> mWeakRef;

        public MyThread(LeakActivity leakActivity) {
            this.mWeakRef = new WeakReference<>(leakActivity);
        }

        @Override
        public void run() {
            while (true) {
                // WeakReference一定要记得判空
                if (mWeakRef.get() != null) {
                    Log.e(TAG, "run: " + mWeakRef.get().mStr);
                } else {
                    Log.e(TAG, "run: LeakActivity已经释放掉了...");
                }
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

这里创建了一个非静态全局变量mStr来模拟让静态内部类MyThread访问。在MyThread的构造方法中加入参数传入LeakActivity对象,内部使用WeakReference持有这个引用。当退出这个页面后,如果系统发生GC,这个WeakReference就会立刻释放掉,所以一定要记得判空。

有童鞋也许要问,如果发生GC后,那mStr变量不就访问不了了么,逻辑就不正确了啊。这里有必要说一下,既然mStr定义为LeakActivity的非静态全局变量,那就默认这个变量的生命周期应该和Activity一致,在这个Activity销毁之后,mStr变量从代码设计的角度看就是不允许再被访问的。如果出于某些业务上的需求,那就声明为LeakActivity的静态全局变量或是MyThread的内部变量。

跑一下上面的代码,数次进入退出LeakActivity,LeakCanary和StrictMode都没有再给出内存泄露的提示了,这个小bug就这么愉快地解决啦~ ^_^

Android生命周期造成的泄露

跟上一节一样,同样是个很唬人的标题,就是这么标题党,哈哈。这里不是说Android自身的生命周期会造成内存泄露,而是说某个持有了Context的对象的生命周期和Context的生命周期不同步导致了Context对象无法释放,从而造成了内存泄露。

这句话有点拗口,举个例子就很容易理解了。下面的LeakObject是一个单例,构造是需要传入一个Context对象。

public class LeakObject {

    private static final String TAG = "LeakObject";

    private static LeakObject instance = null;

    private Context context;

    private LeakObject(Context context) {
        this.context = context;
    }

    public static LeakObject getInstance(Context context) {
        if (instance == null) {
            synchronized (LeakObject.class) {
                if (instance == null) {
                    instance = new LeakObject(context);
                }
            }
        }
        return instance;
    }

    public void sayHi() {
        Log.e(TAG, "sayHi: ");
    }
}

我们在LeakAcitivity中获取一个LeakObject的单例对象并调用sayHi方法。

public class LeakActivity extends AppCompatActivity {

    private static final String TAG = "LeakActivity";
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_leak);
        LeakObject object = LeakObject.getInstance(this);
        object.sayHi();
    }
}

这是很常见的单例模式的应用,我们也经常这么写,这也会出现内存泄露的问题么?我们运行一遍,来回进入几次LeakActivity,可以看到StrictMode给出了下面的提示

E/StrictMode: class com.gnepux.sdkusage.activity.LeakActivity; instances=2; limit=1
android.os.StrictMode$InstanceCountViolation: class com.gnepux.sdkusage.activity.LeakActivity; instances=2; limit=1 at 
android.os.StrictMode.setClassInstanceLimit(StrictMode.java:1)

这是为什么呢?因为我们在申请单例的时候,传入的是LeakActivity的Context,而我们知道LeakObject中的单例静态变量instance是常驻内存中的,它的生命周期跟应用一样长,只要应用没关闭,它就一直在。它持有了一个LeakActivity的引用,这样导致LeakActivity的生命周期跟应用一样长,无法被释放,造成呢内存泄露。

那么该如何解决这个问题呢,我们只要把在getInstance()中传入getApplicationContext()就可以了,这样就确保了单例的持有的是Application的Context。因为ApplicationContext本身就是跟应用的生命周期一样长的,这样就不存在内存泄露了。完美~

通过上面的例子可以看出,如果某个持有Context的对象的生命周期跟Context的生命周期不一致,就会导致Context的内存泄露。不光是例子中说的单例,我们常用的AsyncTask、Handler等都会存在这个问题。

比如AsyncTask持有了Activity的Context,在Activity退出时AsyncTask的任务还没做完,Activity就无法被释放。所以我们需要在onDestroy时手动cancel AsyncTask,确保AsyncTask的生命周期跟Activity同步。

同样,指向Activity等Context资源的静态变量也会导致内存泄露,原理都一样,就不再赘述了。

总结

下面做一个总结,本文从两个角度介绍了Context上下文泄露的原因。

1. Java语言的角度:非静态内部类会持有外部类的引用,可能会导致内存泄露。解决方法是使用静态内部类,同时用WeakReference持有Context引用。同样匿名内部类也会持有外部类的引用,尽量不要使用匿名内部类。

2. Android生命周期的角度:如果某个持有Context的对象的生命周期跟Context的生命周期不一致,就会导致Context的内存泄露。单例、指向Activity等Context资源的静态变量、AsyncTask等都有可能因为这个原因导致内存泄露。确保持有Context引用的对象跟Context的生命周期保持一致。可以使用getApplicationContext或者手动控制。

二者没有严格的区分,有时是某一个的原因造成了泄露,有时是二者结合造成了泄露。我们需要在编码过程中时刻长个心眼,有条件时也可以利用工具来进行分析。

参考资料