垃圾收集调优

本节会介绍影响垃圾收集性能的三个主要的属性、垃圾收集调优的三个基本原则以及 HotSpot VM 垃圾收集调优时需要采集的信息。对于 Java 虚拟机调优而言,理解不同属性选择所带来的取舍、调优的原则以及收集什么信息都是非常重要的。

1. 性能属性

垃圾收集的性能有如下三个重要的指标:

  • 吞吐量:是评价垃圾收集器能力的重要指标之一,指不考虑垃圾收集引起的停顿时间或内存消耗,垃圾收集器能支撑应用程序达到的最高性能指标。

  • 延迟:也是评价垃圾收集器能力的重要指标,度量标淮是缩短由于垃圾收集引起的停顿时间或完全消除因垃圾收集所引起的停顿,避免应用程序运行时发生抖动。

  • 内存占用:垃圾收集器流畅运行所需要的内存数量。

这其中任何一个属性性能的提高几乎都是以另一个或两个属性性能的损失为代价的。然而,对大多数的应用而言,极少出现这三个属性的重要程度都同等的情况。很多时候,某一个或两个属性的性能要比另一个重要。我们需要了解对应用程序而言哪些系统需求是最重要的,也需要知道对应用程序而言这三个性能属性哪些是最重要的。确定哪些属性最重要,并将其映射到应用程序的系统需求,对应用程序而言非常重要。

2. 原则

谈到 JVM 的垃圾收集器调优也有三个需要理解的基本原则:

  • 每次 Minor GC 都尽可能多地收集垃圾对象,我们把这称作 Minor GC 回收原则。遵守这一原则可以减少应用程序发生 Full GC 的频率。Full GC 的长时间暂停是应用无法达到其延迟或吞吐量要求的罪魁祸首。


  • 处理吞吐量和延迟问题时,垃圾处理器能使用的内存越大,即 Java 堆空间越大,垃圾收集的效果越好,应用程序运行也越流畅。我们称之为 GC 内存最大化原则。


  • 在这三个性能属性(吞吐量、延迟、内存占用)中任意选择两个进行 JVM 垃圾收集器调优。我们称之为 GC调优的 3 选 2 原则。

调优 JVM 垃圾收集的过程中谨记这三条原则能帮助你更轻松地调优垃圾收集,达到应用程序的性能要求。

3. 命令行选项及 GC 日志

GC 日志是收集调优所需信息的最好途径,这意味着我们需要通过命令行开启 HotSpot VM 的 GC 统计信息采集功能。为了定位问题,即便在生产系统上开启 GC 日志也是个不错的主意。开启 GC 日志对性能的影响极小,却可以提供丰富的数据,将应用层的事件与垃圾收集或 JVM 层面的事件关联起来。譬如,应用程序运行的某些时刻出现很长时间停顿的情况。开启 GC 日志可以帮助定位这种长时间停顿的源头是否是垃圾收集事件。

HotSpot VM 提供了多个 GC 日志相关的命令行选项,我们推荐使用下面这些已经精简过的命令行集:

  1. -XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xlogge:<filename>

-XX:+PrintGCTimeStamps 会打印从 HotSpot VM 启动直到 GC 开始所经历的时间,-XX:+PrintGCDetails 提供垃圾收集器相关的统计数据,该选项的输出与使用的垃圾收集器密切相关,所以使用不同的垃圾收集器输出结果会有不同。-Xlogge:<filename> 选项可以指定将 GC 的日志信息记录到名为 filename 的文件中。

如果你需要用日历时间格式打印时间戳,可以使用 -XX:+PrintGCDateStamps 命令行选项,使用该选项后输出的格式按照 YYYY-MM-DDTHH-MM-SS.mmm-TZ 的方式显式。

针对高延迟问题调优 HotSpot VM 时,下面这两个命令行选项很有用,通过它们可以获得应用程序由于执行 VM 安全点操作而阻塞的时间以及两个安全点操作之间应用程序运行的时间。

  • -XX:+PrintCCApplicationStoppedTime,获取由安全点操作导致的暂停时间
  • -XX:+PrintGCApplicationConcurrentTime,获取应用程序的运行时间

安全点操作使 JVM 进入到一种状态:所有的 Java 应用线程都被阻塞、执行本地代码的线程都被禁止返回 VM 执行 Java 代码。安全点操作常用于虚拟机需要进行内部操作时,此时所有的 Java 线程都被显式地置于阻塞状态且不能修改 Java 堆的情況。

由于安全点操作会阻塞 Java 代码的执行,了解应用程序的延迟与某安全点是否相关非常有帮助。应用程序线程由于安全点操作发生阻塞时,如果能够观察到其中情况并附有应用程序的日志信息,可以帮助确认超出应用程序要求的延迟是否源于 VM 安全点操作、抑或源于应用程序或系统中的其他事件。

内存占用调优

Java 应用程序分配 Java 对象时,首先在新生代空间中分配对象,存活下来的对象,即经历几次 Minor GC 后还保持活跃的对象会被提升进入老年代空问,方法区中用于存放 VM 和 Java 类的元数据。

-Xms 和 -Xmx 命令行选项指定了 Java 堆空间大小的初始值和最大值,当 -Xms 指定的值小于 -Xmx 的值时,堆空间的大小可以根据应用程序的需要动态地扩展或缩减。关注吞吐量及延迟的 Java 应用程序应该将 -Xms 和 -Xmx 设定为同一值,这是因为无论扩展还是缩减堆空间都需要进行 Full GC,而 Full GC 会降低程序的吞吐量并导致更长的延迟。

新生代、老年代或方法区这三个空间中的任何一个不能满足内存分配请求时,就会发生垃圾收集。换句话说,这三个空间中任何一个被用尽,同时又有新的空间请求无法满足时就会触发垃圾收集。新生代没有足够的空间满足 Java 对象分配请求时,HotSpot VM 会进行 Minor GC 以释放空间。Minor GC 相对于 Full GC 而言,持续的时间要短。经历过几次 Minor GC 后仍然活跃的对象最终会被晋升到老年代。老年代空间不足以容纳新晋升的对象时,HotSpot VM 就会进行 Full GC。方法区没有足够的空间存储新的类元数据时也会发生 Full GC。

1. 优化新生代大小

新生代空间可以通过下面任何一个命令行选项进行设置,单位分别是 GB、MB 或 KB:

  1. # 新生代空间大小的初始值
  2. -XX:NewSize=<n>[g|m|k]
  3. # 新生代空间大小的最大值
  4. -XX:MaxNewSize=<n>[g|m|k]
  5. # 设置新生代空间的初始值、最小以及最大值
  6. -Xmn<n>[g|m|k]
  7. # 设置新生代与老年代的空间占用比例
  8. -XX:NewRatio=<n>

通过 -Xmn 可以很方便地设定新生代空间的初始值和最大值。但是,如果 -Xms 和 -Xmx 并没有设定为同一个值,使用 -Xmn 选项时,Java 堆的大小变化不会影响新生代空间,即新生代空间的大小总保持恒定,而不是随着 Java 堆大小的扩展或缩减做相应的调整。因此,只在 -Xms 与 -Xmx 设为同一值时才使用 -Xmn 选项。

Minor GC 需要的时间与新生代中可访问的对象数直接相关。通常情况下,新生代空间越小,Minor GC 持续的时间越短,但减小新生代空间又会增大 Minor GC 的频率。这是因为以同样的对象分配频率,较小的新生代空间在很短的时间内就会被填满。分析 GC 数据时,如果发现 Minor GC 的间隔时间过长,修正的方法是减少新生代空间。如果 Minor GC 频率太高,修正的方法是增加新生代空间。

调整新生代空间时,需要谨记下面几个准则:

  • 老年代空间大小不应该小于活跃数据大小的 1.5 倍,活跃数据即长时间存活的对象。
  • 新生代空间至少应为 Java 堆大小的 10%,通过 -Xmx 和 -Xms 可以设定该值。新生代过小可能会导致频繁的 Minor GC。
  • 增大 Java 堆大小时,需要注意不要超过 JVM 可用的物理内存数。堆占用过多内存将导致底层系统交换到虚拟内存,反而会造成垃圾收集器和应用程序的性能低下。

2. Survivor 空间

新生代空间通常被划分成了一个 Eden 空间和两个 Survivor 空间。两块 Survivor 空间中,一块标记为 “From” Survivor 空间,另一块标记为 “To” Survivor 空间。Eden 空间是分配新 Java 对象的空间,当 Eden 空间被填满时就会发生 Minor GC,活跃对象会从 Eden 空间复制到标记为 “To” 的 Survivor 空间,同时 “From” Survivor 空间中存活下来的对象也会复制到 “To” Survivor 空间中。

一旦完成 Minor GC,Eden 空间会清空,”From” Survivor 空间也变为空,而 “To” Survivor 空间中保存了还活跃的对象。之后,Survivor 空间将相互交换标记为下一次的 Minor GC 作准备。现在已清空的 “From” Survivor 空间换上了 “To” 标识,而 “To” Survivor 空间换成了 “From” 标识。因此,Minor GC 结束时,Eden 空间和一块 Survivor 空间中保存着经历了上次 Minor GC 存活下来的活跃对象。

如果 Minor GC 时,”To” Survivor 空间不足以容纳所有从 Eden 空间和 “From” Survivor 空间中复制过来的活跃对象,超出的部分会提升至老年代空间。溢出至老年代空间会导致非计划的老年代空间消枆加速,最终导致 Full GC。

调整 Survivor 空间的大小,让其有足够的空间容纳存活对象足够长的时间,直到几个周期之后对象老化(保持对象在新生代中直到它们变得不可达),就能避免发生 Survivor 空间溢出。有效的老化方法可以使老年代中只保存长期活跃的对象。

Survivor 空间的大小可以通过 -XX:SurvivorRatio=<ratio> 参数进行调整,ratio 值表示单个 Survivor 空间同 Eden 空间的大小的比率。下面的等式可以用于计算 Survivor 空间的大小:

  1. survivor 空间的大小 = -Xmn<value>/(-XX:SurvivorRatio=<ratio> + 2)

等式中加 2 的原因是有两个 Survivor 空间,ratio 值越大,Survivor 空间就越小。

当我们调整 Survivor 空间容量时,有一个重要原则:调整 Survivor 空间容量时,如果新生代空间大小不变,增大 Surivor 空间会减少 Eden 空间;而减少 Eden 空间会增加 Minor GC 的频率。因此,为了同时满足应用程序 Minor GC 频率的要求,就需要增大当前新生代空间的大小;即增大 Survivor 空间大小时,Eden 空间的大小应该保持不变。保持 Eden 空间大小恒定,Minor GC 的频率就不会由 Survivor 空间增大而发生变化。

3. 监控晋升阈值

HotSpot VM 在每次 Minor GC 时都会计算晋升阈值以决定什么时候对一个对象进行晋升,晋升阈值就是对象的年龄。一个对象的年龄就是它所经历的 Minor GC 次数。对象首次分配时,它的年龄为 0。下一次 Minor GC 之后,如果该对象还在新生代,其年龄变为 1,以此类推。新生代空间中年龄大于 HotSpot VM 计算出的晋升阈值的对象都会被提升到老年代空间。换句话说,晋升阈值决定了对象在新生代中保持的时间。

晋升阈值计算的依据是 Minor GC 之后新生代要容纳的可达对象需要的空间大小以及目标 Survivor 空间占用的空间大小。CMS 使用的新生代垃圾收集器(ParNew 收集器)会计算晋升阈值。同时,你可以使用 HotSpot VM 的命令行选项 -XX:MaxTenuringThreshold=<n> 指定 HotSpot VM 在对象的年龄超过 值时将其提升到老年代空间。晋升阈值的有效值可以设置在 0~15 之间,因为 HotSpot VM 采用 4 字节存储该值。

使用 HotSpot VM 的命令行选项 -XX:+PrintTenuringDistribution 可以监控晋升的分布或者对象年龄分布,并以此为依据确定最优的最大晋升阈值。在 -XX:+PrintTenuringDistribution 生成的输出中,我们需要关注的是随着对象
年龄的增加,各对象年龄上字节数减少的情况,以及 HotSpot VM 计算出的晋升阈值是否等于或接近设置的最大晋升阈值。

4. 永久代和元空间调整

JVM 载入类的时候,它需要记录这些类的元数据。在 Java 7 里,这部分空间被称为 永久代(Permgen),在 Java 8 中,它们被称为 元空间(Metaspace)。不过永久代和元空间并不完全一样,Java 7 中永久代还保存了一些与类数据无关的杂项对象,这些对象在 Java 8 中被挪到了普通的堆空间内。

永久代或者元空间内并没有保存类实例的具体信息,也没有反射对象,这些内容都保存在常规的堆空间内。永久代和元空间内保存的信息只对编译器或者 JVM 的运行时有用,这部分信息被称为类的元数据。永久代或者元空间的大小与程序使用的类的数量成比率相关,应用程序越复杂,使用的对象越多,永久代或者元空间就越大。使用元空间替换掉永久代的优势之一是我们不再需要对其进行调整,因为元空间默认使用尽可能多的空间。

对于永久代而言,可以通过 -XX:Permsize=N、-XX:MaxPermsize=N 参数调整其大小。而元空间的大小则可以通过 -XX:MetaspaceSize=N 和 -XX:MaxMetaspaceSize=N 参数进行调整。

由于元空间默认的大小是没有限制的,如果元空间增长得过大,通过设置 MaxMetaspaceSize 可以调整元空间的上限,将其限制为一个更小的值,不过这可能会导致应用程序最后由于元空间耗尽,发生 OutOfMenoryError 异常。解决这类问题的终极方法还是定位出为什么类的元空间会变得如此巨大。

5. 自适应调整

如果开启了自适应调整策略,JVM 会不断地尝试,寻找优化性能的机会,所以在 JVM 的运行过程中,堆中的各个部分的空间大小都可能会发生变化。这是一种尽力而为(Best-Effort)的方案,它进行性能调优的依据是以往的性能历史,这种调整方案一般情况下都是比较合理的。

自适应调整可以让应用程序不需要担心它们堆的大小,JVM 会自动调整堆和代的大小,依据垃圾回收算法的性能目标,使用优化的内存量。不过,空间大小的调整终归要花费一定的时间开销,这部分时间大多数消耗在 GC 停顿的时候。如果你投注了大量的时间精细地调优了垃圾回收的参数、定义了应用程序堆大小限制,可以考虑关闭自适应调整。

使用 -XX:-UseAdaptiveSizePolicy 标志可以在全局范围内关闭自适应调整功能(默认情况下,这个标志是开启的)。如果堆容量的最大、最小值设置成同样的值,与此同时新生代的初始值和最大值也设置为同样大小,自适应调整的功能会被关闭。如果想了解应用程序运行时 JVM 的空间是如何调整的,可以开启 -XX:+PrintAdaptiveSizePolicy 标志。开启该标志后,一旦发生垃圾回收,GC 的日志中会包含垃圾回收时不同的分代进行空间调整的细节信息。