JDK17的GC调优策略

26 阅读18分钟

JVM有哪些参数可以调

关于 JVM 的参数,JVM 提供了三类参数。

⼀类是标准参数。 以-开头,所有 HotSpot 都⽀持。例如java -version。这类参数可以使⽤java -help 或者java -?全部打印出来

以下是⼀些常⽤的标准参数:

  • --list_modules : 查看当前JAVA进程中的模块
  • --show-module-resolution: 查看当前JAVA进程中各个模块的依赖关系
  • -verbose:class : 显示类加载的信息
  • -verbose:gc : 显示GC事件

⼆类是⾮标准参数。 以-X 开头,是特定 HotSpot版本⽀持的指令。例如java -Xms200M -Xmx200M。这类指令可以⽤java -X 全部打印出来。这⼀类参数⼀般都还是⽐较稳定,除⾮有重⼤的版本升级,⼀般不会有太⼤的变化。

例如: -Xint 表示当前JAVA进程采⽤解释执⾏。-Xcomp表示当前JAVA进程采⽤编译执⾏。-Xmixed则表示采⽤两种执⾏引擎混合的⽅式执⾏。

-Xbatch 禁⽤后台编译。默认情况下,JVM将该⽅法作为后台任务进⾏编译,在解释器模式下运⾏该⽅法,直到后台编译完成。-Xbatch标志禁⽤后台编译,以便所有⽅法的编译都作为前台任务进⾏,直到完成。此选项等效于-XX:-BackgroundCompilation。

最后⼀类,不稳定参数。 这也是 JVM调优的噩梦。以-XX 开头,这些参数是跟特定HotSpot版本对应的,很有可能换个版本就没有了。JDK中的以下⼏个指令可以帮助开发者了解 这⼀类不稳定参数。

java -XX:+PrintFlagsFinal:所有最终⽣效的不稳定指令。
java -XX:+PrintFlagsInitial:默认的不稳定指令
java -XX:+PrintCommandLineFlags:当前命令的不稳定指令

接下来,我们可以通过在java指令后加⼊相关的参数进⾏定制。例如对于数字型的参数,可以直接在java指令后指定。⽽对于boolean型的参数,可以通过在参数前⾯添加⼀个加号表示设置为true,添加⼀个减号则表示设置为false。例如java -XX:ActiveProcessorCount=1 -XX:+AggressiveHeap -XX:+PrintFlagsFinal -version

基于JDK17优化JVM内存布局

所有的⽅法以及GC活动,都发证在JVM内存中,所以,第⼀步⾸先是需要定制JVM整体的内存布局。之前带⼤家⽤arthas看过,JVM运⾏时,内存整体会分为堆区和⾮堆区。

图片.png 其中堆区Heap是JVM所管理的内存中最⼤的⼀块,主要⽤于存放各种对象实例。 由于JAVA实现了GC垃圾⾃动回收,所以对于堆内存的管理⽅式,会根据GC不同,⽽有很⼤的差距。

另外⼀块⾮堆区则是JVM中相对不是很繁忙的⼀块内存。这⼀块内存进⾏GC垃圾回收的频率相对较低,因此,对这块内存的管理⽅式相⽐堆区更加固定。

⽽我们⾸先要做的,就是定制JVM的堆区和⾮堆区的整体布局。接下来再根据选择的GC算法,定制其中的管理细节。

1、定制堆内存⼤⼩

堆内存的⼤⼩极⼤的决定了JAVA程序执⾏的性能,但也并不是越⼤越好。更⼤的堆内存,固然让JVM能够存下更多的对象,但同时,也加⼤了GC线程的压⼒。

主要涉及到以下⼏个参数:

  • -Xms : 设置堆内存的初始⼤⼩。默认单位是bytes。这个值必须是1024的整数,并且必须⼤于1M。如果不想设置这么精确,也可以在数字后⾯加k或者K表示KB,m或者M表示MB,g或者G表示GB。例如 -Xms62991456 , -Xms6144k, -Xms6m。

    如果不设置这个值,JVM将会默认将堆内存的初始⼤⼩设置为⽼年代与年轻代的内存之和。

    另外,有⼀个不稳定参数 -XX:InitalHeapSize也可以⽤来设置堆内存的初始⼤⼩。如果他出现在-Xms之后,那么初识对内存⼤⼩将最终由这个参数指定。

  • -Xmx : 设置堆内存的最⼤⼤⼩。

    配置⽅式和-Xms⼀样。只不过,他的默认值可以在运⾏时基于操作系统⾃⾏决定。-Xmx配置等同于-XX: MaxHeapSize。

在服务端进⾏部署时,通常将-Xms和-Xmx设置成相同的值,减少JAVA应⽤在运⾏过程中的临时内存申请⾏为。但是,如果内存资源⽐较紧张,那就需要JVM能够按需索取内存。先申请⼀⼩部分内存,内存不够了,再申请⼀部分新的内存空间。这时,有下⾯两个参数需要稍微关注下。

  • -XX:MinHeapFreeRatio -XX:MinHeapSize: 设置⼀次GC后所允许的堆空间的最⼩值。如果剩余的堆空间降落到这个阈值之下,这时堆空间就会启动⼀次扩充。这两个参数⼀个是⽐例,⼀个是⼤⼩。

2、定制⾮堆内存⼤⼩

⾮堆空间存储的内容相⽐堆空间,就更安分⼀些。⾮堆空间的⼤部分内容通常变动都不会很⼤,往往也可以更多的交由JVM统⼀进⾏管理。例如ClassSpace主要存储类模板,⽽⼀个JAVA程序的类信息,绝⼤部分都是在JAVA程序启动过程中就加载到内存中,并且⼏乎不会有什么变化。(虽然也可以通过⾃定义类加载器,在运⾏时加载更多的类,但⽆论是使⽤频率还是类数量,通常都⾮常少。)

设置元空间

MetaSpace元空间主要存储的是JAVA类的⼀些元信息。这包括类的结构信息,如字段、⽅法、注解等,以及运⾏时常量池、字段和⽅法字节码等。简单来说,MeatSpace存储了JAVA程序运⾏所必须得类型信息。这些信息,很显然,你不可能具体限制他的⼤⼩。只要需要,多⼤的空间都必须提供。

在JDK8以前的版本中,类的元数据存储在永久代(PermGen)中。然⽽,从JDK8以后,永久代被移除,取而代之的就是MetaSpace元空间。与永久代不同的是,MetaSpace并不使⽤JVM的内存,⽽是直接使⽤本地内存,这意味着Metaspace的⼤⼩不再受限于JVM内存⼤⼩的限制,⽽是受操作系统可⽤内存的限制。需要注意的是,这样也不意味着Metaspace的⼤⼩完全不受限,如果本地内存耗尽,还是会导致OutOfMemmoryError异常的。

通常需要通过下⾯参数控制元空间⼤⼩。

-XX:MetaspaceSize : 元空间⼤⼩

这⾥设置的并不是元空间具体的⼤⼩,⽽是当元空间⼤⼩超过这个阈值时,就会触发⼀次GC。⽽在后续运⾏过程中,触发GC的阈值会根据元空间的使用情况进行⾃动调整。元空间的默认值取决于平台。

-XX:MaxMetaspaceSize : 元空间最⼤值

设置元空间⼤⼩的最⼤值。默认元空间是⽆限制的。元空间的⼤⼩取决于应⽤本身以及操作系统可提供的内存⼤⼩。⼀个应⽤程序中,元空间内的数据⼤⼩是不应该经常发⽣变动的,所以,设定⼀个合理的最⼤值,可以尽早避免⼀些⾮正常的元空间数据暴涨对操作系统的影响。

设置线程栈空间

JAVA进程在运⾏时,会为每个进程开辟⼀块内存,⽤来执⾏线程中的对应指令。整个内存是⼀个栈结构,先进后出。线程中执⾏的每个⽅法对应栈空间中的⼀个⽅法帧。⽅法帧中主要包含了程序计数器、操作数栈、局部变量表、返回地址等⼏个标准部分。另外,某些具体虚拟机实现还会添加⼀些附加信息。例如HotSpot中还添加了动态链接库以及⼀些⾃定义的附加信息。

线程栈空间⼤部分的内存都会随着⽅法结束⽽释放,所以通常不需要单独设置。但是如果你的应⽤中的⽅法嵌套⾮常多,或者有很多⻓期执⾏的复杂⽅法,那么就需要调整栈空间⼤⼩。如果栈空间内存不够,就会抛出StackOverFlowException。

主要涉及到下⾯参数:

-Xss : 设置线程栈空间的⼤⼩

栈空间默认值⼤⼩,在Linux和MacOS系统中,都是1024KB。在Windows中,则需要依靠配置的虚拟内存⼤⼩决定。例如 -Xss1m, -Xss1024k这样。配置栈空间⼤⼩,还可以⽤另外⼀个参数: -XX:ThreadStackSize。这个参数的作⽤和-Xss是差不多的,只是配置⽅式稍有不同。例如 -XX:ThreadStackSize=1K, -XX:ThreadStackSize=1024k。

设置热点代码缓存空间

在JAVA中,-server选项表示JVM以服务器模式运⾏。在服务器模式下,HotSpot虚拟机会将执⾏频率⾼的热点代码识别出来,提前进⾏编译,并将编译结果缓存起来。后续执⾏时,就可以以编译执⾏的⽅式直接读取缓存,⽽不⽤再⼀⾏代码⼀⾏代码的进⾏解释执⾏。⽽这些热点代码,就保存在⾮堆区的CodeSpace中。

热点代码缓存空间也涉及到⼏个核⼼参数:

-XX:InitialCodeCacheSize=size

设定代码缓存空间的初始⼤⼩

-XX:ReservedCodeCacheSize=size

设定代码缓存空间的最⼤⼤⼩。代码缓冲区最⼤⼤⼩默认值是240MB。如果禁⽌提前编译(-XX:-TieredCompilation) ,那么最⼤的⼤⼩默认是48MB。如果⾃⼰指定,这个值不能⽐初始值⼩。

-XX:+SegmentedCodeCache 启⽤代码缓存分割

这是JDK8中没有的⼀个参数,在JDK17中默认启⽤。这个参数的作⽤主要是优化代码缓存空间的内存使⽤。如果没有打开这个选项,那么所有的代码缓存是⼀个⼤的内存⽚段,这不利于内存空间的灵活使⽤。

这个机制如果需要⽣效,还需要启⽤提前编译-XX:+TieredCompilation 并且-XX:ReservedCodeCacheSize >=240 MB 。

如果你对这个代码缓存分割感兴趣,后⾯⼏个参数或许能帮你⼤概了解⼀下,JVM底层是如何对代码缓存进⾏分割的。

  • -XX:ProfiledCodeHeapSize=size ;
  • -XX:NonNMethodCodeHeapSize=size ;
  • -XX:NonProfiledCodeHeapSize=size ; 启⽤代码缓存分割后,JVM底层就是将代码缓存划分成这三个部分的。

应⽤程序类数据共享

关于元数据区,补充⼀个⽐较⼩众的JVM优化机制。

Application Class Data Sharing(应⽤程序列数据共享,简称AppCDS)是⼀种旨在提⾼运⾏相同代码的多个JVM的启动时间,并减少他们的内存占⽤的⼀种优化机制。

使⽤AppCDS机制,可以在JVM第⼀次运⾏时,对他加载过的类的数据进⾏收集并归档,记录到数据⽂件中。之后,这些数据⽂件还可以被后续的JVM进程使⽤。相⽐于每次从class⽂件中加载类信息,AppCDS可以节省JVM初始化过程中的时间和资源。

# 将类信息归档到hello.jsa⽂件中。
java -Xshare:dump -XX:SharedArchiveFile=hello.jsa -version
# 使⽤归档⽂件启动,并打印类加载⽇志
java -XX:SharedArchiveFile=hello.jsa -Xlog:class+load -version
# 有hello.jsa⽂件,加载的最后⼀个类
[0.021s][info][class,load] java.nio.charset.CoderResult source: shared objects file
# 删掉hello.jsa⽂件后,依然可以加载类,但是⽐有归档⽂件时会慢⼀些
[0.038s][info][class,load] java.nio.charset.CoderResult source: jrt:/java.base

在部署微服务应⽤时,这会是⼀个可选的⽅式。

基于JDK17定制JVM的GC参数

设定完整体的内存布局后,接下来就到了最重要的环节,优化GC参数。不同的GC算法,对内存的管理方法也不同。

图片.png 关于各种GC算法的机制,之前课程中已经进⾏过讲解,这⾥就不再详细分析各种GC算法,只是从参数调优的⻆度进⾏学习。注意,这同样是⼀个没有标准答案的过程,不要希望⼀套配置打天下。多上阵,多试错才是唯⼀正确的⽅法。⽐如以后学习RocketMQ时,⾃⼰多尝试尝试修改不同的参数组合,会有什么不同的效果。

从上⾯RocketMQ的案例也能看到,在优化整体布局时, 在JDK8以前版本时,还设定了-Xmn参数,但是在JDK8以后的版本中,就没有设置这个参数,这其实也跟GC算法有关。

-Xmn :

设置分代收集的GC中年轻代的最⼤⼤⼩。堆中的年轻代⽤于存放新new出来的对象。年轻代的GC会⽐其他区域更频繁。年轻代太⼩,就会导致过于频繁的youngGC,⽽如果年轻代过⼤,⼜会导致youngGC的回收效果变差,加⼤fullGC的压⼒,从⽽进⼀步影响整个程序的运⾏效率。官⽅明确建议,对于基础的分代收集器,建议保持年轻代的⼤⼩在整个堆内存的25%到50%之间。⽽对于G1垃圾回收器,由于G1不再有严格固定的年轻代,所有官⽅明确建议,对G1垃圾回收器,不要设置-Xmn参数。

在JDK17中,已经取消了CMS算法,所以,接下来,主要针对JDK17中的G1和ZGC,分析相关常⻅的重要参数。

G1重要参数

优化G1的配置之前,还是需要先回顾⼀下G1的基本思想。

图片.png G1 GC 是⼀种分代的、并发的、基于区域的垃圾回收器,它将堆内存划分为多个独⽴的区域(Regions),每个区域可以是 Eden 区、Survivor 区或者 Old 区。为了保持系统的响应性,G1 GC 会尽量在达到⽤户设定的停顿时间⽬标(通过 -XX:MaxGCPauseMillis 参数指定)前进⾏垃圾回收。

G1虽然还是⼀个分代的垃圾回收器,但是他的各个Region的划分并不固定,所以,在使⽤G1垃圾回收器时,请忘记以往⾮常熟悉的堆内存分代模型。⽐如-Xmn(年轻代空间⼤⼩),-XX:NewRatio(年轻代与⽼年代⽐例),-XX:SurvivorRatio(年轻代中eden区与suvivor区的⽐例)等等这些参数。对于G1垃圾回收器,最为核⼼的参数就是三个: -XX:+UseG1GC(启动G1),-Xmx(堆内存⼤⼩),-XX:MaxGCPauseMillis(期望G1达到的最⼤停顿时间,默认200毫秒)

接下来,我们就以JDK17为标准,结合RocketMQ的运⾏脚本,整理⼀下在实际项⽬中,还有哪些需要了解的G1相关参数。在之前的课程中,我们已经基于JDK8,给⼤家详细介绍了G1的回收机制,其中也详细介绍了G1的核⼼参数。这⾥就以JDK17为标准,结合RocketMQ的运⾏脚本,整理⼀下RocketMQ这样⼀个开源软件是如何在项⽬中优化G1的。

  • -XX:+UseG1GC :使⽤G1垃圾回收器。在JDK17中,是默认选项。G1垃圾回收器适合那些需要⼤量堆内存(建议是6GB以上)同时还需要有稳定的GC延迟(STW延迟时间稳定在0.5秒以下)的应⽤。
  • -XX:G1HeapRegionSize=size 设定每个Region的⼤⼩。这个值必须是2的N次幂,且范围在1MB到32MB之间。这是G1最为重要的⼀个参数。这个参数的默认值是不固定的,通常JVM会将堆内存划分为2048个Region。每个Region的⼤⼩就是 堆内存/2048。 从这⾥看到,RocketMQ将这个参数设定成了16M,这是⼀个偏⼤的设置了。较⼤的Region区域可以减少GC的频率,从⽽降低停顿时间的影响。但是,这也意味着增加了每次GC回收的停顿时间。所以,设定这个参数时,需要在停顿时间和数据吞吐量之间进⾏权衡。
  • -XX:G1ReservePercent=percent 这个参数是告诉G1,为了满⾜停顿时间的整体⽬标时,应该保留多少⽐例的堆空间作为空闲。这样,在突发的内存分配需求活垃圾回收效率下降时,G1任然有⾜够的缓冲空间来避免⻓时间的停顿。这个参数的默认值是10%,这意味着堆内存的10%将被保留为空闲状态。这显然是⼀种空间换时间的策略,⽽RocketMQ将这个值设定为25%,这也是为了追求性能做的⼀种取舍。显然你也能猜到,RocketMQ这样的设定,如果在内存不太够的情况下,反⽽会造成内存更加紧张。
  • -XX:InitiatingHeapOccupancyPercent=percent 这个参数的默认值是45,表示当整个堆中,⽼年代Region达到45%时,G1就会开始并发标记周期。 RocketMQ将这个参数设定为30,显然是为了更积极的进⾏GC。 但是,并不意味着每次堆内存⽤到了30%就开始GC,与此相关的还有另外两个参数:-XX:G1UseAdaptiveIHOP 和 -XX:G1AdaptiveIHOPNumInitialSamples。 其中,-XX:G1UseAdaptiveIHOP 是⼀个bool型的参数,默认是启动的。这个参数启动后,G1只会在 -XX:G1AdaptiveIHOPNumInitialSamples 参数指定的前⾯⼏次GC活动中按照-XX:InitiatingHeapOccupancyPercent 参数进⾏计算。之后,G1会⾃动根据⽬标调整参数。-XX:G1AdaptiveIHOPNumInitialSamples 的默认值是3。所以,RocketMQ采⽤这种⽐较激进的设计,其实只是为了让RocketMQ的服务在启动时更稳健。
  • -XX:SoftRefLRUPolicyMSPerMB=time 这个参数就⽐较隐蔽了。他表示在每MB的堆内存中,软引⽤经过多⻓时间才被认为过期。默认值是1000,表示1秒。

另外,相⽐JDK8,JDK17中新增了⼏个G1相关的参数

  • -XX:ParallelGCThreads=threads 设置GC的⼯作线程数。默认值取决于GC算法以及CPU核⼼数。⽐如对于G1,可以通过以下⽅式设置线程数为2: -XX:ParallelGCThreads=2
  • -XX:G1HeapWastePercent=percent 设置堆空间的浪费⽐例。HotSpot虚拟机在对空间的使⽤⽐例低于这个值时不会启动GC周期。默认值是5%。
  • -XX:G1OldCSetRegionThresholdPercent=percent 设置⼀次混合GC中需要清理的Old区的内存⽐例。默认值是堆空间的10%。这也是G1⾮常重要的⼀个参数。将他调⼤,可以降低G1的频率,但是会让每⼀次GC的时间变⻓。
  • -XX:G1MixedGCCountTarget=number 设置G1垃圾回收器的线程上限。HotSpot会为了达到清理G1OldCSetRegionThresholdPercent⽐例的Old区的⽬标,会⾃动计算需要启动⼏个G1垃圾回收器。但是垃圾回收器的个数不会超过这个上限。默认值是8.

GC⽇志处理

GC⽇志的重要性,在之前课程中已经给⼤家做过介绍。这⾥就不再做过多讲解,只是根据RocketMQ的经验,整理⼀下要如何打印GC⽇志。

在JDK8以前,JVM中的⽇志打印是⽐较混乱的,很多⽇志被分散在多个不同的地⽅,也需要很多不同的参数进⾏打印。从JDK8往后,JVM的⽇志得到了极⼤的精简,所有⽇志打印相关的指令,都集中到了-Xlog 选项当中。这⾥就直接整理⼀个列表,⽐较JDK8与JDK17在⽇志⽅⾯的参数差异:

图片.png 例如RocketMQ中对JDK8以后的版本,统⼀采⽤以下参数打印GC⽇志 -Xlog:gc*:file=${GC_LOG_DIR}/rmq_srv_gc_%p_%t.log:time,tags:filecount=5,filesize=30M 其中 -Xlog:gc* 表示打印每次GC的详细信息,等同于JDK8中的 -XX:PrintGCDetails。后⾯的file分为三个部分:

第⼀部分是⽂件名

第⼆部分表示历史⽂件的后缀,有以下⼏个选项:

图片.png 第三部分表示历史⽂件的个数和⼤⼩。RocketMQ中的配置就表示保留5个⽂件,每个⽂件写满30M就切换下⼀个⽂件。

其他JVM调优⼩经验

接下来,仔细查看下RocketMQ的启动脚本,会发现⼀⾏注释掉了的配置信息:

#JAVA_OPT="${JAVA_OPT} -Xdebug -
Xrunjdwp:transport=dt_socket,address=9555,server=y,suspend=n"

⼀个精益求精的开源软件,为什么要保留这么⼀⾏注释掉的配置呢?其实这⾥就隐藏了⼀个⾮常有⽤的开发技巧。就是远程进⾏断点调试。

远程断点调试,简单来理解, 就是允许我们⽤本地Debug断点调试的⽅式,去调试在远端服务器上运⾏的应⽤程序。这个技巧对于开发⼀些复杂软件,是⾮常有⽤的。因为软件运⾏情况怎么样,只有部署到真实服务器上才能得到真正的验证。

我们可以⾃⼰做个⼩案例来理解⼀下这种远程调试的⽅案。

import java.util.Scanner;
//-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=5005
public class RemoteDebugTest {
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        String command="";
        int count = 0;
        do{
            command = scanner.next();
            System.out.println("第"+(++count)+"个指令:"+command);
        }while (!"quit".equals(command));
        System.out.println("接收到退出指令");
        System.exit(-1);
    }
}

这个⼩案例主要就是通过监听控制台输⼊,来模拟⼀个⻓时间运⾏的计算程序。如果在IDEA上运⾏,那么平平⽆奇。但是,我们可以将这个⽂件在控制台启动,然后启动时,加上那⼀串莫名其妙的配置信息:

(base) roykingw@roykingwdeMacBook-Pro classes % java -Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=5005 com.roy.RemoteDebugTest
Listening for transport dt_socket at address: 5005

启动后,就会在当前机器上启动⼀个应⽤监听,监听端⼝就是address参数指定的端⼝号。这样,就相当于在服务器上运⾏了⼀个JAVA程序。并且启动后,这个应⽤程序会阻塞住,等待开启远程监听。

接下来,我们就可以在本地IDEA配置远程调试。

⾸先需要在IDEA中配置⼀个远端调试的运⾏环境:

图片.png 然后,就可以在IDEA中打好断点,然后启动这个远程运⾏的环境。 图片.png 接下来,在本地IDEA中debug启动刚才配置的远端JVM调试指令,就可以在远端服务器上正常执⾏应⽤。⽽本地IDEA也会在远端服务器运⾏到断点代码处时,⾃动进⼊断点调试模式。

图片.png 但是要注意⼀下,这种远程调试的⽅式显然是不能⽤在⽣产环境的。因为打开远程调试后,服务端的应⽤程序必须监听到调试请求才会正常执⾏。如果你把IDEA中的调试任务终⽌了,远端的应⽤程序就会重新回归到阻塞状态。