MENU

【歪门邪道】针对Minecraft的JVM调优

June 13, 2022 • 瞎折腾

大学四年过得真快,转眼间就毕业了。趁着做完毕设、毕业之前,和同学搞了个Minecraft服务器。然而2核4G对于140多个Fabric模组来说还是太多了。本文记述我如何对JVM调优。

前言

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

这里准备了5个候选JDK,三个HotSpot,一个OpenJ9,还有一个来自Azul的Zing:

测试时使用的世界Seed为-3566672805144844787

测试流程为:

  1. 删除logs和worlds文件夹
  2. 启动服务端线程
  3. 设置Chunky在主世界,以(0,0)为中心,半径为500生成区块(总计4225个区块)
  4. 完成后停止服务端
  5. 保存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日志分析进行说明:

OpenJDKTemurinIBM SemeruAzul Zulu JDKAzul Zing
Chunky用时8分59秒9分15秒11分20秒9分17秒8分51秒
平均暂停 / ms51.349.686255.38-
平均GC间隔 / s883.557-

注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,不做针对性的修改。

此外除了总体耗时外,生成区块过程中的速度变化也很有意思:

默认参数区块生成速率图.png

纵轴是每秒生成的区块数量,横轴是生成任务开始后的秒数。可以看到最开始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等回收算法,老年代回收的代价比较高(通常意味着更高的暂停时间),所以一般在内存调优的时候会尽量避免老年代出现锯齿状。比如默认参数下的内存分布:

Zing default Java_Heap_Use_Distribution.png

可以看出来每次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秒,分布图如下:

Zing opt Java_Heap_Use_Distribution.png

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可以编译,我推断在这种情况下应当会有性能提升。

Zing default Compiler_Statistics_Compiler_Threads.png

上图是默认参数下的编译线程统计,下图是使用了ReadyNow的统计:

Zing ReadyNow Compiler_Statistics_Compiler_Threads.png

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核真的算是非常资源紧缺了。

-全文完-


知识共享许可协议
【歪门邪道】针对Minecraft的JVM调优天空 Blond 采用 知识共享 署名 - 非商业性使用 - 相同方式共享 4.0 国际 许可协议进行许可。
本许可协议授权之外的使用权限可以从 https://skyblond.info/about.html 处获得。

Archives QR Code
QR Code for this page
Tipping QR Code
Leave a Comment

3 Comments
  1. 凱

    OpenJ9 的整體策略是針對普遍不需要低暫停的服務器程序
    如果是單機遊戲
    嘗試使用balanced策略 -Xgcpolicy:balanced -Xgc:targetPausetime=10
    或 gencon cs 策略, -Xgcpolicy:gencon-Xgc:concurrentScavenge
    最後都補上 -Xgc:dnssExpectedTimeRatioMaximum=2 -Xaggressive
    這樣就應該能有滿意的低暫停與禎數效果了

    1. @凱感谢补充!我是有听说OpenJ9采用了性能换内存消耗的策略,不过因为平时用的少,所以也没怎么深入了解。等以后再开Minecraft服务器的时候可以测试一下

  2. 关于那个ReadyNow,在实际测试的时候,如果资源比较充足的话,JVM可能会尝试加载一些它并不应该加载的东西,从而导致模组加载时Mixin发生错误。这是因为一般情况下Minecraft类第一次被加载,要么是被游戏需要(没有mod修改),要么是被Fabric/Forge加载模组时加载。第二种情况下如果JVM提前加载了要被模组修改的类,那么轮到模组加载的时候,就有可能引发一系列问题。

    当然了,如果不使用Mod服,或者插件服没有涉及到类似Mixin的操作,那么用起来应该是安全的。Mod的话,由于Fabric和Forge整个就是在Mixin这种字节码操作的基础上建立起来的,那么很遗憾,只能每次都冷启动了。