Litong's Blog

Work to become, not to acquire.

第3章 垃圾收集器与内存分配策略

Java 与 C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来。

垃圾收集(Garbage Collection)需要考虑三件事情:

【程序计数器、虚拟机栈、本地方法栈】随线程而生,随线程而灭,【栈帧】随方法的进入和退出而进行入栈、出栈,栈帧中分配多少内存在编译器是可知的(类结果确定下来时就确定了)。这几个预期不需要过多考虑内存如何回收的问题,当方法结束或线程结束,内存自然就跟着回收了。

Java 堆和方法区则有着显著的不确定性:接口的多个实现类需要的内存不一样、方法执行的不同分支所需要的内存也不一样,只有在运行时才能知道,这部分内存的分配和回收是动态的,也是 GC 重点关注的部分。

哪些内存需要回收

引用技术算法

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

引用计数器不能解决对象之间互相引用的问题,主流的 Java 虚拟机都没有选用引用计数算法。

可达性分析算法

通过一些列称为 GC Roots 的跟对象作为起始节点集,从这些阶段开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”,如果某个对象到 GC Roots 间没有任何引用链,或者用图论的话来说就是 GC Roots 到这个对象不可达时,则证明此对象不可能再被使用。

Java 中可固定作为 GC Roots 的对象包括:

除了固定的 GC Roots之外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入。比如分代收集和局部回收(Partial GC)就需要考虑堆的其他区域的对象引用和 JVM 的实现细节。

对象引用

传统定义:如果 reference 类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该 reference 数据是代表某块内存、某个对象的引用。

Java 扩充后的引用:强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4 种。

强引用:即引用的传统定义,类似于“Object obj = new Object()” 这种。强引用存在则必定不会回收。

软引用:用来描述一些还有用,但非必须的对象。在系统将要发生内存溢出前,软引用对象会被列进回收范围之中进行第二次回收。如果回收完软引用对象之后还是没有足够的内存才会抛 OOM 异常。JDK 使用 SoftReference 类来实现软引用。

使用场景:

常用语内存敏感缓存,当堆内存不足时,释放缓存空间。

弱引用:也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次 GC 发生为止,GC 发生时一律回收弱引用对象。JDK 使用 WeakReference 来实现弱引用。

使用场景:

弱引用可用于为了扩展 Final 对象属性的 Map 的键中,即使用 WeakReference 修饰该 Map 的键,该键是一个对象。对该对象的弱引用,不会组织 GC 将该对象回收。当 GC 将对象回收完后,下次操作这个 Map 时可判断其在引用队列中是否出现,如出现过则表示该对象已被回收,从而可以将该条目从 Map 回收。

这些已经被 java.util.WeakHashMap 封装好了,可直接使用,但需要注意它是线程不安全的。

虚引用:最弱的一种引用,是否存在不影响 GC,唯一的目的是在这个对象被 GC 回收时能收到一个系统通知。JDK 使用 PhantomReference 来实现。

何时回收

当对象标记为不可达时,在回收前会经历两次标记过程:

对象在进行可达性分析后发现与 GC Roots 不可达时,将会被第一次标记。随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。假如对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被调用过,那么这两种情况都视为“没有必要执行”。

当对象确实有必要执行 finalize() 方法时,虚拟机回去执行 finalize() 方法。如果执行完后对象重新与 GC Roots 中的对象建立了引用那么此对象不会被回收,否则对象就会真的被回收了。

执行对象的 finalize() 方法时,会将该对象放置到一个名为 F-Queue 的队列之中,虚拟机会创建一个低优先级的 Finalizer 线程去执行曲烈中的 finalize() 方法。因为 finalize() 方法可能执行缓慢甚至死循环,所以该执行队列只会去触发 finalize() 方法,但无法承诺会等待它执行结束。

需要注意对象的 finalize() 方法只会被触发执行一次,如果已经执行过了后面不会再触发执行。finalize 已被官方明确声明为不推荐使用的语法,其存在是为了从 C/C++ 衔接过来,关闭外部资源等清理类工作,完全可以在 try-finally 块中更好的完成。

回收方法区

方法区的回收因条件比较苛刻,通常性价比较低,主要回收两部分内容:废弃的常量和不再使用的类型。

回收废弃的常量与回收 Java 堆中的对象非常类似,也是基于可达性分析来判断。

回收一个类型时需要同时满足:

  1. 该类型的所有实例都已经被回收,也就是 Java 堆上不存在该类及其任何派生子类的实例
  2. 加载该类的类加载器已经被回收,该条件除非是经过精心设计的可替换类加载器的场景如 OSGi, JSP 的重加载等,否则通常很难达成的
  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

虚拟机被允许对满足上述条件的类型进行回收,但仅仅是“被允许”,不是必然。HotSpot 虚拟机提供了 -Xnoclassgc 参数进行控制是否要对类型进行回收。

在大量使用反射、动态代理、CGLib 等字节码框架,动态生成 JSP 以及 OSGi 这类频繁自定义类加载器的场景中,通常都需要 Java 虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。

垃圾收集算法

从如何判定对象消亡的角度出发,垃圾收集算法可以划分为“引用计数式垃圾收集”(Reference Counting GC)和“追踪式垃圾收集”(Tracing GC)两大类。这两类也常被称为“直接垃圾收集”和“间接垃圾收集”。

分代收集理论

分代收集是一套符合大多数程序运行实际情况的经验法则,建立在两个分代假说之上:

  1. 弱分代假说(Weak Generational Hypothesis):绝大多数对象的存在时间都很短
  2. 强分代假说(Strong Generational Hypothesis):经过越多次垃圾收集后仍存活的对象越难回收

    有点像二八原则,80% 的对象只存活了 20% 的时间。

上述两个假设奠定了分代收集理论的设计原则:收集器应该将 Java 堆划分出不同的区域,然后将回收对象根据年龄分配到不同的区域中存储。对象的年龄是对象经历过的垃圾收集过程的次数。

如果一个区域中大多数对象生存时间都很短,很难存活到下次 GC,那么把它们集中放到一起,每次回收时只关注如何保留少量存活而不是去标记那些大佬将要被回收的对象,就能以较低代价回收到大量的空间;相反,如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可用较低的频率来回收这个区域;这样就同时兼顾了垃圾收集的时间开销和内存空间的有效利用。

有点局部性原理的意思。

在 Java 堆划分出不同的区域之后,垃圾收集器才可以每次只回收某一个或某些区域——因而才有了 “Minor GC”,“Major GC”,“Full GC” 这样的类型划分;也才能够针对不同的区域安排与里面存储对象的存活特征相匹配的垃圾收集算法(比如“标记-复制算法”,“标记-清除算法”,“标记-整理算法”)。

商用 JVM 里面至少会把堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域。但分代收集并非至少简单收集一下内存区域这么简单,它存在一个明显的问题:对象不是孤立的,对象之间会存在跨代引用。

假设要进行一次新生代预期内的回收(Minor GC),但新生代中的对象是完全可能被老年代所引用,反过来也一样。因此为了找出新生代中不可达的对象除了从 GC Roots 遍历之外,还需要在老年代中遍历一遍来进行可达性分析。但是这无疑会带来很大的性能负担。为此引出了分代收集理论的第三条假说:

  1. 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。

基于这条假说,只需在新生代上建立一个全局的数据结构“记忆集”(Remembered Set),这个结构把老年代划分成若干块,标识出老年代的哪一块内存会存在跨代引用。当发生 Minor GC 时,只有包含了跨代引用的内存块才会加入到 GC Roots 进行扫描,从而使得比扫描整个老年代节省了时间和性能开销。

部分收集(Partial GC):目标收集的不是完整的 Java 堆,分为:

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

其存在缺点:

标记清除算法是最基础的算法,后续的收集算法大多以其为基础改进而来。

标记-复制算法(半区复制,Semispace Copying)

将可用的存容量划分为大小相等的两块,每次只使用其中的一块。当某一块内存用完了,就将还存活的对象复制到另一块,然后再把已使用过的内存空间一次清理掉。

现在的商用虚拟机大多都优先采用了标记-复制算法,因为新生代中的对象有绝大部分熬不过第一轮 GC。

改进版的标记-复制算法:Appel 式回收

具体做法是把新生代分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存只使用 Eden 和其中一块 Survivor。发送垃圾收集时,将 Eden 和 Survivor 中仍需存活的对象一次性复制到另一块 Survivor 空间上,然后直接清理掉 Eden 和已用过的那块 Survivor 空间。

HotSpot 虚拟机默认的 Eden 和 Survivor 的大小比例是 8:1,也即每次新生代中可用内存空间为整个新生代容量的 90%。但假如要存活的对象容量超过了单个 Survivor 空间(如10%),此时就需要将超出的部分复制到其他安全的区域(实际上大多由老年代来存放)。

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

标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率会降低。更关键的是,如果不想浪费 50% 的空间,就需要有额外的空间进行担保分配,以应对内存块中所有对象都 100% 存活的极端情况。因此老年代不能直接选用该算法。

针对老年代的存活特征,其标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存块的一端移动,然后直接清理掉边界之外的内存。

是否移动回收后存活的对象是一项优缺点并存的风险决策:

从整个程序的吞吐量来看,标记回收时移动对象会更划算。因为内存碎片化导致的内存分配的额外开销已经超过了移动对象时的开销。

还有一种妥协的解决方案,让虚拟机多数时间都采用标记-清除算法,允许存在内存碎片,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。CMS 收集器采用的就是这种方式。

通常标记-清除算法也是需要暂停用户线程来标记、清理可回收对象的,只是停顿时间相对较短。
最新的 ZGC 和 Shenandoah 收集器使用读屏障(Read Barrier)技术实现了整理过程与用户线程的并发执行。

HotSpot 的算法细节实现

根节点枚举

固定可作为 GC Roots 的节点主要在全局性的引用与执行上下文中,但要做到高效查找并不容易。

目前所有收集器在根节点枚举时都必须暂停用户线程,或者必须在一个能保障一致性的快照中(即用户线程看到的只有一个镜像,其引用关系不会再变化)。

主流 Java 虚拟机使用的都是准确式内存(确切地知道内存上存储的数据是什么类型)、准确式垃圾收集。虚拟机有办法直接得到哪些地方存放着对象引用。在 HotSpot 虚拟机中使用一组称为 OopMap 的数据结构来达到这个目的。一旦类加载完成的时候,HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来,同时即时编译过程中,方法栈上和寄存器里哪些位置是引用也会在特定的位置(安全点)记录下来,这样在收集 GC Roots 时就可以直接得知这些信息,并不需要遍历一遍查找。

Oop(Ordinary Object Pointer),普通对象指针。

安全点

OopMap 能协助虚拟机快速准确地完成 GC Roots 枚举,但是运行时可能导致 OopMap 引用关系变化的指令非常多,但不可能为每条指令都生成 OopMap,那样垃圾收集伴随而来的空间成本就会很高。

并不是在代码的任意位置都可以暂停下来进行 GC,而是必须执行到了特定的位置,即安全点(Safepoint),才有可能暂停下来执行 GC。安全点的选择不能太少以至于让 GC 等待时间太久,也不能太多以至于增加了运行时的内存压力。

安全点位置的选取基以“是否具有让程序长时间执行的特征”为标准,一般为指令序列的复用,如方法调用、循环跳转、异常跳转等。

安全点还需要考虑让尽可能多的线程都靠近,有两种方案可供选择:抢先试中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)。

抢先式中断不需线程执行代码去配合,在 GC 发生时系统会把所有用户线程都中断,如果发现部分线程不在安全点上,就回复其运行直到下一次中断时其处在安全点上。现在几乎没有虚拟机采用该种实现了。

主动式中断的思想是当GC 需要中断线程时,不直接对线程操作,而是去设置一个标志位,各线程在执行过程中会在安全点去主动轮询该标志位,发现其为中断标志时就地主动挂起。轮询操作需要足够高效,在 HotSpot 虚拟机中使用内存陷阱的方式将其精简到只有一条汇编指令,其会产生一个自陷异常信号,可在预先注册的异常处理器中挂起该线程。

安全区域

安全点保证了程序在执行时,在不太长的时间内就会遇到可进入 GC 的安全点。但是程序在没有被分配 CPU 时间的时候,线程无法响应虚拟机的中断请求,此时无法走到安全点。由此引入了安全区域(Safe Region)来解决。

安全区域是指能够在某一段代码片段之中,引用关系不会发生变化。因此在安全区域中任意地方开始 GC 都是安全的,安全区域是安全点的延伸。

当线程进入代码片段的安全区域时,首先会标识线程已经进入了安全区域,这样当 GC 开始时虚拟机就可以略过这些线程。当处在安全区域的线程要离开安全区域时,会会判断当前是否完成了 GC Roots 的枚举,会等到完成之后才会离开安全区。

记忆集与卡表

跨代引用时为了避免扫描整个分代区域,引入了存储该区域跨代引用关系的记忆集。所有涉及部分区域回收行为的 GC 收集器都有着类似的设计。

卡表(Card Table)是记忆集的一种具体实现,它的记录精确到一块内存区域,表示该区域内有对象含有跨代指针。卡表最简单的形式可以只是一个字节数组,HotSpot 虚拟机的实现也确实如此。

CARD_TABLE [this address >> 9] = 0;

卡表(CARD_TABLE)数组的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作卡页(Card Page)。如上述 HotSpot 中的卡页大小就是 2^9。卡表中标志位为 1 时表示内存块为 Dirty 即存在跨代引用,GC Roots 枚举时会将该内存块一并扫描。

此处使用 byte 数组而不是 bit 数组主要是速度上的考量,因为现代计算机硬件都是最小按 byte 寻址的,没有直接存储一个 bit 的指令,所以如果要用 bit 操作的话反而需要多执行几条 shift+mask 指令。

写屏障

记忆集和卡表解决了如果缩减GC Roots 扫描范围的问题,但还没有解决卡表元素如何维护的问题。

即时编译后的代码已经是纯粹的机器码了,需要找到一个在机器码层面的手段,以在对象有引用赋值的时候更新卡表,这就引出了 HotSpot 虚拟机里的写屏障(Write Barrier),相当于在“引用类型字段赋值”这个动作上加了一个 AOP 切面,在引用对象赋值时会产生一个环形(Around)通知,供 JVM 执行额外的动作。

赋值的前后都出触发写屏障,分为写前屏障(Pre-Write Barrier,相当于前置切面)和写后屏障(Post-Write Barrier,相当于后置切面),类似于按需订阅。

除了写屏障的开销外,卡表在高并发场景下还面临着”伪共享“(False Sharing)问题。即当多个线程的变量刚好共享同一个缓存行(Cache Line)时,会彼此影响而导致性能降低(写回、无效化或者同步)。一种简单的解决方案是在更新卡表之前先检查是否已标记过,减少不必要的写屏障操作。

并发的可达性分析

可达性分析算法理论上要在一个能保障一致性的快照中才能够进行分析。

三色标记法:

用户线程和收集器在并发工作时,必须要隔离它们对对象访问图上的修改。为此,我们需要将两种更新操作隔离开来:

然后在并发扫描结束之后,再将这些更新操作记录的引用关系重新扫描。虚拟机对更新操作的隔离都是通过写屏障来实现的。

经典垃圾收集器

收集算法是内存回收的方法论,垃圾收集器是内存回收的实现者。

Young generation GC: Serial, ParNew, Parallel Scavenge Old generation GC: CMS(Concurrent Mark Sweep), Serial Old(MSC), Parallel Old Mix generation GC: G1(Garbage First)

Serial 收集器

出现最早的收集器,是一个单线程工作的收集器,在垃圾收集时必须暂停其他所有工作线程,直到它运行结束。”Stop The World”由此而来。

新生代收集算法:复制算法 老年代收集算法:标记-整理算法

Serial 收集器至今依然是 HotSpot 虚拟机运行在客户端模式下的默认新生代收集器,它的特点就是简单而高效。

它是所有收集器里额外内存消耗最小的。在用户场景分配内存不多,且回收新生代内存不大的场景下,Serial 收集器的停顿时间可以 控制在十几毫秒最多一百多毫秒内,不频繁 GC 的情况下许多用户都是可以接受的。

ParNew 收集器

ParNew 收集器实质上是 Serial 收集器的多线程并行版本。其在垃圾收集时同样需要暂停其他所有工作线程,只是会使用多条 GC 线程并行收集新生代。ParNew 默认会开启的 GC 线程数与处理器核心数相同。

新生代收集算法:复制算法 老年代收集算法:标记-整理算法

除了 Serial 收集器外,目前只有它能与 CMS 收集器配合工作。

CMS 是第一款真正意义上支持并发的垃圾收集器,它首次实现了让垃圾收集线程与用户线程同时工作,伴随着 JDK 5 发布。但遗憾的是,CMS 作为老年代的收集器,却无法和 JDK 14.0 中已存在的新生代收集器 Parallel Scavenge 配合工作。所以在 JDK 5 中使用 CMS 来收集老年代的时候,新生代只能选择 Serial/ParNew。

G1 是一个面向全堆的收集器,不再需要其他新生代收集器的配合。所以从 JDK 9 开始,ParNew + CMS 的组合就不再是官方推荐的了。

Parallel Scavenge 和 G2 收集器没有使用 HotSpot 中原本设计的垃圾收集器的分代框架,而是选择另外独立实现。Serial 和 ParNew 收集器则公用了这部分分代设计的框架代码。

Parallel Scavenge 收集器

同样基于 标记-复制 算法,也能够多线程并行收集,和 ParNew 非常相似,但其特点是它的关注点不同,CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量。而吞吐量用处理器运行用户代码的时间与处理器总消耗时间的比值来衡量。

停顿时间越短,就越适合需要与用户交互或需要保证服务响应质量的程序,能提升用户体验。

而高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。

Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的 -XX: MaxGCPauseMills 参数,以及直接设置吞吐量大小的 -XX: GCTimeRatio 参数。

Parallel Scavenge 收集器也经常被称作“吞吐量优先收集器”,它还有一个参数 -XX: +UseAdaptiveSizePolicy 可以自适应调节新生代的大小。

Serial Old 收集器

Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用 标记-整理 算法,主要供客户端模式下的 HotSpot 虚拟机使用。

Parallel Old 收集器

Parallel Old 是 Parallel Scavenge 收集器的老年代版本,支持多线程并发收集,基于标记-整理算法。

在注重吞吐量或者处理器资源较为稀缺的场合,可以优先考虑 Parallel Scavenge(新生代) + Parallel Old(老年代)这个组合。

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,基于标记-清除算法,整个过程分为四个步骤:

1)初始标记(CMS initial mark)

初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要”Stop The World”。

2)并发标记(CMS concurrent mark)

从 GC Roots 的直接关联对象开始遍历整个对象图,耗时较长,不需要停顿用户线程,可与垃圾收集线程一起并发运行。

3)重新标记(CMS remark)

修正并发标记期间,因用户程序继续运作而导致标记产生变动的那部分对象的标记记录,需要”Stop The World“。

3)并发清除(CMS concurrent sweep)

并发清除标记为可回收的对象,不需要移动存活对象,该阶段可以与用户线程并发运行。

CMS 执行回收过程中,耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程并行工作,所以整体上 CMS 收集器的内存回收过程是与用户线程一起并发执行的。

CMS 的优点在于并发收集、低停顿。缺点有:

Garbage First 收集器

Garbage First(简称 G1)开创了收集器向局部收集的设计思路和基于 Region 的内存布局形式。

G1 是一款主要面向服务端应用的垃圾收集器,在 JDK9 发布之后G1取代Parallel Scavenge+Parallel Old的组合,成为服务端模式下的默认收集器,而 CMS 则被声明为不推荐使用的收集器。

作为 CMS 收集器的替代者和继承人,设计者们希望做出一款能够简历起”停顿时间模型“(Pause Prediction Model)的收集器,停顿时间模型是指能够支持在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过 N 毫秒这样的目标。

G1 收集器的回手集(Collection Set)是以哪块内存中存放的垃圾数量、回收收益来衡量的,这就是 G1 的 Mixed GC 模式。

G1 开创的基于 Region 的堆内存布局是它能够实现这个目标的关键。G1 不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每个 Region 都可以根据需要,扮演新生代的 Eden、Survivor、老年代空间。收集器可以针对不同角色的 Region 采用不同的策略去处理。

Region 中有一类特殊的 Humongous 区域,专门用来存储大对象,大对象会被存放在 N 个连续的 Humongous Region 之中。G1 认为超过 Region 容量一半的对象即为大对象。

G1 仍然保留了新声嗲和老年代的概念,但是它们是一系列不需要连续的动态集合。G1 单次回收的最小单元是 Region,每次收集的内存空间都是 Region 的整数倍。具体思路是让 G1 收集器去跟踪各个 Region 里面的垃圾堆积的”价值“大小,即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(默认值是 200ms),优先处理回收价值收益最大的那些 Region,这也正是”Garbage First”名称的由来。

这种使用 Region 划分内存空间,以及具有优先级的区域回收方式,保证了 G1 收集器在有限的时间内获取尽可能搞的收集效率。

G1 在实现上有几个需要关注的细节问题:

1)如何处理 Region 里面存在的跨 Region 的引用对象?

使用记忆集Remembered Set)避免全队作为 GC Roots 扫描,不过在 G1 收集器上记忆集的实现稍复杂些,具体是一种”双向“的卡表结构,记录了”我指向谁“,也记录了”谁指向我“。因此 G1 收集器要比其他的传统收集器有着更高的内存占用负担。根据经验,G1 至少要耗费大约相当于 Java 堆容量 10%~20% 的额外内存来维持收集器工作。

2)如何保证收集线程和用户线程并行执行时对引用对象的关系更新?

CMS 收集器是采用增量更新算法实现的,而 G1 则是通过原始快照(SATB)算法来实现的。在并发回收时新分配的对象会被存放在 Region 的两个名为 TAMS(Top at Mark Start)的指针地址以上,新分配的对象被 G1 在本轮回收时默认为是存活的,所以如果回收速度跟不上分配速度,也会存在导致 Full GC 而产生长时间的”Stop The World”。

除去在计算用户线程运行过程中使用写屏障维护记忆集的操作,G1 收集器的大致步骤如下:

1)初始标记(Initial Marking)

仅仅是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值以便在下一阶段并发运行时能正确分配新对象在 TAMS 指针的位置。初始标记节点需要 Stop The World。

2)并发标记(Concurrent Marking)

从 GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,耗时较长,可与用户线程并发执行。当对象图扫描完以后,还要重新处理 SATM 记录下的在并发时有引用变动的对象。

3)最终标记(Final Marking)

对用户线程做一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。

4)筛选回收(Live Data Couting and Evacuation)

负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可自由选择任意个 Region 构成回手集,然后把回收集 Region 中的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。该阶段必须暂停用户线程,由多条收集线程并行执行。

G1 收集器除了并发标记阶段外,其他阶段都是需要暂停用户线程的,就是为了在设定的目标延迟下获得尽可能高的吞吐量。

G1 的期望停顿时间设置要符合实际,一般一两百毫秒或者两三百毫秒会是比较合理的。

从 G1 开始,最先进的垃圾收集器都设计为追求能够应付应用的分配速率,而不追求一次把整个 Java 堆都清理干净。

G1 收集器整体上是基于”标记-整理“算法,但在局部上看又是基于”标记-复制“算法实现的,这两种算法都意味着 G1 运行期间不会产生内存碎片,有利于程序长时间运行,在分配大对象时不会因为找不到可用内存而触发 GC。

在一般的经验上,小内存应用上 CMS 的表现大概率要优于 G1,而在大内存应用上 G1 则大多能发挥其优势,这个Java 堆内存的平衡点通常在 6~8GB 之间。

低延迟垃圾收集器

衡量垃圾收集器的三项最重要的指标是:内存占用(Footprint)、吞吐量(Throughtput)和延迟(Latency),但是要在这三个方面同时具有卓越表现的“完美”收集器是及其困难甚至是不可能的,一款优秀的收集器通常最多可以达到其中两项。

在内存占用、吞吐量和延迟这三项指标里,延迟的重要性日益突出。随着硬件技术的增长,内存会增多,吞吐量会上升,但是延迟则不是。

最后的两款收集器,Shenandoah 和 ZGC 在几乎整个工作过程中都是并发,只有初始标记、最终标记这些阶段有短暂的停顿,而且这部分的停顿时间基本是固定的,与堆的容量、堆中对象的数量没有正比关系。这两款收集器目前还处于试验阶段,被官方命名为“低延迟收集器”(Low-Latency Garbage Collector)。