美文网首页
Android—常见的内存泄漏场景及解决方案

Android—常见的内存泄漏场景及解决方案

作者: 东方未曦 | 来源:发表于2019-08-25 21:26 被阅读0次

    我的CSDN: ListerCi
    我的简书: 东方未曦

    一、引言

    一般情况下Android的内存泄漏是因为,存在引用指向一个本该被回收的对象,例如已经执行onDestroy()的Activity。在这种情况下,由于Activity内某些对象的生命周期比Activity要长,在Activity理论上被销毁时,该对象依旧存在并持有Activity的引用,因此内存回收机制(GC)无法释放Activity,最终导致内存泄漏。

    为了发现和修复APP中存在的内存泄漏,开发人员会在APP上安装内存检测工具(如leakcanary),当出现内存泄漏时,该工具会提供一个报告,里面包含了一条引用链,指明可能造成内存泄漏的引用。开发人员需要在合适的地方切断引用链,以便GC释放掉没有被引用的对象。

    有些内存泄漏的修复很简单,将非静态内部类内部类改为静态内部类或者将Context改为ApplicationContext后检测工具就检测不出内存泄漏了,但是这到底是为什么呢?而且就算检测工具检测不出内存泄漏,就真的万无一失了吗?

    带着这些问题,我们来分析一下Android常见的内存泄漏场景以及解决方案。

    二、Java内存管理及垃圾回收机制

    在了解Android的内存泄漏之前,我们需要先了解Java的内存管理以及垃圾回收机制。

    2.1 内存管理

    Java的内存分配区域主要分为以下几个部分。

    1. 静态变量区

    用于存储被static修饰的静态变量,这块区域在程序开始运行时就已经分配完毕,并且存在于程序的整个运行过程。

    2. 栈

    主要用于分配局部变量,包括基本类型的变量和对象的引用变量,当局部变量的作用域结束之后,Java会自动释放掉该变量占用的内存空间。

    3. 堆

    堆是动态内存区域,程序运行期间新建的对象实例和数组都存储在堆中,垃圾回收机制(GC)管理的就是这块内存。为了及时地将不被使用的对象释放掉,GC需要监控每一个对象的状态,当一个对象不再被引用时,GC就会释放该对象。

    4. 常量池

    常量池中的内容在编译时就已经确定,主要包含代码中的基本类型和对象类型的常量值。
    例如,String就是对象类型,如果在编译时确定了String的值(String s = "test"),那么它的值就存储在常量池中,而它的引用存储在栈中。如果String的值是在程序运行时确定的(String s = new String("...")),那么它的值就存储在堆中。

    假设当前有一个实例A存储在堆中,我们定义了一个引用a指向实例A。此时引用a其实是保存在栈中的,它的值为实例A在堆内存中的首地址,此时程序就可以通过a读写A的值。

    2.2 垃圾回收机制

    上面提到,当一个对象不再被引用时,GC就应该将其回收。确实有一种引用计数法来判断一个对象是否需要被释放,当该对象的引用计数为0时代表它需要被回收。但是如果存在两个对象,没有别的引用指向它们,但是它们互相引用,此时它们的引用计数都不为0,导致无法释放,容易造成内存泄漏。

    目前主流的的方法是通过可达性分析来判断一个对象是否需要被释放。该算法的基本思路就是通过一些被称为引用链(GC Roots)的对象作为起点,从这些节点开始向下搜索,搜索走过的路径被称为(Reference Chain),当一个对象到GC Roots没有任何引用链相连时(即从GC Roots节点到该节点不可达),则证明该对象是不可用的。

    在Java中,可作为GC Root的对象包括以下几种:

    • 虚拟机栈(栈帧中的本地变量表)中引用的对象
    • 方法区中类静态属性引用的对象
    • 方法区中常量引用的对象
    • 本地方法栈中JNI(即一般说的Native方法)引用的对象

    三、Android常见内存泄漏场景

    3.1 内部类持有外部类引用造成的内存泄漏

    1. 非静态内部类

    我们知道非静态内部类可以访问外部类的变量,它通过变量this$0隐式地持有外部类的引用,这个变量是编译器为非静态内部类添加的,如果内部类的生命周期超过外部类,则会引发内存泄漏。

    造成这种情况的具体原因很多,可能是多线程或者监听器未反注册。如果需要快速修复,可以将内部类改为static,但是static变量的生命周期与App相同,该变量不会被回收。因此最好是在出现内存泄漏时,通过引用链寻找可以切断的地方。后文的监听器和Handler都属于这种情况。

    2. 匿名内部类

    匿名内部类引发内存泄漏的原因与非静态内部类相似,匿名内部类通过xxx$1.class持有了外部类的引用,如果匿名内部类的生命周期超过外部类,在外部类例如Activity销毁时,内部类依旧持有外部类的引用,就会引发内存泄漏。

    如果像下面这样直接在匿名内部类中使用Runnable或者Handler时就非常容易引起内存泄漏。由于Runnable执行的时间很可能超过Activity,Activity在onDestroy()后匿名内部类依旧存在,最终导致Activity泄露。

    button.setOnClickListener(new View.OnClickListener() {
        @override
        public void onClick(View view) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    // ......
                }
           }).start();
        }
    });
    

    匿名内部类引发的内存泄漏不易修改,因为没有办法获得该对象的引用,也就无法在Activity被销毁时通过引用清除这些资源。因此对于可能引发内存泄漏的匿名内部类来说,应该改为内部类实现。

    3.2 多线程造成的内存泄漏

    1. Runnable(Thread)

    当异步线程持有外部Activity的引用时,如果Activity销毁时线程还没有执行完,就会导致内存泄漏。
    解决办法很简单,只需要在Activity销毁之前终止线程即可。

    2. AsyncTask

    AsyncTaskHandler+Thread的封装,用于完成异步任务。我们在使用时,一般继承AsyncTask并重写doInBackground()方法和onPostExecute()方法,doInBackground()方法进行耗时操作,onPostExecute()方法在主线程更新UI。
    其常见的内存泄漏原因与Runnable类似,也是由于AsyncTask未执行完时Activity被销毁,而AsyncTask又持有Activity的引用,导致Activity无法释放,引起内存泄漏。

    对于AsyncTask造成的内存泄漏,推荐使用cancel+isCancelled来解决。
    如果一个任务没有被执行并且cancel方法被调用,那么任务会立即取消且不会被执行。对于已经在执行的任务,cancel方法只能保证其onPostExecute()不会被执行,也就是说,即使调用了cancel方法,任务也不会立即停止,需要等待doInBackground()方法完成。cancel方法不会终止一个正在运行的线程,只是给它设置cancelled状态,通知该线程应该中断了。
    因此给任务调用cancel方法后还要检查当前task的状态,保证其及时退出。

    @Override
    protected Integer doInBackground(Void... args) {
        // Task被取消了,马上退出
        if(isCancelled()) return null;
        .......
        // Task被取消了,马上退出
        if(isCancelled()) return null;
    }
    

    虽然有这样的解决办法,但是对于异步操作,这里更推荐RxJava。

    3.3 视图造成的内存泄漏

    1. WebView

    在进行混合开发时,经常需要在Activity中嵌入WebView来访问前端页面,此时需要注意WebView的创建和回收问题。
    在Activity中使用WebView时,推荐使用动态创建和回收的方式进行管理。在布局文件中定义一个ViewGroup,然后动态地将WebView添加到ViewGroup中。

    @override
    protected void onCreate(Bundle savedInstanceState) {
        mWebView = new WebView(this);
        // WebView settings
        mWebView.setWebViewClient(...);
        mWebView.setWebChromeClient(...);
        // 将 WebView 添加到布局中的 ViewGroup 中
        FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(
                    ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
        mWebViewLayout.addView(mWebView, layoutParams);
    }
    

    之后在Activity的onDestroy()方法中回收WebView相关资源。由于WebView内部存在component callbacks,该回调在onAttachedToWindow()方法中进行注册,并在onDetachedFromWindow()方法中进行反注册。为了顺利反注册该回调,需要在WebView执行destroy()之前将其从布局上移除。(具体见下方的参考2)

    @override
    protected void onDestroy() {
        // 从父容器移除 WebView 后再将其销毁
        if (mWebView != null) {
            mWebView.loadDataWithBaseURL(
                    null, "", "text/html", "utf-8", "");
            mWebView.clearHistory();
            mWebView.setWebViewClient(null);
            mWebView.setWebChromeClient(null);       
            mWebViewLayout.removeView(mWebView);
            mWebView.destroy();
            mWebView = null;
        }
    }
    
    2. static view

    如果某个View在初始化时需要消耗大量资源,并且要求其在Activity生命周期中不变,就可能将其修饰为static加载到视图树上。由于View在新建时就持有Activity的引用,因此Activity销毁时需要释放资源。

    public View(Context context) {
        mContext = context; // 此时View已经持有Activity的引用
        // ......
    }
    

    面对这种情况,最好是将View设置为普通变量,可以避免这类内存泄漏。

    3.4 广播、监听器等未反注册

    这一类的内存泄漏主要与观察者模式有关,一般情况下是有多个观察者(Observer)对同一个被观察者(Observable)进行监听。
    如果有一个Manager对观察者进行统一管理的话,那么观察者的对被观察者监听的注册反注册一定是成对出现的,不然就会出现内存泄漏。在监听器一节中会详细描述这种场景。

    1. 广播

    广播的主要流程如下:

    1:广播接收者BroadcastReceiver通过Binder机制向AMS(Activity Manager Service)进行注册
    2:广播发送者通过binder机制向AMS发送广播
    3:AMS查找符合相应条件(IntentFilter/Permission等)的BroadcastReceiver,将广播发送到BroadcastReceiver(一般情况下是Activity)相应的消息循环队列中
    4:消息循环执行拿到此广播,回调BroadcastReceiver中的onReceive()方法

    根据上述流程,Activity在销毁之前应及时反注册,否则广播管理者会一直保留当前Activity的引用,而广播管理者的生命周期是整个Application,最终会导致内存泄漏。

    2. 监听器

    上面提过,如果存在一个统一的Manager对监听器进行管理的话,注册和反注册一定要成对出现,否则很容易出现内存泄漏的情况。下面来分析该场景。

    假设当前存在一个监听器如下所示。

    public interface MyListener {
        void run(...);
    }
    

    定义一个ListenerManager来对所有的监听器进行管理。

    public class ListenerManager {
        // 单例模式
        private static final INSTANCE = new ListenerManager();
        // 存储所有的监听器
        private List<MyListener> mListeners = new CopyOnWriteArrayList<>();
    
        public static ListenerManager getInstance() {
            return INSTANCE;
        }
    
        // 注册监听器时将该监听器添加到列表中
        public void registerListener(MyListener listener) {
            if (listener == null) return;
            if (mListeners.contains(listener)) return;
            mListeners.add(listener);
        }
    
        // 反注册时将该监听器从列表中移除
        public void unRegisterListener(MyListener listener) {
            if (listener == null) return false;
            return mListeners.remove(listener);
        }
    
        public void run() {
            for (MyListener listener : mListeners) {
                listener.run(...);
            }
        }
    }
    

    在使用到该监听的Activity中添加如下代码。

    public class TestActivity {
        private TestListener mTestListener;
    
        @override
        protected void onCreate(...) {
            // ...
            mTestListener = new TestListener();
            ListenerManager.getInstance().registerListener(mTestListener);
        }
    
        @override
        protected void onDestroy() {
            // ...
            ListenerManager.getInstance().unRegisterListener(mTestListener);
        }
    
        private class TestListener implements MyListener {
            @override
            void run(...) {
                // ...
            }
        }
    }
    

    可以看到,在Activity中使用了内部类的形式定义了监听器,随后在onCreate()方法中注册,并在onDestroy()中反注册。那么如果没有反注册会出现什么情况呢?

    首先ListenerManager的生命周期比Activity要长,如果Activity未进行反注册,ListenerManager中的mListeners会一直持有TestListener对象的引用,又因为TestListener是内部类,它持有Activity的引用。
    最终形成了ListenerManager->mListeners->mTestListener->Activity的引用链,导致Activity无法被释放,形成了内存泄漏。

    3.5 其余情况

    1. Handler

    Handler作为Android的一种消息机制,通过HandlerMessageMessageQueueLooper四个类协调合作完成通信任务。
    其中,Message是消息实体,包含硬件消息和软件消息;
    MessageQueue是消息队列,主要的功能是向消息池投递消息和取走消息池的消息;
    Handler是辅助类,主要功能是向消息池发送消息事件(Handler.sendMessage())和处理相应消息事件(Handler.handleMessage());
    Looper是循环机制,不断循环执行将消息分发给目标处理者。

    如果我们在Activity中创建非静态的Handler实例并重写handleMessage()方法,此时Handler隐式持有外部Activity的引用,而MessageQueue会持有Message引用,Message又持有Handler引用(Message需要知道自己会被发往哪个Handler)。
    也就是说,如果Message不被消费,Activity就不会被释放,如果使用postDelayed,在信息被消费前关闭了Activity,就会造成内存泄漏。

    面对这种情况,最好是在Activity执行onDestroy()时调用HandlerremoveCallbacksAndMessages清除所有信息;也可以选择将Handler定义为静态内部类,这样就不会持有外部Activity的引用了。

    2. 资源未关闭

    资源性对象(比如Cursor、File等)往往都做了一些缓冲,应该在Activity销毁时及时关闭或者注销,否则这些资源将不会被回收,造成内存泄漏。

    3. 工具类生命周期问题

    有时代码中会新建工具类用于完成一系列相同的操作,某些工具类在新建时需要传入Context,如下所示。

    public class Utils {
        private Context mContext;
        public Utils(Context context) {
            mContext = context;
        }
    }
    

    有时候工具类对象是在Activity内部新建的,它的生命周期与Activity的生命周期相同,那么即使它持有context也不会引发内存泄漏问题。但是如果工具类的生命周期比Activity长(如单例),那么传入了哪个Activity的context,哪个Activity就会泄露。
    正确的做法是使用ApplicationContext代替Context,使得工具类的生命周期与APP相同,就不会引发Activity的内存泄露。

    不过如果该工具类只在某几个场景下用到呢?如果它的生命周期还是整个APP,虽然没有内存泄漏,但也是浪费了一部分内存。这时候就需要开发人员对工具类的生命周期进行管理,可以选择在合适的时候清除该工具类对象。

    四、参考

    1. 公司大佬笔记
    2. WebView内存泄漏--解决方法小结

    相关文章

      网友评论

          本文标题:Android—常见的内存泄漏场景及解决方案

          本文链接:https://www.haomeiwen.com/subject/zxcisctx.html