美文网首页Android进阶之路Android开发Android开发经验谈
Android面试被问到内存泄漏了咋整?

Android面试被问到内存泄漏了咋整?

作者: Android进阶小麦 | 来源:发表于2020-08-19 21:46 被阅读0次

    前言

    内存泄漏即该被释放的内存没有被及时的释放,一直被某个或某些实例所持有却不再使用导致GC不能回收。
    文末准备了一份完整系统的进阶提升的技术大纲和学习资料,希望对于有一定工作经验但是技术还需要提升的朋友提供一个方向参考,以及免去不必要的网上到处搜资料时间精力。

    Java内存分配策略

    Java程序运行时的内存分配策略有三种,分别是静态分配,栈式分配,和堆式分配。对应的三种策略使用的内存空间是要分别是静态存储区(也称方法区),栈区,和堆区。

    • 静态存储区(方法区):主要存放静态数据,全局static数据和常量。这块内存在程序编译时就已经分配好,并且在程序整个运行期间都存在。

    • 栈区:当方法执行时,方法内部的局部变量都建立在栈内存中,并在方法结束后自动释放分配的内存。因为栈内存分配是在处理器的指令集当中所以效率很高,但是分配的内存容量有限。

    • 堆区:又称动态内存分配,通常就是指在程序运行时直接new出来的内存。这部分内存在不适用时将会由Java垃圾回收器来负责回收。

    栈与堆的区别:

    在方法体内定义的(局部变量)一些基本类型的变量和对象的引用变量都在方法的栈内存中分配。当在一段方法块中定义一个变量时,Java就会在栈中为其分配内存,当超出变量作用域时,该变量也就无效了,此时占用的内存就会释放,然后会被重新利用。

    堆内存用来存放所有new出来的对象(包括该对象内的所有成员变量)和数组。在堆中分配的内存,由Java垃圾回收管理器来自动管理。在堆中创建一个对象或者数组,可以在栈中定义一个特殊的变量,这个变量的取值等于数组或对象在堆内存中的首地址,这个特殊的变量就是我们上面提到的引用变量。我们可以通过引用变量来访问堆内存中的对象或者数组。

    举个例子:

    public class Sample {
        int s1 = 0;
        Sample mSample1 = new Sample();
    
        public void method() {
            int s2 = 0;
            Sample mSample2 = new Sample();
        }
    }
        Sample mSample3 = new Sample();
    复制代码
    

    如上局部变量s2mSample2存放在栈内存中,mSample3所指向的对象存放在堆内存中,包括该对象的成员变量s1mSample1也存放在堆中,而它自己则存放在栈中。

    结论:

    局部变量的基本类型和引用存储在栈内存中,引用的实体存储在堆中。——因它们存在于方法中,随方法的生命周期而结束。

    成员变量全部存储于堆中(包括基本数据类型,引用和引用的对象实体)。——因为它们属于类,类对象终究要被new出来使用。

    了解了Java的内存分配之后,我们再来看看Java是怎么管理内存。

    Java是如何管理内存

    由程序分配内存,GC来释放内存。内存释放的原理为该对象或者数组不再被引用,则JVM会在适当的时候回收内存。

    内存管理算法:

    1. 引用计数法:对象内部定义引用变量,当该对象被某个引用变量引用时则计数加1,当对象的某个引用变量超出生命周期或者引用了新的变量时,计数减1。任何引用计数为0的对象实例都可以被GC。这种算法的优点是:引用计数收集器可以很快的执行,交织在程序运行中。对程序需要不被长时间打断的实时环境比较有利。缺点:无法检测出循环引用。

    引用计数无法解决的循环引用问题如下:

        public void method() {
            //Sample count=1
            Sample ob1 = new Sample();
            //Sample count=2
            Sample ob2 = new Sample();
            //Sample count=3
            ob1.mSample = ob2;
            //Sample count=4
            ob2.mSample = ob1;
            //Sample count=3
            ob1=null;
            //Sample count=2
            ob2=null;
            //计数为2,不能被GC
        }
    复制代码
    

    Java可以作为GC ROOT的对象有:虚拟机栈中引用的对象(本地变量表),方法区中静态属性引用的对象,方法区中常量引用的对象,本地方法栈中引用的对象(Native对象)

    1. 标记清除法:从根节点集合进行扫描,标记存活的对象,然后再扫描整个空间,对未标记的对象进行回收。在存活对象较多的情况下,效率很高,但是会造成内存碎片。

    2. 标记整理算法:同标记清除法,只不过在回收对象时,对存活的对象进行移动。虽然解决了内存碎片的问题但是增加了内存的开销。

    3. 复制算法:此方法为克服句柄的开销和解决堆碎片。把堆分为一个对象面和多个空闲面。把存活的对象copy到空闲面,主要空闲面就变成了对象面,原来的对象面就变成了空闲面。这样增加了内存的开销,且在交换过程中程序会暂停执行。

    4. 分代算法:

    分代垃圾回收策略,是基于:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的回收算法,以便提高回收效率。

    年轻代:

    1. 所有新生成的对象首先都是存放在年轻代。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。

    2. 新生代内存按照8:1:1的比例分为一个eden区和两个survivor(survivor0,survivor1)区。一个Eden区,两个 Survivor区(一般而言)。大部分对象在Eden区中生成。回收时先将eden区存活对象复制到一个survivor0区,然后清空eden区,当这个survivor0区也存放满了时,则将eden区和survivor0区存活对象复制到另一个survivor1区,然后清空eden和这个survivor0区,此时survivor0区是空的,然后将survivor0区和survivor1区交换,即保持survivor1区为空, 如此往复。

    3. 当survivor1区不足以存放 eden和survivor0的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次Full GC,也就是新生代、老年代都进行回收

    4. 新生代发生的GC也叫做Minor GC,MinorGC发生频率比较高(不一定等Eden区满了才触发)

    年老代:

    1. 在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。

    2. 内存比新生代也大很多(大概比例是1:2),当老年代内存满时触发Major GC即Full GC,Full GC发生频率比较低,老年代对象存活时间比较长,存活率标记高。

    持久代:

    用于存放静态文件,如Java类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。

    Android常见的内存泄漏汇总

    集合类泄漏

    先看一段代码

       List<Object> objectList = new ArrayList<>();
            for (int i = 0; i < 10; i++) {
                Object o = new Object();
                objectList.add(o);
                o = null;
            }
    复制代码
    

    上面的实例,虽然在循环中把引用o释放了,但是它被添加到了objectList中,所以objectList也持有对象的引用,此时该对象是无法被GC的。因此对象如果添加到集合中,还必须从中删除,最简单的方法

      //释放objectList
            objectList.clear();
            objectList=null;
    复制代码
    

    单例造成的内存泄漏

    由于单例的静态特性使得其生命周期跟应用的生命周期一样长,所以如果使用不恰当的话,很容易造成内存泄漏。比如下面一个典型的例子。

    public class SingleInstanceClass {
    
        private static SingleInstanceClass instance;
    
        private Context mContext;
    
        private SingleInstanceClass(Context context) {
            this.mContext = context;
        }
    
        public SingleInstanceClass getInstance(Context context) {
            if (instance == null) {
                instance = new SingleInstanceClass(context);
            }
            return instance;
        }
    }
    复制代码
    

    正如前面所说,静态变量的生命周期等同于应用的生命周期,此处传入的Context参数便是祸端。如果传递进去的是Activity或者Fragment,由于单例一直持有它们的引用,即便Activity或者Fragment销毁了,也不会回收其内存。特别是一些庞大的Activity非常容易导致OOM。

    正确的写法应该是传递Application的Context,因为Application的生命周期就是整个应用的生命周期,所以没有任何的问题。

    public class SingleInstanceClass {
    
        private static SingleInstanceClass instance;
    
        private Context mContext;
    
        private SingleInstanceClass(Context context) {
            this.mContext = context.getApplicationContext();// 使用Application 的context
        }
    
        public SingleInstanceClass getInstance(Context context) {
            if (instance == null) {
                instance = new SingleInstanceClass(context);
            }
            return instance;
        }
    }
    
    or
    
    //在Application中定义获取全局的context的方法
     /**
         * 获取全局的context
         * @return 返回全局context对象
         */
        public static Context getContext(){
            return context;
        }
    
    public class SingleInstanceClass {
    
        private static SingleInstanceClass instance;
    
        private Context mContext;
    
        private SingleInstanceClass() {
           mContext=MyApplication.getContext;
        }
    
        public SingleInstanceClass getInstance() {
            if (instance == null) {
                instance = new SingleInstanceClass();
            }
            return instance;
        }
    }
    
    复制代码
    

    匿名内部类/非静态内部类和异步线程

    • 非静态内部类创建静态实例造成的内存泄漏
      我们都知道非静态内部类是默认持有外部类的引用的,如果在内部类中定义单例实例,会导致外部类无法释放。如下面代码:
    public class TestActivity extends AppCompatActivity {
        public static InnerClass innerClass = null;
    
        @Override
        protected void onCreate(@Nullable Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            if (innerClass == null)
                innerClass = new InnerClass();
        }
        private class InnerClass {
            //...
        }
    }
    复制代码
    

    TestActivity销毁时,因为innerClass生命周期等同于应用生命周期,但是它又持有TestActivity的引用,因此导致内存泄漏。

    正确做法应将该内部类设为静态内部类或将该内部类抽取出来封装成一个单例,如果需要使用Context,请按照上面推荐的使用Application 的 Context。当然,Application 的 context 不是万能的,所以也不能随便乱用,对于有些地方则必须使用 Activity 的 Context,对于Application,Service,Activity三者的Context的应用场景如下:

    • 匿名内部类
      android开发经常会继承实现Activity/Fragment/View,此时如果你使用了匿名类,并被异步线程持有了,那要小心了,如果没有任何措施这样一定会导致泄露。如下代码:
    public class TestActivity extends AppCompatActivity {
      //....
    
        private Runnable runnable=new Runnable() {
            @Override
            public void run() {
    
            }
        };
    
        @Override
        protected void onCreate(@Nullable Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
           //......
        }
    
    }
    复制代码
    

    上面的runnable所引用的匿名内部类持有TestActivity的引用,当将其传入异步线程中,线程与Activity生命周期不一致就会导致内存泄漏。

    • Handler造成的内存泄漏
      Handler造成内存泄漏的根本原因是因为,Handler的生命周期与Activity或者View的生命周期不一致。Handler属于TLS(Thread Local Storage)生命周期同应用周期一样。看下面的代码:
    public class TestActivity extends AppCompatActivity {
        private Handler mHandler = new Handler() {
            @Override
            public void dispatchMessage(Message msg) {
                super.dispatchMessage(msg);
            }
        };
    
        @Override
        protected void onCreate(@Nullable Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            mHandler.postDelayed(new Runnable() {
                @Override
                public void run() {
                //do your things
                }
            }, 60 * 1000 * 10);
    
            finish();
        }
    }
    复制代码
    

    在该TestActivity中声明了一个延迟10分钟执行的消息 MessagemHandler将其 push 进了消息队列 MessageQueue 里。当该 Activity 被finish()掉时,延迟执行任务的Message还会继续存在于主线程中,它持有该 Activity 的Handler引用,所以此时 finish()掉的 Activity 就不会被回收了从而造成内存泄漏(因 Handler 为非静态内部类,它会持有外部类的引用,在这里就是指TestActivity)。

    修复方法:采用内部静态类以及弱引用方案。代码如下:

    public class TestActivity extends AppCompatActivity {
        private MyHandler mHandler;
    
        private static class MyHandler extends Handler {
            private final WeakReference<TestActivity> mActivity;
    
            public MyHandler(TestActivity activity) {
                mActivity = new WeakReference<>(activity);
            }
    
            @Override
            public void dispatchMessage(Message msg) {
                super.dispatchMessage(msg);
                TestActivity activity = mActivity.get();
                //do your things
            }
        }
    
        private static final Runnable mRunnable = new Runnable() {
            @Override
            public void run() {
                //do your things
            }
        };
    
        @Override
        protected void onCreate(@Nullable Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            mHandler = new MyHandler(this);
            mHandler.postAtTime(mRunnable, 1000 * 60 * 10);
    
            finish();
        }
    
    }
    
    复制代码
    

    需要注意的是:使用静态内部类 + WeakReference 这种方式,每次使用前注意判空。

    前面提到了 WeakReference,所以这里就简单的说一下 Java 对象的几种引用类型。

    Java对引用的分类有 Strong reference, SoftReference, WeakReference, PhatomReference 四种。

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

    下面几个方法都可以移除 Message:

    public final void removeCallbacks(Runnable r);
    
    public final void removeCallbacks(Runnable r, Object token);
    
    public final void removeCallbacksAndMessages(Object token);
    
    public final void removeMessages(int what);
    
    public final void removeMessages(int what, Object object);
    复制代码
    

    尽量避免使用 staic 成员变量

    如果成员变量被声明为 static,那我们都知道其生命周期将与整个app进程生命周期一样。

    这会导致一系列问题,如果你的app进程设计上是长驻内存的,那即使app切到后台,这部分内存也不会被释放。按照现在手机app内存管理机制,占内存较大的后台进程将优先回收,意味着如果此app做过进程互保保活,那会造成app在后台频繁重启。就会出现一夜时间手机被消耗空了电量、流量,这样只会被用户弃用。
    这里修复的方法是:

    不要在类初始时初始化静态成员。可以考虑lazy初始化。
    架构设计上要思考是否真的有必要这样做,尽量避免。如果架构需要这么设计,那么此对象的生命周期你有责任管理起来。

    • 避免 override finalize():
    1. finalize 方法被执行的时间不确定,不能依赖与它来释放紧缺的资源。时间不确定的原因是: 虚拟机调用GC的时间不确定以及Finalize daemon线程被调度到的时间不确定。

    2. finalize 方法只会被执行一次,即使对象被复活,如果已经执行过了 finalize 方法,再次被 GC 时也不会再执行了,原因是:含有 finalize 方法的 object 是在 new 的时候由虚拟机生成了一个 finalize reference 在来引用到该Object的,而在 finalize 方法执行的时候,该 object 所对应的 finalize Reference 会被释放掉,即使在这个时候把该 object 复活(即用强引用引用住该 object ),再第二次被 GC 的时候由于没有了 finalize reference 与之对应,所以 finalize 方法不会再执行。

    3. 含有Finalize方法的object需要至少经过两轮GC才有可能被释放。

    其它

    内存泄漏检测工具强烈推荐 squareup 的 LeakCannary,但需要注意Android版本是4.4+的,否则会Crash。

    学习分享

    最后为了帮助大家深刻理解Android相关知识点的原理以及面试相关知识,这里放上相关的我搜集整理的14套腾讯、字节跳动、阿里、百度等2020面试真题解析,我把技术点整理成了视频和PDF(实际上比预期多花了不少精力),包知识脉络 + 诸多细节。

    网上学习 Android的资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。希望这份系统化的技术体系对大家有一个方向参考。
    点我的GitHub可见:【Github】

    image

    相关文章

      网友评论

        本文标题:Android面试被问到内存泄漏了咋整?

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