JVM GC

引言

前面我们已经在整体上简单地介绍了一下 JVM 组成部分,本文着重介绍其中 GC 相关的内容,更多关于 JVM 的文章均收录于<JVM系列文章>

垃圾回收

相信在前面的 JVM 基本结构中,大家已经认识到了 JVM 中内存的结构,即方法区,堆,Java 栈,本地方法栈,PC 计数器,这些是由 JVM 维护的供 Java 程序使用的内存,我们称之为运行时数据区。除此之外,还有 JVM 自身要使用的内存,如执行引擎和本地库接口使用的内存,这些是在运行时数据区之外的。
JVM GC
而我们接下来要谈论的垃圾回收,主要的回收目标就是堆空间。而这里所说的垃圾,特指内存中不会再被使用的对象,将这些对象所用的内存回收后,就会有空闲的区域腾出来,这样其他地方才能使用到这些内容。如果不及时堆内存中的垃圾进行清理,那么这些垃圾对象所占用的内存会一直保存到应用结束,被保留的内存无法被其他对象使用。如果大量不会被使用的对象一直占着内存不放,需要内存空间时,就有可能导致内存溢出。

在早期的 C/C++ 时代,内存回收的工作基本上是手动进行的,开发人员使用 new 创建一个对象后,就担负着通过 delete 释放这块内存的责任。这种显示的释放内存的方案虽然可以灵活地控制内存释放的时间,但是会给开发人员带来很大的管理负担。如果有一处内存区域由于程序员疏忽忘记被回收,就会产生内存泄漏,垃圾对象永远无法被清除,随着系统运行不断地增长,直到内存溢出。

为了将程序员从繁重的内存管理任务中解放出来,可以更加专注于业务开发,就需要一个垃圾回收机制来帮助开发人员在合适的时候进行垃圾对象的释放,这时候,开发人员只需要关注内存的申请,而内存的释放可以由系统自动识别和完成。

垃圾回收算法

那么有什么好的方案能够完成垃圾回收的工作呢?这里会涉及到几个常见的回收理论,它们分别是:引用计数法,标记压缩法,标记清除法,复制算法和分代分区思想。

引用计数法

引用计数法是最经典也是最古老的垃圾收集方法,它的实现很简单,每个对象 A 都有一个对应的引用计数器,当有任意一个对象 B 引用了对象 A,则引用计数器加一,当引用取消时,引用计数器减一,如果对象 A 的引用计数器的值为 0,则说明对象 A 不再被其他对象使用,换句话说,我们此时可以释放对象 A。

但是,引用计数法有两个严重的问题:

  1. 无法处理循环引用的情况。
  2. 引用计数器每次引用产生和消除的时候,都要伴随一次加法或者减法运算,所以对系统性能有一定的影响。

JVM GC
上图展示的就是循环引用的例子,图中左半部分是正常的对象引用关系,在后续的程序执行中这些正常引用的对象很可能会被再次使用。而右侧的两个对象之间,互相引用,它们都不会在后续的程序执行过程中被使用,但是因为它们的引用计数器都不为 0 ,也就是说,这两个对象会一直得不到回收。

标记清除法

标记清除算法是现代垃圾回收算法的思想基础。标记清除算法将垃圾回收分为两个阶段: 标记阶段和清除阶段。在标记阶段, 首先通过根节点, 标记所有从根节点开始的可达对象。因此, 未被标记的对象就是未被引用的垃圾对象。然后, 在清除阶段, 清除所有未被标记的对象。标记清除算法可能产生的最大问题是空间碎片。
JVM GC
如上的例子中,我们使用了一标记清除算法进行垃圾回收,从根节点开始(可能是当前栈空间的对象引用,或类静态属性等)扫描,所有的有引用关系的对象均被标记为存活,而那些不可达的对象则是垃圾对象,在标记操作结束后,系统回收那些垃圾对象。回收后的空间是不连续的,所以之后在进行大对象的内存分配时,在不连续的内存空间中分配的效率要低于连续的空间。因此,这是该算法最大的缺点。

标记复制算法

复制算法则主要是为了解决标记清除算法中内存回收后不连续的问题而衍生出来的。它将内存空间分为两块,每次只使用其中一块,这里假设正在使用 A,在垃圾回收时,先从根节点开始,标记处所有正在使用的对象,然后将正在使用的内存 A 中的存活对象复制到未使用的内存 B 中,之后清除正在使用的内存块 A 中的所有对象,然后程序转而使用那块原来未使用的内存区域 B。
JVM GC
如果系统中垃圾对象很多,复制算法需要复制的对象就会相对较少,因此,在使用该算法时,效率还是很高的。而且,以复制的方式进行垃圾回收海能保证回收后剩余可用内存是连续的,没有内存碎片。虽然,有以上两大优点,但是复制算法会导致内存折半,只能使用一半内存,而且如果存活对象较多时,要复制大量对象,对效率也有影响。

在 JVM 很多垃圾回收器中都是用到了标记复制的思想,为了解决经典标记复制算法中内存折半的问题,JVM 将内存划分为 3 个区,eden 区,from 区和 to 区。其中 from 和 to 可以视为用于复制的两块大小相同,地位相等,且可以进行角色互换的区域。from 和 to 也被称为 survivor 空间,即幸存者空间,用于存放未被回收的对象。

以下图为例,当发生垃圾回收时,会从根节点出发,标记 eden 区和 from 区的所有存活对象,然后将这些对象复制到 to 区。而下次进行垃圾回收时,to 和 from 区角色互换,届时会将 to 区 和 eden 区的幸存对象复制到 from 区。通过这种方法,就解决了标记复制算法中内存折半导致的内存使用率过低的问题,这里我们可以通过调整 eden 区,from 区和 to 区的比例,来控制内存的利用率。
JVM GC

新生代: 存放年轻对象的堆空间。年轻对象指刚刚创建的, 或者经历垃圾回收次数不多的对象。
老年代: 存放老年对象的堆空间。老年对象指经历过多次垃圾回收依然存活的对象。

但是,缩小了 from 和 to 区的大小势必会引发另一个问题:如果要复制的对象过多,大于 from 和 to 的内存大小时,就会无法使用。所以,在上图中你会看到 JVM 不仅将内存分为 eden 和 survivor,还划分出一个老年代对象区。对于那些较大的对象,或者在前面的标记复制算法过程中复制次数过多的对象(年龄较大)会被移动到老年代对象区中,这样就能减少复制的对象规模,让标记复制算法工作在一个最适合的场景中。

标记压缩算法

复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生, 但是在老年代, 更常见的情況是大部分对象都是存活对象。如果依然使用复制算法, 由于存活对象较多, 复制的成本也将很高。因此, 基于老年代垃圾回收的特性, 需要使用其他的算法。标记压缩算法是一种老年代的回收算法。它在标记清除算法的基础上做了一些优化。和标记清除算法一样, 标记压缩算法也首先需要从根节点开始, 对所有可达对象做一次标记。但之后, 它并不只是简单地清理未标记的对象, 而是将所有的存活对象压缩到内存的一端。之后, 清理边界外所有的空间。这种方法既避免了碎片的产生, 又不需要两块相同的内存空间, 因此, 其性价比较高。如下图所示, 在通过根节点标记出所有可达对象后, 沿虚线进行对象移动, 将所有的可达对象都移动到一端, 并保持它们之间的引用关系, 最后, 清理边界外的空间, 即可完成回收工作。
JVM GC
这里简单地看,标记压缩算法应该完爆标记复制算法啊,因为标记压缩算法复制的对象数少,而且还不需要额外的内存区域保存存活的对象。实际上,在进行压缩的过程中也要进行计算,才能规划好压缩方案,而对于那些存活比率较低的新生代对象来说,规划压缩方案的消耗可能还没有直接复制来得快,所以标记复制算法和标记压缩算法分别代表了空间换时间和时间换空间思想,并分别活跃在各自擅长的场景中。

分代算法

在前面对标记复制算法进行分代改造的过程中,我们就谈到了新生代,老年代这些概念,实际上这就是分代算法的体现。分代算法将内存根据对象的特点分成了几块,根据每块内存的区间对象特点,使用不同的回收算法,来提高回收的效率。

一般来说, Java 虚拟机会将所有的新建对象都放入称为新生代的内存区域, 新生代的特点是对象朝生夕灭, 大约 90% 的新建对象会被很快回收, 因此, 新生代比较适合使用复制算法。当一个对象经过几次回收后依然存活, 对象就会被放入称为老年代的内存空间,此外当 survivor 区内存紧张时,也会将一些对象“破格”提升到老年代。在老年代中, 几乎所有的对象都是经过几次垃圾回收后依然得以存活的。因此, 可以认为这些对象在一段时期内, 甚至在应用程序的整个生命周期中, 将是常驻内存的。在极端情况下, 老年代对象的存活率可以达到 100%。如果依然使用复制算法回收老年代, 将需要复制大量对象。再加上老年代的回收性价比也要低于新生代, 因此这种做法是不可取的。根据分代的思想, 可以对老年代的回收使用与新生代不同的标记压缩或标记清除算法, 以提高垃圾回收效率。如下图所示, 就显示了这种分代回收的思想。
JVM GC
对于新生代和老年代来说, 通常, 新生代回收的频率很高, 但是每次回收的耗时都很短, 而老年代回收的频率比较低, 但是会消耗更多的时间。为了支持高颊率的新生代回收, 虚拟机可能使用一种叫作卡表(Card Table)的数据结构。卡表为一个比特位集合, 每一个比特位可以用来表示老年代的某一区域中的所有对象是否持有新生代对象的引用。这样在新生代 GC 时, 可以不用花大量时间扫描所有老年代对象, 来确定每一个对象的引用关系, 而可以先扫描卡表, 只有当卡表的标记位为 1 时, 才需要扫描给定区域的老年代对象, 而卡表位为 0 的所在区域的老年代对象, 一定不含有新生代对象的引用。如下图所示, 卡表中每一位表示老年代 4KB 的空间, 卡表记录为 0 的老年代区域没有任何对象指向新生代, 只有卡表位为 1 的区域才有对象包含新生代引用, 因此在新生代 GC 时, 只需要扫描卡表位为 1 所在的老年代空间。使用这种方式, 可以大大加快新生代的回收速度,并且在具有分代特性的回收器中广泛使用。
JVM GC

分区算法

一般来说, 在相同条件下, 堆空间越大, 一次 GC 时所需要的时间就越长, 从而产生的停顿也越长。对于交互式应用或者对吞吐量有要求的后端服务程序来说,为了更好地控制 GC 产生的停顿时间, 将一块大的内存区域分割成多个小块, 根据目标的停顿时间, 每次合理地回收若干个小区间, 而不是整个堆空间, 从而减少一次 GC 所产生的停顿。
JVM GC

识别存活对象

前面,我们着重介绍了各种垃圾回收算法,它们是 JVM 垃圾回收器的理论基础,但是我们还没有详细介绍怎么判断一个对象是存活的,这一节我们就着重介绍一下这部分的内容。

我们知道,在 JVM 中是通过一些 GC Root 作为基础,遍历出所有存活的对象的,那么这些 GC Root 主要都包括什么呢?

  1. 活着的线程和虚拟机栈(栈桢中的本地变量表)中的引用的对象
  2. 方法区中的类静态属性引用的对象
  3. 方法区中的常量引用的对象
  4. 本地方法栈中 JNI 的引用的对象
  5. 所有 synchronized 锁住的对象引用,即用于同步的监控对象
  6. finalize 执行队列中的对象

上述的这几个 GC Root 是最主要的情况,JVM 从这些 Root 出发找到所有可触及的对象,并将它们标记为存活。而对于那些已经死掉的对象,还有可能在某一条件下 “复活” 自己。

对象的复活

当一个对象第一次被标记为死亡时,并不会立即被销毁,而是会将其放置在一个 finalize 队列中,JVM 会使用一个优先级较低的线程执行这些对象的 finalize 函数,如果一个对象在 finalize 函数的执行过程中将自身的应用挂载到了任意 GC Root 可以访问到的地方,那么就不会销毁该对象,这个过程,我们称之为对象的“复活”。不过通过 finalize 复活的这套机制只能使用一次,下次该对象再次被标记为死亡时,就不会再执行 finalize 函数了。

public class Finalize {

    public static Finalize obj;

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

    public static void main(String[] args) throws InterruptedException {
        Finalize o = new Finalize();
        o = null;
        System.gc();
        Thread.sleep(1000);
        if (obj != null) {
            System.out.println("object alive");
        }
        obj = null;
        System.gc();
        Thread.sleep(1000);
        if (obj == null) {
            System.out.println("finalize only invoke once");
        }
    }
}

上例中展示的就是如何在 finalize 中“复活”一个对象,从下面的执行结果中我们也能看出 finalize 只会执行一次。

in finalize
object alive
finalize only invoke once

如果一个对象的 finalize 函数被调用后,并没有复活自己,或者 finalize 函数已经执行过一次,那么该对象就是真正的要被回收的对象了。

finalize 函数是一个非常糟糕的模式, 不推荐使用 finalize 函数释放资源。因为 finalize 函数有可能发生引用外泄, 在无意中复活对象;而且 finalize 是被系统调用的, 调用时间是不明确的, 因此不是一个好的资源释放方案, 推荐在 try-catch-finally 语句中进行资源的释放。

引用的强度

在 Java 中对象引用还进行了更进一步的划分以满足不同的需求,它们包括:强引用,软引用,弱引用和虚引用。强引用就是程序中一般使用的引用类型, 强引用的对象是可触及的, 不会被回收。相对的, 软引用、弱引用和虚引用的对象在一定条件下, 都是可以被回收的。

强引用
Object strongRef = new Object();

如上所示的例子中,使用到的就是强引用,通过它可以直接访问引用的对象,强引用所指向的对象在任何时候都不会被系统回收, 虚拟机宁愿抛出 OOM 异常, 也不会回收强引用所指向对象。所以,如果强引用使用不当,可能导致内存泄漏问题。

软引用

软引用是比强引用弱一点的引用类型,一个对象如果只被软引用,那么只有当堆空间不足时,才会被回收。

public class SoftRef {

    private byte[] data = new byte[5 * 1024 * 1024];
    public static void main(String[] args) throws InterruptedException {
        SoftRef softRef = new SoftRef();
        ReferenceQueue referenceQueue = new ReferenceQueue<SoftRef>();
        SoftReference<SoftRef> softReference = new SoftReference<SoftRef>(softRef, referenceQueue);
        softRef = null;
        if (referenceQueue.poll() == null) {
            System.out.println("current reference queue is empty");
        }
        if (softReference.get() != null) {
            System.out.println("could get ref");
        }
        System.gc();
        if (softReference.get() != null) {
            System.out.println("still could get ref");
        }
        byte[] b = new byte[1024 * 1024 * 6];
        if (softReference.get() == null) {
            System.out.println("couldn't get ref");
        }
        if (referenceQueue.poll() == softReference) {
            System.out.println("reference queue work");
        }
    }
}

在上例中,我们通过 -Xmx10m 设定堆最大为 10m,然后先创建了软引用并进行垃圾回收,会发现软引用的对象并未被回收,而当我们再次申请一个大对象时,因为当前剩余内存大小不足以应对新的内存请求,所以对之前的软引用对象进行了回收。

current reference queue is empty
could get ref
still could get ref
couldn't get ref
reference queue work

除此之外,我们还可以在创建软引用的时候将该软引用注册在一个引用队列中,这样在该软引用对应的对象被回收时,JVM 会将该软引用推入队列中来作为对象回收的一个通知。注意:如果一个对象通过 finalize 复活了自己的话,那么该对象的引用就不会被推入引用队列。只有一个对象真正的被回收时,才会被加入到引用队列。

弱引用

弱引用是一个比软引用更弱的引用类型,和软引用不同的是,弱引用只要发生了 GC 就会被清除,而不关心当前内存是否严重不足。由于垃圾回收器线程通常优先级较低,因此并不一定能很快地发现弱引用对象。在这种情况下,弱引用对象能存活很长一段时间。和软引用一样,弱引用也可以注册到一个引用队列中,当进行对象回收时,JVM 会将弱引用推入该队列。

public class WeakRef {
    public static void main(String[] args) throws InterruptedException {
        WeakRef WeakRef = new WeakRef();
        ReferenceQueue<WeakRef> referenceQueue = new ReferenceQueue<WeakRef>();
        WeakReference<WeakRef> ref = new WeakReference<WeakRef>(WeakRef, referenceQueue);
        WeakRef = null;
        if (ref.get() != null) {
            System.out.println("weak reference alive");
        }
        if (referenceQueue.poll() == null) {
            System.out.println("reference queue is empty");
        }
        System.gc();
        Thread.sleep(1000);
        if (ref.get() == null) {
            System.out.println("weak reference cleaned");
        }
        if (referenceQueue.poll() == ref) {
            System.out.println("reference queue work");
        }
    }
}

上例的输出结果如下:

weak reference alive
reference queue is empty
weak reference cleaned
reference queue work

软引用、弱引用都非常适合来保存那些可有可无的缓存数据。如果这么做, 当系统内存不足时, 这些缓存数据会被回收, 不会导致内存溢出。而当内存资源充足时, 这些缓存数据又可以存在相当长的时间, 从而起到加速系统的作用。

虚引用

虚引用是所有引用类型中最弱的一个。一个持有虚引用的对象, 和没有引用几乎是一样的, 随时都可能被垃圾回收器回收。当试图通过虚引用的 get 方法取得引用对象时, 总是会失败。并且, 虛引用必须和引用队列一起使用, 它的作用在于跟踪垃圾回收过程。当垃圾回收器准备回收一个对象时, 如果发现它还有虚引用, 就会在回收对象后, 将这个虚引用加入引用队列, 以通知应用程序对象的回收情况。

public class PhantomReference<T> extends Reference<T> {

    /**
     * Returns this reference object's referent.  Because the referent of a
     * phantom reference is always inaccessible, this method always returns
     * <code>null</code>.
     *
     * @return  <code>null</code>
     */
    public T get() {
        return null;
    }

    /**
     * Creates a new phantom reference that refers to the given object and
     * is registered with the given queue.
     *
     * <p> It is possible to create a phantom reference with a <tt>null</tt>
     * queue, but such a reference is completely useless: Its <tt>get</tt>
     * method will always return null and, since it does not have a queue, it
     * will never be enqueued.
     *
     * @param referent the object the new phantom reference will refer to
     * @param q the queue with which the reference is to be registered,
     *          or <tt>null</tt> if registration is not required
     */
    public PhantomReference(T referent, ReferenceQueue<? super T> q) {
        super(referent, q);
    }

}

垃圾回收器

在 Java 虚拟机中, 垃圾回收器可不仅仅只有一种, 什么情况下要使用哪一种, 对性能又有什么样的影响, 这都是我们必须要了解的。接下来我们将具体介绍虚拟机中的垃圾回收器类型, 以及它们的特点和使用方法, 并进一步探讨有关对象在内存中的分配和回收的问题。

串行垃圾回收器

串行垃圾回收器时最古老的垃圾回收器,它主要具有两个特点:

  1. 使用单一线程进行垃圾回收,对于并行能力较弱的计算机来说,这种方案往往具有很高的性能表现,而对于并发能力较强的计算机来说,显然会造成资源的浪费
  2. 它是独占式的垃圾回收,在进行垃圾回收的工程中,所有应用程序线程都会暂停,这种所有应用程序线程都暂停的情况我们称之为 Stop-The-World,它会造成非常糟糕的用户体验

JVM GC
串行垃圾回收器是一个既可以工作在新生代又可以老年代的回收器。面对不同的对象特点时,它会选用不同的垃圾回收算法:

  • 工作在新生代时:使用标记复制算法
  • 工作在老年代时:使用标记压缩算法

要想让 JVM 使用串行垃圾回收器,可以通过如下参数:

  • -XX:+UseSerialGC: 新生代和老年代都使用串行垃圾回收器
  • -XX:+UseParNewGC: 新生代使用 ParNew 回收器,老年代使用串行垃圾回收器
  • -XX:+UseParallelGC: 新生代使用 ParallelGC 回收器,老年代使用串行垃圾回收器

并行垃圾回收器

并行回收器在串行回收器的基础上做了改进, 它使用多个线程同时进行垃圾回收。对于并行能力强的计算机, 可以有效缩短垃圾回收所需的实际时间。

新生代 ParNew

ParNew 回收器是一个工作在新生代的垃圾收集器。它只是简单地将串行回收器多线程化, 它的回收策略、算法以及参数和新生代串行回收器一样(标记复制)。ParNew 回收器的工作示意图如下图所示。ParNew 回收器也是独占式的回收器, 在收集过程中, 应用程序会全部暂停。但由于并行回收器使用多线程进行垃圾回收, 因此, 在并发能力比较强的 CPU 上, 它产生的停顿时间要短于串行回收器, 而在单 CPU 或者并发能力较弱的系统中, 并行回收器的效果不会比串行回收器好, 由于多线程的压力, 它的实际表现很可能比串行回收器差。
JVM GC
注意:ParNew 回收器只工作在新生代对象中,而如下两个参数可以在新生代对象中启用该回收器:

  • -XX:+UseParNewGC: 新生代使用 ParNew 回收器,老年代使用串行垃圾回收器
  • -XX:+UseConcMarkSweepGC: 新生代使用 ParNew 回收器,老年代使用 CMS

ParNew 回收器工作时的线程数量可以使用 -XX:ParallelGCThreads 参数指定。一般, 最好与 CPU 数量相当, 避免过多的线程数, 影响垃圾收集性能。

新生代 ParallelGC

新生代 ParallelGC 回收器也是使用标记复制算法的收集器。从表面上看, 它和 ParNew 回收器一样, 都是多线程、独占式的收集器。但是, ParallelGC 回收器有一个重要的特点: 它非常关注系统的吞吐量。

新生代 ParallelGC 回收器可以使用以下参数启用。

  • -XX:+UseParallelGC: 新生代使用 ParallelGC 回收器,老年代使用串行垃圾回收器
  • -XX:+UseParallelOldGC: 新生代使用 ParallelGC 回收器,老年代使用 ParallelOld 回收器

ParallelGC 回收器提供了两个重要的参数用于控制系统的吞吐量。

  • -XX:MaxGCPauseMillis: 设置最大垃圾收集停顿时间。它的值是一个大于 0 的整数。ParallelGC 在工作时, 会调整 Java 堆大小或者其他一些参数, 尽可能地把停顿时间控制在 MaxGCPauseMillis 以内。如果希望减少停顿时间, 而把这个值设得很小, 为了达到预期的停顿时间, 虚拟机可能会使用一个较小的堆(一个小堆比一个大堆回收快), 而这将导致垃圾回收变得很频繁, 从而增加了垃圾回收总时间, 降低了吞吐量。
  • -XX:GCTimeRatio: 设置呑吐量大小。它的值是一个 0 到 100 之间的整数。假设GCTimeRatio 的值为 n, 那么系统将花费不超过 1/(1+n) 的时间用于垃圾收集。比如 GCTimeRatio 等于19, 则系统用于垃圾收集的时间不超过 1/(1+19)=5%。默认情况下, 它的取值是99, 即不超过 1/(1+99)=1% 的时间用于垃圾收集。

除此以外, ParallelGC 回收器与 ParNew 回收器另一个不同之处在于它还支持一种自适应的 GC 调节策略。使用 -XX:+UseAdaptiveSizePolicy 可以打开自适应 GC 策略。在这种模式下, 新生代的大小、eden 和 survivor 的比例、晋升老年代的对象年龄等参数会被自动调整, 以达到在堆大小、吞吐量和停顿时间之间的平衡点。在手工调优比较困难的场合, 可以直接使用这种自适应的方式, 仅指定虚拟机的最大堆、目标吞吐量(GCTimeRatio)和停顿时间(MaxGCPauseMillis), 让虚拟机自己完成调优工作。

ParallelGC 回收器关注系统吞吐量。可以通过 -XX:MaxGCPauseMillis 和 -XX:GCTimeRatio 设置期望的停顿时间和吞吐量大小。但是鱼和熊掌不可兼得, 这两个参数是相互矛盾的, 通常如果减少一次收集的最大停顿时间, 就会同时减少系统吞吐量, 增加系统吞吐量又可能会同时增加一次垃圾回收的最大停顿。

老年代 ParallelOldGC

老年代 ParallelOldGC 回收器也是一种多线程并发的收集器。和新生代 ParallelGC 回收器一样, 它也是一种关注吞吐量的收集器。从名字上看, 它在 ParallelGC 中间插入了 Old, 表示这是一个应用于老年代的回收器, 并且和 ParallelGC 新生代回收器搭配使用。

ParallelOldGC 回收器使用标记压缩算法, 下图显示了老年代 ParallelOldGC 回收器的工作模式。
JVM GC
使用 -XX:+UseParallelOldGC 可以在新生代使用 ParallelGC 回收器, 老年代使用ParallelOldGC 回收器。这是一对非常关注吞吐量的垃圾回收器组合。在对吞吐量敏感的系统中, 可以考虑使用。参数 -XX:ParallelGCThreads 也可以用于设置垃圾回收时的线程数量。

CMS 回收器

CMS 是 Concurrent Mark Sweep 的缩写,意为并发标记清除,它是一款使用标记清除算法的并行回收器。与 ParallelGC 和 ParallelOldGC 不同,它主要关注与系统的停顿时间。CMS 是只针对老年代的回收器所以通常和 ParNew 配合使用,也就是前面所说的 -XX:+UseConcMarkSweepGC 参数。

CMS 与之前介绍的各种垃圾回收器之间最大的区别是,CMS 能让(部分)垃圾回收过程和应用程序并发执行。它尽可能的将一些花费时间较长的引用扫描工作隔离出来,让这部分工作和应用程序并发执行,然后在一个关键的时间点将应用程序暂停,检查引用扫描结果的正确性并进行校正,在垃圾回收线程确保所有需要保留的对象都记录在案没有漏下的时候,再放行应用程序,之后的清理工作也可以使用并发的方式进行,因为 CMS 使用的是标记清除算法,清除过程中不会影响到现存对象的使用。之所以选用标记清除法,主要是因为 CMS 面向的是老年代对象,一般来说能存活很久。

工作流程

JVM GC
CMS 工作时, 主要步骤有:

初始标记
初始标记阶段是第一个 STW 的阶段,这个阶段的目标是从 GC Root 出发,查找到所有由 GC Root 和年轻代存活对象直接引用的老年代对象。注意,这里我们只是扫描直接引用的老年代对象,而不进行更深入的老年代扫描,所以这个过程是很迅速的。扫描结束后,我们将这些被扫描到的对象打上标记(Marked obj),如下图所示。之后的扫描过程会以这些对象作为出发点进行。
JVM GC

并发标记
根据初始标记的对象,标记出所有存活的老年代对象,在这个阶段垃圾回收器和应用程序是可以并发执行的,也就是说这里扫描的结果并不一定是完全正确的,就如下图所示的 current obj 可能引用关系发生了改变,而我们在并发标记阶段中可能并不能感知到这种变化。
JVM GC

预清理(清理前的准备工作)
就像前面所说的,在进行并发标记时可能对象的引用关系已经发生了改变,很显然我们要重新检查这些对象的最新引用关系,但是重新扫描又很浪费资源。所以,JVM 将老年代的内存划分为了多个区域(我们称之为 Card),每当一个区域中的对象发生了引用关系改变时,就将该区域标记为“脏”,这个机制是通过写屏障(write barrier)实现的,这里大家可以把它理解成一个切面,在进行赋值 A.field = B 之前, 标记目标对象 A 所处的内存区为“脏”。
JVM GC
这样,我们只需要重新扫描所有的“脏”区域,就能修正存活对象的标记了,此外预清理阶段还会做一些别的准备工作。
JVM GC
但是,这并没有真正的解决问题,因为预清理阶段和应用程序仍然是并发执行的,所以这一步并不能保证当前所有存活的对象都被标记了,也就是说我们之后无论如何都需要将应用程序暂停,然后进行最后的存活对象标记,才能保证不遗漏任何一个存活对象。而预清理这一步的意义就是,尽可能的让之后的 STW 重新标记阶段快速完成,因为我们引入了老年代脏区域机制,所以通常来说,只要预清理处理的足够迅速(大于引用关系的变化速度),脏区域应该会越来越少,这样最终的 STW 重新标记阶段就会更快的完成。

基于这样的思想,预清理进行得越彻底后续的 STW 重新标记过程就会越快完成,这样应用程序停顿的时间就越小。但是预清理这一步毕竟也要要有个头啊,有很多因素可以导致预处理阶段的结束,比如重复执行的次数,工作量的多少,持续的时间等等,当达到某一确定的阈值时,预处理阶段就会结束。

重新标记
重新标记阶段是另一个 STW 阶段。因为我们在前面的几个阶段的扫描工作都是基于初始标记的老年代可直接访达的对象,而之后的并发环节中可能 GC Root 又引入了新的老年代对象,这些是我们没有进行扫描的,而且就像前面所说的还会有一些引用关系变动而出现的“脏区域“,所以我们要重新进行一次标记的修正,这个过程和初始标记一样从 GC Root 出发,扫描整个新生代内存,进而找出所有存活新生代对象和 GC Root 关联到的老年代对象。细心的同学会发现,之前我们只记录了老年代的“脏”内存区,并没有记录新生代的脏内存区,这主要是因为新生代对象关系变化比较大,很可能大部分对象所处的内存区都是脏的,所以 CMS 索性直接扫描新生代的全部对象关系。

因为前面我们已经进行过一部分扫描工作了,所以在这次扫描时如果一个老年代对象在前几步中已经扫描过并且该对象并不处于“脏”内存区,那么就不需要继续扫描了,因而这一步的 STW 过程通常来说会比前面几个垃圾回收器具有更短的停顿时间。而且,在 STW 的阶段校正存活对象就能保证结果的正确性了。注意:这里的正确性只是保证了不漏下任何一个存活对象,而对于原来标记为存活,但是在真正回收时已经没有被任何地方引用的对象,CMS 是不会进行检查的,这也是为了最小化停顿时间考虑,对于这类漏网之鱼,我们称之为浮动垃圾。

因为重新标记过程本质上也是基于 GC Root 扫描所有存活对象(只不过可以跳过部分扫描),所以 CMS 回收器还是希望在进行重新扫描时,年轻代的对象数量越小越好(GC Root 直接连接的对象就少,扫描就快),这样 STW 阶段的时间也会更短。所以 JVM 提供了一个 -XX:+CMSScavengeBeforeRemark 参数来让重新标记之前主动进行一次新生代回收,然后再开始重新标记过程,这时候新生代大小理论上应该是最小的。

但是,在预清理阶段实际上会做一件和 -XX:+CMSScavengeBeforeRemark 参数背道而驰的事,它除了进行正常的清理准备和检查以外,可能会尝试控制一次停顿时间,让重新标记阶段尽量避开年轻代回收,这是为什么呢?因为 CMS 回收器是以减少停顿时间作为目标的,如果刚进行完一次年轻代 GC 又紧接着进行 STW 的重新标记过程,可能会造成应用程序停顿太长时间。为了避免这种情况, 预处理时, 会刻意等待一次新生代 GC 的发生, 然后根据历史性能数据预测下一次新生代 GC 可能发生的时间, 然后在当前时间和预测时间的中间时刻, 进行重新标记。这样, 从最大程度上避免新生代 GC 和重新标记重合, 尽可能减少应用程序停顿时间。

无论是年轻代 GC 和重新标记背靠背发生,还是年轻代对象过多导致的扫描时间过长,都与 CMS 的设计初衷(最小化停顿时间)相违背。所以,如何选择还是要从实际的情况出发,做好这两种情况的权衡,才能让 CMS 工作的更加高效。
并发清除
经过重新标记之后,我们就能开始移除未使用的对象并回收相应的内存空间了,这个过程可以和应用程序并发进行。
JVM GC
并发重置
最后一个阶段,我们需要重置 CMS 算法使用到的内部数据,然后准备下一次垃圾回收过程。

上述步骤中初始标记和重新标记是独占系统资源的, 而预清理、并发标记、并发清除和并发重置是可以和用户线程一起执行的。因此, 从整体上说, CSM 收集不是独占式的, 它可以在应用程序运行过程中进行垃圾回收。
JVM GC

CMS 参数

启用 CMS 回收器的参数是 -XX:+UseConcMarkSweepGC。CMS 是多线程回收器, 设置合理的工作线程数量也对系统性能有重要的影响。CMS 默认启动的并发线程数是 (ParallelGCThreads + 3)/4),ParallelGCThreads 表示 GC 并行时使用的线程数量, 如果新生代使用 ParNew, 那么 ParallelGCThreads 也就是新生代 GC 的线程数量。这意味着有 4 个 ParallelGCThreads 时, 才会有 1 个 CMS 并发线程。

并发线程数量也可以通过 -XX:ConcGCThreads 或者 -XX:ParallelCMSThreads 参数直接设定。当 CPU 资源比较紧张时, 受到 CMS 回收器线程的影响, 应用系统的性能在垃圾回收阶段可能会非常糟糕。

并发是指收集器和应用线程交替执行, 并行是指同时由多个线程一起执行 GC。因此并行回收器不一定是并发的。因为并行回收器执行时, 应用程序完全挂起, 不存在交替执行的步骤。

由于 CMS 回收器不是独占式的回收器, 在 CMS 回收过程中, 应用程序仍然在不停地工作。在应用程序工作过程中, 又会不断地产生垃圾。这些新生成的垃圾在当前 CMS 回收过程中是无法清除的。同时, 因为应用程序没有中断, 所以在 CMS 回收过程中, 还应该确保应用程序有足够的内存可用。因此, CMS 回收器不会等待堆内存饱和时才进行垃圾回收, 而是当堆内存使用率达到某一阀值时便开始进行回收, 以确保应用程序在 CMS 工作过程中, 依然有足够的空间支持应用程序运行。

这个回收阈值可以使用 -XX:CMSInitiatingOccupancyFraction 来指定, 默认是 68。即当老年代的空间使用率达到 68% 时, 会执行一次 CMS 回收。如果应用程序的内存使用率增长很快, 在 CMS 的执行过程中, 已经出现了内存不足的情况, 此时, CMS 回收就会失败, 虚拟机将启动老年代串行回收器进行垃圾回收。在这种时候,应用程序将完全中断,直到垃圾回收完成,这时,应用程序的停顿时间可能会很长。

因此, 根据应用程序的特点, 可以对 -XX:CMSInitiatingOccupancyFraction 进行调优。如果内存增长缓慢, 则可以设置一个稍大的值, 大的阈值可以有效降低 CMS 的触发频率, 减少老年代回收的次数可以较为明显地改善应用程序性能。反之, 如果应用程序内存使用率增长很快, 则应该降低这个阈值, 以避免频繁触发老年代串行收集器。

CMS 是一个基于标记清除算法的回收器。而标记清除算法将会造成大量内存碎片, 离散的可用空间无法分配较大的对象。就如下图所示。为了解决这个问题,CMS 回收器提供了几个用于内存压缩整理的参数。-XX:+UseCMSCompactAtFullCollection 开关可以使 CMS 在域圾收集完成后, 进行一次内存碎片整理, 内存碎片的整理不是并发进行的。-XX:CMSFullGCsBeforeCompaction 参数可以用于设定进行多少次 CMS 回收后, 进行一次内存压缩。
JVM GC

Log

下列的 Log 中描述的就是一次完整的 CMS 垃圾回收过程,可以看到,CMS 的整个工作过程包括了初始化标记、并发标记、预清理、重新标记、并发清理和重置等几个重要阶段。

# 初始标记,当前老年代内存 10812086K,最大老年代内存 11901376K,当前使用的堆内存 10887844K,总计堆内存 12514816K,后面是停顿时间
64.425: [GC (CMS Initial Mark) [1 CMS-initial-mark: 10812086K(11901376K)] 10887844K(12514816K), 0.0001997 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
# 并发标记,后面是花费的时间
64.425: [CMS-concurrent-mark-start]
64.460: [CMS-concurrent-mark: 0.035/0.035 secs] [Times: user=0.07 sys=0.00, real=0.03 secs]
# 并发预清理,后面是花费的时间
64.460: [CMS-concurrent-preclean-start]
64.476: [CMS-concurrent-preclean: 0.016/0.016 secs] [Times: user=0.02 sys=0.00, real=0.02 secs]
# 并发预清理的后半段,循环扫描所有脏内存区,并适时结束预清理阶段,后面是花费的时间
64.476: [CMS-concurrent-abortable-preclean-start]
65.550: [CMS-concurrent-abortable-preclean: 0.167/1.074 secs] [Times: user=0.20 sys=0.00, real=1.07 secs]
# 重新标记,当前年轻代内存使用 387920K,年轻代的总大小 613440 K,启动多个 GC 线程线程并发(parallel)重新扫描的过程耗时 0.0085125 秒,随后是三个子阶段的耗时(弱引用扫描,类卸载,清除无用的符号表),最后是老年代内存,堆内存和耗时
65.550: [GC (CMS Final Remark) [YG occupancy: 387920 K (613440 K)]65.550: [Rescan (parallel) , 0.0085125 secs]65.559: [weak refs processing, 0.0000243 secs]65.559: [class unloading, 0.0013120 secs]65.560: [scrub symbol table, 0.0008345 secs]65.561: [scrub string table, 0.0001759 secs][1 CMS-remark: 10812086K(11901376K)] 11200006K(12514816K), 0.0110730 secs] [Times: user=0.06 sys=0.00, real=0.01 secs]
# 并发清理,花费的时间
65.561: [CMS-concurrent-sweep-start]
65.588: [CMS-concurrent-sweep: 0.027/0.027 secs] [Times: user=0.03 sys=0.00, real=0.03 secs]
# 并发重置,花费的时间
65.589: [CMS-concurrent-reset-start]
65.601: [CMS-concurrent-reset: 0.012/0.012 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]

G1 回收器

G1 回收器(Garbage-First)是在 JDK 1.7 中正式使用的全新的垃圾回收器, 官方希望它能取代 CMS 回收器。G1 回收器拥有独特的垃圾回收策略, 这和之前提到的回收器截然不同。从分代上看, G1 依然属于分代垃圾回收器, 它会区分年轻代和老年代, 依然有 eden 区和 survivor 区, 但从堆的结构上看, 它并不要求整个 eden 区、年轻代或者老年代都连续。它使用了分区算法。它的设计思想和 CMS 回收器非常类似,只不过将分区思想融合其中,其特点如下。

  • 并行性: G1 在回收期间, 可以由多个 GC 线程同时工作, 有效利用多核计算能力。
  • 并发性: G1 拥有与应用程序交替执行的能力, 部分工作可以和应用程序同时执行, 因此一般来说, 不会在整个回收期间完全阻塞应用程序。
  • 分代 GC: G1 依然是一个分代收集器, 但是和之前回收器不同, 它同时兼顾年轻代和老年代的回收工作。而对比其他回收器, 它们或者工作在年轻代, 或者工作在老年代。因此, 这里是一个很大的不同。
  • 空间整理: G1 在回收过程中, 会进行适当的对象移动, 不像 CMS, 只是简单地标记清理对象, 在若干次 GC 后, CMS 必须进行一次碎片整理。而 G1 不同, 它每次回收都会有效地复制对象, 减少空间碎片。
  • 可预见性: 由于分区的原因, G1 可以只选取部分区域进行内存回收, 这样缩小了回收的范围, 因此对于全局停顿也能得到较好的控制。

G1 回收器将堆空间分成多个小区域,每次回收时,只回收其中几个区域,通过这种方式来控制 GC 导致的停顿时间。它的回收过程分为 3 个阶段。
JVM GC
从上图中我们可以看到,G1 回收器的工作流程起始于一次年轻代 GC,然后开始并发标记,标记结束后进行混合 GC,这个阶段不仅会回收全部年轻代内存,而且还会回收部分老年代对象。混合 GC 结束后,会重新回到起点,再开始下一次 GC 循环。

新生代 GC

和之前的思路一样,新生代 GC 过程主要回收 eden 区和 survivor 区,一旦 eden 区占满,新生代 GC 就会启动,eden 区和 survivor 区的一部分数据被回收,一部分对象保存到新的 survivor 区中,而一部分晋升到老年代。所以,GC 结束后,应该至少存在一个 survivor 区,并且老年代的区域会增多。
JVM GC
G1 的新生代 GC 和其他垃圾回收器的做法基本相同,都是标记复制算法,只不过这里引入了分区思想。

并发标记周期

G1 的并发标记阶段和 CMS 有些类似,它们都是为了降低停顿时间,而将可以和应用程序并发的部分单独提取出来执行。并发标记周期可以分为以下几步:

  1. 初始标记:标记从根节点直接可达的对象,因为之前的新生代 GC 也要进行类似的工作,所以这里的初始标记是和新生代 GC 共用同一个 STW 时间,也就是说初始标记和新生代 GC 是伴随在一起进行的。
  2. 根区域扫描:由于初始阶段和新生代 GC 是一同进行的,所以初始标记后(新生代 GC 结束),eden 被清空,并且存活目标被移入 survivor 区。所以,在根扫描阶段,将扫描 survivor 区直接可达的老年代区域,并标记这些直接可达的对象。这个过程和 CMS 十分类似,它们都可以和应用程序并发执行。但是根扫描阶段不能和下一次新生代 GC 一同进行(因为根扫描阶段会使用到 survivor 区的数据,而下一次新生代 GC 会修改 survivor 区的内容),因此如果在根扫描阶段新生代 GC 被触发(eden 空间不足),这次新生代 GC 会等到根扫描阶段结束后才进行,当这种情况发生时,新生代 GC 的停顿时间会比较长。
  3. 并发标记:和 CMS 类似,根据根扫描的结果(老年代中可以被 GC Root 或存活新的生代对象直接引用的对象)扫描整个堆,并做好标记,这个过程可以和应用程序并发执行。该阶段处理期间可能会发生多次新生代 GC。
  4. 重新标记:和 CMS 一样,并发标记并不能保证最终对象存活性标记的正确性,所以需要一个 STW 阶段来保证正确性。因此,这一步主要是对标记结果进行修正,只不过这里 G1 回收器在修正标记时用到了全新的方案,不需要重新扫描整个 GC Root,我们后面详细介绍。
  5. 独占清理:独占清理是需要 STW 的,这一阶段的目标是计算各个区域的存活对象和 GC 对象的比例并进行排序,识别出可供混合回收的区域,实际的内存复制过程是在混合回收中进行。这个阶段还会更新记忆集(Remembered Set), 这个我们后面介绍。
  6. 并发清除:因为 G1 回收器将内存划分成了很多个小区域,所以在重新标记阶段后,很可能会出现某些区域完全都是垃圾对象,对于这些对象,我们可以并发的清除,不需要引起应用程序的停顿。

下图展示了并发标记周期前后堆的可能的情况。由于并发标记周期包含一次新生代 GC, 故新生代会被整理, 但由于并发标记周期执行时, 应用程序依然在运行, 因此, 并发标记周期结束后, 又会有新的 eden 空间被使用。并发标记周期执行前后最大的不同是在该阶段后, 系统增加了一些标记为 G 的区域。这些区域被标记, 是因为它们内部的垃圾比例较高, 因此希望在后续的混合 GC 中进行收集(注意在并发标记周期中并未正式收集这些区域)。这些将要被回收的区域会被 G1 记录在一个称为 Collection Sets(回收集)的集合中。
JVM GC
并发回收阶段的整体工作流程如下,其中初始标记、重新标记和独占清理外,其他几个阶段都是可以和应用程序并发执行的。
JVM GC
在 G1 回收器中,并没有是使用 CMS 的那种 Card Table 设置为 dirty 表明对象发生改变并在后续重新扫描 dirty card 的方案。在介绍 G1 的方案之前我们需要先了解一些概念。

无论是在 CMS 中,还是在 G1 中都涉及存活对象的并发标记过程,因为该过程和应用程序是并发进行的,所以应用程序可能会修改引用关系,进而出现漏标和错标。错标不会影响程序的正确性,只是会造成前面所说的浮动垃圾。但漏标则会将存活的对象当做垃圾清理掉,之后的程序运行就有可能出错。

为了分析这个漏标的问题,可以用 3 种颜色来区分一个对象被扫描的进度(三色标记法):

  • 白色:本对象未被标记为存活,标记阶段结束后,会被当做垃圾回收
  • 灰色:对象被标记为存活,但是它的 field 还没有被扫描
  • 黑色:对象被标记为存活,并且它的 field 都被扫描完了,之后不会在扫描

基于三色标记法,我们可以总结一下什么情况下会出现漏标,一个 white 对象(存活对象,但是还没被扫描到)在并发标记阶段被漏标的一种情况是:

  1. 应用程序将这个 white 对象赋值到一个 black 对象的 field 上
  2. 应用程序删除了这个 white 对象的所有在 gray 对象上的直接或间接引用

因此,要避免这类对象的漏标,只需要打破上述两个条件中的一个即可。CMS 的方案中打破的是第一条,该方案也被称为增量更新(Incremental update),当 ObjectA.field1 = ObjectB 时,CMS 将 ObjectA 所处的内存区标记为 dirty,之后会重新扫描所有 dirty 内存区的对象,这实际上可以等价于当我要将一个 white 对象赋值到一个 black 对象的 field 上时,会将该 black 对象的颜色改成 gray,这意味着它的所有 field 会被重新扫描。

而 G1 的思路是打破第二条条件,当进行 ObjectC.field = ObjectA.field1 then ObjectA.field1 = null 赋值操作时,G1 不会重新扫描 ObjectC 的所有 field,而是将 ObjectA.field1 提前标记为 gray(本质上是将该对象放入扫描栈,栈中的对象会在并发标记阶段和重新标记阶段被扫描),然后再进行赋值。你可以理解为条件 2 中的 gray 对象的对应 field 被提前扫描了,变成了 gray。

G1 回收器的上述操作也是通过 write barrier 实现的,相关的代码如下:

//  share/vm/gc_implementation/g1/g1SATBCardTableModRefBS.hpp
// This notes that we don't need to access any BarrierSet data
// structures, so this can be called from a static context.
template <class T> static void write_ref_field_pre_static(T* field, oop newVal) {
  T heap_oop = oopDesc::load_heap_oop(field);
  // 如果字段的原始值不为空就放进队列,但是如果现在不一定处于垃圾回收阶段的话呢?我们往下看。
  if (!oopDesc::is_null(heap_oop)) {
    enqueue(oopDesc::decode_heap_oop(heap_oop));
  }
}
// share/vm/gc_implementation/g1/g1SATBCardTableModRefBS.cpp
void G1SATBCardTableModRefBS::enqueue(oop pre_val) {
  // Nulls should have been already filtered.
  assert(pre_val->is_oop(true), "Error");
  // 在这一步会检查当前是不是处于并发标记阶段
  if (!JavaThread::satb_mark_queue_set().is_active()) return;
  Thread* thr = Thread::current();
  if (thr->is_Java_thread()) {
    JavaThread* jt = (JavaThread*)thr;
    jt->satb_mark_queue().enqueue(pre_val);
  } else {
    MutexLockerEx x(Shared_SATB_Q_lock, Mutex::_no_safepoint_check_flag);
    JavaThread::satb_mark_queue_set().shared_satb_queue()->enqueue(pre_val);
  }
}

为了尽量减少 write barrier 对应用程序性能的影响,G1 将一部分原本要在 barrier 里做的事情挪到别的线程上并发执行。实现这种分离的方式是通过 logging 形式的 write barrier 实现的:应用程序只在 barrier 里把要做的事情的信息记(log)到一个队列里,然后另外的线程从队列里取出信息批量完成剩余的动作。每个 Java 线程有一个独立的、定长的 SATB Mark Queue,应用程序在 barrier 里只把 old_value 压入该队列中。一个队列满了之后,它就会被加到全局的 SATB 队列集合 SATB Mark Queue Set 里等待处理,然后给对应的 Java 线程换一个新的、干净的队列继续执行下去。并发标记(concurrent marker)会定期检查全局 SATB 队列集合的大小。当全局集合中队列数量超过一定阈值后,concurrent marker 就会处理集合里的所有队列:把队列里记录的每个对象都标记上,并将其引用字段压到标记栈(marking stack)上等后面做进一步标记。

除此之外,还有一种漏标的可能:从 GC Root 新引入的 white 对象,因为 CMS 会在重新标记阶段从 GC Root 出发扫描出所有的漏标对象,所以解决了这一问题。

而 G1 不需要重新扫描 GC Root,G1 的做法是,在并发标记阶段,如果有新 new 出来的的对象被引用,那么这类对象就直接被破格提升为 black,为什么这类对象不是标记为 gray 并重新扫描呢?这是因为如果这些 GC 开始之后 new 的新对象如果引用了 GC 开始之前就存在的老对象的话,也会被前面的 write barrier 处理方案 cover 住。很显然这里的方案也会造成浮动垃圾。G1 是如何实现这一方案的呢?在 GC 开始时,给每个内存区(Region)当前内存使用的水位线打上标记(TAMS,top-at-mark-start),这也就意味着如果一个对象在 GC 开始之后被 new 出来,那么它的内存地址应该大于 TAMS。

G1 所使用的整个这套算法被称为 SATB(Snapshot-At-The-Beginning),在 GC 开始时(初始标记阶段) G1 收集器会对内存做一个逻辑快照,这个快照包括当前所有存活对象,即便之后某一对象在真正进行回收时已经从存活对象变成垃圾对象,也会被保留不进行回收,并且在建立这个快照之后新 new 的对象都会被认为是存活对象,换句话说 G1 回收器也和 CMS 一样存在浮动垃圾。仔细想想它为什么被命名为 SATB,在进行赋值时 ObjectA.field1 = ObjectB,G1 会将 field1 认定为存活对象,因为至少在垃圾回收开始的那一刻(快照时间点),它确实是存活的(这里我们不考虑 field1 被修改了很多次的情况)。

混合收集

在并发标记周期中,虽然有些内存已经被回收(整个 Region 都为垃圾的情况),但是这毕竟是少数,因为并发阶段已经知道了那些区域包含的垃圾对象比较多,所以在混合回收阶段,我们就优先回收那些垃圾比例较高的区域。除此之外,混合回收不仅会回收那些老年代区域,还会回收年轻代区域,也就是说每次混合回收过程会挑选所有年轻代区域,并且挑选几个最具回收价值的老年代区域进行回收。这也就是为什么这个阶段被叫做混合回收阶段。

Full GC

和 CMS 类似, 并发收集由于让应用程序和 GC 线程交替工作, 因此不能完全避免在特别繁忙的场合会出现在回收过程中内存不充足的情况。当遇到这种情况时, G1 也会转入一个 Full GC 进行回收,就会使用 serial old GC 来收集整个 GC heap。

文章说明

更多有价值的文章均收录于贝贝猫的文章目录

JVM GC

版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!

创作声明: 本文基于下列所有参考内容进行创作,其中可能涉及复制、修改或者转换,图片均来自网络,如有侵权请联系我,我会第一时间进行删除。

参考内容

[1]《实战 Java 虚拟机》
[2]《深入理解 Java 虚拟机》
[3] GC复制算法和标记-压缩算法的疑问
[4] Java中什么样的对象才能作为gc root,gc roots有哪些呢?
[5] concurrent-mark-and-sweep
[6] 关于 -XX:+CMSScavengeBeforeRemark,是否违背cms的设计初衷?
[7] Java Hotspot G1 GC的一些关键技术
[8] Java 垃圾回收权威指北
[9] [[HotSpot VM] 请教G1算法的原理](https://hllvm-group.iteye.com/group/topic/44381)
[10] [[HotSpot VM] 关于incremental update与SATB的一点理解](https://hllvm-group.iteye.com/group/topic/44529)
[11] Java线程的6种状态及切换
[12] Java 8 动态类型语言Lambda表达式实现原理分析
[13] Java 8 Lambda 揭秘
[14] 字节码增强技术探索
[15] 不可不说的Java“锁”事
[16] 死磕Synchronized底层实现--概论
[17] 死磕Synchronized底层实现--偏向锁
[18] 死磕Synchronized底层实现--轻量级锁
[19] 死磕Synchronized底层实现--重量级锁

上一篇:Sentinel 实现原理——处理链


下一篇:Sentinel 简介