Android Java垃圾回收机制

414 阅读10分钟

在了解JVM垃圾回收机制之前先简单介绍一下Jvm和Jvm内存模型, 从Jvm内存模型中入手对于理解GC会有很大的帮助,不过这里只需要了解一个大概。

JVM简介

JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。

JVM内存模型

bf096b63f6246b6042db690ee7f81a4c500fa2f7.png 大家一般new的对象和数组都是在堆中的,而GC主要回收的内存也是这块堆内存

堆内存模型

20190708002556644.png

JDK8开始,Metaspace(元空间)替代了永久代,如下图所示:

20190708002605581.png

堆大小 = 新生代( Young ) + 老年代( Old ),其可以通过参数 –Xms-Xmx 来指定:–Xms用于设置初始分配大小,默认为物理内存的1/16;-Xmx用于设置最大分配内存,默认为物理内存的1/4

堆内存由垃圾回收器的自动内存管理系统回收。

堆内存分为两大部分:新生代和老年代。比例为1:2。

新生代:

主要是用来存放新生的对象。一般占据堆的1/3空间。由于频繁创建对象,所以新生代会频繁触发MinorGC进行垃圾回收。

         新生代又分为 Eden区、ServivorFrom(上图的S0)、ServivorTo(上图的S1)三个区,比例为8:1:1

         Eden区:Java新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老年代)。当Eden区内存不够的时候就会触发MinorGC,对新生代区进行一次垃圾回收。

         ServivorTo:保留了一次MinorGC过程中的幸存者。

         ServivorFrom:上一次GC的幸存者,作为这一次GC的被扫描者。

         当触发GC后,jvm会将Eden和其中一个servivor的对象全部复制到另外一个servivor中(例如从from 到 to),在复制过程中,如果对象达到了老生代的要求就会被复制到老生代,复制到servivor的每个对象的年龄加一,然后清空Eden和之前的survivor区域。从这里就可以看出在任意时刻一定会存在一个servivor区域处于空闲状态。

老生代:

主要存放应用程序中生命周期长的内存对象。

     老年代的对象比较稳定,所以MajorGC不会频繁执行。在进行MajorGC前一般都先进行了一次MinorGC,使得有新生代的对象晋身入老年代,导致空间不够用时才触发。当无法找到足够大的连续空间分配给新创建的较大对象时也会提前触发一次MajorGC进行垃圾回收腾出空间。

    MajorGC采用标记—清除算法:首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象。MajorGC的耗时比较长,因为要扫描再回收。MajorGC会产生内存碎片,为了减少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配。

     当老年代也满了装不下的时候,就会抛出OOM(Out of Memory)异常。

下面有一些思考:

  1. 为什么要分新生代和老年代?
  2. 新生代为什么分一个Eden区和两个Survivor区?
  3. 一个Eden区和两个Survivor区的比例为什么是8:1:1? 其实这几个问题都是垃圾回收机制所采用的算法决定的。 下面我们就逐步分析一下

如何判定对象是否是垃圾

目前市面上有两种算法

1. 引用计数算法

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。 给对象添加一个引用计数器,有访问就加1,引用失效就减1

void demo1() {
    Object ref1 = new Object();//Object引用计数为1
    Object ref2 = ref1;//Object引用计数为2
    ref1 = null;//Object一个引用失效减1,引用计数减为1
    ref2 = null;//Object最后一个引用失效,可以被回收
}

但是引用计数的方式,有一个明显的缺点-循环引用

Class Obj {
    public Object prop;
}

Class Demo {
    public static void main(String[] args) {
        Obj obj1 = new Obj(); //第一个Obj引用计数为1
        Obj obj2 = new Obj(); //第二个Obj引用计数为1
        obj1.prop = obj2; //第一个Obj引用计数为2
        obj2.prop = obj1; //第二个Obj引用计数为2
        obj1 = null; //第一个Obj引用计数减为1 
        obj2 = null; //第二个Obj引用计数减为1
        //到这的时候其实obj1和obj2都已经是null了,但因为循环引用导致引用计数永远不为0
    }
}

像上面这种情况,就需要再将obj1和obj2置为null前,先解开循环引用,手动将内存释放

obj1.prop = null;
obj2.prop = null;
obj1 = null;
obj2 = null;

优点是简单,高效,现在的objective-c用的就是这种算法。
缺点是很难处理循环引用,比如图中相互引用的两个对象则无法释放。
这个缺点很致命,有人可能会问,那objective-c不是用的好好的吗?
我个人并没有觉得objective-c好好的处理了这个循环引用问题,它其实是把这个问题抛给了开发者。

2. 可达性分析算法(根搜索算法)

为了解决上面的循环引用问题,Java采用了一种新的算法:可达性分析算法。
从GC Roots(每种具体实现对GC Roots有不同的定义)作为起点,向下搜索它们引用的对象,可以生成一棵引用树,树的节点视为可达对象,反之视为不可达。

v2-a231ef2001b0edb4ee49e053955e47d9_1440w.jpg OK,即使循环引用了,只要没有被GC Roots引用了依然会被回收,完美!
但是,这个GC Roots的定义就要考究了,Java语言定义了如下GC Roots对象:

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

因为垃圾回收的时候,需要整个的引用状态保持不变,否则判定是判定垃圾,等我稍后回收的时候它又被引用了,这就全乱套了。所以,GC的时候,其他所有的程序执行处于暂停状态,卡住了。
幸运的是,这个卡顿是非常短(尤其是新生代),对程序的影响微乎其微 (关于其他GC比如并发GC之类的,在此不讨论)。
所以GC的卡顿问题由此而来,也是情有可原,暂时无可避免。


上面讲了如何判定对象是垃圾,下面我们讲讲判定是垃圾对象之后如何操作的

几种垃圾回收算法

1. 标记清除算法 (Mark-Sweep)

标记-清除算法分为两个阶段:标记阶段和清除阶段。标记阶段的任务是标记出所有需要被回收的对象,清除阶段就是回收被标记的对象所占用的空间。
优点是简单,容易实现。
缺点是容易产生内存碎片,碎片太多可能会导致后续过程中需要为大对象分配空间时无法找到足够的空间而提前触发新的一次垃圾收集动作。\

1689384-20200514003846052-1501866867.png

2. 复制算法 (Copying)

复制算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。
优缺点就是,实现简单,运行高效且不容易产生内存碎片,但是却对内存空间的使用做出了高昂的代价,因为能够使用的内存缩减到原来的一半。
从算法原理我们可以看出,Copying算法的效率跟存活对象的数目多少有很大的关系,如果存活对象很多,那么Copying算法的效率将会大大降低。

20160528101205774.png

3. 标记整理算法 (Mark-Compact)

该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。
所以,特别适用于存活对象多,回收对象少的情况下。

20180329140647160.png

4. 分代回收算法

分代回收算法其实不算一种新的算法,而是根据复制算法和标记整理算法的的特点综合而成。这种综合是考虑到java的语言特性的。
这里重复一下两种老算法的适用场景:

复制算法:适用于存活对象很少。回收对象多
标记整理算法: 适用用于存活对象多,回收对象少

刚好互补!不同类型的对象生命周期决定了更适合采用哪种算法。
于是,我们根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为老年代(Old Generation)和新生代(Young Generation),老年代的特点是每次垃圾收集时只有少量对象需要被回收 (一般使用的是标记整理Mark-Compact算法),而新生代的特点是每次垃圾回收时都有大量的对象需要被回收 (采取Copying算法),那么就可以根据不同代的特点采取最适合的收集算法。
这就是分代回收算法。
现在回头去看堆内存为什么要划分新生代和老年代,是不是觉得如此的清晰和自然了?

深入理解分代回收算法

对于这个算法,我相信很多人还是有疑问的,我们来各个击破,说清楚了就很简单。

为什么不是一块Survivor空间而是两块?

这里涉及到一个新生代和老年代的存活周期的问题,比如一个对象在新生代经历15次(仅供参考)GC,就可以移到老年代了。问题来了,当我们第一次GC的时候,我们可以把Eden区的存活对象放到Survivor A空间,但是第二次GC的时候,Survivor A空间的存活对象也需要再次用Copying算法,放到Survivor B空间上,而把刚刚的Survivor A空间和Eden空间清除。第三次GC时,又把Survivor B空间的存活对象复制到Survivor A空间,如此反复。
所以,这里就需要两块Survivor空间来回倒腾。

为什么Eden空间这么大而Survivor空间要分的少一点?

新创建的对象都是放在Eden空间,这是很频繁的,尤其是大量的局部变量产生的临时对象,这些对象绝大部分都应该马上被回收,能存活下来被转移到survivor空间的往往不多。所以,设置较大的Eden空间和较小的Survivor空间是合理的,大大提高了内存的使用率,缓解了Copying算法的缺点。
我看8:1:1就挺好的,当然这个比例是可以调整的,包括上面的新生代和老年代的1:2的比例也是可以调整的。
新的问题又来了,从Eden空间往Survivor空间转移的时候Survivor空间不够了怎么办?直接放到老年代去。

小结

1、垃圾回收主要针对堆内存进行

2、垃圾回收首先要知道什么是垃圾,即不再被使用的内存,引用计数法和可达性算法用于判断内存是否是垃圾

3、常见标记回收垃圾的算法有标记清除、复制算法、标记整理算法。当前商业虚拟机垃圾回收采用分代收集算法