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

本文用的是 HotSpot 虚拟机,其垃圾收集器有 10 种,分别是:

  • Serial
  • ParNew
  • Parallel Scavenge
  • Serial Old
  • Parallel Old
  • CMS
  • G1(Garbage First)

低延迟收集器:

  • Shenandoah
  • ZGC

不清理垃圾的收集器:

  • Epsilon

P90 3-6

Serial(最经典)

Serial 是 HotSpot 中最经典的垃圾收集器。因为它是单线程收集器,因此其工作的时候一定要「Stop The World」。

其采取的收集策略是:新生代采用「标记-复制」算法,老年代采用「标记-整理」算法。

其优势是:简单而高效。因此 Serial 是 HotSpot 在客户端模式下的默认收集器。

ParNew(HotSpot 中第一款退出历史舞台的收集器)

ParNew 可以理解为是 Serial 的多线程并行版本。

并行(Parallel):多条垃圾收集器之间的关系。

并发(Concurrent):垃圾收集器线程与用户线程之间的关系。

Parallel Scavenge(吞吐量优先收集器)

也是基于「标记-复制」算法,多线程并行收集器,诸多特性类似 ParNew。

但是,它的关注点不是尽可能的缩短垃圾收集时用户线程的停顿时间,而是达到一个可控的吞吐量(Throughput)

假设,虚拟机执行完某个任务,用户代码加上垃圾收集的时间一共花了 100 分钟,垃圾收集花费了 1 分钟,那么吞吐量就是 99%。

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

相应参数:

-XX:MaxGCPauseMillis:控制最大垃圾收集停顿时间。

-XX:GCTimeRatio:设置吞吐量大小。

-XX:UseAdaptiveSizePolicy:开启后,虚拟机会根据当前系统的运行情况收集性能监控信息,动态的调整这些参数(-Xmn:新生代大小,-XX:SurvivorRatio:Eden 与 Survivor 区的比例,-XX:PretenureSizeThreshold:晋升老年代对象大小)以提供最适合的停顿时间或最大的吞吐量。

Serial Old

单线程收集器。使用「标记-整理」算法。

主要供客户端模式下的 HotSpot 使用。在服务端下有两种用途:1). 在 JDK 5 及之前的版本中与 Parallel Scavenge 搭配使用;2). 作为 CMS 收集器在并发收集中发生 Concurrent Mode Failure 错误时的替补方案。

Parallel Old

多线程并行收集器。使用「标记-整理」算法。

在注重吞吐量或者处理器资源较为紧缺的场合,都可以优先选择 Parallel Scavenge 加 Parallel Old 的组合。

CMS(Concurrent Mark Sweep,并发低停顿收集器)

以最短回收停顿时间为目标。基于「标记-清除」算法。

运行过程分为如下四个步骤:

  1. 初始标记(CMS initial mark),仅标记一下 GC Roots 可以关联到的对象,速度很快。
  2. 并发标记(CMS concurrent mark),从 GC Roots 的直接关联对象开始遍历整个对象图的过程。
  3. 重新标记(CMS remark),修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录(增量更新)。
  4. 并发清除(CMS concurrent sweep),清理删除掉标记阶段判断的已经死亡对象。

1、3 需要 Stop The World。

缺点:

  1. 处理器资源敏感。面向并发设计的程序的通病。其默认开启的回收线程数是:(处理器核心数量 + 3)/ 4
  2. 无法处理浮动垃圾(Floating Garbage),有可能出现 Concurrent Mode Faliure 失败,进而导致另一次完全 Stop The World 的 Full GC 产生。同样,在垃圾收集阶段用户线程还在运行,因此需要预留足够空间来存放用户线程产生的垃圾。可以通过 -XX:CMSItitiatingOccu-pancyFraction 参数来设置,当老年代空间占用率达到多少时开启垃圾回收(触发百分比)。这个参数的设定要根据实际环境来选择:太小,如果垃圾增长不是很快,就有点浪费空间;太大,容易出现 Concurrent Mode Faliure 异常,这时会启动后备预案,冻结用户线程执行,临时启用 Serial Old 来收集老年代的垃圾。
  3. 因为采用了「标记-清除」算法,因此会导致空间碎片过多。会给大对象分配带来麻烦,明明老年代总空间够,但是因为都是分散的所以不能用来分配给大对象,这会导致 Full GC。提供的两个相关参数 -XX:UseCMSCompactAtFullCollection-XX:CMSFullGCsBeforeCompaction 在 JDK 9 以后都废弃了。

CMS 已经被标为 Deprecate(不推荐使用)。

Garbage First(G1,全功能垃圾收集器)

G1 是垃圾收集器技术发展史上的里程碑式成果,开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式。其主要面向服务端,在 JDK 9 发布以后,已经取代了 Parallel Scavenge 加 Parallel Old 组合,成为服务端默认的收集器。

G1 收集器的设计者希望能做出一款「停顿预测模型」(Pause Prediction Mode)的收集器。停顿预测模型:能够支持指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过 N 毫秒的目标。

想要实现这样的目标,那就不能再面向分代或者是整个 Java 堆来收集垃圾了。G1 的做法是:可以面向堆内任何部分来组成回收集(Collection Set)进行回收。其衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是 G1 的 Mixed GC 模式。

在 Java 堆的划分上,G1 将整个堆空间划分成多个大小相等的独立区域(Region),每个 Region 可以扮演 Edin、Survivor 或老年代空间,Region 大小可用参数 -XX:G1HeapRegionSize 来设定。将 Region 看作是最小的回收单元,这样可以有计划的避免在整个 Java 堆中进行全区域的垃圾收集。

Region 中还有一类特殊区域:Humongous,专门用来存放大对象(超过一个 Region 容量的一半)。如果一个超大对象超过了一个 Region 的空间,那么会分配 N 个连续的 Humongous Region。G1 将 Humongous Region 作为老年代来对待。

具体回收思路是:G1 收集器去跟踪各个 Region 里面的垃圾堆积的「价值」(回收所获得的空间大小以及回收所需要的时间经验值)大小,然后维护一个优先级列表,每次根据用户设定的收集停顿时间(-XX:MaxGCPauseMillis,默认 200ms)优先处理回收价值收益最大的 Region,这也是 Garbage First 名称的由来。

同时,G1 也有一些细节需要妥善处理:

  • 如何处理跨 Region 引用对象?

使用记忆集(Remembered Set)。每个 Region 都要维护自己的记忆集。G1 的记忆集本质是一种哈希表,Key 是别的 Region 的起始地址(谁指向我),Value 是一个集合,里边存储的是卡表的索引号(我指向谁)。这是一种「双向」卡表结构。因为 Region 个数比传统分代多,同时是双向的,所以 G1 有更高的内存占用负担。

  • 在并发标记阶段如何保证收集线程与用户线程会不干扰?

采用原始快照(SATB)算法。另外,针对在 G1 运行过程中新创建的对象,G1 设立了两个 TAMS(Top At Mark Start)指针,把 Region 中一部分空间划分出来用于并发回收过程中的新对象分配。并发过程中新分配的对象的地址必须要在这两个 TAMS 指针位置以上。如果 G1 回收速度赶不上新对象的分配速度,那么会被迫冻结所有用户线程,执行一次 Full GC。

  • 怎样建立可靠的停顿预测模型?

使用「衰减均值」(Decaying Average)。衰减平均值:比普通的平均值更容易受到新数据的影响,更准确的代表最近的平均状态。Region 的统计状态越新越能决定其回收的价值。

G1 运行过程:

  1. 初始标记(Initial Marking)。仅标记一下 GC Roots 能直接关联的对象,并修改 TAMS 指针。
  2. 并发标记(Concurrent Marking)。从 GC Roots 开始进行可达性分析,递归的找出要回收对象。
  3. 最终标记(Final Marking)。短暂暂停用户线程,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。
  4. 筛选回收(Live Data Counting and Evacuation)。更新 Region 的统计数据,对 Region 的回收价值和成本进行排序,根据期望停顿时间来制定回收计划。将回收的 Region 中的存活对象复制到空的 Region 中,再清理掉整个旧 Region 全部空间。

其目标是:在延迟可控的基础上,尽可能提高吞吐量。

不足:内存占用(Footprint)和程序运行时的额外执行负载(Overload)都比较高。

Shenandoah

「免费开源版」比「收费商业版」功能更多,只在 OpenJDK 才会包含,OracleJDK 中不可用。

设计目标:能在任何堆内存大小下都可以把垃圾收集的停顿时间控制在十毫米以内

Shenandoah 类似是 G1 的继承者。但是也有一些不同:

  1. 支持并发的整理算法。
  2. 默认不使用分代收集。
  3. 废弃记忆集,使用「连接矩阵」(Connection Matrix)这种全局的数据结构来记录跨 Region 引用。可以简单理解为一张二维表格,如果 Region N 有对象指向 Region M,就在该表格的 N 行 M 列打上一个标记。

运行过程:

  1. 初始标记(Initial Mark)。标记 GC Roots 直接关联对象。
  2. 并发标记(Concurrent Marking)。遍历对象图,标记所有可达对象。
  3. 最终标记(Final Marking)。处理剩余的 SATB 扫描,并统计出回收价值最高的 Region。
  4. 并发清理(Concurrent Cleanup)。清理整个区域 0 存活的 Region(Immediate Garbage Region)。
  5. 并发回收(Concurrent Evacuation)。先将回收集里面的存活对象复制一份到其他未被使用的 Region 中。但在移动时,用户线程可能继续读写访问被移动对象,而且对象移动后,整个内存中所有指向该对象的引用还是旧对象的引用,很难一瞬间改过来。解决方法是,使用读屏障和「Brooks Pointers」的转发指针。
  6. 初始引用更新(Initial Update Reference)。并没有实际进行引用更新。设立这个阶段只是为了建立一个线程集合点,用来确保所有并发回收阶段中进行的收集器线程都已经完成分配给它们的对象移动任务。
  7. 并发引用更新(COncurrent Update Reference)。真正进行引用更新操作,只需要按照内存物理地址顺序,线性的搜索出引用类型,把旧值改为新值。
  8. 最终引用更新(Final Update Reference)。修正存在于 GC Roots 中的引用。
  9. 并发清理(Concurrent Cleanup)。回收 Region 空间。

重点是「并发标记」、「并发回收」和「并发引用更新」三个阶段。

P08 3-16

转发指针(Forwarding Pointer 或者 Indirection Pointer):一种用来实现对象移动与用户线程并发的解决方案。该方法不用需要使用需要操作系统层面支持的「内存保护陷阱」。而是在原有对象布局结构的最前面统一增加一个新的引用字段,在正常处于不并发移动的情况下,该对象指向对象自己。当需要并发移动时,只需要修改旧对象上转发指针的引用位置,使其指向新对象。

同时,必须要对转发指针的访问操作采取同步措施(尤其是在并发读写时),收集器线程和用户线程只有其中之一可以访问成功,另一个必须等待。Shenandoah 采用「比较并交换」(Compare And Swap,CAS)来保证并发时对象访问的正确性。

不足:

  • 因为为每一个对象都添加了转发指针,在「对象访问」时(Java 万事万物皆对象),都必须要访问该指针,因此需要注意「执行频率」的问题。同时为了覆盖全部对象访问操作,Shenandoah 不得不设置读、写访问屏障。
  • 读屏障的使用代价比写屏障要到,因为对象读取比写入频率要更大。为了缓解这个问题,Shenandoah 计划在 JDK 13 中使用「引用访问屏障」(Load Reference Barrier):内存屏障只拦截对象中数据类型为引用类型的读写操作。

ZGC(Z Garbage Collection)

与 Shenandoah 的目标高度相似,但是实现思路存在显著差异。

主要特征:基于 Region 内存布局的,(暂时)不设分代的(因此无跨代引用问题),使用了读屏障、染色指针和内存多重映射等技术来实现可并发「标记-整理」算法的,以低延迟为首要目标的一款垃圾收集器。

ZGC 的 Region 具有动态性——动态的创建和销毁,以及动态的区域容量大小。Region 区域分为小型、中型和大型三种。

  • 小型 Region(Small Region):容量固定为 2MB,用于存放小于 256KB 的小对象。
  • 中型 Region(Medium Region):容量固定为 32MB,用于存放大于等于 256KB 但小于 4MB 的对象。
  • 大型 Region(Large Region):容量不固定,可以动态变化,但必须是 2 的整数倍,用于存放 4MB 或以上的大对象。大型 Region 在 ZGC 中不会被重新分配。

ZGC 的核心问题:并发整理算法。ZGC 采用染色指针(Colored Pointer)直接把标记信息记录在引用对象的指针上,之前是遍历引用图来标记对象,现在可以说是遍历引用图来标记「引用」。

染色指针:一种将少量额外的信息存储在指针上的技术。64 位操作系统,实际只用了 4、50 位(架构不同,使用数量不同),剩下的高位地址并没有被使用到,不可以用来寻址。如果只用 46 位寻址,这样可以支持 64TB 的内存,但大部分的服务器都用不到这么大。因此 GC 就盯上了这 46 位地址的高 4 位,用来存储 4 个标志信息,分别是:是否只能通过 finalize() 方法才能被访问(Finalizable)、是否进入重分配集(Remapped)、三色标记(Marked 1 和 Marked 0)。

染色指针三大优势:

  1. 一旦某个 Region 的存活对象被移走之后,这个 Region 就能立即被清理解放和重用掉,不用等待堆中所有指向该 Region 的引用都被修正后才清理。这也是染色指针的「自愈」特性。
  2. 大幅减少垃圾收集过程中内存屏障的使用数量。
  3. 染色指针作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后能进一步提高性能。

实现染色指针在操作系统层面的难点:JVM 只是操作系统中一个普通的线程,那 JVM 对操作系统的地址空间的修改,能让操作系统正确的执行么?

在 Solaris/SPARC 平台中可以使用「虚拟地址掩码」,设置以后机器指令可以自动忽略掉染色指针的标记位。但在 x86-64 平台就需要用到「虚拟内存映射」技术。

ZGC 运行过程:

  1. 并发标记(Concurrent Mark)。遍历对象图做可达性分析,ZGC 会更新染色指针中的 Marked 0 和 Marked 1 标记位。
  2. 并发预备重分配(Concurrent Prepare for Relocate)。这个阶段根据特定的查询条件统计出本次收集过程中要清理哪些 Region。
  3. 并发重分配(Concurrent Reloca)。将重分配集中的存活对象复制到新的 Region 上,并为重分配集中的每个 Region 维护一张转发表(Forward Table)记录从旧对象到新对象的转向关系。得益于染色指针的使用,ZGC 可以仅从引用就明确得知一个对象是否处于重分配集中,如果此时用户线程访问了位于重分配集中的对象,这次访问就会被预置的内存屏障所截获,然后立即根据 Region 上的转发表记录将访问转发到新复制的对象上,并且同时修正更新该引用的值。ZGC 将这种行为称为指针的「自愈」(Self-Healing)。只有第一次访问就对象才会陷入转发。
  4. 并发重映射(Concurrent Remap)。修正整个堆中指向重分配集中旧对象的所有引用。该任务并不迫切,因为可以自愈。ZGC 巧妙的将并发重映射阶段的工作,合并到下一次垃圾收集循环中的并发标记阶段去做。因为它们都要遍历所有的对象,这样就可以省一次遍历。一旦所有指针被修正,转发表就可以释放了。

ZGC 的不足:因为不分代,所以承受的对象分配速率不会太高。

新一代垃圾回收器ZGC的探索与实践 - 美团技术团队

Epsilon

被形容为一个无操作的收集器(A No-Op Garbage Collector)。

垃圾收集器除了垃圾收集这个本职工作以外,还要负责堆的管理与布局对象的分配、与解释器的协作、与编译器的协作、与监控子系统协作等职责。其中堆的管理与布局和对象的分配是一个垃圾收集器必须具备的内容。

如果用户程序只需要运行数分钟甚至数秒的时间,只要 Java 虚拟机能正确分配内存,在堆耗尽之前就会退出,那么负载极小、没有任何回收行为的 Epsilon 便是很好的选择。