垃圾收集算法

Java基础

浏览数:60

2019-3-30

什么是垃圾回收?

  垃圾回收,Garbage Collection,简称GC。

  在我们日常生活中的垃圾,我们会丢入垃圾桶,等待清洁工处理掉。

  Java中的垃圾,指存在于内存中,不会再被使用的对象,需要把这些无用的对象进行清理掉,那么,这些垃圾对象所占用的空间就可以被腾出来被其他对象使用,对内存空间管理来说,识别和清理垃圾是非常重要的。

  而怎样找出并清理这些垃圾呢?这里介绍几种垃圾收集算法。

引用计数算法

  引用计数法是最简单的一种方法。

  它的实现很简单:给对象配备一个整型的引用计数器,每当有一个地方引用这个对象时,计数器值就加1,当引用失效时,计数器值就减1,只要该对象的计数器的值为0,那么这个对象就是不可能再被使用的,就可以视作垃圾处理。

  引用计数法的实现简单,判断效率也高,但是,在java虚拟机内却没有使用引用计数法来管理内存,最主要的原因是它无法解决对象之间互相引用的问题。

  当然,引用计数法还有一个缺点,就是每次因引用的产生和消除的时候,需要伴随一个计数器的加法操作和减法操作,对系统的性能会有一定影响。但是这一点并不是致命的。

致命的是引用循环问题,示例如下:

public class Test {
    public Object obj = null;
    public static void main(String[] args) throws Exception{
        Test a = new Test();
        Test b = new Test();
        a.obj = b;
        b.obj = a;
        a = null;
        b = null;
        System.gc();
    }
}

  如果使用引用计数法,a和b两个对象是无法被回收的。

可达性分析算法

  在java中,判断对象是否存活,是通过可达性分析来实现的,在后续的各种垃圾收集的算法中,几乎都是通过可达性分析来标记垃圾的。

  它的思路是通过一些称作“GC Roots”的对象作为起始点,从这些节点开始往下搜索,搜索走过的路径叫做引用链,当一个对象到GC Roots没有引用链相连接的时候,就可以叫做这个对象到GC Roots不可达,则此对象就可以定为不可用。

如图所示:

  尽管obj5、obj6、obj7之间互相引用,但是他们到GC Roots都是不可达的,所以他们将会被判定为可回收垃圾。

  在Java中,GC Roots包括下面几种:

1.Java栈(栈帧中的本地变量表)里面引用的对象。
2.方法区中静态属性引用的对象。
3.方法区中常量引用的对象。
4.本地方法栈中JNI引用的对象。

标记清除算法

  标记清除算法将垃圾回收分为标记、清除两个阶段,这种分步执行的思路奠定了现代垃圾收集算法的思想基础。

  和引用计数法不同的是,标记清除算法不需要运行环境检测每一次内存分配和指针操作,只需要在标记阶段中根据可达性分析算法就可以找出所有存活对象,进行标记,未标记的对象即视为垃圾。在清除阶段,清除所有未被标记的对象。

如图:

  标记清除算法最大的问题就是会产生空间碎片,由于被执行内存回收的对象占用的内存有可能是一些不连续的内存块,清除之后就会产生大量的不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要给较大对象分配内存时,无法找到足够的连续内存。

复制算法

  为了解决标记清除算法的缺陷,JVM后来引入了复制算法。

  复制算法的核心思想是:将原有的内存空间分为两块,每次只使用其中一块,在垃圾回收时,将正在使用的内存中的存活对象复制到另一块内存空间去,然后,清除这一块内存空间中的所有对象,交换两个内存块的角色,完成垃圾回收。

  如果系统中垃圾对象很多,那么复制算法需要复制的存活对象数量就不会太大,在真正需要回收垃圾的时候,复制算法的效率是很高的。

  并且对象在垃圾回收过程中统一被复制到新的内存空间中,因此可以确保回收后的内存空间是没有碎片的。

如图:

  复制算法的缺陷是将系统内存折半。

 

  在Java的新生代串行垃圾回收器中,就使用了复制算法的思想。

  (存放年轻对象的堆空间就叫新生代,年轻对象是指刚刚创建的或者没怎么经历过垃圾回收的对象,同样的,存放老年对象的堆空间叫做老年代,老年对象指经历过多次垃圾回收依然还没死的对象)

  新生代分为eden空间、from空间和to空间3个部分,其中from和to空间可以视为用于复制的两块大小相同、地位相等、并且可以进行角色互换的空间块。from空间和to空间也称为survivor空间,即幸存者空间,用来存放没有被回收的对象。

看图理解:

  在垃圾回收时,Eden空间中的存活对象会被复制到未使用的survivor空间中(假设是To),正在使用的survivor空间(假设是from)中的年轻对象也会被复制到to空间中(大对象、或者老年对象会直接进入老年代,如果to空间已满,则对象也会直接进入老年代),这时候eden空间和from空间中剩余对象就是垃圾对象,可以直接清空,接下来,from空间和to空间将会互换位置,to空间则存放此次回收后的存活对象,这种赶紧的复制算法既保证了空间的连续性,又避免了大量的内存空间浪费。

  其实复制算法无非就是使用to空间作为一个临时的空间交换角色。

  复制算法比较适用于新生代,因为在新生代,垃圾对象通常会多于存活对象,复制算法的效果会比较好。

标记压缩算法

  复制算法的高效性是建立在存活对象少,垃圾对象多的前提下的,这种情况在年轻代经常发生,但是在老年代更常见的情况是大部分对象都是存活对象,如果依然使用复制算法,那么复制的成本会很高,所以基于老年代垃圾的特性,需要使用其他的算法。

  标记压缩算法就是老年代使用的算法,也可以叫做标记整理算法,他其实是在标记清除算法上做了相应的优化,标记阶段依然是使用可达性分析对所有存活对象进行标记,但之后并不是简单的清理未标记的对象,而是将所有标记的对象压缩到内存的另一端,之后,再清理边界外的所有空间。

如图所示:

  标记压缩算法的最终效果相当于标记清除算法执行完成后,再进行一次内存碎片整理,这种方法既避免了碎片的产生,又不需要两块相同的内存空间,性价比相对于来说较高。

分区算法

  在垃圾回收过程中,应用程序所有的线程都会处于一种挂起状态,暂停一切正常工作,等待垃圾回收结束。

  如果垃圾回收时间过长,将严重影响用户的体验或者系统稳定性,为了解决这个问题,所以诞生了一个叫分区算法的概念,也可以叫做增量算法。

  它的基本思想是,如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。将java堆的内存空间分割成多个小片区域,每次垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程,依次反复,直到垃圾收集完成。

分代收集算法

  分代收集算法并没有什么新的思想,它只是根据对象存活周期的不同特点将内存划分为几块,一般是把java堆分为新生代和老年代,这样可以根据各个年代的特点采用最适合的垃圾收集算法。

  一般来说,java虚拟机会把所有新建的对象都放入称为新生代的内存区域,新生代的特点是对象会很快被回收,所以,在新生代就选择效率较高的复制算法。

  当一个对象经过几次垃圾回收后依然存活,对象就会被放入老年代区域,在老年代中几乎所有的对象都是经过几次垃圾回收后依然存活的,可以认为这些对象在一段时期内,甚至在应用程序的整个生命周期中,都是常驻内存的,如果依然要用复制算法回收老年代,将要复制大量对象,所以这种做法是不可取的。

  根据分代的思想,需要对老年代的回收使用与新生代不同的标记清除或标记压缩算法,可以提高垃圾回收的效率。

  分代收集的思想被现有的虚拟机广泛使用,几乎目前所有的商业虚拟机的垃圾回收器都采用了这种思想。

见下图: