0%

JVM 相关总结(二)

JVM 相关总结(二)

GC 的背景与一般原理

为什么会有 GC

本质上是因为内存资源的有限性,因此需要大家共享使用,手工申请,手动释放。

原理

引用计数(Reference Counting)

可能导致循环依赖的问题

可达性分析(Reachability Analysis)

也可叫引用跟踪,通过一系列“GC Roots”根据引用关系向下搜索

可以作为 GC Roots 的对象

  1. 当前正在执行的方法里的局部变量和输入参数
  2. 活动线程(Active threads)
  3. 所有类的静态字段(static field)
  4. JNI 引用

此阶段暂停的时间,与堆内存大小,对象的总数没有直接关系,而是由存活对象(alive objects)的数量来决定。所以增加堆内存的大小并不会直接影响标记阶段占用的时间。

image-20210408221111317

怎样才能标记和清除上百万对象?

答案是 STW(Stop The World),让全世界停下来。

分代假设

大部分新生对象很快无用;存活较长时间的对象,可能存活更长时间。

内存池划分:不同类型对象不同区域,不同策略处理。

GC 算法

  • 清除算法(标记-清除算法 Mark-Sweep)
  • 整理算法(标记-清除-整理算法 Mark-Sweep-Compact)
  • 复制算法(标记-复制算法 Mark-Copy)

垃圾回收器

串行 GC(Serial GC)/ParNewGC

-XX:+UseSerialGC 配置串行 GC

串行 GC 对年轻代使用 mark-copy(标记-复制) 算法,对老年代使用 mark-sweep-compact(标记-清除-整理)算法。

两者都是单线程的垃圾收集器,不能进行并行处理,所以都会触发全线暂停(STW),停止所有的应用线程。

因此这种 GC 算法不能充分利用多核 CPU。不管有多少 CPU 内核,JVM 在垃圾收集时都只能使用单个核心。

CPU 利用率高,暂停时间长。简单粗暴,就像老式的电脑,动不动就卡死。

该选项只适合几百 MB 堆内存的 JVM,而且是单核 CPU 时比较有用。

-XX:+USeParNewGC 改进版本的 Serial GC,可以配合 CMS 使用。

并行 GC(Parallel GC)

-XX:+UseParallelGC
-XX:+UseParallelOldGC
-XX:+UseParallelGC -XX:+UseParallelOldGC

年轻代和老年代的垃圾回收都会触发 STW 事件。

在年轻代使用 标记-复制(mark-copy)算法,在老年代使用 标记-清除-整理(mark-sweep-compact)算法。

-XX:ParallelGCThreads=N 来指定 GC 线程数, 其默认值为 CPU 核心数。

并行垃圾收集器适用于多核服务器,主要目标是增加吞吐量。因为对系统资源的有效使用,能达到更高的吞吐量:
• 在 GC 期间,所有 CPU 内核都在并行清理垃圾,所以总暂停时间更短;
• 在两次 GC 周期的间隔期,没有 GC 线程在运行,不会消耗任何系统资源。

CMS GC(Mostly Concurrent Mark and Sweep Garbage Collector)

-XX:+UseConcMarkSweepGC

其对年轻代采用并行 STW 方式的 mark-copy (标记-复制)算法,对老年代主要使用并发 mark-sweep (标记-清除)算法。

CMS GC 的设计目标是避免在老年代垃圾收集时出现长时间的卡顿,主要通过两种手段来达成此目标:

  1. 不对老年代进行整理,而是使用空闲列表(free-lists)来管理内存空间的回收。
  2. 在 mark-and-sweep (标记-清除) 阶段的大部分工作和应用线程一起并发执行。
    也就是说,在这些阶段并没有明显的应用线程暂停。但值得注意的是,它仍然和应用线程争抢 CPU 时间。默认情况下,CMS 使用的并发线程数等于 CPU 核心数的 1/4。

如果服务器是多核 CPU,并且主要调优目标是降低 GC 停顿导致的系统延迟,那么使用 CMS 是个很明智的选择。进行老年代的并发回收时,可能会伴随着多次年轻代的 minor GC。

好处:暂停时间比并行 GC 短;

缺点:复杂;吞吐量没有并行 GC 高;

并行 Parallel 与并发 Concurrent 的区别?

在 JVM 语言环境里面,并发是指,一部分线程做业务,一部分线程做 GC;并行是指处理 GC 的时候是多线程的,跟单线程区别。

CMS 跟 Serial,ParNew 都是老的一套架构,一般配合使用。

CMS 的六个阶段

阶段 1: Initial Mark(初始标记)- STW

这个阶段伴随着 STW 暂停。初始标记的目标是标记所有的根对象,包括根对象直接引用的对象,以及被年轻代中所有存活对象所引用的对象(老年代单独回收)。

阶段 2: Concurrent Mark(并发标记)

在此阶段,CMS GC 遍历老年代,标记所有的存活对象,从前一阶段 “Initial Mark” 找到的根对象开始算起。 “并发标记”阶段,就是与应用程序同时运行,不用暂停的阶段。

阶段 3: Concurrent Preclean(并发预清理)

此阶段同样是与应用线程并发执行的,不需要停止应用线程。 因为前一阶段【并发标记】与程序并发运行,可能有一些引用关系已经发生了改变。如果在并发标记过程中引用关系发生了变化,JVM 会通过“Card(卡片)”的方式将发生了改变的区域标记为“脏”区,这就是所谓的 卡片标记(Card Marking)。

阶段 4: Final Remark(最终标记)- STW

最终标记阶段是此次 GC 事件中的第二次(也是最后一次)STW 停顿。本阶段的目标是完成老年代中所有存活对象的标记。因为之前的预清理阶段是并发执行的,有可能 GC 线程跟不上应用程序的修改速度。所以需要一次 STW 暂停来处理各种复杂的情况。通常 CMS 会尝试在年轻代尽可能空的情况下执行 Final Remark 阶段,以免连续触发多次 STW 事件。

阶段 5: Concurrent Sweep(并发清除)

此阶段与应用程序并发执行,不需要 STW 停顿。JVM 在此阶段删除不再使用的对象,并回收他们占用的内存空间。

阶段 6: Concurrent Reset(并发重置)

此阶段与应用程序并发执行,重置 CMS 算法相关的内部数据,为下一次 GC 循环做准备。

CMS 总结

CMS 垃圾收集器在减少停顿时间上做了很多复杂而有用的工作,用于垃圾回收的并发线程执行的同时,并不需要暂停应用线程。 当然,CMS 也有一些缺点,其中最大的问题就是老年代内存碎片问题(因为不压缩),在某些情况下 GC 会造成不可预测的暂停时间,特别是堆内存较大的情况下。

G1 GC

G1 的全称是 Garbage-First,意为垃圾优先,哪一块的垃圾最多就优先清理它。

G1 GC 最主要的设计目标是:将 STW 停顿的时间和分布,变成可预期且可配置的。

事实上,G1 GC 是一款软实时垃圾收集器,可以为其设置某项特定的性能指标。为了达成可预期停顿时间的指标,G1 GC 有一些独特的实现。

首先,堆不再分成年轻代和老年代,而是划分为多个(通常是 2048 个)可以存放对象的小块堆区域 (smaller heap regions)。每个小块,可能一会被定义成 Eden 区,一会被指定为 Survivor 区或者 Old 区。在逻辑上,所有的 Eden 区和 Survivor 区合起来就是年轻代,所有的 Old 区拼在一起那就是老年代。

-XX:+UseG1GC -XX:MaxGCPauseMillis=50

这样划分之后,使得 G1 不必每次都去收集整个堆空间,而是以增量的方式来进行处理:每次只处理一部分内存块,称为此次 GC 的回收集 (collection set)。每次 GC 暂停都会收集所有年轻代的内存块,但一般只包含部分老年代的内存块。

G1 的另一项创新是,在并发阶段估算每个小堆块存活对象的总数。构建回收集的原则是:垃圾最多的小块会被优先收集。这也是 G1 名称的由来。

G1 GC 配置参数

-XX:+UseG1GC:启用 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:这个参数就是计算花在 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 会尽量保证控制在
这个范围内。

G1 GC 的处理步骤

1、年轻代模式转移暂停(Evacuation Pause)

G1 GC 会通过前面一段时间的运行情况来不断的调整自己的回收策略和行为,以此来比较稳定地控制暂停时间。在应用程序刚启动时,G1 还没有采集到什么足够的信息,这时候就处于初始的 fully-young 模式。当年轻代空间用满后,应用线程会被暂停,年轻代内存块中的存活对象被拷贝到存活区。如果还没有存活区,则任意选择一部分空闲的内存块作为存活区。

拷贝的过程称为转移(Evacuation),这和前面介绍的其他年轻代收集器是一样的工作原理。

2、并发标记(Concurrent Marking)

同时我们也可以看到,G1 GC 的很多概念建立在 CMS 的基础上,所以下面的内容需要对 CMS 有一定的理解。

G1 并发标记的过程与 CMS 基本上是一样的。G1 的并发标记通过 Snapshot-At-The-Beginning(起始快照)的方式,在标记阶段开始时记下所有的存活对象。即使在标记的同时又有一些变成了垃圾。通过对象的存活信息,可以构建出每个小堆块的存活状态,以便回收集能高效地进行选择。

这些信息在接下来的阶段会用来执行老年代区域的垃圾收集。

有两种情况是可以完全并发执行的:

  1. 如果在标记阶段确定某个小堆块中没有存活对象,只包含垃圾;
  2. 在 STW 转移暂停期间,同时包含垃圾和存活对象的老年代小堆块。

当堆内存的总体使用比例达到一定数值,就会触发并发标记。这个默认比例是 45%,但也可以通过 JVM 参数 InitiatingHeapOccupancyPercent 来设置。和 CMS 一样,G1 的并发标记也是由多个阶段组成,其中一些阶段是完全并发的,还有一些阶段则会暂停应用线程。

3、转移暂停:混合模式(Evacuation Pause (mixed))

并发标记完成之后,G1 将执行一次混合收集(mixed collection),就是不只清理年轻代,还将一部分老年代区域也加入到 回收集 中。混合模式的转移暂停不一定紧跟并发标记阶段。有很多规则和历史数据会影响混合模式的启动时机。比如,假若在老年代中可以并发地腾出很多的小堆块,就没有必要启动混合模式。

因此,在并发标记与混合转移暂停之间,很可能会存在多次 young 模式的转移暂停。

具体添加到回收集的老年代小堆块的大小及顺序,也是基于许多规则来判定的。其中包括指定的软实时性能指标,存活性,以及在并发标记期间收集的 GC 效率等数据,外加一些可配置的 JVM 选项。混合收集的过程,很大程度上和前面的 fully-young gc 是一样的。

G1 GC 的注意事项

特别需要注意的是,某些情况下 G1 触发了 Full GC,这时 G1 会退化使用 Serial 收集器来完成垃圾的清理工作,它仅仅使用单线程来完成 GC 工作,GC 暂停时间将达到秒级别的。

1. 并发模式失败

G1 启动标记周期,但在 Mix GC 之前,老年代就被填满,这时候 G1 会放弃标记周期。解决办法:增加堆大小,或者调整周期(例如增加线程数 -XX:ConcGCThreads 等)。

2. 晋升失败

没有足够的内存供存活对象或晋升对象使用,由此触发了 Full GC(to-space exhausted/to-space overflow)。

解决办法:

  1. 增加 –XX:G1ReservePercent 选项的值(并相应增加总的堆大小)增加预留内存量。

  2. 通过减少 –XX:InitiatingHeapOccupancyPercent 提前启动标记周期。

  3. 也可以通过增加 –XX:ConcGCThreads 选项的值来增加并行标记线程的数目。

3. 巨型对象分配失败

当巨型对象找不到合适的空间进行分配时,就会启动 Full GC,来释放空间。

解决办法:增加内存或者增大 -XX:G1HeapRegionSize

ZGC

-XX:+UnlockExperimentalVMOptions -XX:+UseZGC -Xmx16g

ZGC 最主要的特点包括:

  1. GC 最大停顿时间不超过 10ms
  2. 堆内存支持范围广,小至几百 MB 的堆空间,大至 4TB 的超大堆内存(JDK13 升至 16TB)
  3. 与 G1 相比,应用吞吐量下降不超过 15%
  4. 当前只支持 Linux/x64 位平台,JDK15 后支持 MacOS 和 Windows 系统

各个 GC 对比

如图:

image-20210414002137322

常用 GC 组合

常用的组合为:

  1. Serial+Serial Old 实现单线程的低延迟垃圾回收机制;
  2. ParNew+CMS,实现多线程的低延迟垃圾回收机制;
  3. Parallel Scavenge 和 Parallel Scavenge Old,实现多线程的高吞吐量垃圾回收机制;

image-20210414002330526

GC 如何选择

选择正确的 GC 算法,唯一可行的方式就是去尝试,一般性的指导原则:

  1. 如果系统考虑吞吐优先,CPU 资源都用来最大程度处理业务,用 Parallel GC;
  2. 如果系统考虑低延迟有限,每次 GC 时间尽量短,用 CMS GC;
  3. 如果系统内存堆较大,同时希望整体来看平均 GC 时间可控,使用 G1 GC。

对于内存大小的考量:

  1. 一般 4G 以上,算是比较大,用 G1 的性价比较高。
  2. 一般超过 8G,比如 16G-64G 内存,非常推荐使用 G1 GC。

GC 总结

到目前为止,我们一共了解了 Java 目前支持的所有 GC 算法,一共有 7 类:

  1. 串行 GC(Serial GC): 单线程执行,应用需要暂停;
  2. 并行 GC(ParNew、Parallel Scavenge、Parallel Old): 多线程并行地执行垃圾回收,关注与高吞吐;
  3. CMS(Concurrent Mark-Sweep): 多线程并发标记和清除,关注与降低延迟;
  4. G1(G First): 通过划分多个内存区域做增量整理和回收,进一步降低延迟;
  5. ZGC(Z Garbage Collector): 通过着色指针和读屏障,实现几乎全部的并发执行,几毫秒级别的延迟,线性可扩展;
  6. Epsilon: 实验性的 GC,供性能分析使用;
  7. Shenandoah: G1 的改进版本,跟 ZGC 类似。

可以看出 GC 算法和实现的演进路线:

  1. 串行 -> 并行:重复利用多核 CPU 的优势,大幅降低 GC 暂停时间,提升吞吐量。
  2. 并行 -> 并发: 不只开多个 GC 线程并行回收,还将 GC 操作拆分为多个步骤,让很多繁重的任务和应用线程一起并发执行,减少了单次 GC 暂停持续的时间,这能有效降低业务系统的延迟。
  3. CMS -> G1: G1 可以说是在 CMS 基础上进行迭代和优化开发出来的,划分为多个小堆块进行增量回收,这样就更进一步地降低了单次 GC 暂停的时间
  4. G1 -> ZGC::ZGC 号称无停顿垃圾收集器,这又是一次极大的改进。ZGC 和 G1 有一些相似的地方,但是底层的算法和思想又有了全新的突破。

“脱离场景谈性能都是耍流氓”。

目前绝大部分 Java 应用系统,堆内存并不大比如 2G-4G 以内,而且对 10ms 这种低延迟的 GC 暂停不敏感,也就是说处理一个业务步骤,大概几百毫秒都是可以接受的,GC 暂停 100ms 还是 10ms 没多大区别。另一方面,系统的吞吐量反而往往是我们追求的重点,这时候就需要考虑采用并行 GC。

如果堆内存再大一些,可以考虑 G1 GC。如果内存非常大(比如超过 16G,甚至是 64G、128G),或者是对延迟非常敏感(比如高频量化交易系统),就需要考虑使用新 GC(ZGC/Shenandoah)。

欢迎关注我的其它发布渠道