从零开始的JVM学习--垃圾收集器

137 阅读16分钟

简单介绍

什么是垃圾收集器

什么是垃圾收集器?

如果说垃圾收集算法是内存回收的方法论,那么「垃圾收集器」就是内存回收的具体实现或者说是内存回收的执行者

「垃圾收集器」本身并没有优劣之分,我们需要做的是根据具体场景选择合适的「垃圾收集器」。

于是本篇重点在介绍各种「垃圾收集器」的特点,并且介绍它们适合的应用场景和原因。

安全点(SafePoint)

什么是SafePoint?

程序执行时并非在所有地方都能停顿下来开始GC,只有在到达SafePoint时才能暂停

SafePoint的选定既不能太少以至于让GC等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。

基本上是以程序「是否具有让程序长时间执行的特征」为标准进行选定的。

「长时间执行」的最明显特征就是「指令序列复用」(例如方法调用、循环跳转、异常跳转等)。所以具有这些功能的指令才会产生Safepoint

SafePoint的目的并不是让其他线程停下,而是找到一个稳定的执行状态在这个执行状态下,JVM的堆栈不会发生变化。这样垃圾回收器便能够“安全”地执行「可达性分析」。只要不离开这个SafePointJVM便能够在垃圾回收的同时,继续运行这段本地代码。

如何在 SafePoint 停顿?

对于SafePoint,另一个需要考虑的问题就是如何在GC发生时让所有线程(这里不包括执行JNI调用的线程)都“跑”到最近的SafePoint上再停顿下来。

两种解决方案:

  • 抢先式中断(Preemptive Suspension)

    「抢先式中断」不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在SafePoint上,就恢复线程,让它“跑”到SafePoint上。

    现在几乎没有虚拟机采用这种方式来暂停线程从而响应GC事件。

  • 主动式中断(Voluntary Suspension)

    「主动式中断」的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志。各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。

    「轮询标志的地方」和SafePoint是重合的,另外再加上创建对象需要分配内存的地方。

垃圾收集器

本章会提及几个算法包括但不限于「复制算法」,「标记-整理算法」,「标记清除算法」。

这些内容都在我的博客:从零开始的JVM学习--GC中介绍。

新生代收集器

Serial

什么是Serial?

image.png

Serial(串行)是一个单线程的垃圾收集器,只开启一条 GC 线程进行垃圾回收,并且在垃圾收集过程中停止一切用户线程( Stop The World)。

由于 Serial 收集器只使用一条 GC 线程,避免了线程切换的开销,从而简单高效。

Serial在新生代使用「复制算法」。

适用场景

Serial 垃圾收集器适合Client场景下使用。

Serial简单而高效,Serial 收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。

一般「客户端」应用所需内存较小,不会创建太多对象,而且堆内存不大,因此垃圾收集器回收时间短,即使在这段时间停止一切用户线程,也不会感觉明显卡顿。

Serial收集一两百兆垃圾的停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿时间是可以接受的。

ParNew

什么是ParNew?

image.png ParNewSerial 的多线程版本。由多条 GC 线程并行地进行垃圾清理。但清理过程依然需要 Stop The World

ParNew 追求「低停顿时间」,与 Serial 唯一区别就是使用了多线程进行垃圾收集,在多 CPU 环境下性能比 Serial 会有一定程度的提升;但线程切换需要额外的开销,因此在单 CPU 环境中表现不如 Serial

ParNew在新生代使用「复制算法」。

适用场景

ParNewServer 场景下默认的新生代收集器。 (停顿时间越短就越适合与用户交互的程序,良好的相应速度能提升用户体验)

除了Serial收集器,只有ParNew可以和CMS收集器配合使用。

根据上面的介绍我们知道ParNew适合多核CPU环境。并且ParNew 追求低停顿,因此ParNew适合交互式应用

Parallel Scavenge

什么是Parallel Scavenge?

Parallel ScavengeParNew 一样,都是多线程、新生代垃圾收集器。但是这两者的目的是不一样的:

  • Parallel Scavenge 追求 「CPU 吞吐量」

    「高吞吐量」则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务。

  • ParNew 追求「降低用户停顿时间」

    「降低用户停顿时间」是以牺牲吞吐量和新生代空间来换取的。(新生代空间变小,垃圾回收变得频繁,导致吞吐量下降)

吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)

提高吞吐量的利弊

追求「高吞吐量」,可以减少 GC 执行实际工作的时间,高效率的利用CPU的时间

然而,仅仅偶尔运行 GC 意味着每当 GC 运行时将有许多工作要做,因为在此期间积累在堆中的对象数量很高。

单个 GC 需要花更多的时间来完成,从而导致更高的「暂停时间」。而考虑到「低暂停时间」,最好频繁运行 GC 以便更快速完成,反过来又导致「吞吐量」下降。

停顿时间和吞吐量相关参数

  • -XX:GCTimeRadio 设置垃圾回收时间占总 CPU 时间的百分比。
  • -XX:MaxGCPauseMillis 设置垃圾处理过程最久停顿时间。
  • -XX:+UseAdaptiveSizePolicy 开启自适应策略。我们只要设置好堆的大小和 MaxGCPauseMillisGCTimeRadio,收集器会自动调整新生代的大小、EdenSurvivor 的比例、对象进入老年代的年龄,以最大程度上接近我们设置的 MaxGCPauseMillisGCTimeRadio

适用场景

Parallel Scavenge 追求 CPU 吞吐量,能够在较短时间内完成指定任务,因此适合没有交互的后台计算

老年代收集器

Serial Old

什么是Serial Old?

image.png

Serial Old 收集器是 Serial 的老年代版本,都是单线程收集器,只启用一条 GC 线程。

Serial Old 工作在老年代,使用「标记-整理算法」,Serial 工作在新生代,使用「复制算法」。

适用场景

上一个标题已经介绍了Serial Old 不过是Serial的老年代版本,并且在Client场景下一般是两个一起搭配适用的。

所以Serial Old适合Client场景的。

如果是在Server场景下也有两个应用场景:

  • 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
  • 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

Parallel Old

什么是Parallel Old?

image-20221012141255739

Parallel Old 收集器是 Parallel Scavenge 的老年代版本,都追求 CPU 吞吐量。

适用场景

注重吞吐量以及 CPU 资源敏感的场景下,可以优先考虑 Parallel Scavenge + Parallel Old

CMS

什么是CMS?

CMS(Concurrent Mark Sweep,并发标记清除)收集器是以获取最短回收停顿时间为目标的收集器(追求低停顿)

CMS在垃圾收集时使得用户线程和 GC 线程并发执行,因此在垃圾收集过程中用户也不会感到明显的卡顿。

Mark Sweep指的就是「标记-清除算法」,所以可以清楚CMS是基于「标记-清除算法」的。

CMS 的执行流程

下图标成蓝色的是CMS线程:

image.png

  • 初始标记(CMS initial mark)

    Stop The World,仅使用一条初始标记线程对所有与 GC Roots 直接关联的对象进行标记。

  • 并发标记(CMS concurrent mark)

    不需要停顿,使用多条标记线程,与用户线程并发执行。此过程进行可达性分析,标记出所有废弃对象。速度很慢。

  • 重新标记(CMS remark)

    Stop The World,使用多条标记线程并发执行,将刚才并发标记过程中新出现的废弃对象标记出来。

  • 并发清除(CMS concurrent sweep)

    不需要停顿,只使用一条 GC 线程,与用户线程并发执行,清除刚才标记的对象。这个过程非常耗时。

为什么说 CMS 可以做到与应用程序线程并发执行?

根本原因在于采用基于「标记-清除」的算法并对算法过程进行了细粒度的分解。以流水线方式拆分了收集周期,将耗时长的操作单元保持与应用线程并发执行。

并发标记与并发清除过程耗时最长,而这个过程中收集器线程可以与用户线程一起工作。因此总体上说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。

CMS 的优点

并发收集,低停顿。

CMS 的缺点

  • 吞吐量低

    「低停顿时间」是以牺牲「吞吐量」为代价的,导致 CPU 利用率不够高。

  • 无法处理浮动垃圾

    可能出现 Concurrent Mode Failure。「浮动垃圾」是指「并发清除」阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于「浮动垃圾」的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。

    如果预留的内存不够存放「浮动垃圾」,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS

  • 导致频繁 Full GC

    「标记 - 清除算法」导致的内存碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC

对于产生内存的问题,可以通过开启 -XX:+UseCMSCompactAtFullCollection,在每次 Full GC 完成后都会进行一次「内存压缩整理」,将零散在各处的对象整理到一块。

设置参数 -XX:CMSFullGCsBeforeCompaction 告诉 CMS,经过了 N 次 Full GC 之后再进行一次内存整理。

适用场景

CMS以获取最短回收停顿时间为目标,非常符合在注重用户体验的应用上使用。

通用收集器

Garbage First(G1)

什么是G1?

G1 收集器以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。

HotSpot 开发团队赋予G1的使命是未来可以替换掉 CMS 收集器。

G1没有新生代和老年代的概念,而是将堆划分为一块块独立的 Region(重新定义堆空间) 。也因此我们把G1分类成「通用收集器」。

  • 什么是Region?

    image-20221012191936482

    通过引入 Region 的概念,原来一整块内存空间划分成多个的小空间(新生代和老年代不再物理隔离),使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。

    Region的大小是一致的,数值是在1M到32M字节之间的一个2的幂值数,JVM会尽量划分2048个左右、同等大小的Region

    通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个「优先列表」,每次根据允许的收集时间,优先回收价值最大的 Region

  • 为什么需要Region?

    这么做的目的是在进行收集时不必在全堆范围内进行,这是它最显著的特点。

    区域划分的好处就是带来了「停顿时间可预测的收集模型」(用户可以指定收集操作在多长时间内完成)。可以说G1提供了接近实时的收集特性。

    G1收集器之所以能建立「停顿时间可预测的收集模型」,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集

    G1 会通过一个合理的计算模型,计算出每个Region的收集成本并量化。这样收集器在给定了“停顿”时间限制的情况下,总是能选择一组恰当的Regions作为收集目标,让其收集开销满足这个限制条件,以此达到实时收集的目的。

    由于每次都是回收价值最大的Region,因此可以获得最大的回收效率。

    由于侧重点在于回收最大垃圾量的Region这个特点让G1获得了Garbage First(垃圾优先)的名字。

  • 一个对象和它内部所引用的对象可能不在同一个 Region 中,那么当垃圾回收时,是否需要扫描整个堆内存才能完整地进行一次可达性分析?

    并不!每个 Region 都有一个 Remembered Set,用于记录本区域中所有对象引用的对象所在的区域,进行可达性分析时,只要在 GC Roots中再加上 Remembered Set 即可防止对整个堆内存进行遍历。

    关于Remembered Set的内容我在我的博客:从零开始的JVM学习--GC的「分代收集理论」章节中有有介绍

G1的特点

  • 并行与并发

    G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop The World的时间。

    部分其他收集器原来需要停顿Java线程执行的GC操作,G1收集器仍然可以通过并发的方式让Java程序继续运行。

  • 分代收集

    从分代上看,G1依然属于分代型垃圾回收器,它会区分年轻代和老年代,年轻代依然有Eden区和Survivor区。

    但从堆的结构上看,它不要求整个Eden区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。

    将堆空间分为若干个区域(Region) , 这些区域中包含了逻辑上的年轻代和老年代。

    和之前的各类回收器不同,它同时兼顾年轻代和老年代。对比其他回收器,或者工作在年轻代,或者工作在老年代;

  • 空间整合

    从整体上看, G1 是基于「标记-整理算法」实现的收集器。

    从局部(两个 Region 之间)上看是基于「复制算法」实现的。

    但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。

    这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC

  • 可预测的停顿

    这是G1相对于CMS的一个优势,降低停顿时间是G1CMS共同的关注点。

G1收集器的工作流程

如果不计算维护 Remembered Set 的操作,G1 收集器的工作过程分为以下几个步骤:

  • 初始标记

    Stop The World,但是耗时很少。 仅使用一条初始标记线程对所有与 GC Roots「 直接关联」的对象进行标记。并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,

  • 并发标记

    不需要停顿。此过程进行「可达性分析」,找出存活对象。这阶段耗时较长,但可与用户程序并发执行。

  • 最终标记

    Stop The World,但是可以并行执行。 使用多条标记线程并发执行。是为了修正「并发标记」期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中

  • 筛选回收

    可以和用户线程并发执行。首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。 因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。

相关参数设置

  • -XX:+UseG1GC 指定使用G1收集器。
  • -XX:G1HeapRegionSize 设置每个Region的大小。值是2的幂,范围是1MB到32MB之间,目标是根据最小的Java堆大小划分出约2048个区域。默认是堆内存的1/2000。
  • -XX:MaxGCPauseMillis 设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到)。默认值是200ms
  • -XX:ParallelGCThread 设置STW工作线程数的值。最多设置为8
  • -XX:ConcGCThreads 设置并发标记的线程数。将n设置为并行垃圾回收线程数(ParallelGCThreads)的1/4左右。
  • -XX:InitiatingHeapOccupancyPercent 设置触发并发GC周期的Java堆占用率阈值。超过此值,就触发GC。默认值是45。

适用场景

G1是一款面向服务端应用的垃圾收集器。主要针对配备多核CPU,及大容量内存的机器。

如果发现以下特征可以考虑使用G1收集器以追求更佳性能:

  • 实时数据占用了超过半数的堆空间;
  • 对象分配率或“晋升”的速度变化明显;
  • 期望消除耗时较长的GC或停顿(超过0.5——1秒)。

小结

本篇文章介绍了最经典主流的几款垃圾收集器。根据工作的位置不同对它们进行了分类,并且根据工作特点介绍了它们各自的适用场景。

其他还有很多的垃圾收集器比如ShenandoahZGCAliGCZing... 但是由于它们不是那么主流和篇幅原因没有放到本章介绍,以后有机会我也会专门写博客介绍。

本文参考: