Java垃圾回收算法详解

这是我参与11月更文挑战的第7天,活动详情查看:2021最后一次更文挑战

Java中的垃圾指的是运行程序中没有任何指针指向的对象,这些对象就是需要被回收的垃圾。Java中的垃圾回收器GC可以及时回收没用的对象资源,确保程序在长时间运行下不会内存溢出;同时GC还可以进行内存碎片整理,以便JVM分配新的对象。

一、垃圾回收相关概念

在了解JVM垃圾回收之前,先储备一些相关概念。

System.gc()

调用System.gc()或者Runtime.getRuntime().gc()会显式触发Full GC,然而该方法调用并无法保证JVM 100%会调用垃圾回收器。一般情况下,垃圾回收是自动进行的,无需手动触发。

内存溢出与泄露

  1. 内存溢出:内存溢出对应的异常为OutOfMemoryError,指的是没有空闲内存,并且垃圾回收器也无法提供更多内存。引发内存溢出主要有以下两种情况:
    • 分配对象时,可用内存不足,触发垃圾回收。垃圾回收后,还是没有足够空间分配对象,则OOM;
    • 分配一个超级大的对象,直接超过堆的最大值,JVM直接OOM。
  2. 内存泄露:指的是存在不再被使用的对象,但是GC又不能回收他们。

STW

Stop The World简称STW,指的是GC事件发生过程中,会产生应用程序的停顿,整个应用程序都会被暂停。下面即将介绍的可达性分析算法会导致STW,因为:

  1. 分析工作必须在一个能确保一致性的快照中进行;
  2. 一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上;
  3. 如果分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证。

GC完成之后,STW便随之结束。

并行与并发

程序的并行与并发和垃圾回收器的并行与并发概念有所不同,先来看看程序中的并行与并发。

程序并行与并发

并发(Concurrent):操作系统中,一个时间段内有几个程序都处于已启动运行到运行完毕之间,这几个程序都是在同一个处理器上运行。并发并不是真正意义上的“同时进行”,只是CPU把一个时间划分为几个时间片段,然后在这几个时间片段之间来回切换。由于CPU处理的速度非常快,我们感觉上以为多个应用程序同时在进行;

并行(parallel):当系统有多个CPU时(或者一个CPU有多个核时),另一个CPU可以执行另一个进程,两个进程互不抢占CPU资源,可以同时进行。

垃圾回收并行与并发

并行:多个垃圾回收线程同时进行(如果只有一个回收线程,称之为串行),此时用户线程仍处于等待状态;

并发:用户线程和垃圾回收线程同时进行,用户线程不停顿。

垃圾回收器的并行与并发如下图所示:

gc01.png

引用Reference

JDK1.2之后,Java对引用的概念进行了扩充,分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)和终结器引用(Final Reference):

gc02.png

强引用

最传统的引用定义,程序中普遍存在的引用赋值,类似Object obj = new Object()这种引用关系。无论在任何情况下,只要强引用关系还在,垃圾回收器就永远不会回收掉被引用的对象。

软引用

软引用是用来描述一些还有用,但非必需的对象。只被软引用关联着的对象,在系统即将要发生内存溢出异常前,会被放入到Reference Queue,进行二次回收。简而言之就是内存不足时回收。

软引用常用来实现内存敏感的缓存,比如内存充足时,就是用缓存,内存不足时就清除缓存,避免程序OOM。

软引用例子:

Object obj = new Object();
SoftReference<Object> softReference = new SoftReference<>(obj);
obj = null;
复制代码

弱引用

弱引用关联的对象只能生存到下一次垃圾收集发生为止。在系统GC时,只要发生弱引用,不管系统堆空间是否充足,都会回收。简而言之就是发现即回收。

弱引用例子:

Object obj = new Object();
WeakReference<Object> weakReference = new WeakReference<>(obj);
obj = null;
复制代码

虚引用

一个对象是否有虚引用的存在,完全不会决定对象的生命周期。如果一个对象仅存在虚引用,那么它和没有引用几乎是一样的,随时都可能被垃圾回收器回收。此外,我们无法通过虚引用来获取被引用的对象,当调用虚引用的get方法获取对象时,将返回null。

虚引用必须和引用队列一起使用:

Object obj = new Object();
ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();
PhantomReference<Object> phantomReference = new PhantomReference<>(obj, referenceQueue);
obj = null;
phantomReference.get(); // null
复制代码

虚引用对象被垃圾回收器回收后,会将这个虚引用加入引用队列,已通知应用程序对象回收情况。虚引用的唯一作用是用于跟踪垃圾回收过程(通过观察引用队列里是否有对应的虚引用)。

二、垃圾回收算法

垃圾回收分为标记阶段(对象存活判断)和清除阶段(垃圾对象回收)。

  1. 标记阶段:在堆中存放着几乎所有的Java对象实例,在GC执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已死亡对象。只有被标记为已经死亡的对象,GC才会进行回收释放内存空间。这个阶段的算法包括引用计数算法和可达性分析算法;
  2. 清除阶段:当成功区分出内存中的存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放内存。目前JVM中比较常见的三种垃圾算法是标记清除算法、复制算法和标记压缩算法。

引用计数算法

引用计数算法(Reference Counting)算法对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况。对于一个对象Object,只要有任何一个对象引用了它,则它的引用计数器加1,引用失效时,引用计数器就减1。所以当一个对象的引用计数器值为0时,说明该对象不再被引用,可进行回收。

优点:实现简单,效率高,垃圾回收没有延迟;

缺点:

  1. 计数器的存在增加了存储空间的开销;
  2. 每次赋值都需要更新计数器,增加了程序时间开销;
  3. 致命问题:无法处理循环引用的情况(正因为如此,Java压根没有使用该算法)。

举个循环引用的例子:

gc03.png

上面例子中,Object1的引用计数器值为1,但实际可能这三个对象都已经不再被使用,它们的引用计数器的值都不为0,无法被回收,这就导致了内存泄露。

可达性分析算法

可达性分析算法同样具备实现简单执行高效等特点,并且还解决了引用计数算法无法处理的循环引用问题,可达性分析算法也叫根搜索算法、追踪性垃圾收集算法。

算法思路

可达性分析算法的基本思路:

  1. 以根对象集合(GC Roots)为起点,从上至下搜索被根对象连接的目标对象是否可达,搜索走过的路径称为引用链;
  2. 如果目标对象没有任何引用链相连,则是不可达的,意味着对象已经死亡属于垃圾;
  3. 只有那些能够被根对象集合直接或者间接连接的对象才是存活对象。

如下图所示:

gc04.png

绿色对象为存活对象,它们直接或间接与GC Roots相连,橙色对象为可回收对象。

那么哪些对象可以作为GC Roots呢?Java中,GC Roots包括以下几类元素:

  1. 虚拟机栈中引用的对象;
  2. 本地方法栈内引用的对象;
  3. 方法区中类静态属性引用的对象,如Java类的引用类型静态变量;
  4. 方法区中常量引用的对象;
  5. 所有被synchronized持有的对象;
  6. Java虚拟机内部引用;
  7. 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

在使用可达性分析算法判断内存是否可回收时,分析工作必须在一个能保障一致性的快照中进行。这点也是导致GC进行时必须Stop The World(STW,程序暂停)的一个重要原因。

对象复活演示

对象的finalization机制:当垃圾回收器回收一个垃圾对象之前,总会先调用这个对象的finalize()方法,用于在对象回收时进行资源释放。

finalize()方法为Object的一个非final方法,所以子类可以进行重写。

因为finalize()方法的存在,Java中一个对象可能处于三种状态:

  1. 可触及的,从GC Roots开始可以到达该对象;
  2. 可复活的,对象的所有引用都被释放,但对象可能在finalize()方法中复活;
  3. 不可触及的,对象的finalize()方法被调用,并且没有复活。不可触及的对象不可能被复活,因为finalize()方法只会被调用一次

判断一个对象是否可回收需要经历两次标记过程:

  1. 如果对象到GC Roots没有引用链,那么进行第一次标记;
  2. 对已经标记了的对象进行筛选,判断对象是否有必要执行finalize()方法:
    • 如果对象没有重写finalize()方法,或者finalize()方法已经被调用过,则该对象是不可触及的;
    • 如果重写了,但还没执行过,则该对象会被放入F-Queue队列;
    • GC对F-Queue队列中的对象进行遍历,如果对象在finalize()方法中与引用链上的任意一个对象建立了联系,那么该对象被复活,从F-Queue队列中移出。当该对象再次出现没有引用的情况时,finalize()方法不会再被调用,该对象直接被判定为不可触及。

finalize()方法执行是由虚拟机自动创建的低优先级的Finalizer线程触发,举个例子:

public class Test {
    public static void main(String[] args) throws InterruptedException {
        TimeUnit.SECONDS.sleep(120);
    }
}
复制代码

启动main线程,让程序阻塞,然后用jvisualvm观察线程状态:

gc06.png

举个对象通过finalize()方法复活的例子:

public class Test {
    /**
     * 静态类变量,属于GC Roots
     */
    public static Test obj;

    @Override
    protected void finalize() {
        System.out.println("finalize方法中不做任何事情");
    }

    public static void main(String[] args) {
        try {
            obj = new Test();
            // 移除对象引用链
            obj = null;
            System.gc();
            System.out.println("第一次GC");
            // 因为Finalizer线程优先级很低,让程序暂停2秒,确保finalize()方法调用
            TimeUnit.SECONDS.sleep(2);
            isAlive(obj);

            obj = null;
            System.gc();
            System.out.println("第二次GC");
            TimeUnit.SECONDS.sleep(2);
            isAlive(obj);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private static void isAlive(Object obj) {
        if (obj == null) {
            System.out.println("对象已死");
        } else {
            System.out.println("对象还活着");
        }
    }
}

// 第一次GC
// finalize方法中不做任何事情
// 对象已死
// 第二次GC
// 对象已死
复制代码

可以看到,将obj置为null后,第一次GC,该对象就已经被回收。

下面往finalize()方法中添加逻辑,让对象复活:

public class Test {
    /**
     * 静态类变量,属于GC Roots
     */
    public static Test obj;

    @Override
    protected void finalize() {
        System.out.println("复活obj");
        obj = this;
    }

    public static void main(String[] args) {
        try {
            obj = new Test();
            // 移除对象引用链
            obj = null;
            System.gc();
            System.out.println("第一次GC");
            // 因为Finalizer线程优先级很低,让程序暂停2秒,确保finalize()方法调用
            TimeUnit.SECONDS.sleep(2);
            isAlive(obj);

            obj = null;
            System.gc();
            System.out.println("第二次GC");
            TimeUnit.SECONDS.sleep(2);
            isAlive(obj);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private static void isAlive(Object obj) {
        if (obj == null) {
            System.out.println("对象已死");
        } else {
            System.out.println("对象还活着");
        }
    }
}

// 第一次GC
// 复活obj
// 对象还活着
// 第二次GC
// 对象已死
复制代码

上面例子也证明了finalize()方法最多只被执行一次。

严禁主动调用某个对象的finalize()方法,应该交给垃圾回收机制调用,因为: 1、在finalize()方法里可能导致对象复活; 2、finalize()方法执行时间没有保障,如果对象不被回收,finalize()方法没有执行机会; 3、一个糟糕的finalize()方法会严重影响GC性能。

标记清除算法

标记清除算法执行过程:当内存快被耗尽时,会停止整个程序(STW),进行两项工作:

  • 标记:回收器从GC Roots开始遍历,标记所有可达对象(在对象头中进行标记);
  • 清除:回收器对堆内存进行从头到尾线性遍历,如果对象头中没有标记,则说明是不可达对象,进行回收(回收细节:JVM会维护一个空闲地址列表,把可回收对象的地址存放进来,这个时候对象并没有真正被删掉,下次新对象进行分配时候直接覆盖可回收对象)。

缺点:

  1. 效率不是很高,因为要进行两次遍历工作;
  2. STW会影响用户体验;
  3. 这种方式整理出来的内存不是连续的,碎片化。

复制算法

为了解决标记清除算法的缺点,复制算法提出了新思路。

复制算法的基本思想:将内存一分为二,每次只使用其中一块。GC时,将存活的对象复制到另一块未使用的内存中,然后当前内存块的对象全部清除,两个内存块交互角色,如次循环反复(堆内存中的幸存者0区和1区就是这么搞的)。

优点:

  1. 不需要进行两次遍历,效率更高;
  2. 复制算法可以保证空间连续性,没有碎片问题。

当然也有缺点:

  1. 内存只有一半是可用的,浪费了内存空间;
  2. 对象复制后,地址肯定变了,那么栈中对象的引用地址也要改变,这就增加了时间开销;
  3. 极端情况下,假如当前内存块中没有垃圾,那么需要将所有存活对象都复制到另一块内存,费力不讨好。

标记压缩算法

标记压缩算法是标记清除算法的改进版本,基本思路是:

  • 标记:和标记清除算法的标记阶段一致;
  • 压缩:将存活的对象压缩(移动)到内存的一端,按顺序排放,之后清理边界外的所有空间。

使用标记压缩算法后,我们就没必要维护一个内存空闲列表了,只需要记录内存使用的末端地址即可,有新对象需要分配内存时,只需要从这个末端地址开始继续往后排,然后更新这个末端地址即可(这种分配方式也称为指针碰撞)。

优点:

  1. 没有碎片化问题;
  2. 不会像复制算法那样,浪费一半的内存空间。

缺点:

  1. 从效率上来看,因为多了压缩对象过程,所以不及标记清除算法;
  2. 涉及到对象的移动,所以栈中的引用地址也要变,也会增加时间开销;
  3. 压缩过程中,也需要STW。

算法比较与实际应用

可以看到,没有完美的垃圾回收算法,大家都有优缺点:

标记清除 标记压缩 复制
速度 中等 最慢 最快
内存开销 少,但有碎片 少,没有碎片 多,没有碎片
移动对象

没有最完美的算法,只有最合适的算法。我们可以在堆内存中针对不同区域的特性使用不同的回收算法,取长补短(分代收集):

  • 新生代:该区域的对象大多朝生夕死,存活率低,回收频繁,所以复制算法非常适合用于该区域;
  • 老年代:老年代内存区域较大,对象生命周期长,回收不那么频繁,所以这个区域一般使用标记清除或者标记压缩算法。

增量收集算法

上述的三种垃圾回收算法都要进行STW,如果一次性将所有的垃圾进行处理,会造成系统长时间的停顿,为了改善这个问题,增量收集算法应运而生。

增量收集算法让垃圾收集线程和应用程序交替执行,每次垃圾收集线程只收集一小片区域的内存,接着切换到应用程序线程,以此反复,直到垃圾收集完成。但该算法的基础还是传统的标记清除算法和复制算法。

优点:没有长时间的STW;

缺点:线程切换会使得垃圾回收的总体成本上升,造成系统吞吐量下降。