大多数 JVM 都需要使用两种不同的 GC 算法——一种用来清理年轻代,另一种用来清理老年代。
我们可以选择 JVM 内置的各种算法。如果不通过参数明确指定垃圾收集算法,则会使用相应 JDK 版本的默认实现。
常见垃圾收集器
串行 GC(Serial GC)
串行 GC 对年轻代使用 mark-copy(标记—复制)算法,对老年代使用 mark-sweep-compact(标记—清除—整理)算法。
两者都是单线程的垃圾收集器,不能进行并行处理,所以都会触发全线暂停(STW),停止所有的应用线程。
要启用此款收集器,只需要指定一个 JVM 启动参数即可,同时对年轻代和老年代生效:
1 | -XX:+UseSerialGC |
该选项只适合几百 MB 堆内存的 JVM,而且是单核 CPU 时比较有用
并行 GC(Parallel GC)
并行垃圾收集器这一类组合,在年轻代使用“标记—复制(mark-copy)算法”,在老年代使用“标记—清除—整理(mark-sweep-compact)算法”。年轻代和老年代的垃圾回收都会触发 STW 事件,暂停所有的应用线程来执行垃圾收集。两者在执行“标记和复制/整理”阶段时都使用多个线程,因此得名“Parallel”。通过并行执行,使得 GC 时间大幅减少。
通过命令行参数 -XX:ParallelGCThreads=NNN
来指定 GC 线程数,其默认值为 CPU 核心数。可以通过下面的任意一组命令行参数来指定并行 GC:
1 | -XX:+UseParallelGC |
并行垃圾收集器适用于多核服务器,主要目标是增加吞吐量。因为对系统资源的有效使用,能达到更高的吞吐量:
- 在 GC 期间,所有 CPU 内核都在并行清理垃圾,所以总暂停时间更短;
- 在两次 GC 周期的间隔期,没有 GC 线程在运行,不会消耗任何系统资源。
另一方面,因为此 GC 的所有阶段都不能中断,所以并行 GC 很容易出现长时间的卡顿(注:这里说的长时间也很短,一般来说例如 minor GC 是毫秒级别,full GC 是几十几百毫秒级别)。
如果系统的主要目标是最低的停顿时间/延迟,而不是整体的吞吐量最大,那么就应该选择其他垃圾收集器组合。
注:长时间卡顿的意思是,此 GC 启动之后,属于一次性完成所有操作,于是单次 暂停 的时间会较长。
CMS 垃圾收集器
CMS GC 的官方名称为 Mostly Concurrent Mark and Sweep Garbage Collector(最大并发—标记—清除—垃圾收集器)。其对年轻代采用并行 STW 方式的 mark-copy(标记—复制)算法,对老年代主要使用并发 mark-sweep(标记—清除)算法。
CMS GC 的设计目标是避免在老年代垃圾收集时出现长时间的卡顿,主要通过两种手段来达成此目标:
- 第一,不对老年代进行整理,而是使用空闲列表(free-lists)来管理内存空间的回收。
- 第二,在 mark-and-sweep(标记—清除)阶段的大部分工作和应用线程一起并发执行。
也就是说,在这些阶段并没有明显的应用线程暂停。但值得注意的是,它仍然和应用线程争抢 CPU 时间。默认情况下,CMS 使用的并发线程数等于 CPU 核心数的 1/4。
通过以下选项来指定 CMS 垃圾收集器:
1 | -XX:+UseConcMarkSweepGC |
如果服务器是多核 CPU,并且主要调优目标是降低 GC 停顿导致的系统延迟,那么使用 CMS 是个很明智的选择。通过减少每一次 GC 停顿的时间,很多时候会直接改善系统的用户体验。因为多数时候都有部分 CPU 资源被 GC 消耗,所以在 CPU 资源受限的情况下,CMS GC 会比并行 GC 的吞吐量差一些(对于绝大部分系统,这个吞吐和延迟的差别应该都不明显)。
在实际情况下,进行老年代的并发回收时,可能会伴随着多次年轻代的 minor GC。在这种情况下,full GC 的日志中就会掺杂着多次 minor GC 事件.
总之,CMS 垃圾收集器在减少停顿时间上做了很多复杂而有用的工作,用于垃圾回收的并发线程执行的同时,并不需要暂停应用线程。当然,CMS 也有一些缺点,其中最大的问题就是老年代内存碎片问题(因为不压缩),在某些情况下 GC 会造成不可预测的暂停时间,特别是堆内存较大的情况下。
G1 垃圾收集器
G1 的全称是 Garbage-First,意为垃圾优先,哪一块的垃圾最多就优先清理它。
G1 GC 最主要的设计目标是:将 STW 停顿的时间和分布,变成可预期且可配置的。
事实上,G1 GC 是一款软实时垃圾收集器,可以为其设置某项特定的性能指标。例如可以指定:在任意 xx 毫秒时间范围内,STW 停顿不得超过 yy 毫秒。举例说明:任意 1 秒内暂停时间不超过 5 毫秒。G1 GC 会尽力达成这个目标(有很大概率会满足,但并不完全确定)。
G1 GC 的特点
为了达成可预期停顿时间的指标,G1 GC 有一些独特的实现。
首先,堆不再分成年轻代和老年代,而是划分为多个(通常是 2048 个)可以存放对象的 小块堆区域(smaller heap regions)。每个小块,可能一会被定义成 Eden 区,一会被指定为 Survivor 区或者 Old 区。在逻辑上,所有的 Eden 区和 Survivor 区合起来就是年轻代,所有的 Old 区拼在一起那就是老年代,如下图所示:
这样划分之后,使得 G1 不必每次都去收集整个堆空间,而是以增量的方式来进行处理:每次只处理一部分内存块,称为此次 GC 的回收集(collection set)。每次 GC 暂停都会收集所有年轻代的内存块,但一般只包含部分老年代的内存块,见下图带对号的部分:
G1 的另一项创新是,在并发阶段估算每个小堆块存活对象的总数。构建回收集的原则是:垃圾最多的小块会被优先收集。这也是 G1 名称的由来。
通过以下选项来指定 G1 垃圾收集器:
1 | -XX:+UseG1GC -XX:MaxGCPauseMillis=50 |
G1 GC 常用参数设置
-XX:+UseG1GC
:启用 G1 GC,JDK 7 和 JDK 8 要求必须显示申请启动 G1 GC;-XX:G1NewSizePercent
:初始年轻代占整个 Java Heap 的大小,默认值为 5%;-XX:G1MaxNewSizePercent
:最大年轻代占整个 Java Heap 的大小,默认值为 60%;-XX:G1HeapRegionSize
:设置每个 Region 的大小,单位 MB,需要为 1、2、4、8、16、32 中的某个值,默认是堆内存的 1/2000。如果这个值设置比较大,那么大对象就可以进入 Region 了。-XX:ConcGCThreads
:与 Java 应用一起执行的 GC 线程数量,默认是 Java 线程的 1/4,减少这个参数的数值可能会提升并行回收的效率,提高系统内部吞吐量。如果这个数值过低,参与回收垃圾的线程不足,也会导致并行回收机制耗时加长。-XX:+InitiatingHeapOccupancyPercent
(简称 IHOP):G1 内部并行回收循环启动的阈值,默认为 Java Heap 的 45%。这个可以理解为老年代使用大于等于 45% 的时候,JVM 会启动垃圾回收。这个值非常重要,它决定了在什么时间启动老年代的并行回收。-XX:G1HeapWastePercent
:G1 停止回收的最小内存大小,默认是堆大小的 5%。GC 会收集所有的 Region 中的对象,但是如果下降到了 5%,就会停下来不再收集了。就是说,不必每次回收就把所有的垃圾都处理完,可以遗留少量的下次处理,这样也降低了单次消耗的时间。-XX:G1MixedGCCountTarget
:设置并行循环之后需要有多少个混合 GC 启动,默认值是 8 个。老年代 Regions 的回收时间通常比年轻代的收集时间要长一些。所以如果混合收集器比较多,可以允许 G1 延长老年代的收集时间。-XX:+G1PrintRegionLivenessInfo
:这个参数需要和-XX:+UnlockDiagnosticVMOptions
配合启动,打印 JVM 的调试信息,每个 Region 里的对象存活信息。-XX:G1ReservePercent
:G1 为了保留一些空间用于年代之间的提升,默认值是堆空间的 10%。因为大量执行回收的地方在年轻代(存活时间较短),所以如果你的应用里面有比较大的堆内存空间、比较多的大对象存活,这里需要保留一些内存。-XX:+G1SummarizeRSetStats
:这也是一个 VM 的调试信息。如果启用,会在 VM 退出的时候打印出 RSets 的详细总结信息。如果启用-XX:G1SummaryRSetStatsPeriod
参数,就会阶段性地打印 RSets 信息。-XX:+G1TraceConcRefinement
:这个也是一个 VM 的调试信息,如果启用,并行回收阶段的日志就会被详细打印出来。-XX:+GCTimeRatio
:大家知道,GC 的有些阶段是需要 Stop—the—World,即停止应用线程的。这个参数就是计算花在 Java 应用线程上和花在 GC 线程上的时间比率,默认是 9,跟新生代内存的分配比例一致。这个参数主要的目的是让用户可以控制花在应用上的时间,G1 的计算公式是 100/(1+GCTimeRatio)。这样如果参数设置为 9,则最多 10% 的时间会花在 GC 工作上面。Parallel GC 的默认值是 99,表示 1% 的时间被用在 GC 上面,这是因为 Parallel GC 贯穿整个 GC,而 G1 则根据 Region 来进行划分,不需要全局性扫描整个内存堆。-XX:+UseStringDeduplication
:手动开启 Java String 对象的去重工作,这个是 JDK8u20 版本之后新增的参数,主要用于相同 String 避免重复申请内存,节约 Region 的使用。-XX:MaxGCPauseMills
:预期 G1 每次执行 GC 操作的暂停时间,单位是毫秒,默认值是 200 毫秒,G1 会尽量保证控制在这个范围内。
这里面最重要的参数,就是:
-XX:+UseG1GC
:启用 G1 GC;-XX:+InitiatingHeapOccupancyPercent
:决定什么情况下发生 G1 GC;-XX:MaxGCPauseMills
:期望每次 GC 暂定的时间,比如我们设置为 50,则 G1 GC 会通过调节每次 GC 的操作时间,尽量让每次系统的 GC 停顿都在 50 上下浮动。如果某次 GC 时间超过 50ms,比如说 100ms,那么系统会自动在后面动态调整 GC 行为,围绕 50 毫秒浮动。
年轻代模式转移暂停(Evacuation Pause)
通过前面的分析可以看到,G1 GC 会通过前面一段时间的运行情况来不断的调整自己的回收策略和行为,以此来比较稳定地控制暂停时间。在应用程序刚启动时,G1 还没有采集到什么足够的信息,这时候就处于初始的 fully-young 模式。当年轻代空间用满后,应用线程会被暂停,年轻代内存块中的存活对象被拷贝到存活区。如果还没有存活区,则任意选择一部分空闲的内存块作为存活区。
拷贝的过程称为转移(Evacuation),这和前面介绍的其他年轻代收集器是一样的工作原理。
并发标记(Concurrent Marking)
同时我们也可以看到,G1 GC 的很多概念建立在 CMS 的基础上,所以下面的内容需要对 CMS 有一定的理解。
G1 并发标记的过程与 CMS 基本上是一样的。G1 的并发标记通过 Snapshot-At-The-Beginning(起始快照) 的方式,在标记阶段开始时记下所有的存活对象。即使在标记的同时又有一些变成了垃圾。通过对象的存活信息,可以构建出每个小堆块的存活状态,以便回收集能高效地进行选择。
这些信息在接下来的阶段会用来执行老年代区域的垃圾收集。
有两种情况是可以完全并发执行的:
- 如果在标记阶段确定某个小堆块中没有存活对象,只包含垃圾;
- 在 STW 转移暂停期间,同时包含垃圾和存活对象的老年代小堆块。
当堆内存的总体使用比例达到一定数值,就会触发并发标记。这个默认比例是 45%,但也可以通过 JVM 参数 InitiatingHeapOccupancyPercent 来设置。和 CMS 一样,G1 的并发标记也是由多个阶段组成,其中一些阶段是完全并发的,还有一些阶段则会暂停应用线程。
转移暂停:混合模式(Evacuation Pause(mixed))
并发标记完成之后,G1 将执行一次混合收集(mixed collection),就是不只清理年轻代,还将一部分老年代区域也加入到 回收集 中。混合模式的转移暂停不一定紧跟并发标记阶段。有很多规则和历史数据会影响混合模式的启动时机。比如,假若在老年代中可以并发地腾出很多的小堆块,就没有必要启动混合模式。因此,在并发标记与混合转移暂停之间,很可能会存在多次 young 模式的转移暂停。
具体添加到回收集的老年代小堆块的大小及顺序,也是基于许多规则来判定的。其中包括指定的软实时性能指标,存活性,以及在并发标记期间收集的 GC 效率等数据,外加一些可配置的 JVM 选项。混合收集的过程,很大程度上和前面的 fully-young gc 是一样的。
GC 选择的经验总结
综合来看,G1 是 JDK11 之前 HotSpot JVM 中最先进的准产品级(production-ready) 垃圾收集器。重要的是,HotSpot 工程师的主要精力都放在不断改进 G1 上面。在更新的 JDK 版本中,将会带来更多强大的功能和优化。
可以看到,G1 作为 CMS 的代替者出现,解决了 CMS 中的各种疑难问题,包括暂停时间的可预测性,并终结了堆内存的碎片化。对单业务延迟非常敏感的系统来说,如果 CPU 资源不受限制,那么 G1 可以说是 HotSpot 中最好的选择,特别是在最新版本的 JVM 中。当然这种降低延迟的优化也不是没有代价的:由于额外的写屏障和守护线程,G1 的开销会更大。如果系统属于吞吐量优先型的,又或者 CPU 持续占用 100%,而又不在乎单次 GC 的暂停时间,那么 CMS 是更好的选择。
总之,G1 适合大内存,需要较低延迟的场景。
选择正确的 GC 算法,唯一可行的方式就是去尝试,并找出不合理的地方,一般性的指导原则:
- 如果系统考虑吞吐优先,CPU 资源都用来最大程度处理业务,用 Parallel GC;
- 如果系统考虑低延迟有限,每次 GC 时间尽量短,用 CMS GC;
- 如果系统内存堆较大,同时希望整体来看平均 GC 时间可控,使用 G1 GC。
对于内存大小的考量:
- 一般 4G 以上,算是比较大,用 G1 的性价比较高。
- 一般超过 8G,比如 16G-64G 内存,非常推荐使用 G1 GC。
最后讨论一个很多开发者经常忽视的问题:
JDK 8 的默认 GC 是什么?
很多人或觉得是 CMS,甚至 G1,其实都不是。
答案是:并行 GC 是 JDK8 里的默认 GC 策略。
注意,G1 成为 JDK9 以后版本的默认 GC 策略,同时,ParNew + SerialOld 这种组合不被支持。