最新消息:欢迎访问Android开发中文站!联系QQ:1304524325

Android内存泄漏思考

新手入门 loading 386浏览 0评论

Android内存泄漏是一个经常要遇到的问题,程序在内存泄漏的时候很容易导致OOM的发生。那么如何查找内存泄漏和避免内存泄漏就是需要知晓的一个问题,首先我们需要知道一些基础知识。

Java的四种引用

强引用: 强引用是Java中最普通的引用,随意创建一个对象然后在其他的地方引用一下,就是强引用,强引用的对象Java宁愿OOM也不会回收他

软引用: 软引用是比强引用弱的引用,在Java gc的时候,如果软引用所引用的对象被回收,首次gc失败的话会继而回收软引用的对象,软引用适合做缓存处理 可以和引用队列(ReferenceQueue)一起使用,当对象被回收之后保存他的软引用会放入引用队列

弱引用: 弱引用是比软引用更加弱的引用,当Java执行gc的时候,如果弱引用所引用的对象被回收,无论他有没有用都会回收掉弱引用的对象,不过gc是一个比较低优先级的线程,不会那么及时的回收掉你的对象。 可以和引用队列一起使用,当对象被回收之后保存他的弱引用会放入引用队列

虚引用: 虚引用和没有引用是一样的,他必须和引用队列一起使用,当Java回收一个对象的时候,如果发现他有虚引用,会在回收对象之前将他的虚引用加入到与之关联的引用队列中。 可以通过这个特性在一个对象被回收之前采取措施

下面是一个例子:

public class Main {

    public static void main(String[] args) throws InterruptedException {
        ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();
        String sw = "虚引用";
        switch (sw) {
            case "软引用":
                Object objSoft = new Object();
                SoftReference<Object> softReference = new SoftReference<>(objSoft, referenceQueue);
                System.out.println("GC前获取:" + softReference.get());
                objSoft = null;
                System.gc();
                Thread.sleep(1000);
                System.out.println("GC后获取:" + softReference.get());
                System.out.println("队列中的结果:" + referenceQueue.poll());
                break;
                /*
                 * GC前获取:java.lang.Object@61bbe9ba
                 * GC后获取:java.lang.Object@61bbe9ba
                 * 队列中的结果:null
                 * */
            case "弱引用":
                Object objWeak = new Object();
                WeakReference<Object> weakReference = new WeakReference<>(objWeak, referenceQueue);
                System.out.println("GC前获取:" + weakReference.get());
                objWeak = null;
                System.gc();
                Thread.sleep(1000);
                System.out.println("GC后获取:" + weakReference.get());
                System.out.println("队列中的结果:" + referenceQueue.poll());
                /*
                * GC前获取:java.lang.Object@61bbe9ba
                * GC后获取:null
                * 队列中的结果:java.lang.ref.WeakReference@610455d6
                * */
                break;
            case "虚引用":
                Object objPhan = new Object();
                PhantomReference<Object> phantomReference = new PhantomReference<>(objPhan, referenceQueue);
                System.out.println("GC前获取:" + phantomReference.get());
                objPhan = null;
                System.gc();
                //此处的区别是当objPhan的内存被gc回收之前虚引用就会被加入到ReferenceQueue队列中,其他的引用都为当引用被gc掉时候,引用会加入到ReferenceQueue中
                Thread.sleep(1000);
                System.out.println("GC后获取:" + phantomReference.get());
                System.out.println("队列中的结果:" + referenceQueue.poll());
                /*
                * GC前获取:java.lang.Object@61bbe9ba
                * GC后获取:null
                * 队列中的结果:java.lang.ref.WeakReference@610455d6
                * */
                break;
        }
    }

}

Java GC

目前oracle jdk和open jdk的虚拟机都为Hotspot,android 为Dalvik和Art

曾经的GC算法:引用计数

简短的说引用计数就是对每一个对象的引用计算数字,如果引用就+1,不引用就-1,回收掉引用计数为0的对象。来达到垃圾回收

弊端:如果两个对象都应该被回收但是他俩却互相依赖,那么他两者的引用永远都不会为0,那么就永远无法回收, 无法解决循环引用的问题

这个算法只在很少数的虚拟机中使用过

现代的GC算法

  • 标记回收算法(Mark and Sweep GC) :从”GC Roots”集合开始,将内存整个遍历一次,保留所有可以被GC Roots直接或间接引用到的对象,而剩下的对象都当作垃圾对待并回收,这个算法需要中断进程内其它组件的执行并且可能产生内存碎片。
  • 复制算法(Copying) :将现有的内存空间分为两快,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。
  • 标记-压缩算法(Mark-Compact) :先需要从根节点开始对所有可达对象做一次标记,但之后,它并不简单地清理未标记的对象,而是将所有的存活对象压缩到内存的一端。之后,清理边界外所有的空间。这种方法既避免了碎片的产生,又不需要两块相同的内存空间,因此,其性价比比较高。
  • 分代 :将所有的新建对象都放入称为年轻代的内存区域,年轻代的特点是对象会很快回收,因此,在年轻代就选择效率较高的复制算法。当一个对象经过几次回收后依然存活,对象就会被放入称为老生代的内存空间。对于新生代适用于复制算法,而对于老年代则采取标记-压缩算法。

以上四种算法信息引用自QQ空间团队分享 Android GC 那点事&version=11000003&pass_ticket=nhSGhYD4LC9FWvUPv26Y7AdIzqEDu8FTImf2AKlyrCk%3D) ,总结的特别棒

导致内存泄漏的原因

对象在GC Root中可达,也就是他的引用不为空,所以GC无法回收它也就会导致内存泄漏

GC Root起点

  • 虚拟机栈中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • JNI引用的对象

GC可以续一秒

当一个对象在引用链中失S#x53BB;了引用,那么他就真的要告别世界了吗,其实并不是,虚拟机会给他“缓刑”,每一个对象有一个finalize() 方法,虚拟机是否给他缓刑取决于这个对象的这个方法是否被执行,如果这个对象的这个方法没有被覆盖或者这个方法被执行过一次,那么就要“行刑”了。真的是“续一秒”

如果这个对象的finalize()方法应该被执行,那么虚拟机会将它放在F-Queue队列中,稍后虚拟机会自动创建一个Finalizer线程去执行这个队列中的对象的这个方法。如果对象在finalize()中成功自救,举个例子,把自己和一个存在的对象强引用,那么就不会被回收,否则就真的被回收了。

但是虚拟机并不会保证Finalizer线程执行结束再进行回收,因为如果在某一个对象的finalize()方法中执行了死循环或者超级耗时的操作,虚拟机等待这个执行结束的话就会导致整个Gc崩溃了

首先注意这个方法只能被执行一次,第二次就会标记了这个方法被执行过不会再执行了,其次,这个方法不一定会被执行到,所以不要依赖finalize()去自救。这不是好的做法。

并发GC和非并发GC

Android2.3之后支持了并发的GC。

  • 非并发GC : 虚拟机在执行GC的时候进行Stop the world,也就是挂起其他所有的线程,通常会持续上百毫秒,一次Mark,然后直接清理

  • 并发GC : 跟非并发的简单gc来比较,一般非并发GC需要耗费上百ms的时间来进行,而并发gc仅仅需要10ms左右的时间,效率大幅度提升(数据来自:技术小黑屋大大),但是并发gc由于需要进行重复的处理改动的对象,所以需要更多的CPU资源

两者的差别:

首先非并发GC简单粗暴,直接挂起所有的线程,此时Java堆中肯定不会有任何的添加和修改,此时去递归GC树,然后标记-清理。但是这样会造成很大的开销,大家都等着你岂不是很没面子= =

然而非并发的GC是一点一点来的,跟线程同步进行这样就不会有很长时间的等待,但是你要明白一个道理,想把地扫干净这段时间必须没人来踩,所以他要有挂起线程的过程。

那么并发是怎么实现的呢?首先有个知识点就是Jvm在分配内存的时候,有两种方式

  • 指针碰撞:一个指针,申请一块内存就指针挪动相应的距离,不会产生内存碎片,这要求内存是很规整的
  • 空闲列表:每次申请一块内存给需要的对象,然后有一个列表记录了哪些位置被申请了,下次申请的时候就不申请这个位置,这样适用于内存不是很规整的情况

创建对象是一个频繁的操作,那么我们如何保证原子性呢?两种方案

  • CAS(Compare and Swap)策略配上失败重试来保证原子性
  • 每个线程分配一个TLAB : 很简单,每个线程自己有自己的一块内存,那么分配的时候自己锁自己的分区就行了,提高了效率

我们用的是第二种 233

所以获取Java堆锁的时候,重点来了,我们逐个线程去锁TLAB,而不是一次全锁住,当然提高了并发GC的效率,所以更快。但是引来的问题就是并发的问题,所以下一步要重复去修改在一个个探索时候被改的对象。也就需要更多的CPU资源。

我们为什么要关注GC

首先我们知道虚拟机如何去GC才能了解到如何让一个对象被正确的回收,这样才不能内存泄漏

其次无论是并发GC还是非并发GC都会导致挂起其他的所有线程,那么就会带来程序卡顿。

ART在GC上做到了更加细粒度的控制,可以更加流畅的GC

常见的内存泄漏案例:Handler内存泄漏

首先铺垫一句话:非静态的内部类和匿名类会隐式的持有外部类的引用

public class MainActivity extends AppCompatActivity {

    private Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            Log.d("smallSohoSolo", "Hello Handler");
        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        mHandler.postDelayed(new Runnable() {
            @Override
            public void run() {
                Log.d("smallSohoSolo", "Running");
            }
        }, 1000 * 60 * 10); //10分钟之后执行
        finish();
    }
}

这段代码有很明显的内存泄漏,首先Handler和Runnable都是匿名内部类的实例,他们都会持有MainActivity的引用,

  1. Handler发送的消息到了消息队列中
  2. Activity被结束掉
  3. 这个消息中包含了Handler的引用,Handler包含了Activity的引用,而且他还是个Runnable,也是匿名内部类,也间接包含了MainActivity引用
  4. 在Main Lopper中,当此消息被取出来,这未执行的10分钟里面,MainActivity没法回收
  5. 内存泄漏

有人可能会说短暂的内存泄漏又能怎样?这是错误的想法,因为只要发生内存泄漏,在这段时间只要进行了大内存的操作(比如加载一个照片墙),就有风险因为这个内存泄漏造成OOM(占用内存肯定剩下的少了)

上面这个如何修改呢?

将Runnable和Handler改成static 或者在外部定义内部使用。

其他常见的内存泄漏

  • 静态变量内存泄漏:使用静态变量来引用一个事物,在不使用之后没有下掉,那么引用存在就会一直泄漏
  • 单例导致的内存泄漏:使用的单例中保存了不应该被一直持有的对象,那么就会造成内存泄漏
  • 由第三方库使用不当导致的内存泄漏:比如EventBus,Activity销毁的时候没有反注册就会导致引用一直被持有无法回收
  • 还有很多。。。他们都是因为引用没有被清理造成的

如何查看内存泄漏

简单粗暴 —> LeakCanary: Square出品的库,当出现内存泄漏的时候会出现

精打细算 —> Android Studio 内存工具: 可以Dump下来当前的内存路径,然后分析出来哪些对象目前的状态。很强

参考文献

来自:https://techblog.toutiao.com/2017/08/16/untitled-4/

转载请注明:Android开发中文站 » Android内存泄漏思考

您必须 登录 才能发表评论!