JVM 垃圾回收器分类

当我们在讨论垃圾回收器时,往往也会涉及到很多的概念;譬如并行(Parallel)与并发(Concurrent)、Minor GC 与 Major/Full GC。

  • 并行指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态;并发指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个 CPU 上。
  • Minor GC 指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快;Major GC 指发生在老年代的 GC,出现了 Major GC,经常会伴随至少一次的 Minor GC(但非绝对的,在 Parallel Scavenge 收集器的收集策略里就有直接进行 Major GC 的策略选择过程),Major GC 的速度一般会比 Minor GC 慢 10 倍以上。

从不同角度分析垃圾回收器,可以将其分为不同的类型:

  • 线程数:分为串行垃圾回收器和并行垃圾回收器。
    • 串行垃圾回收器一次只使用一个线程进行垃圾回收;
    • 并行垃圾回收器一次将开启多个线程同时进行垃圾回收。在并行能力较强的 CPU 上,使用并行垃圾回收器可以缩短 GC 的停顿时间;
  • 工作模式:分为并发式垃圾回收器和独占式垃圾回收器。
    • 并发式垃圾回收器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间;
    • 独占式垃圾回收器 (Stop the world) 一旦运行,就停止应用程序中的其他所有线程,直到垃圾回收过程完全结束;
  • 碎片处理方式:分为压缩式垃圾回收器和非压缩式垃圾回收器。
    • 压缩式垃圾回收器会在回收完成后,对存活对象进行压缩整理,消除回收后的碎片;
    • 非压缩式的垃圾回收器不进行这步操作;
  • 工作的内存区间:新生代垃圾回收器和老年代垃圾回收器。

垃圾回收器的衡量指标

我们最常用的评价垃圾回收器的指标就是吞吐量与停顿时间:

  • 停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户的体验;
  • 高吞吐量则可以最高效率地利用 CPU 时间,尽快地完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务;

具体的指标列举如下:

  • 吞吐量:指在应用程序的生命周期内,应用程序所花费的时间和系统总运行时间的比值。系统总运行时间=应用程序耗时+GC 耗时。如果系统运行了 100min,GC 耗时 1min,那么系统的吞吐量就是 (100-1)/100=99%;
  • 垃圾回收器负载:和吞吐量相反,垃圾回收器负载指来记回收器耗时与系统运行总时间的比值;
  • 停顿时间:指垃圾回收器正在运行时,应用程序的暂停时间。对于独占回收器而言,停顿时间可能会比较长。使用并发的回收器时,由于垃圾回收器和应用程序交替运行,程序的停顿时间会变短,但是,由于其效率很可能不如独占垃圾回收器,故系统的吞吐量可能会较低;
  • 垃圾回收频率:指垃圾回收器多长时间会运行一次。一般来说,对于固定的应用而言,垃圾回收器的频率应该是越低越好。通常增大堆空间可以有效降低垃圾回收发生的频率,但是可能会增加回收产生的停顿时间;
  • 反应时间:指当一个对象被称为垃圾后多长时间内,它所占据的内存空间会被释放;
  • 堆分配:不同的垃圾回收器对堆内存的分配方式可能是不同的。一个良好的垃圾回收器应该有一个合理的堆内存区间划分;

在对象查找算法的帮助下我们可以找到内存可以被使用的,或者说那些内存是可以回收,更多的时候我们肯定愿意做更少的事情达到同样的目的。

JVM 垃圾回收器对比

1999 年随 JDK1.3.1 一起来的是串行方式的 Serial GC,它是第一款垃圾回收器;此后,JDK1.4 和 J2SE1.3 相继发布。2002 年 2 月 26 日,J2SE1.4 发布;Parallel GC 和 Concurrent Mark Sweep (CMS)GC 跟随 JDK1.4.2 一起发布,并且 Parallel GC 在 JDK6 之后成为 HotSpot 默认 GC。这三个垃圾回收器也是各有千秋,Serial GC 适合最小化地使用内存和并行开销的场景、Parallel GC 适合最大化应用程序吞吐量的场景、CMS GC 适合最小化中断或停顿时间的场景。上图即展示了多种垃圾回收器之间的关系;不过随着应用程序所应对的业务越来越庞大、复杂,用户越来越多,没有合适的回收器就不能保证应用程序正常进行,而经常造成 STW 停顿的回收器又跟不上实际的需求,所以才会不断地尝试对搜集器进行优化。Garbage First(G1)GC 正是面向这种业务需求所生,它是一个并行回收器,把堆内存分割为很多不相关的区间(Region);每个区间可以属于老年代或者年轻代,并且每个年龄代区间可以是物理上不连续的。

名称 作用域 算法 特性 设置
Serial Serial GC 作用于新生代,Serial Old GC 作用于老年代垃圾收集 二者皆采用了串行回收与 "Stop-the-World",Serial 使用的是复制算法,而 Serial Old 使用的是电俄式-标记压缩算法 基于串行回收的垃圾回收器适用于大多数对于暂停时间要求不高的 Client 模式下的 JVM 使用-XX:+UserSerialGC 手动指定使用 Serial 回收器执行内存回收任务
Throughput/Parallel Parallel 作用于新生代,Parallel Old 作用于老年代 并行回收和 "Stop-the-World",Parallel 使用的是复制算法,Parallel Old 使用的是标记-压缩算法 程序吞吐量优先的应用场景中,在 Server 模式下内存回收的性能较为不错 使用-XX:+UseParallelGC 手动指定使用 Parallel 回收器执行内存回收任务
CMS,Concurrent-Mark-Sweep 老年代垃圾回收器,又称作Mostly-Concurrent 回收器 使用了标记清除算法,分为初始标记( Initial-Mark,Stop-the-World )、并发标记( Concurrent-Mark )、再次标记( Remark,Stop-the-World )、并发清除( Concurrent-Sweep ) 并发低延迟,吞吐量较低。经过 CMS 收集的堆会产生空间碎片,会带来堆内存的浪费 使用-XX:+UseConcMarkSweepGC 来手动指定使用 CMS 回收器执行内存回收任务
G1,Garbage First 没有采用传统物理隔离的新生代和老年代的布局方式,仅仅以逻辑上划分为新生代和老年代,选择的将 Java 堆区划分为 2048 个大小相同的独立 Region 块 使用了标记压缩算法 基于并行和并发、低延迟以及暂停时间更加可控的区域化分代式服务器类型的垃圾回收器 使用-XX:UseG1GC 来手动指定使用 G1 回收器执行内存回收任务

关于标记阶段有几个关键点是值得注意的:

  • 开始进行标记前,需要先暂停应用线程,否则如果对象图一直在变化的话是无法真正去遍历它的。暂停应用线程以便 JVM 可以尽情地收拾家务的这种情况又被称之为安全点(Safe Point),这会触发一次 Stop The World(STW)暂停。触发安全点的原因有许多,但最常见的应该就是垃圾回收了。
  • 暂停时间的长短并不取决于堆内对象的多少也不是堆的大小,而是存活对象的多少。因此,调高堆的大小并不会影响到标记阶段的时间长短。

当标记阶段完成后,GC 开始进入下一阶段,删除不可达对象。

Serial GC

串行回收器主要有两个特点:第一,它仅仅使用单线程进行垃圾回收;第二,它独占式的垃圾回收。在串行回收器进行垃圾回收时,Java 应用程序中的线程都需要暂停,等待垃圾回收的完成,这样给用户体验造成较差效果。虽然如此,串行回收器却是一个成熟、经过长时间生产环境考验的极为高效的 回收器。新生代串行处理器使用复制算法,实现相对简单,逻辑处理特别高效,且没有线程切换的开销。在诸如单 CPU 处理器或者较小的应用内存等硬件平台不是特别优越的场合,它的性能表现可以超过并行回收器和并发回收器。在 HotSpot 虚拟机中,使用 -XX:+UseSerialGC 参数可以指定使用新生代串行回收器和老年代串行回收器。当 JVM 在 Client 模式下运行时,它是默认的垃圾回收器。老年代串行回收器使用的是标记-压缩算法。和新生代串行回收器一样,它也是一个串行的、独占式的垃圾回收器。由于老年代垃圾回收通常会使用比新生代垃圾回 收更长的时间,因此,在堆空间较大的应用程序中,一旦老年代串行回收器启动,应用程序很可能会因此停顿几秒甚至更长时间。虽然如此,老年代串行回收器可以 和多种新生代回收器配合使用,同时它也可以作为 CMS 回收器的备用回收器。若要启用老年代串行回收器,可以尝试使用以下参数:-XX:+UseSerialGC: 新生代、老年代都使用串行回收器。

Serial GC 的工作步骤如下所示:

Serial GC 工作步骤

Parallel GC

Parallel Scavenge 收集器的特点是它的关注点与其他收集器不同,CMS 等收集器的关注点尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput)。Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器是在 JDK 1.6 中才开始提供的。使用 -XX:+UseParallelOldGC 可以在新生代和老生代都使用并行回收回收器,这是一对非常关注吞吐量的垃圾回收器组合,在对吞吐量敏感的系统中,可以考虑使用。参数 -XX:ParallelGCThreads 也可以用于设置垃圾回收时的线程数量。

Parallel GC 的工作步骤如下所示:

Parallel GC 工作步骤

CMS GC

CMS(Concurrent Mark-Sweep)是以牺牲吞吐量为代价来获得最短回收停顿时间的垃圾回收器,适用于对停顿比较敏感,并且有相对较多存活时间较长的对象(老年代较大)的应用程序;不过 CMS 虽然减少了回收的停顿时间,但是降低了堆空间的利用率。CMS GC 采用了 Mark-Sweep 算法,因此经过 CMS 收集的堆会产生空间碎片;为了解决堆空间浪费问题,CMS 回收器不再采用简单的指针指向一块可用堆空间来为下次对象分配使用。而是把一些未分配的空间汇总成一个列表,当 JVM 分配对象空间的时候,会搜索这个列表找到足够大的空间来存放住这个对象。另一方面,由于 CMS 线程和应用程序线程并发执行,CMS GC 需要更多的 CPU 资源。同时,因为 CMS 标记阶段应用程序的线程还是在执行的,那么就会有堆空间继续分配的情况,为了保证在 CMS 回收完堆之前还有空间分配给正在运行的应用程序,必须预留一部分空间。也就是说,CMS 不会在老年代满的时候才开始收集。相反,它会尝试更早的开始收集,已避免上面提到的情况:在回收完成之前,堆没有足够空间分配!默认当老年代使用 68%的时候,CMS 就开始行动了。– XX:CMSInitiatingOccupancyFraction =n 来设置这个阀值。

CMS GC 工作步骤如下所示:

CMS GC 工作步骤

  • 初始标记(STW initial mark):在这个阶段,需要虚拟机停顿正在执行的任务,官方的叫法 STW(Stop The Word)。这个过程从垃圾回收的"根对象"开始,只扫描到能够和"根对象"直接关联的对象,并作标记。所以这个过程虽然暂停了整个 JVM,但是很快就完成了。
  • 并发标记(Concurrent marking):这个阶段紧随初始标记阶段,在初始标记的基础上继续向下追溯标记。并发标记阶段,应用程序的线程和并发标记的线程并发执行,所以用户不会感受到停顿。
  • 并发预清理(Concurrent precleaning):并发预清理阶段仍然是并发的。在这个阶段,虚拟机查找在执行并发标记阶段新进入老年代的对象(可能会有一些对象从新生代晋升到老年代,或者有一些对象被分配到老年代)。通过重新扫描,减少下一个阶段"重新标记"的工作,因为下一个阶段会 Stop The World。
  • 重新标记(STW remark):这个阶段会暂停虚拟机,回收器线程扫描在 CMS 堆中剩余的对象。扫描从"跟对象"开始向下追溯,并处理对象关联。
  • 并发清理(Concurrent sweeping):清理垃圾对象,这个阶段回收器线程和应用程序线程并发执行。
  • 并发重置(Concurrent reset):这个阶段,重置 CMS 回收器的数据结构,等待下一次垃圾回收。

G1 GC

G1 GC 是 JDK 1.7 中正式投入使用的用于取代 CMS 的压缩回收器,它虽然没有在物理上隔断新生代与老生代,但是仍然属于分代垃圾回收器;G1 GC 仍然会区分年轻代与老年代,年轻代依然分有 Eden 区与 Survivor 区。G1 GC 首先将堆分为大小相等的 Region,避免全区域的垃圾收集,然后追踪每个 Region 垃圾堆积的价值大小,在后台维护一个优先列表,根据允许的收集时间优先回收价值最大的 Region;同时 G1 GC 采用 Remembered Set 来存放 Region 之间的对象引用以及其他回收器中的新生代与老年代之间的对象引用,从而避免全堆扫描。G1 GC 的分区示例如下图所示:

随着 G1 GC 的出现,Java 垃圾回收器通过引入 Region 的概念,从传统的连续堆内存布局设计,逐步走向了物理上不连续但是逻辑上依旧连续的内存块;这样我们能够将某个 Region 动态地分配给 Eden、Survivor、老年代、大对象空间、空闲区间等任意一个。每个 Region 都有一个关联的 Remembered Set(简称 RS),RS 的数据结构是 Hash 表,里面的数据是 Card Table (堆中每 512byte 映射在 card table 1byte)。简单的说 RS 里面存在的是 Region 中存活对象的指针。当 Region 中数据发生变化时,首先反映到 Card Table 中的一个或多个 Card 上,RS 通过扫描内部的 Card Table 得知 Region 中内存使用情况和存活对象。在使用 Region 过程中,如果 Region 被填满了,分配内存的线程会重新选择一个新的 Region,空闲 Region 被组织到一个基于链表的数据结构(LinkedList)里面,这样可以快速找到新的 Region。

总结而言,G1 GC 的特性如下:

  • 并行性:G1 在回收期间,可以有多个 GC 线程同时工作,有效利用多核计算能力;
  • 并发性:G1 拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况;
  • 分代 GC:G1 依然是一个分代回收器,但是和之前的各类回收器不同,它同时兼顾年轻代和老年代。对比其他回收器,或者工作在年轻代,或者工作在老年代;
  • 空间整理:G1 在回收过程中,会进行适当的对象移动,不像 CMS 只是简单地标记清理对象。在若干次 GC 后,CMS 必须进行一次碎片整理。而 G1 不同,它每次回收都会有效地复制对象,减少空间碎片,进而提升内部循环速度。
  • 可预见性:为了缩短停顿时间,G1 建立可预存停顿的模型,这样在用户设置的停顿时间范围内,G1 会选择适当的区域进行收集,确保停顿时间不超过用户指定时间。

G1 GC 的工作步骤如下所示:

G1 GC 工作步骤

  • 初始标记(标记一下 GC Roots 能直接关联的对象并修改 TAMS 值,需要 STW 但耗时很短)
  • 并发标记(从 GC Root 从堆中对象进行可达性分析找存活的对象,耗时较长但可以与用户线程并发执行)
  • 最终标记(为了修正并发标记期间产生变动的那一部分标记记录,这一期间的变化记录在 Remembered Set Log 里,然后合并到 Remembered Set 里,该阶段需要 STW 但是可并行执行)
  • 筛选回收(对各个 Region 回收价值排序,根据用户期望的 GC 停顿时间制定回收计划来回收);

ZGC

与标记对象的传统算法相比,ZGC 在指针上做标记,在访问指针时加入 Load Barrier(读屏障),比如当对象正被 GC 移动,指针上的颜色就会不对,这个屏障就会先把指针更新为有效地址再返回,也就是,永远只有单个对象读取时有概率被减速,而不存在为了保持应用与 GC 一致而粗暴整体的 Stop The World。

下一节:GC 从其底层实现方式(即 GC 算法)来看,大体可以分为两大类:基于可达性分析的 GC 和基于引用计数法的 GC。当然,这样的分类也不是绝对的,很多现代 GC 的设计就融合了引用计数和可达性分析两种。