GC调优基础

GC调优基础

虽然处理堆时各种GC算法有所差异,但是它们的基本配置参数是一致的.很多情况下,我们只需要这些基本的配置就能运行应用程序.

调整堆的大小

选择堆的大小是一种平衡,如果分配的堆过小,程序的大部分时间可能消耗在GC上,没有足够的时间去运行应用程序逻辑.
如果粗暴地设置一个大堆,将有以下风险:

  • GC停顿的时间取决于堆的大小,如果增加堆的大小,停顿的持续时长也会变长.这种情况下,虽然停顿的频率会变得更少,但是他们持续的时间会让程序的整体性能变慢.
  • 如果JVM使用的内存超过计算机的物理内存,部分不活跃的内存可能会被操作系统置换到交换分区(页面文件)中,JVM不会了解到这些细节.由于Full GC会访问整个JVM堆,那么内存交换一定会发生.Full GC停顿的时间将会以正常停顿时间的数个数量级的增长.类似的如果使用Concurrent收集器,后台线程在回收堆的时候,它的速度会被拖慢,因为要等待从磁盘复制数据到内存,结果导致发生代价昂贵的并发模式失败.
    因此,调整堆的大小时,永远不要把堆的大小设置的超过物理内存,另外如果同一台机器运行多个JVM,那么这个规则适用于所有堆的和.除此之外,你还必须为JVM自身以及其他应用程序预留部分内存,通常情况下,对于一般操作系统,应该至少预留1G的内存空间.

堆的大小由两个参数控制:

  • 最小值:-Xms(-XX:InitialHeapSize)
  • 最大值:-Xmx(-XX:MaxHeapSize)

默认值的调整取决于多个因素,包括操作系统的类型,系统内存大小,使用的JVM,其他命令行标志也会对该值造成影响;堆的大小是JVM自适应调优的核心.

Windows下Java11,物理内存为32G的情况下,初始值为512mb,最大值为8G,

1
2
3
4
5
6
7
8
9
10
11
D:\me\blog-test>java -XX:+PrintFlagsFinal -version | findStr InitialHeapSize
size_t InitialHeapSize = 534773760 {product} {ergonomic}
openjdk version "11.0.11" 2021-04-20
OpenJDK Runtime Environment GraalVM CE 21.1.0 (build 11.0.11+8-jvmci-21.1-b05)
OpenJDK 64-Bit Server VM GraalVM CE 21.1.0 (build 11.0.11+8-jvmci-21.1-b05, mixed mode, sharing)

D:\me\blog-test>java -XX:+PrintFlagsFinal -version | findStr MaxHeapSize
size_t MaxHeapSize = 8541700096 {product} {ergonomic}
openjdk version "11.0.11" 2021-04-20
OpenJDK Runtime Environment GraalVM CE 21.1.0 (build 11.0.11+8-jvmci-21.1-b05)
OpenJDK 64-Bit Server VM GraalVM CE 21.1.0 (build 11.0.11+8-jvmci-21.1-b05, mixed mode, sharing)

堆的大小具有初始值和最大值,JVM的目标是根据系统可用资源情况找到一个合理的默认值.堆的大小具有初始值和最大值的这种设计让JVM能够根据实际的负荷情况更灵活地调整JVM的行为,如果JVM发现使用初始的堆的大小,频繁地发生GC,它会尝试增大堆的空间,直到JVM的GC的频率回归到正常的范围,或者直到堆的大小增大到它的上限值.

通常,如果应用程序运行所需要的堆不超过运行平台默认的最大值,这个方法就可以工作的很好.
然后,如果应用程序在GC时消耗了太长的时间,你有可能需要通过设置-Xmx标志增大堆的大小.选择怎样的大小,没有硬性或者简单的法则.一个经验的法则是,一次Full GC后,应该释放出70%的空间.

即使显式地设置了堆的大小,还是会发生堆的自动调节,初始时堆以默认的大小开始运行,为了达到根据垃圾收集算法设置的性能目标,JVM会逐步增大堆的大小.将堆的大小设置的比实际需要更大不一定会带来性能损失.堆不会无限制地增大,JVM会调节堆的大小直到其满足GC的性能目标.

备注:如果你确切地直到应用程序需要多大的堆,那么你可以将堆的初始值和最大值设置成对应值,这种设置可以稍微提高GC的运行效率,因为它不需要估算堆是否需要调整大小了.

总结:JVM会根据其运行的机器,尝试估算合适的最大,最小堆的大小;除非应用程序需要比默认值更大的堆,否则在调优时,尽量考虑通过调整GC算法的性能目标,而非微调堆的大小来改善程序性能.

代空间的调整

堆的大小被确定后,我们还学要确定代的大小,如果新生代的大小分配的过小,那么会频繁地发生Minor GC,同样老年代就会比较大,发生的Full GC的频率较低,这里需要找到一个平衡.
调整代空间标识

  • 设置新生代与老年代的空间比率: -XX:NewRatio=N
  • 设置新生代的初始大小: -XX:NewSize=N
  • 设置新生代的最大大小: -XX:MaxNewSize=N
  • 将新生代的最大最小值设置为同一值的快捷方法 -XmnN

NewRation的默认值为2

1
2
3
4
5
tqd@tqd-pc:/mnt/d/me/blog-test$ java -XX:+PrintFlagsFinal -version | grep NewRatio
uintx NewRatio = 2 {product} {default}
openjdk version "11.0.11" 2021-04-20
OpenJDK Runtime Environment GraalVM CE 21.1.0 (build 11.0.11+8-jvmci-21.1-b05)
OpenJDK 64-Bit Server VM GraalVM CE 21.1.0 (build 11.0.11+8-jvmci-21.1-b05, mixed mode, sharing)

新生代初始大小计算公式:

1
Initial Young Generation Size = Initial Heap Size / ( 1 + NewRatio )

默认情况下新生代的初始大小为初始堆的33%.

我们也可以通过设置NewSize的值,显式地设定新生代的初始大小,其优先级高于通过NewRatio计算出来的值.虽然NewSize有默认值,但是在未设置的情况下,以NewRatio计算出来的为准

小结:整个堆范围内,不同代的大小划分是由新生代所占用的空间控制的;新生代的大小会随着整个堆的增大而增长,但是也是随着整个堆的空间比率波动变化的(依据新生代的初始值和最大值)

永久代和元空间的调整

JVM载入类的时候,它需要记录这些类的元数据.这些数据被保留在单独的堆空间中,在Java7中称为永久代(Permanent Generation),在Java8中称为元空间(Metaspace).
永久代与元空间不完全一样,在Java7中,永久代还保存了一些与类无关的杂项数据.这些数据在Java8中被移动到了普通的堆空间,Java8还在根本上改变了保存在这个特殊区域内的元数据的类型.

目前没有一个好算法可以提前计算出程序永久代/元空间需要多大空间的好算法.
永久代/元空间的大小与程序使用类的数量成正比例,应用程序越复杂,使用的对象越多,永久代/元空间就越大.
备注:使用元空间替换掉永久代的优势之一是我们不需要对其进行调整.
配置标志:

  • 永久代初始大小: -XX:PermSize=N
  • 永久代最大值: -XX:MaxPermSize=N
  • 元空间初始大小: -XX:MetaspaceSize=N
  • 元空间最大值: -XX:MetaspaceSize=N
    备注:元空间默认是没有大小限制的,Java8的应用可能由于元空间被填满而耗尽内存,可以使用NMT(Native Memory Tracking)诊断这类问题.通过设置MaxMetaspaceSize调整元空间的上限,一旦超过会触发OutOfMemoryError错误.解决此类问题需要定位出为什么类的元空间这么大.

虽然名叫永久代,元空间,但其中的数据并不会被永久保存,保存在其中的类会像对象一样会经历垃圾回收,当类加载器不再被引用时,其加载的类,会等待GC回收.

堆转储信息可以用于诊断存在那些类加载器.而这些信息放过来可以帮助确定是否存在类加载的泄露,最终导致永久代/元空间被耗尽,除此之外使用jmap和-permstat参数(Java7)或者clstats参数(Java8)可以输出类加载器相关信息.这些命令不稳定,不推荐使用.

小结:永久代/元空间保存着类的元数据(并非类的本体数据),他以分离的堆的形式存在;典型的应用在启动后不需要载入新的类,这个区域的初始值可以根据所有类都加载后的情况设置,使用优化的初始值能够加快启动的过程;开发中的应用服务器(或者任何需要频繁载入类的环境)上经常能碰到永久代或元空间耗尽而触发的Full GC,这时老的元数据会被回收.

控制并发

除了Serial收集器,几乎所有的收集器使用的算法都是基于多线程.启动线程的数量由-XX:ParallelGCThreads=N参数控制,这个参数会影响以下操作:

  • 使用-XX:+UseParallelGC收集新生代.
  • 使用-XX:+UseParallelOldGC收集老年代.
  • 使用-XX:+UseParNewGC收集新生代.
  • 使用-XX:+UseConcMarkSweepGC收集老年代时空停顿阶段.
  • 使用-XX:+UseG1GC收集老年代时空停顿阶段.
  • 使用-XX:UseG1GC收集新生代.
    由于GC操作会暂停所有应用程序线程,JVM为了缩短停顿时间就必须尽可能地利用更多的CPU资源,默认情况下JVM会在每一个CPU上运行一个垃圾收集线程,最多不超过8个,一旦超过8个,则采用以下公式计算:
1
ParallelGCThreads = 8 + (N-8)*5/8, N表示CPU的数量

小结: 几乎所有的垃圾收集算法中基本的垃圾回收线程都依据机器上CPU的数目计算得出;多个JVM运行在同一个机器上,依据公式计算出的线程数量可能过高,必须进行优化(减少).

自适应调整

自适应调整策略,JVM会不断地尝试,寻找优化性能的机会,JVM运行的过程中,堆,代以及Survivor空间的大小都可能发生变化.

这是一种尽力而为的方案,它根据过往的性能历史,假设即将来的GC周期跟最近的历史GC周期的状况可能很相似.事实证明,在多种负荷下这一假设是正确的.即使某个时刻内存的分配发生突变的情况,JVM也能依据最新的情况重新调整它的大小.

自适应调整有两方面的好处:

  • 小型的应用程序不需要再为指定过大的堆而担心.
  • 很多应用程序不需要担心他们堆的大小,如果使用堆的大小超过平台默认值,他们可以放心地分配更大的堆,而不用关心其中的细节.JVM会自动调整堆和代的大小,根据垃圾回收算法的性能目标,使用优化的内存量,自适应调整就是让自动调整生效的法宝.

空间的调整终归需要花费一定的时间开销,这部分时间大多数消耗在GC停顿的时候,如果你投注了大量的时间精细地调优了垃圾回收参数,定义了堆的大小限制,可以考虑关闭自适应调整.如果应用程序的运行明显划分为不同的阶段,你希望对这些阶段中的某个阶段进行垃圾回收的优化,那么关闭自适应调优也是很有帮助的.

使用-XX:-UseAdaptiveSizePolicy标志关闭自适应调整功能(默认开启);如果堆容量的最大值,最小值设置为一样,同时新生代的初始值和最大值也设置为同样大小,自适应功能会被关闭,不过此时Survivor空间是个意外.

可以启用标志-XX:+PrintAdaptiveSizePolicy标志,了解应用程序运行时JVM是如何调整的,一旦发生垃圾回收,GC的日志中包含不同代进行空间调整的细节信息.

小结: JVM在堆内部如何调整新生代与老年代的百分比是由自适应调整机制控制的;通常情况下我们应该开启自适应调整,垃圾回收算法依赖于调整后的代的大小来达到它停顿的性能目标;对于已经精细优化过的堆,关闭自适应调整能获得一定的性能提升.

垃圾回收工具

输出垃圾回收日志

垃圾回收对JVM性能影响至关重要,观察垃圾回收对应用程序性能的最好方法就是尽量熟悉垃圾回收的日志.垃圾回收日志中包含了程序运行过程中的每一次垃圾回收操作.

垃圾回收日志的细节依据使用的垃圾回收算法各有不同,不过垃圾回收日志的基本结构是一致的.

开启垃圾回收日志

  • 开启基本日志 -verbose:gc 或者 -XX:+PrintGC (默认关闭)
  • 开启详细日志 -XX:+PrintGCDetails (默认关闭)

备注:出了详细日志,我们还需要开启-XX:+PrintGCTimeStamps 或者 -XX:+PrintGCDateStamps,便于我们精准地判断几次GC操作的时间,时间戳是相对JVM启动的时间,日期戳是实际的日期字符串

垃圾回收日志输出到文件

默认情况下垃圾回收日志直接输出到控制台,使用-Xloggc:filename标志能修改输出到某个文件(除非显示的启动PrintGCDetails,不然仅输出基本日志).使用日志循环(Log rotation)标志限制保存在GC日志文件中的数据量,标志如下:

  • 启用日志文件循环 -XX:+UseGCLogfileRotation
  • 设置日志文件数量限制 -XX:NumberOfGCLogfiles=N
  • 设置日志文件大小限制 -XX:GCLogfileSize=N

备注: 默认情况下UseGCLogfileRotation是关闭的,开启UseGCLogfileRotation后,文件的数量为0,即为不限制数量,默认的文件大小也为0,即为不限制大小,如果设置的文件大小不足8K,那么日志文件的大小也会以8K为单位进行规整.

阅读垃圾回收日志

我们可以手工解析,阅读垃圾回收的日志,也可以借助外部工具.

  • GC Histogram可以根据日志文件生成对于的图表与表格.
  • jconsole可以查看堆的实时使用情况
  • jstat 可以使用脚本的方式获取堆的使用信息,比如选项-gcutil,它能输出消耗在GC上的时间,以及每个GC区域使用的百分比.

备注 jstat接受一个可选的参数,指定每隔多少毫秒重复执行这个命令.

小结

  1. GC日志是分析GC相关问题的重要线索;我们应该开启GC日志标志(即使在生产服务器上).
  2. 使用PrintGCDetails标志能获得更详尽的GC日志信息.
  3. 使用工具能很有效地帮助我们解析和理解GC日志的内容,尤其是在对GC日志中的数据进行归纳汇总时,他们非常有帮助.
  4. 使用jstat能动态地观察程序的垃圾回收操作.

总结

对任何一个Java应用程序,垃圾收集的性能都是其构成整体性能的关键一环.虽然对大多数的应用程序来说,调优的工作仅仅是选择合适的垃圾收集算法,或者在需要的时候,增大应用程序的堆空间.

自适应调整让JVM能够自动调整它的行为,使用给定的堆,提供尽可能好的性能.