GC 优化案例

GC 优化案例

Major GC 和 Minor GC 频繁

Minor GC 每分钟 100 次,Major GC 每 4 分钟一次,单次 Minor GC 耗时 25ms,单次 Major GC 耗时 200ms,接口响应时间 50ms。由于这个服务要求低延时高可用,结合上文中提到的 GC 对服务响应时间的影响,计算可知由于 Minor GC 的发生,12.5%的请求响应时间会增加,其中 8.3%的请求响应时间会增加 25ms,可见当前 GC 情况对响应时间影响较大。

50ms+25ms)× 100/60000ms = 12.5%,50ms × 100次/60000ms = 8.3%

首先优化 Minor GC 频繁问题。通常情况下,由于新生代空间较小,Eden 区很快被填满,就会导致频繁 Minor GC,因此可以通过增大新生代空间来降低 Minor GC 的频率。例如在相同的内存分配率的前提下,新生代中的 Eden 区增加一倍,Minor GC 的次数就会减少一半。扩容 Eden 区虽然可以减少 Minor GC 的次数,但会增加单次 Minor GC 时间么?根据上面公式,如果单次 Minor GC 时间也增加,很难保证最后的优化效果。我们结合下面情况来分析,单次 Minor GC 时间主要受哪些因素影响?是否和新生代大小存在线性关系? 首先,单次 Minor GC 时间由以下两部分组成:T1(扫描新生代)和 T2(复制存活对象到 Survivor 区)如下图。(注:这里为了简化问题,我们认为 T1 只扫描新生代判断对象是否存活的时间,其实该阶段还需要扫描部分老年代。)

单次 MinorGC 复制

  • 扩容前:新生代容量为 R,假设对象 A 的存活时间为 750ms,Minor GC 间隔 500ms,那么本次 Minor GC 时间= T1(扫描新生代 R)+T2(复制对象 A 到 S)。

  • 扩容后:新生代容量为 2R,对象 A 的生命周期为 750ms,那么 Minor GC 间隔增加为 1000ms,此时 Minor GC 对象 A 已不再存活,不需要把它复制到 Survivor 区,那么本次 GC 时间 = 2 × T1(扫描新生代 R),没有 T2 复制时间。

可见,扩容后,Minor GC 时增加了 T1(扫描时间),但省去 T2(复制对象)的时间,更重要的是对于虚拟机来说,复制对象的成本要远高于扫描成本,所以,单次 Minor GC 时间更多取决于 GC 后存活对象的数量,而非 Eden 区的大小。因此如果堆中短期对象很多,那么扩容新生代,单次 Minor GC 时间不会显著增加。通过扩容新生代为为原来的三倍,单次 Minor GC 时间增加小于 5ms,频率下降了 60%,服务响应时间 TP90,TP99 都下降了 10ms+,服务可用性得到提升。如何选择各分区大小应该依赖应用程序中对象生命周期的分布情况:如果应用存在大量的短期对象,应该选择较大的年轻代;如果存在相对较多的持久对象,老年代应该适当增大。

请求高峰期发生 GC,导致服务可用性下降

减少老年代扫描

GC 日志显示,高峰期 CMS 在重标记(Remark)阶段耗时 1.39s。Remark 阶段是 Stop-The-World(以下简称为 STW)的,即在执行垃圾回收时,Java 应用程序中除了垃圾回收器线程之外其他所有线程都被挂起,意味着在此期间,用户正常工作的线程全部被暂停下来,这是低延时服务不能接受的。本次优化目标是降低 Remark 时间。

GC 日志

下图展示了 CMS 各个阶段可以标记的对象,用不同颜色区分。

  1. Init-mark 初始标记(STW),该阶段进行可达性分析,标记 GC ROOT 能直接关联到的对象,所以很快。
  2. Concurrent-mark 并发标记,由前阶段标记过的绿色对象出发,所有可到达的对象都在本阶段中标记。
  3. Remark 重标记(STW),暂停所有用户线程,重新扫描堆中的对象,进行可达性分析,标记活着的对象。因为并发标记阶段是和用户线程并发执行的过程,所以该过程中可能有用户线程修改某些活跃对象的字段,指向了一个未标记过的对象,如下图中红色对象在并发标记开始时不可达,但是并行期间引用发生变化,变为对象可达,这个阶段需要重新标记出此类对象,防止在下一阶段被清理掉,这个过程也是需要 STW 的。特别需要注意一点,这个阶段是以新生代中对象为根来判断对象是否存活的。
  4. 并发清理,进行并发的垃圾清理。

CMS 各阶段要标记的对象

降低 Remark 时间最直接的方式就是仅对老年代进行扫描,但是,Remark 阶段仅扫描老年代是否可行?结论是不可行,原因如下:

跨代引用

如果仅扫描老年代中对象,即以老年代中对象为根,判断对象是否存在引用,上图中,对象 A 因为引用存在新生代中,它在 Remark 阶段就不会被修正标记为可达,GC 时会被错误回收。新生代对象持有老年代中对象的引用,这种情况称为“跨代引用”。因它的存在,Remark 阶段必须扫描整个堆来判断对象是否存活,包括图中灰色的不可达对象。灰色对象已经不可达,但仍然需要扫描的原因:新生代 GC 和老年代的 GC 是各自分开独立进行的,只有 Minor GC 时才会使用根搜索算法,标记新生代对象是否可达,也就是说虽然一些对象已经不可达,但在 Minor GC 发生前不会被标记为不可达,CMS 也无法辨认哪些对象存活,只能全堆扫描(新生代+老年代)。由此可见堆中对象的数目影响了 Remark 阶段耗时。分析 GC 日志可以得出同样的规律,Remark 耗时>500ms 时,新生代使用率都在 75%以上。这样降低 Remark 阶段耗时问题转换成如何减少新生代对象数量。

新生代中对象的特点是“朝生夕灭”,这样如果 Remark 前执行一次 Minor GC,大部分对象就会被回收。CMS 就采用了这样的方式,在 Remark 前增加了一个可中断的并发预清理(CMS-concurrent-abortable-preclean),该阶段主要工作仍然是并发标记对象是否存活,只是这个过程可被中断。此阶段在 Eden 区使用超过 2M 时启动,当然 2M 是默认的阈值,可以通过参数修改。如果此阶段执行时等到了 Minor GC,那么上述灰色对象将被回收,Reamark 阶段需要扫描的对象就少了。除此之外 CMS 为了避免这个阶段没有等到 Minor GC 而陷入无限等待,提供了参数 CMSMaxAbortablePrecleanTime,默认为 5s,含义是如果可中断的预清理执行超过 5s,不管发没发生 Minor GC,都会中止此阶段,进入 Remark。根据 GC 日志红色标记 2 处显示,可中断的并发预清理执行了 5.35s,超过了设置的 5s 被中断,期间没有等到 Minor GC,所以 Remark 时新生代中仍然有很多对象。

对于这种情况,CMS 提供 CMSScavengeBeforeRemark 参数,用来保证 Remark 前强制进行一次 Minor GC。由于跨代引用的存在,CMS 在 Remark 阶段必须扫描整个堆,同时为了避免扫描时新生代有很多对象,增加了可中断的预清理阶段用来等待 Minor GC 的发生。只是该阶段有时间限制,如果超时等不到 Minor GC,Remark 时新生代仍然有很多对象,我们的调优策略是,通过参数强制 Remark 前进行一次 Minor GC,从而降低 Remark 阶段的时间。

减少新生代扫描

新生代 GC 存在同样的问题,即老年代可能持有新生代对象引用,所以 Minor GC 时也必须扫描老年代。JVM 是如何避免 Minor GC 时扫描全堆的? 经过统计信息显示,老年代持有新生代对象引用的情况不足 1%,根据这一特性 JVM 引入了卡表(card table)来实现这一目的。如下图所示:

卡表

卡表的具体策略是将老年代的空间分成大小为 512B 的若干张卡(card)。卡表本身是单字节数组,数组中的每个元素对应着一张卡,当发生老年代引用新生代时,虚拟机将该卡对应的卡表元素设置为适当的值。如上图所示,卡表 3 被标记为脏(卡表还有另外的作用,标识并发标记阶段哪些块被修改过),之后 Minor GC 时通过扫描卡表就可以很快的识别哪些卡中存在老年代指向新生代的引用。这样虚拟机通过空间换时间的方式,避免了全堆扫描。

总结来说,CMS 的设计聚焦在获取最短的时延,为此它“不遗余力”地做了很多工作,包括尽量让应用程序和 GC 线程并发、增加可中断的并发预清理阶段、引入卡表等,虽然这些操作牺牲了一定吞吐量但获得了更短的回收停顿时间。

发生 Stop-The-World 的 GC

GC 日志如下图(在 GC 日志中,Full GC 是用来说明这次垃圾回收的停顿类型,代表 STW 类型的 GC,并不特指老年代 GC),根据 GC 日志可知本次 Full GC 耗时 1.23s。这个在线服务同样要求低时延高可用。本次优化目标是降低单次 STW 回收停顿时间,提高可用性。

GC 日志

首先,什么时候可能会触发 STW 的 Full GC 呢?

  • Perm 空间不足;
  • CMS GC 时出现 promotion failed 和 concurrent mode failure(concurrent mode failure 发生的原因一般是 CMS 正在进行,但是由于老年代空间不足,需要尽快回收老年代里面的不再被使用的对象,这时停止所有的线程,同时终止 CMS,直接进行 Serial Old GC);
  • 统计得到的 Young GC 晋升到老年代的平均大小大于老年代的剩余空间;
  • 主动触发 Full GC(执行 jmap -histo:live [pid])来避免碎片问题。

然后,我们来逐一分析一下:

  • 排除原因 2:如果是原因 2 中两种情况,日志中会有特殊标识,目前没有。
  • 排除原因 3:根据 GC 日志,当时老年代使用量仅为 20%,也不存在大于 2G 的大对象产生。
  • 排除原因 4:因为当时没有相关命令执行。
  • 锁定原因 1:根据日志发现 Full GC 后,Perm 区变大了,推断是由于永久代空间不足容量扩展导致的。

找到原因后解决方法有两种:

  • 通过把-XX:PermSize 参数和-XX:MaxPermSize 设置成一样,强制虚拟机在启动的时候就把永久代的容量固定下来,避免运行时自动扩容。
  • CMS 默认情况下不会回收 Perm 区,通过参数 CMSPermGenSweepingEnabled、CMSClassUnloadingEnabled,可以让 CMS 在 Perm 区容量不足时对其回收。

对于性能要求很高的服务,建议将 MaxPermSize 和 MinPermSize 设置成一致(JDK8 开始,Perm 区完全消失,转而使用元空间。而元空间是直接存在内存中,不在 JVM 中),Xms 和 Xmx 也设置为相同,这样可以减少内存自动扩容和收缩带来的性能损失。虚拟机启动的时候就会把参数中所设定的内存全部化为私有,即使扩容前有一部分内存不会被用户代码用到,这部分内存在虚拟机中被标识为虚拟内存,也不会交给其他进程使用。

上一页