Java虚拟机—垃圾回收的相关算法

 2023-01-23
原文作者:香香软软的战列舰 原文地址:https://juejin.cn/post/6898350052666769416

Java虚拟机进行垃圾回收,有两个阶段,一是对内存进行标记,二是将未标记的内存进行清除,各个阶段都有不同的算法。

标记阶段

JVM进行内存回收,首先需要区分出内存中哪些是存活对象,哪些是死亡对象。只有被标记死亡的对象,GC才会在执行垃圾回收时进,释放掉其占用的内存空间,这个过程称为垃圾标记阶段

判断对象是否存活,一般有两种方式:引用计数算法可达性分析算法

  • 引用计数算法

引用技术算法,就是对每个对象保存一个整形的引用计数器属性,用于记录对象被引用的情况。如一个对象A,另一个对象B引用了A,那么A的计数器就+1,当这个引用失效时就-1。对象的引用为0,表示对象不再使用。

该算法的优点:

实现简单,垃圾对象便于标识,判定效率高,回收几乎没有延时。

缺点也很明显:

需要单独的字段存储计数器,增加了存储空间的开销;计数器的加减操作,增加了时间开销;而且,最关键的是无法处理循环引用的情况

循环引用:

    class A{
        B b;//A对象中引用了B对象
    }
    class B{
        A a;//B对象又引用了A对象
    }

此时,如果还存在对象C引用了A

    class C{
        A a;
    }
    void main(){
    	A a = new A();
        B b = new B();
        C c = new C();
        c.a = a;//a的引用计数器为1
        a.b = b;//b的引用计数器为1
        b.a = a;//a的引用计数器为2
        //把对象c中的a引用置为null
        c.a = null;//a的引用计数器为1
    }

在c中的a置为null后,对象a和对象b互相引用,导致没有办法被JVM回收,导致内存泄漏。所以JVM没有使用。Python中倒是使用了引用计数算法,需要手动解除循环依赖,或者使用弱引用weakref。

  • 可达性分析算法(GC Roots)

可达性分析算法也叫根搜索算法,跟踪性垃圾收集。相对于引用计数算法而言,可达性分析算法不仅同样具备简单和执行高效的特点,而且能够有效的解决循环依赖的问题,防止内存泄漏。

可达性分析算法是以跟对象集合(GC Roots)为起始点,从上到下的方式搜索被跟对象集合所连接的目标对象是否可达。存活的对象都会被根对象集合直接或间接连接着,搜索所走过的路径成为引用链(Reference Chain)。

如果目标对象没有任何引用链相连,则是不可达的,就以为着该对象已经死亡,可以标记为垃圾对象。

GC Roots包括以下几类:

虚拟机栈中引用的对象,如各个线程栈帧中使用到的参数、局部变量等

本地方法栈JNI引用的对象

常量、静态属性引用的对象

被同步锁synchronized持有的对象

Java虚拟机的内部引用,如基本数据类型对应的Class,一些常驻对象(NPE,OOM等),类加载器

反应JVM内部情况的JMXBean,JVMTI中注册回调、本地代码缓存等。

除上述外,还有可能临时性的加入其他的对象,共同构成GC Roots,如分代收集局部回收

一般来说,Root采用栈的方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但自己又不存放在堆内存中,那么他就是一个Root。

如果只针对堆中的某一块区域进行垃圾回收,必须考虑到这个区域的对象完全有可能被其他区域的对象所引用,这是就需要将关联区域的对象也加入到GC Roots中,才能保证可达性分析的准确性。

使用可达性分析算法, 必须在一个能保证一致性的快照中进行,这也是导致Stop The World的一个重要原因,因为枚举根节点时必须要停顿。

清除阶段

当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占用的内存空间。

在这个阶段,有三种常见垃圾收集算法,分别是标记清除算法、标记复制算法和标记压缩算法。

  • 标记清除算法(Mark-Sweep)

该算法是一种非常基础和常见的垃圾手机算法,于1960年应用于Lisp语言。

当堆内存的有效空间(availabe memory)被耗尽的时候,就会停止整个程序(Stop The World),然后进行两项工作,一是进行标记、二是进行清除。

标记:收集器(Collector)从引用根节点开始遍历,标记所有被引用的对象,一般是在对象的header中记录为可达对象

清除:收集器对堆内存从头到尾进行串行遍历,如果发现某个对象在其header中没有标记为可达对象,则将其清除。 清除并不是真的置空,而是把需要清除的对象的地址保存在空闲的地址列表中,下次需要时,直接覆盖

202301011658109731.png

优点:

容易理解、容易实现。

缺点:

需要两次遍历,效率不高;进行GC时整个程序STW,体验较差;产生较多的内存碎片,碎片空间难利用。

  • 复制算法(-Coping)

该算法与1963年出现,被引入到了Lisp的另一个实现版本中。

该算法的核心思想是:将内存空间分为两块,每次只使用其中的一块,在垃圾回收时,将正在使用的内存中的存活对象,复制到未使用的内存块中。之后清除正在使用的内存块中的所有对象。交换两个内存块的角色,完成垃圾回收。堆内存中的Survivor区就是基于该算法。

202301011658114362.png

优点:

简单高效、不会造成内存碎片;

缺点:

有一半的内存空间无法被使用;对于G1这种分拆成大量region的GC,复制而不是移动,意味着GC需要维护region之间对象的引用关系,不管是内存占用和时间开销都很大。极端情况下,整个区域所有的对象都存活,那么每个对象都要回收一遍。

应用场景:

内存区域中的垃圾对象很多,复制算法需要复制的存活对象很少(适合新生代使用)。

  • 标记压缩算法(Mark-Compact)

在堆的老年代,经GC后存活的对象很多,不适合使用复制算法。而标记清除算法容易产生较多的内存碎片,为此在标记清除算法的基础上,标记压缩算法应运而生。该算法与1970年发布,现在已经广泛应用到各种垃圾收集器中。

该算法的标记阶段和标记清除算法一样。第二阶段则是将所有存活的对象移动到内存的一端,按顺序排放。之后再清除边界外的所有空间。

标记:开销与存活对象成正比

清除:开销与内存区域大小成正比

压缩:开销与存活对象的大小成正比

该算法其实就是在标记清除算法的中间,加入一次内存整理

202301011658119133.png

优点:

不会产生内存碎片;能够完整的利用整个内存空间;

缺点:

需要维护对象之间的引用关系;加入了整理阶段,性能较差;移动对象的过程中,需要STW。

  • 三种算法的对比
标记清除算法 复制算法 标记压缩算法
速度 中等 最快 最慢
空间开销 少,但会产生内存碎片 高,需要两倍的内存,不会产生内存碎片 少,不会产生内存碎片
移动对象