掘金 后端 ( ) • 2024-06-16 18:00

垃圾回收器简介

上文我们介绍了垃圾回收的一些基本概念以及算法,本文我们介绍一下垃圾回收的具体实现,也就是各种各样的垃圾收集器。

一、垃圾回收器(Collectors)

JVM提供了多种垃圾收集器,应对不同场景,本文仅介绍官方(HotSpot)支持的垃圾收集器。

1.1 串行垃圾收集器(Serial Collector)

串行垃圾收集器,顾名思义,采用单线程的垃圾收集器,在年轻代使用标记复制算法,老年代使用标记整理算法。这是一个比较古老的垃圾收集器了,你可能觉得它没什么用了。但其实是在特定场景下,依然是个不错的选择。

  1. 单核机器,多线程实际上也不会提升性能(反而会下降)。
  2. 小堆(小于100M),单线程足够了,没必要多线程。

可以使用-XX:+UseSerialGC,指定使用串行垃圾收集器。

1.2 并行垃圾收集器(Parallel Collector)

可以简单理解为是串行垃圾收集器(Serial Collector)的并行版本,也是在年轻代使用标记复制算法,老年代使用标记整理算法。主要区别,就是他在垃圾回收时,是并行处理的,可以更好的利用多处理器的性能。

并行垃圾收集器也被称之为吞吐量优先(throughput collector)的垃圾处理器。也就是说,并行垃圾收集器有着较好的吞吐量。

🤔在GC回收场景下,如何定义吞吐量呢 所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值。

吞吐量 = 运行用户代码的时/处理器总消耗时间

一些重要参数参数

  • -XX:+UseParallelGC 开启使用并行垃圾收集器;
  • -XX:ParallelGCThreads=N 并行垃圾收集的线程数,默认当机器核数小于8时,与机器核数相同,核数>8时,等于核数的5/8.
    • 增加并发线程数,可以使GC暂停时间缩小,但也会产生更多的内存碎片:因为在年轻代回收,部分对象晋升到老年代时,会先向老年代堆空间预定一些空间作为复制到老年代的存活对象的缓冲池,这样的分配最终会产生一些内存碎片。因减少并行收集器的线程数或者增大老年代堆空间的大小会降低内存碎片给应用带来的影响。
  • -XX:MaxGCPauseMillis=N,最大暂停时间的目标,单位毫秒,这里的停顿时间,
  • -XX:GCTimeRatio=N,设置吞吐量目标,是垃圾回收时间与用户程序运行时间的比值。例如N=19,代表垃圾回收时间:用户运行时间目标为1:19,吞吐量为95%。默认N=99,也就是吞吐量99%。
  • -Xmx:指定最大内存,并行垃圾收集器有个隐含目标,就是满足以上要求的情况下,使用堆内存尽可能的小。
    • 然而,实践中,我们一般把-Xmx、-Xms(最大最小堆)设置成一致的,防止应用在启动时不断GC扩容。例如-Xmx1g -Xms1g :堆大小固定为1G。

并行垃圾收集器在每次垃圾回收后,统计垃圾回收时间&吞吐量(System.gc()触发的不算在内)。如果没有达到指定目标,则会调整年轻代/老年代比例和堆大小,来实现可控的停顿&吞吐量。

  • 如果停顿时间没有达到预期,则缩小没有达到预期的那一代的比例,即年轻代回收停顿没有达到预期,就缩小年轻代,老年代达不到,就缩小老年代,都达不到,那就缩小时间较长的那一代(不出意外时间较长的就是老年代)。
  • 吞吐量不达标,则可以通过增加堆大小来实现,堆太小无法发挥并发收集的优势。

🤔两个目标,哪个更优先呢 可以看到,停顿时间与吞吐量的目标是矛盾的,堆越小,停顿时间越少,堆越大,理论吞吐量越高。此时,最大停顿时间的优先级更高一些。

🤔如果我指定堆大小 & 年轻代老年代的比例呢 实践中,一般会通过-Xmx<N> -Xms<N>指定堆大小,防止应用启动时频繁GC扩容。有时也会根据应用的特性,直接通过-XX:NewRatio=n指定年轻代/老年代的比例。而并行垃圾收集器通过调整年轻代/老年代比例和整个堆大小,来实现可控的停顿&吞吐量。也就是说,在这种设定情况下,指定停顿时间&吞吐量,垃圾回收器也没有办法自动调整。

🙄古早的版本(JDK1.5以及以前),并行垃圾收集器不支持老年代回收

  • 年轻代 :Parallel Scavenge JDK1.4+
  • 老年代 :Parallel Old JDK1.6+

可以看到在JDK1.5以及之前,并行收集器仅支持年轻代垃圾回收,只能与串行垃圾收集器(负责老年代),配合使用,也就有点鸡肋。不过这都是古早的事情了,不用太在意。

参考:The Parallel Collector

1.3 CMS(Concurrent Mark Sweep Collector)

CMS(Concurrent Mark Sweep),从JDK5开始支持,从名字中,可以看出他的实现:

  • Concurrent(并发):并发执行,部分回收操作是与用户线程并发执行的。
  • Mark Sweep(标记清除):采用标记清除算法,注意CMS是一款老年代垃圾回收器,需要与ParNew收集器(负责年轻代)一同使用。
    • 注意这个ParNew与前文的Parallel Collector不是一个垃圾回收器,ParNew也是并行,不支持停顿时间、吞吐量的目标参数,然而Parallel Collector与CMS无法组合使用。

CMS是一款侧重低停顿的垃圾回收器。与吞吐量相比,这也是我们大部分应用程序更看重的指标。侧重低停顿的垃圾收集器,都是靠把耗时部分垃圾回收的操作,与用户线程并行而实现的,CMS当然也是如此,它的整个过程分为四个步骤:

  1. 初始标记:需要停止用户线程(stop the world),不过只是标记一下GC Roots能直接关联到的对象,速度很快;
  2. 并发标记:与用户线程并发执行,接着初始标记的根对象继续向下标记,识别一个对象是否为垃圾,耗时较长,不过是并发的;
  3. 重新标记:需要停止用户线程,修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
  4. 并发清除:清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

CMS的大部分耗时的流程,与用户线程并发,因此可以做到较低的停顿,但他也有一些问题:

  1. Concurrent Mode Failure(并发模式失败),CMS必须在老年代还有一定空间时,就开启垃圾回收的流程,因为在垃圾回收过程中,用户线程还会产生垃圾。CMS会根据历史经验,估算一个比例,达到这个比例就开启回收流程。另外,也会在比例到达-XX:CMSInitiatingOccupancyFraction=N参数指定的比例后,开启回收,默认比例为92%(不同版本可能不同)。如果正在垃圾回收过程中,老年代就已经不不足,并发模式就无法持续,也就是Concurrent Mode Failure,而触发一次由串行垃圾收集器(Serial Collector)执行的Full GC,造成长时间的停顿。
  2. 内存碎片:CMS使用标记清除算法,无法避免的产生内存碎片,进而带来麻烦:
    • 内存分配变复杂,较慢
    • 无法提供足够的连续内存空间,导致内存分配失败,触发Full GC。

🤔有哪些优化手段呢

  • 适当调低-XX:CMSInitiatingOccupancyFraction=N,降低Concurrent Mode Failure发生的可能性。
  • 通过-XX:ConcGCThreads=N(默认为(处理器核心数量 +3)/4),增加CMS并发线程数,以加快垃圾回收速度。然而这会影响用户线程,需要谨慎设置。
  • 通过-Xmn-XX:NewRatio=n等参数,增大老年代空间。
  • 可以通过-XX:+UseCMS-CompactAtFullCollection=N设置多少次Full GC才会触发一次压缩。默认为0,即每次Full GC都会触发内存压缩。注意这是一个饮鸩止渴的参数,虽然每次不压缩的Full GC的停顿会短一些,但内存碎片会更严重。

可以通过-XX:+UseConcMarkSweepGC指定使用CMS垃圾收集器。

注:CMS在JDK9中已经被标记为废弃,由G1替代,个人建议,在JDK8以后使用G1替代。

参考 concurrent_mark_sweep_cms_collector JVM垃圾收集器详解之CMS

1.4 G1(Garbage-First Collector)

G1垃圾收集器,在JDK7开始支持,在JDK8.40后提补齐并发的类卸载功能,趋于成熟。在JDK9中,已经作为默认的垃圾收集器了。它可以看做是CMS的继任者,在JDK9中,CMS已经被标记为废弃了。

作为CMS的继任者,G1也是一款侧重低停顿的垃圾收集器,更确切的说,是建立了一套“停顿时间模型”(Pause Prediction Model)的收集器,尽量在指定的(-XX:MaxGCPauseMillis=N,单位ms)时间内,完成垃圾收集。当然,也不能太离谱,通常是几百ms。

1.4.1 堆内存布局

G1垃圾收集器,与之前的各种垃圾收集器,最大的不同是,它并不是把堆简单的分为年轻代和老年代,而是切分成一块块固定大小的区域(Region),每个区域大小为1MB~32MB,且应为2的N次幂。每块区域可以根据需求扮演年轻代或者老年代。大小超过区域一半的对象,即被判定为大对象,存放大对象的区域被称之为Humongous区域,逻辑上属于老年代的一种,G1会对此进行特殊的优化。

image.png

上图为官网中的内存分布示意图,红色为年轻代,标记S的为年轻代的survivor区域。蓝色为老年代,H为Humongous区域。

1.4.2 垃圾回收

G1的年轻代垃圾回收,与其他垃圾回收器类似,也是标记复制算法。

但是在老年代回收上,则有非常大不同。他并不会一次性回收所有的老年代,而是根据垃圾回收的代价,选择部分老年代 + 年轻代,进行回收,称之为mixed gc。这也是G1可以尽量在给定的时间内完成垃圾回收的关键。

与CMS最大的区别是,G1采用的垃圾回收算法不同,从局部来说G1是使用的标记复制法,把存活对象从一个Region复制到另外的Region,但从整个堆来说G1的逻辑又相当于是标记整理法。不管从局部还是整体上,G1都不会产生内碎片。

关于G1,本文仅做简单的介绍,详细内容,会在后面的系列文章中分析。

参考:

1.5 ZGC

ZGC是官方推出的最新的一款垃圾收集器,在JDK11中作为试验特性推出,在JDK15中,已经不再是实验功能,可以正式投入生产使用了。它也是一款侧重于低延时的垃圾回收器,官方给出的停顿时间是,小于10ms,在JDK16以后,更是提升到,令人震惊的小于1ms,因此也被称之为亚秒级的垃圾回收器。

zgc_stop_time.png

1.5.1 堆内存布局

ZGC与G1一样,也把堆划分为多个灵活的区域,这些区域被称为Zpage。然而,与G1不同的是,ZGC对Zpage的划分更加精细,这样就可以实现更灵活的内存分配策略:

  • 小区域(Small Zpage):每个小区域的大小为2MB,专门用于存放大小不超过256KB的小型对象
  • 中区域(Medium Zpage):每个中区域的大小为32MB,用于存放大小在256KB到4MB之间的中等大小对象。
  • 大区域(Large Zpage):大区域的大小则根据实际需求动态分配,每个大区域只保存一个大于4MB的大型对象,但区域大小必须是2的整数倍。也就是说,虽然叫做大区域,但实际大小可能比中区域还小,最小可以是4MB.

1.5.2 垃圾回收算法

ZGC采用的是标记复制+整理的算法(JDK16+),是不是很神奇?!其实也简单,就是如果还有足够的剩余空间,就复制,否则为整理算法。

  • 复制算法更简单高效,但需要预留空间,ZGC在JDK16之前,就是复制算法,如果预留空间不足,则会抛出OutOfMemoryError异常。
  • 整理算法不需要额外空间,但更复杂,通常会带来一些开销。例如,移动对象对象的顺序很重要,否则可能会覆盖尚未移动的对象。这需要GC线程之间更多的协作,不利于并行处理。

1.5.3 自愈指针

ZGC能够实现这么短的停顿时间,主要依靠“自愈指针”,可以实现并行的垃圾回收。我们先回顾一下,之前的垃圾回收器是怎么做的。

  • CMS,采用标记清除算法,仅清除“死”的对象,不移动存活的对象,因此可以做到垃圾回收与用户线程并发,但带来了内存碎片。
  • G1,采用标记复制算法,垃圾回收时,需要复制存活对象,需要修改存活对象的引用,不得不停止用户线程。

ZGC采用的是复制+整理的算法,但居然做到了,与用户线程并发。主要靠的就是这个“自愈指针”。在垃圾回收时,在把一个对象转移到了新位置,会在指针上,打上标记,并维护一个转发表,记录新老位置的映射。当用户线程此时并发访问了这个被转移的对象,发现了该对象已经被转移,会根据转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self- Healing)能力。

🤔具体如何实现的呢 在后续文章中详细介绍。

参考:

二、垃圾回收器选择

上文介绍了官方提供的垃圾收集器,下面我们讨论一下该如何选择。

2.1 衡量垃圾收集器的指标

不同垃圾收集器,有着不同的侧重点,衡量一款垃圾收集器,有以下指标。

  1. 吞吐量:处理器用于运行用户代码的时间与处理器总消耗时间的比值。比如并发垃圾收集器,就拥有比较好的吞吐量。
  2. 停顿时间:垃圾回收时,需要暂停用户线程的时间。较新的垃圾收集器,都更侧重低停顿,比如CMS/G1/ZGC。
  3. 内存占用:额外的内存空间占用。为了更好的垃圾回收,垃圾收集器会占用些额外的空间,用于辅助垃圾回收。比如G1垃圾收集器,通常会额外占用整个堆的10%的内存,甚至更多。

2.2 该选择哪种垃圾收集器

该选择哪种垃圾收集器呢,受影响因素很多:

  • 应用场景是什么,更在意吞吐量呢,还是停顿时间;
  • 使用的JDK的版本是多少,支持哪些垃圾收集器;
  • 机器配置怎么样,有多少内存可以分配给虚拟机;

以下就个人经验,提供一些垃圾收集器的几条建议。

  1. 🤔 应用的侧重点是什么,高吞吐 or 低停顿?
    • 科学计算,比较在意吞吐量,不在意停顿时间,可以使用并行垃圾收集器(Parallel Collector);
    • 桌面甚至是嵌入式应用,堆很小(<100mb),机器性能差,这种场景下,再高端的垃圾收集器,也无用武之地(甚至效果很差),就用最古老的串行垃圾收集器(Serial Collector)即可。
    • 面向用户的应用(一般场景),这一般都更在乎停顿,用户无法接受较高的延迟,那就使用CMS/G1/ZGC。
  2. 🤔 使用JDK的版本是什么,都支持哪些垃圾收集器。升级JDK的成本比较高,一般要进行回归测试,除非使用的JDK版本已经很老了,无法满足需求,一般不会升级JDK。这时,就需要注意,不要选择还不稳定的垃圾收集器。
    • G1,在JDK7开始支持,在JDK8.40后提补齐并发的类卸载功能,趋于成熟。因此JDK18.40以上,才建议使用G1。
    • ZGC,在JDK11中作为试验特性推出,在JDK15中,已经不再是实验功能,可以正式投入生产使用,在JDK16中做了很多优化,JDK21中支持了分代回收。因此JDK15以上,才建议使用ZGC,可以更新到JDK16更好。
  3. 🤔 CMS VS G1,在JDK8下,我们可能会纠结是使用G1呢,还是CMS呢
    • 堆大小:G1使用标记复制算法,需要额外的空间,在较大的堆上表现较好,超过6G,建议使用G1,否则CMS。
    • 大对象:CMS的一个大问题就是内存碎片,因此如果你的应用中,经常有大字符串,或者大数组的申请,建议使用G1。因为G1对大对象有一定优化。而CMS存在内存碎片,经常的大对象申请,可能会因为内存碎片问题,无法申请到连续的内存而退化到FULL GC。

以上仅仅是理论,具体该选择哪款垃圾收集器,还是要结合实践分析。

X.参考

  1. Available Collectors
  2. Java HotSpot VM Options
  3. Java中垃圾回收器GC对吞吐量的影响测试