JDK17+ZGC初体验
作者:得物技术 查看原文
前言
垃圾回收器的暂停问题一直是Java工程师关注的重点,特别是对实时响应要求较高的服务来说,CMS和G1等主流垃圾回收器的数十毫秒乃至上百毫秒的暂停时间相当致命。此外,调优门槛也相对较高,需要对垃圾回收器的内部机制有一定的了解,才能够进行有效的调优。
为了解决此类问题,JDK 11开始推出了一种低延迟垃圾回收器ZGC。ZGC使用了一些新技术和优化算法,可以将GC暂停时间控制在10毫秒以内,而在JDK 17的加持下,ZGC的暂停时间甚至可以控制在亚毫秒级别!
ZGC
ZGC相关介绍、原理,网上已经有很多类似文章,这里只做简单介绍。
2.1 设计目标
ZGC 最初在 JDK 11 中作为实验性功能引入,并在 JDK 15 中宣布为生产就绪。 作为一款低延迟垃圾收集器,旨在满足以下目标:
- 8MB到16TB的堆大小支持
- 10ms最大GC暂时
- 最糟糕的情况下吞吐量会降低15%(低延时换吞吐量很值,吞吐量扩容即可解决)
2.2 ZGC 内存分布
ZGC与传统的CMS、G1不同、它没有分代的概念,只有类似G1的Region概率,ZGC 的 Region可以具有如下图所示的大中下三类容量:
- 小型 Region(Small Region):容量固定为2MB,用于放置小于 256KB的小对象。
- 中型 Region(Medium Region):容量固定为 32MB,用于放置大于 256KB但是小于 4MB的对象。
- 大型 Region(Large Region):容量不固定,可以动态变化,但必须为 2MB的整数倍,用于放置 4MB或以上的大对象。每个大型 Region中会存放一个大对象,这也预示着虽然名字叫“大型 Region”,但它的实际容量完全有可能小于中型Region,最小容量可低至4MB。大型 Region在ZGC的实现中是不会被重分配的(重分配是ZGC的一种处理动作,用于复制对象的收集器阶段)因为复制大对象的代价非常高。
2.3 GC工作过程
与CMS中的ParNew和G1类似,ZGC也采用标记-复制算法,不过ZGC通过着色指针和读屏障技术,解决了转移过程中准确访问对象的问题,在标记、转移和重定位阶段几乎都是并发执行的,这是ZGC实现停顿时间小于10ms目标的最关键原因。
从上图中可以看出,ZGC只有三个STW阶段:初始标记,再标记,初始转移。
具体转移过程,网上有大量类似文章,这里不做详细介绍,大家有兴趣可以参考以下文章:
- 新一代垃圾回收器ZGC的探索与实践
- ZGC 最新一代垃圾回收器 | 程序员进阶
为什么选择JDK17呢?
JDK 17于9月14日发布,是一个长期支持(LTS)版本,这意味着它将在很多年内得到支持和更新。这也是第一个LTS版本,其中包含了一个可用于生产环境的ZGC版本。回顾一下,ZGC的实验版本已经包含在JDK 11(之前的LTS版本)中,而第一个可用于生产环境的ZGC版本出现在JDK 15(一个非LTS版本)中。
升级过程
从JDK8+G1升级到JDK17+ZGC,主要是在代码层面和JVM启动参数层面的做适配。
4.1 JDK下载
首先jdk17选择的是openjdk,下载地址:https://jdk.java.net/archive/,选择版本17 GA
4.2 代码适配
- JDK11移除了 Java EE and CORBA 的模块
项目中如果用到javax.annotation. 、javax.xml. 等等开头的包,需要手动引入对应依赖
<dependency>
<groupId>javax.annotation</groupId>
<artifactId>javax.annotation-api</artifactId>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
</dependency>
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-core</artifactId>
</dependency>
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-impl</artifactId>
</dependency>
- maven相关依赖版本升级
<!-- 仅供参考 -->
<maven-compiler-plugin.version>3.8.1</maven-compiler-plugin.version>
<maven-assembly-plugin.version>3.3.0</maven-assembly-plugin.version>
<maven-resources-plugin.version>3.2.0</maven-resources-plugin.version>
<maven-jar-plugin.version>3.2.0</maven-jar-plugin.version>
<maven-surefire-plugin.version>3.0.0-M5</maven-surefire-plugin.version>
<maven-deploy-plugin.version>3.0.0-M1</maven-deploy-plugin.version>
<maven-release-plugin.version>3.0.0-M1</maven-release-plugin.version>
<maven-site-plugin.version>3.9.1</maven-site-plugin.version>
<maven-enforcer-plugin.version>3.0.0-M2</maven-enforcer-plugin.version>
<maven-project-info-reports-plugin.version>3.1.0</maven-project-info-reports-plugin.version>
<maven-plugin-plugin.version>3.6.1</maven-plugin-plugin.version>
<maven-javadoc-plugin.version>3.3.0</maven-javadoc-plugin.version>
<maven-source-plugin.version>3.2.1</maven-source-plugin.version>
<maven-jxr-plugin.version>3.0.0</maven-jxr-plugin.version>
- Lombok版本升级https://projectlombok.org/changelog
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<!-- <version>1.16.20</version>-->
<version>1.18.22</version>
</dependency>
- Java9 模块化后,不允许应用程序查看来自JDK的所有类,会影响部分反射的运行,需要通过以下命令解决
--add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.lang.reflect=ALL-UNNAMED
- 本地使用了transmittable-thread-local-2.14.2.jar后启动报错
在agent后面加上日志输出即可解决,至于原因,猜测是跟类加载顺序有关系
-javaagent:/Users/admin/Documents/transmittable-thread-local-2.14.2.jar
=ttl.agent.logger:STDOUT
以上内容仅针对彩虹桥项目升级遇到的问题,不同的业务代码适配的情况可能不一样,需要根据实际情况寻找解决方案。
4.3 JVM参数替换
下面是一些通用GC参数和ZGC特有参数以及ZGC的一些诊断选型,来自官网:Main - Main - OpenJDK Wiki
通用GC****参数 | ZGC特有参数 | ZGC 诊断选型 |
`-XX:MinHeapSize, -Xms | -XX:ZAllocationSpikeTolerance | |
-XX:InitialHeapSize, -Xms | -XX:ZCollectionInterval | -XX:ZVerifyForwarding |
-XX:MaxHeapSize, -Xmx | -XX:ZFragmentationLimit | -XX:ZProactive |
-XX:SoftMaxHeapSize | -XX:ZMarkStackSpaceLimit | -XX:ZVerifyMarking |
-XX:ConcGCThreads | -XX:ZProactive | -XX:ZVerifyObjects |
-XX:ParallelGCThreads | -XX:ZUncommit | -XX:ZVerifyRoots |
-XX:UseDynamicNumberOfGCThreads | -XX:ZUncommitDelay | -XX:ZVerifyViews |
-XX:UseLargePages | -XX:ZStatisticsInterval | 注意:使用以上参数需要开启-XX:+UnlockDiagnosticVMOptions |
-XX:UseTransparentHugePages | ||
-XX:UseNUMA | ||
-XX:SoftRefLRUPolicyMSPerMB | ||
-XX:AllocateHeapAt` |
具体每个参数的含义,这里不做介绍,可参考官网文档The java Command,里面有详细说明。
JKD8+G1的启动参数:
-server -Xms36600m -Xmx36600m
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:+PrintReferenceGC
-XX:+ParallelRefProcEnabled
-XX:G1HeapRegionSize=16m
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/opt/apps/errorDump.hprof
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintHeapAtGC
-XX:+PrintGCApplicationConcurrentTime
-verbose:gc
-Xloggc:/opt/apps/logs/${app_name}-gc.log
JDK17+ZGC的启动参数如下:
-server -Xms36600m -Xmx36600m
#开启ZGC
-XX:+UseZGC
#GC周期之间的最大间隔(单位秒)
-XX:ZCollectionInterval=120
#官方的解释是 ZGC 的分配尖峰容忍度,数值越大越早触发GC
-XX:ZAllocationSpikeTolerance=4
#关闭主动GC周期,在主动回收模式下,ZGC 会在系统空闲时自动执行垃圾回收,以减少垃圾回收在应用程序忙碌时所造成的影响。如果未指定此参数(默认情况),ZGC 会在需要时(即堆内存不足以满足分配请求时)执行垃圾回收。
-XX:-ZProactive
#GC日志
-Xlog:safepoint=trace,classhisto*=trace,age*=info,gc*=info:file=/opt/logs/gc-%t.log:time,level,tid,tags:filesize=50M
#发生OOM时dump内存日志
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/opt/apps/errorDump.hprof
压测结果
直接上图
正如 ZGC 设计目标所描述,它将 GC 暂停时间从过去的几十毫秒降低到了令人惊叹的亚毫秒级别。然而,这种超低延迟表现也需要一定的代价,因为在实现低延迟的同时,ZGC 会占用一定的 CPU 资源。通常情况下,ZGC 占用的 CPU 比例不会超过 15%。在彩虹桥项目中,使用以上推荐的 JVM 参数后,ZGC 占用的 CPU 资源为 6% 左右。
ZGC日志
6.1 输出ZGC日志
GC日志中包含有关 GC 操作的详细信息,可以帮我们分析当前GC存在的问题。先来看一下上面JVM参数中关于GC日志的参数
-Xlog:safepoint=trace,classhisto*=trace,age*=info,gc*=info:file=/opt/logs/gc-%t.log:time,level,tid,tags:filesize=50M
safepoint=trace
:记录关于 safepoint 的 trace 级别日志。Safepoint 是 JVM 中一个特殊的状态,它用于确保所有线程在特定操作(如垃圾回收、代码优化等)之前进入安全状态。classhisto*=trace
:记录与类的历史相关的 trace 级别日志。age*=info
:记录与对象年龄(在新生代中存在的时间)相关的 info 级别日志。gc*=info
:记录与垃圾回收相关的 info 级别日志。file=/opt/logs/gc-%t.log
:将日志写入到/opt/logs/
目录下的文件中,文件名为gc-%t.log
,其中%t
是一个占位符,表示当前时间戳。time,level,tid,tags
:在每个日志记录中包含时间戳、日志级别、线程 ID 和标签。filesize=50M
:设置日志文件的大小限制为 50MB。当日志文件大小达到此限制时,JVM 将创建一个新的日志文件并继续记录。
更详细的gc日志配置可以参考:https://docs.oracle.com/en/java/javase/17/docs/specs/man/java.html#enable-logging-with-the-jvm-unified-logging-framework
6.2 STW关键日志
其中我们重点关注的就是GC的STW情况,以下是一些关键字代表GC STW阶段
- 最基本的STW三阶段, 初始标记:日志中Pause Mark Start,再标记:日志中Pause Mark End,初始转移:日志中Pause Relocate Start。
- 内存分配阻塞:这一般是因为垃圾生产速度大于回收速度,垃圾来不及回收,垃圾将堆占满时,线程会阻塞等待GC完成,关键字是Allocation Stall(被阻塞的线程名称)
如果出现此类日志,可以尝试如下方法解决:
-XX:ZCollectionInterval
该配置含义:两个 GC 周期之间的最大间隔(单位秒)。默认情况下,此选项设置为 0(禁用),可以适当调小该配置,让GC周期缩短、提升垃圾回收速度,但这会提升应用CPU占用。-XX:ZAllocationSpikeTolerance
官方的解释是 ZGC 的分配尖峰容忍度。其实就是数值越大,越早触发回收。可以适当调大该配置,更早触发回收,提升垃圾回收速度,但这会提升应用CPU占用。- 安全点:所有线程进入到安全点后才能进行GC,ZGC定期进入安全点判断是否需要GC。先进入安全点的线程需要等待后进入安全点的线程直到所有线程挂起。日志关键字safepoint ... stopped
- dump线程、 内存:比如jstack、jmap命令,一般是手动dump导致,日志关键字HeapDumper
Linux大页内存
在openjdk的官网上也能看到,开启Linux大页内存后会提升应用的性能。
经过几轮压测实际测试下来,发现在开启Linux大页后,CPU有8%左右的下降,但是由于大页面会提前预留指定大小的内存,会导致机器的内存使用率较高。而且目前生产环境没有其他应用开启此配置,稳定性有待考究,生产环境自行评估是否开启。
总结
在本篇文章中,我们探讨了如何升级到JDK 17,并使用最新一代垃圾回收器ZGC。经过实践和测试,我们发现升级后的系统在垃圾回收方面表现出色,暂停时间被有效控制在1毫秒内。尽管这一优化过程可能会消耗额外的CPU资源,但所获得的超低GC暂停时间显然是非常值得的。总之,相比其他垃圾回收器,ZGC 的性能和稳定性已经非常优秀,而且不需要太多的调优。在大多数情况下,使用 ZGC官方推荐的默认设置即可获得优秀的性能表现。对于那些RT敏感型应用,升级到JDK 17并采用ZGC是一个明智的选择。