前言展开目录
Java 和 JVM 一直是一个很庞大的系统。Java 语言在 JVM 的基础上隐藏了很多细节,从而让程序员更关注功能而非性能。而 JVM 的作用则是对程序员编写的代码进行优化,因此 JVM 中引入了垃圾回收、即时编译等一系列先进而复杂的子系统。这种复杂度也使得 JVM 的性能并不像 C++、Go 或者 Rust 这样值观:你以为一段循环即可测量某个操作的性能,实际上这个操作可能随着循环的进行被即时编译机制优化。
总之,本文希望以一种尽量量化的方式说明如何对 JVM 进行调优,从而避免「摆弄开关」和「按照坊间传闻」调优。本文的测试环境是一个 4 核心 8GB 内存的虚拟机,安装了 openSUSE Leap 15.4,Linux 内核 5.14.21-150400.22-default,带有 2GB 交换空间。
测试程序为 MCPLUS 整合包(版本 1.18.1 Beta4),额外加入了 Chunky 用于生成区块。
选择最佳 JVM展开目录
测试时使用的世界 Seed 为 -3566672805144844787。
测试流程为:
- 删除 logs 和 worlds 文件夹
- 启动服务端线程
- 设置 Chunky 在主世界,以(0,0)为中心,半径为 500 生成区块(总计 4225 个区块)
- 完成后停止服务端
- 保存 logs 文件夹
测量途中除了收集 Minecraft 服务端日志(Chunky 日志)外,还收集 JVM 的垃圾回收日志。
测试时使用如下脚本:
• #! /bin/bash • • JVM_OPENJDK=openjdk-17.0.2 • JVM_TEMURIN=temurin-jdk-17.0.3+7 • JVM_SEMERU=ibm-semeru-open-jdk-17.0.3+7 • JVM_ZULU=zulu17.34.19-ca-jdk17.0.3-linux_x64 • JVM_ZING=zing22.05.0.0-3-jdk17.0.3-linux_x64 • • sudo rm -rf /home/skyblond/jdks/test/logs* • • for JVM_NAME in $JVM_OPENJDK $JVM_TEMURIN $JVM_SEMERU $JVM_ZULU $JVM_ZING; do • • echo '========================================' • echo "Start testing $JVM_NAME" • echo '========================================' • • sudo rm -rf /home/skyblond/jdks/test/world • • mkdir logs • sudo nice -n -20 /home/skyblond/jdks/$JVM_NAME/bin/java \ • -Xmx6G -Xms6G -Xlog:gc:logs/gc.log -Xlog:gc* \ • -jar fabric-server-launch.jar nogui | tee logs/full.log • • chmod 777 -R /home/skyblond/jdks/test/logs • mv -v /home/skyblond/jdks/test/logs /home/skyblond/jdks/test/logs-$JVM_NAME • • echo '========================================' • echo "Finish testing $JVM_NAME" • echo '========================================' • • done
该脚本定义了 5 个 JVM 的路径,然后再循环中依次使用这些 JVM 启动 MC 服务端,并在服务端退出后保存日志。其中启动 MC 服务端的时候,设置堆大小为 6GB 内存,开启垃圾回收日志并存储于 logs 中(MC 服务端的日志文件夹)。最终 logs 文件夹中有三个我们需要的文件:
- gc.log:垃圾回收日志
- latest.log:MC 服务端的输出
- full.log:控制台输出,混合了垃圾回收日志和 MC 服务端输出,由于 GC 日志的时间从 JVM 启动那一刻开始计算,而 MC 的日志则以现实时间计算,因此需要利用该文件校对时间。具体做法是在启动时找到两条相邻的 MC 日志输出,看夹在中间的 gc 日志时间。这个方法要比看第一条日志的时间精确许多,因为 JVM 预热 / 启动的时候 MC 还不会输出日志
由于时间有限,跑一次测试基本上要 50 分钟到 60 分钟,因此我只测量了一次。虚拟机内的进程相对稳定,而虚拟机外启动了众多程序,例如微信、Chrome、Spotify 等,这些程序对于测量结果的影响尚不明确。因此测量结果将辅以 GC 日志分析进行说明:
OpenJDK | Temurin | IBM Semeru | Azul Zulu JDK | Azul Zing | |
Chunky 用时 | 8 分 59 秒 | 9 分 15 秒 | 11 分 20 秒 | 9 分 17 秒 | 8 分 51 秒 |
平均暂停 /ms | 51.3 | 49.68 | 62 | 55.38 | - |
平均 GC 间隔 /s | 8 | 8 | 3.55 | 7 | - |
注 1:OpenJ9 产生的 GC 日志使用 IBM PMAT 工具分析,Azul Zing 产生的 GC 日志使用 GC Log Analyzer 工具分析,其他使用 G1 的 HotSpot 虚拟机产生的日志使用 GCViewer 分析,版本为提交 fd90b9d492121b2f5251d8555fd0e89cd73d53ea,本次提交中优化了对 Java 17 中 G1 日志的解析。
对于使用 G1 的三个 HotSpot 虚拟机,我们更关注 Young GC(Normal)和 Mixed GC 的占比,这类似以前的新生代和老年代 —— 一般来说大部分 GC 应当发生在新生代,即 Young GC,有时候也叫 Normal GC。当新生代也不够用了,传统的垃圾收集器开始进行 Full GC 以回收老年代,而 G1 则对老年代进行部分回收,直到有足够用的空域内存。因此使用 G1 收集器同样也要注意是否有太多的新生代对象漏到老年代,从而因此过多的 Mixed GC 拖慢速度。
- OpenJDK:114 次 GC 暂停中有 8 次是因为 Mixed GC
- Temurin:115 次 GC 暂停中有 8 次是因为 Mixed GC
- Azul Zulu JDK:120 次 GC 暂停中有 2 次是因为 Mixed GC
从 Mixed GC 上来看,我更青睐 Azul Zulu JDK,因为频繁的 Mixed GC 带来的就是 MC 卡顿(掉 TPS),但从生成区块的性能来说,Zulu JDK 是 HotSpot 中最差的一个,也许它的策略是用略低的性能换取稳定的表现。
对于 OpenJ9,虽然它性能最差,但是在测试时发现它的内存占用非常好:别的虚拟机已经开始 Swap 了,它的 6GB 堆刚用了大约三分之二。此外我看 Minecraft 社区也对 OpenJ9 有所讨论,并认为可能是最简单的获得性能提升的方法。此外,Minecraft 社区普遍反馈使用 OpenJ9 在各方面都获得了提升(我个人猜测是内存占用量减少,从而整体减少的进入 Swap 的内存,因此整体系统表现为「不卡了」),同时也有针对 OpenJ9 的调优参数,这里为了方便起见,所有虚拟机使用默认参数:即不修改 GC,不做针对性的修改。
此外除了总体耗时外,生成区块过程中的速度变化也很有意思:
纵轴是每秒生成的区块数量,横轴是生成任务开始后的秒数。可以看到最开始 Azul Zing 的速度是最慢的,但随着循环运行,Zing 的即时编译器开始介入进行优化,到最后 Zing 的速度竟然是最快的了。
最后说说我的 JDK 选择吧,这五种 JDK 都是可以免费获得的,其中只有 Azul Zing 是商业使用付费,开发和评测使用无需付费,其他的 JDK 基本上都是开源的。抛开性能,我认为相关的工具也很重要。对于 HotSpot 虚拟机,它的 GC 日志可视化软件靠社区用爱发电,毕竟是开源软件嘛,Oracle 都把 Java 开源了,还不许人家留点做咨询的家伙事儿?但是 Azul 和 IBM 可是两家商业公司,其中 IBM 还是百年老店。这里我要吐槽一下 IBM 的陈年工具,最后一次更新在 2014 年,而且图表也不够直观,总之就是难上手。在这种我需要快速对比不同 JVM 的场景下,难上手反直观的工具,即便其功能再强大,我也会很嫌弃。反而 Azul 在工具方面做的非常好,除了缺乏必要的统计之外,图标和各种信息都非常详尽、直观。
所以我的选择是 Azul Zing,如果内存实在是紧缺的话,可以考虑 IBM Semeru,使用 OpenJ9。如果受限于 Azul Zing 的许可 / 商用限制,可以考虑 Azul Zulu JDK,他们的 Mixed GC 更少,也许能够避免一些突然掉 TPS 的情况,但是整体 TPS 可能稍低一些。当然,如果嫌麻烦,用 Linux 发行版本自带的 OpenJDK 也不是不行,在性能测试中可以看出随着各种优化机制的接入,性能第二的就是 OpenJDK。至于 Temurin,他们号称自己的 JDK 经过各种兼容性测试,但是对我而言好像影响不大。
在工具方面,我首推 Azul Zing,在性能上我也推荐 Azul Zing,虽然他很慢热,但这并不是要害。
至于调优,所有 HotSpot 系在 Java 17 上都是用 G1 作为默认的垃圾收集器,有些 JDK 也编译了 ZGC。对于 G1,Oracle 官方的建议是使用默认值,在默认值(平衡)情况下 G1 会尝试在高吞吐下获得较小、统一的暂停。如果想要追求高吞吐低暂停,那么可以设置 -XX:MaxGCPauseMillis 来降低目标 GC 停顿时间,同时为了能够让 G1 达成这个目标,最好配合 -Xmx 多给他一些内存。至于 ZGC,在宣传上它的性能要比 G1 更好(3TB 堆上世界暂停小于 1ms),但实际测试时,由于扫描、标记等操作是与用户线程并行的,因此反而挤占了 Minecraft 的线程,导致性能降低。实际测试中,OpenJDK 使用 ZGC 后,生成区块的时间从 8 分 59 秒延长到了 10 分 45 秒。
内存调优展开目录
接下来我们看一下使用默认参数运行的时候,Azul Zing 虚拟机的瓶颈。一般来说 JVM 的瓶颈在于垃圾回收,因为 JIT 编译或者其他优化都可以通过多线程的形式与用户线程一起跑,而唯独垃圾回收不得不暂停用户线程。在现代垃圾回收器中,一般都按照分代理论(ZGC 除外),优先考虑来的快去的快的新生代 / 年轻代。然而当新生代垃圾回收跟不上新生代的分配速度时,那么多出来的对象就只能被迫晋升到老年代,这个代的对象一般被认为比较长寿,即不会轻易丢弃。然而现实是这些来的快去的快的对象进入老年代之后,仍然改变不了他们去的快的命运。因此它们将很快成为老年代的垃圾。对于传统的 CMS 等回收算法,老年代回收的代价比较高(通常意味着更高的暂停时间),所以一般在内存调优的时候会尽量避免老年代出现锯齿状。比如默认参数下的内存分布:
可以看出来每次 GC 时老年代出现锯齿状,而通过查阅文档,我发现 Zing 并不支持手动设置新生代或老年代的大小,但有两个参数可以间接控制对象晋升和垃圾回收:
- -XX:GPGCTimeStampPromotionThresholdMS=10000:这个参数控制新生代对象晋升到老年代前等待的时间。堆大于 2GB 时默认为 2000 毫秒,小于等于 2GB 时为 500 毫秒。考虑到区块生成任务不需要在内存中持久保留过多对象,因此我将这个参数设置成了 10 秒。
- -XX:GPGCTargetPeakNewGenOccupancyPercent=70:这个参数为堆内存中新生代占用设置了一个软限制,当新生代占用堆达到了这个百分比时,将自动触发一次 GC。这个参数默认是 0,也就是说软上限和硬上限一致。考虑到所有区块生成完即可回收,因此我将这个参数设置为了 75%,当新生代对象占用了超过 75% 的堆内存时将会触发一次 GC。
这两个参数对于性能的影响并不是线性缩放的。例如第一个参数在整体上限定了晋升老年代的条件,对于游戏来说,如果一个玩家正在跑图,那么玩家大约需要 5~6 秒来走过一个平坦的区块;如果玩家在自己的家或者其他长期停留的区块,如果这个参数设置过长将导致每次 GC 都尝试释放这个区块,反而增大了 GC 的压力。
第二参数也是类似,该参数对于突发负载比较有用,即平时预留出足够的内存,当遇到突发负载时可以直接分配内存,而不用等待 GC 释放资源。但如果该值较低,则大量玩家同时在线时,内存用量降不下来,可能会导致频繁 GC。我认为在正常运行服务器的时候,这个值可以设置为 90% 或 95%,当玩家登录和跑图的时候会分配大量内存,为这类场景留出余量能够极大改善用户登录和加载区块的体验。
修改完这些参数后,我得到的最快耗时是 8 分 56 秒,分布图如下:
Azul Zing ReadNow!展开目录
为了解决 JVM 启动慢热的问题,Azul Zing 提出了 “ReadyNow!”。这个技术实际上就是利用文件保存相关信息,让未来的 JVM 启动时可以直接根据以前的信息对相关代码进行优化,从而避免预热的问题。
要使用这项技术,需要指定两个 JVM 参数用于保存文件:-XX:ProfileLogIn=<file> -XX:ProfileLogOut=<file>,在开启这两个开关之后,官方建议将所有重要的函数都执行至少 5 万次。
于是我就让他生成 5 万个区块,然后删掉世界文件重新运行测试。启动 JVM 后需要等待一段时间,这个时候虽然 Minecraft 服务端启动完毕了,但是 Zing 在后台进行即时编译,需要等 CPU 使用率平缓之后再开始测试。耗时从 8 分 51 秒下降到了。。。emmmm。。。。9 分 21 秒?
通过 GC 日志可以看到,在使用默认设置时,C2 编译线程稳定在 2 个活跃线程,而开启 ReadyNow 后一上来 C2 就有 3 个线程,并且在生成区块的时候,活动线程数量介于 2 到 3 个之间。我推断编译线程占用了用户线程的 CPU 时间,从而导致生成地图反而变慢。因为 2022 年只有 4 个 CPU 核心确实不叫多,而且生成区块这个任务并不是单线程的,所以任何需要额外 CPU 时间的优化其实都是负优化。除非 CPU 核心特别多,Minecraft 用不过来,这个时候 C2 可以编译,我推断在这种情况下应当会有性能提升。
上图是默认参数下的编译线程统计,下图是使用了 ReadyNow 的统计:
4 核 8G 都这样,那腾讯云的 2 核 4G 就更拉倒了。
透明大页(THP)展开目录
然而优化还不止于此,我们还有最后一个手段:优化系统调用。HotSpot 虚拟机管理内存时不使用系统调用,但是 Azul Zing 不一样。因此我们还可以开启内核的透明大页(Transparent Huge Pages)。Linux 内存页面的默认大小是 4KB,这个是历史遗留问题,因为早期内存少,而现在内存大了,程序需要的内存也变多了,对应的,页面也就多了。页面多了,页表也就跟着大了,带来的影响就是缺页中断次数增加,拖慢了系统效率。如果能够分配较大的内存页面的话,一次缺页中断就可以获得大量内存,岂不美哉?通过设置透明大页,Linux 内核将尝试分配 2MB 的页面,如果满足不了则退回 4KB。但是注意,对于数据库等针对 4KB 页面做出优化的软件,大页面可能反而会降低程序性能。索性我们的 JVM 能够从中受益。
我使用的是 openSUSE,四舍五入算 RedHat 系列,按照 Azul 官网的说明开启大页后,使用调优后的参数运行一下测试(10 秒晋升,新生代软限制 90%),耗时为 8 分 52 秒,比优化参数后快了一些,我预期在内存分配率较高的场景下,玩家的体验能够有所上升。
当然了,如果你是付费用户的话,可以直接安装 Azul Zulu Prime System Tools,这一套工具包含了针对 Azul Zing 的各种内核优化核工具。
总结展开目录
JVM 调优可能是 Java 编程中最麻烦的一部分,这和其他深奥且复杂的知识一样,大部分时间用不上(回想刚学 Java 的时候,JVM 就像是一个神奇的黑盒子,编译一次,到任何平台上都能运行,多神奇),但知道了会很有用(当面对大负载或者大流量时,你的程序可能并不会如你所愿的运行,而你的代码没有问题,问题在于 Java 的其他子系统上)。
总而言之,本文是在《Java 性能优化实践》这本书的启发下撰写的,最终调优的参数就只有两个,额外开启了透明大页:
• mkdir logs • sudo nice -n -20 /home/skyblond/jdks/$JVM_NAME/bin/java \ • -Xmx6G -Xms6G -Xlog:gc:logs/gc.log -Xlog:gc* \ • -XX:GPGCTimeStampPromotionThresholdMS=10000 \ • -XX:GPGCTargetPeakNewGenOccupancyPercent=90 \ • -jar fabric-server-launch.jar nogui | tee logs/full.log
另外书上说开启 GC 日志对于性能没有影响,并且十分有助于调优和排查问题,因此这里也开了 gc 日志。对于 ReadyNow 技术,我认为没有 8 核或者 16 核就不要想了吧,虽然 Minecraft 的并行程度不高,但是 2 核和 4 核真的算是非常资源紧缺了。