JVM GC

我们常说的垃圾回收算法可以分为两部分:对象的查找算法与真正的回收方法。不同回收器的实现细节各有不同,但总的来说基本所有的回收器都会关注如下两个方面:找出所有的存活对象以及清理掉所有的其它对象——也就是那些被认为是废弃或无用的对象。

Java 虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同的厂商、不同版本的虚拟机所提供的垃圾收集器都可能会有很大差别,并且一般都会提供参数供用户根据自己的应用特点和要求组合出各个年代所使用的收集器。其中最主流的四个垃圾回收器分别是:通常用于单 CPU 环境的 Serial GC、Throughput/Parallel GC、CMS GC、G1 GC。

GC 调优

GC 的各项指标,是衡量 Java 进程内存使用是否健康的重要标尺。

GC 优化步骤

GC 优化一般步骤可以概括为:确定目标、优化参数、验收结果。首先,明确应用程序的系统需求是性能优化的基础,系统的需求是指应用程序运行时某方面的要求,譬如:

  • 高可用,可用性达到几个 9。
  • 低延迟,请求必须多少毫秒内完成响应。
  • 高吞吐,每秒完成多少次事务。

明确系统需求之所以重要,是因为上述性能指标间可能冲突。比如通常情况下,缩小延迟的代价是降低吞吐量或者消耗更多的内存或者两者同时发生。一般的业务场景会偏重于关注高可用与低延迟,如何量化 GC 时间和频率对于响应时间和可用性的影响。通过这个量化指标,可以计算出当前 GC 情况对服务的影响,也能评估出 GC 优化后对响应时间的收益,这两点对于低延迟服务很重要。

业务指标投射到 GC 的核心指标,就是 GC Pause(包括 MinorGC 和 MajorGC)的频率和次数,以及每次回收的内存详情。假设单位时间 T 内发生一次持续 25ms 的 GC,接口平均响应时间为 50ms,且请求均匀到达,根据下图所示:

GC 对业务请求影响的示意图

那么有(50ms+25ms)/T 比例的请求会受 GC 影响,其中 GC 前的 50ms 内到达的请求都会增加 25ms,GC 期间的 25ms 内到达的请求,会增加 0-25ms 不等,如果时间 T 内发生 N 次 GC,受 GC 影响请求占比=(接口响应时间+GC 时间)×N/T 。可见无论降低单次 GC 时间还是降低 GC 次数 N 都可以有效减少 GC 对响应时间的影响。

GC Pause 的频率和次数可以通过 jstat 工具直接得到,内存回收详情则需要分析 GC 日志。通过收集 GC 信息,结合系统需求,确定优化方案,例如选用合适的 GC 回收器、重新设置内存比例、调整 JVM 参数等。进行调整后,将不同的优化方案分别应用到多台机器上,然后比较这些机器上 GC 的性能差异,有针对性的做出选择,再通过不断的试验和观察,找到最合适的参数。

常见的调优策略

由于垃圾回收器种类繁多,针对不同的应用,调优策略也有所区别,因此下面介绍几种通用的的 GC 调优策略。

  • 选择合适的 GC 回收器。根据应用对延迟、吞吐的要求,结合各垃圾回收器的特点,合理选用。推荐使用 G1 替换 CMS 垃圾回收器,G1 的性能是在逐步优化的,在 8GB 内存及以下的机器上,其各方面的表现也在赶上甚至有超越之势。G1 调参较方便,而 CMS 垃圾回收器参数太过复杂、容易造成空间碎片化、对 CPU 消耗较高等弊端,也使其目前处于废弃状态。Java 11 里新引入的 ZGC 垃圾回收器,基本可用做到全阶段并发标记和回收,值得期待。
  • 合理的堆内存大小设置。堆大小不要设置过大,建议不要超过系统内存的 75%,避免出现系统内存耗尽。最大堆大小和初始化堆的大小保持一致,避免堆震荡。新生代的大小设置比较关键,我们调整 GC 的频率和耗时,很多时候就是在调整新生代的大小,包括新生代和老年代的占比、新生代中 Eden 区和 Survivor 区的比例等,这些比例的设置还需要考虑各代中对象的晋升年龄,整个过程需要考虑的东西还是比较多的。如果使用 G1 垃圾回收器,新生代大小这一块需要考虑的东西就少很多了,自适应的策略会决定每一次的回收集合(CSet)。新生代的调整是 GC 调优的核心,非常依赖经验,但是一般来说,Young GC 频率高,意味着新生代太小(或 Eden 区和 Survivor 配置不合理),Young GC 时间长,意味着新生代过大,这两个方向大体不差。
  • 降低 Full GC 的频率。如果出现了频繁的 Full GC 或者 老年代 GC,很有可能是存在内存泄漏,导致对象被长期持有,通过 dump 内存快照进行分析,一般能较快地定位问题。除此之外,新生代和老年代的比例不合适,导致对象频频被直接分配到老年代,也有可能会造成 Full GC,这个时候需要结合业务代码和内存快照综合分析。
  • 通过配置 GC 参数,可以帮助我们获取很多 GC 调优所需的关键信息,如配置 -XX:+PrintGCApplicationStoppedTime -XX:+PrintSafepointStatistics -XX:+PrintTenuringDistribution,分别可以获取 GC Pause 分布、安全点耗时统计、对象晋升年龄分布的信息,加上 -XX:+PrintFlagsFinal 可以让我们了解最终生效的 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% 。Copy to clipboardErrorCopied

首先优化 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 也设置为相同,这样可以减少内存自动扩容和收缩带来的性能损失。虚拟机启动的时候就会把参数中所设定的内存全部化为私有,即使扩容前有一部分内存不会被用户代码用到,这部分内存在虚拟机中被标识为虚拟内存,也不会交给其他进程使用。

GC 参数与日志

参数配置

-server       -- 启用能够执行优化的编译器,显著提高服务器的性能Copy to clipboardErrorCopied

空间设置参数

-Xmx4000M     -- 堆最大值
-Xms4000M     -- 堆初始大小
-Xmn600M      -- 年轻代大小
-XX:PermSize=200M         -- 持久代初始大小
-XX:MaxPermSize=200M      -- 持久代最大值
-Xss256K                  -- 每个线程的栈大小,JDK 1.5 以后默认是 1M,在 IBM 的 jdk 中还有-Xoss 参数(此时每个线程占用的 stack 空间为 256K 大小)
-XX:LargePageSizeInBytes=128M        -- 内存页的大小不可设置过大,会影响 Perm 的大小
-XX:NewRatio=3 -- 为 Tenured:Young 的初始尺寸比例(设置了大小就不再设置此值),此时 Young 占用整个 HeapSize 的 1/4 大小。
-XX:SurvivorRatio=1       -- 年轻代中 Eden 区与两个 Survivor 区的比值Copy to clipboardErrorCopied

垃圾回收器选择参数

-XX:+DisableExplicitGC    -- 关闭 System.gc()
-XX:MaxTenuringThreshold=3 -- 一般一个对象在 Young 经过多少次 GC 后会被移动到 OLD 区。
-XX:+UseFastAccessorMethods          -- 原始类型的快速优化
-XX:SoftRefLRUPolicyMSPerMB=0          -- 每兆堆空闲空间中 SoftReference 的存活时间
-XX:+UseAdaptiveSizepollcy -- 收集器自动根据实际情况进行一些比例以及回收算法调整。
-XX:+UseParNewGC          -- 设置年轻代为并行收集
-XX:+UseParallelGC -- 一种较老的并行回收算法。
-XX:+UseParallelOldGC -- 对 Tenured 区域使用并行回收算法。
-XX:ParallelGCThread=10 -- 并行的个数,一般和 CPU 个数相对应。
-XX:+UseConcMarkSweepGC   -- 使用 CMS 内存收集
-XX:+CMSParallelRemarkEnabled        -- 降低标记停顿
-XX:+UseCMSCompactAtFullCollection   -- 在 FULL GC 的时候,对年老代进行压缩,可能会影响性能,但是可以消除碎片
-XX:CMSFullGCsBeforeCompaction=0     -- 此值设置运行多少次 GC 以后对内存空间进行压缩、整理
-XX:+CMSClassUnloadingEnabled        -- 回收动态生成的代理类 SEE:http://stackoverflow.com/questions/3334911/what-does-jvm-flag-cmsclassunloadingenabled-actually-do
-XX:+UseCMSInitiatingOccupancyOnly   -- 使用手动定义初始化定义开始 CMS 收集,禁止 HotSpot 自行触发 CMS GC
-XX:CMSInitiatingOccupancyFraction=80  -- 使用 CMS 作为垃圾回收,使用 80% 后开始 CMS 收集;在并发 GC 下,由于一边使用,一边 GC,就不能在不够用的时候 GC,默认情况下是在使用了 68%的时候进行 GC,通过该参数可以调整实际的值。Copy to clipboardErrorCopied

日志策略参数

-XX:+PrintGCDetails                    --输出GC日志详情信息
-XX:+PrintGCApplicationStoppedTime     --输出垃圾回收期间程序暂停的时间
-Xloggc:$WEB_APP_HOME/.tomcat/logs/gc.log  --把相关日志信息记录到文件以便分析.
-XX:+HeapDumpOnOutOfMemoryError            --发生内存溢出时生成heapdump文件
-XX:HeapDumpPath=$WEB_APP_HOME/.tomcat/logs/heapdump.hprof  --heapdump文件地址Copy to clipboardErrorCopied

各平台默认的垃圾回收器

GC 日志

Young GC 日志条目明细

Full GC 日志条目明细

CMS GC 日志详细分析

[GC (CMS Initial Mark) [1 CMS-initial-mark: 19498K(32768K)] 36184K(62272K), 0.0018083 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
[CMS-concurrent-mark-start]
[CMS-concurrent-mark: 0.011/0.011 secs] [Times: user=0.02 sys=0.00, real=0.00 secs]
[CMS-concurrent-preclean-start]
[CMS-concurrent-preclean: 0.001/0.001 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
[CMS-concurrent-abortable-preclean-start]
 CMS: abort preclean due to time [CMS-concurrent-abortable-preclean: 0.558/5.093 secs] [Times: user=0.57 sys=0.00, real=5.09 secs]
[GC (CMS Final Remark) [YG occupancy: 16817 K (29504 K)][Rescan (parallel), 0.0021918 secs][weak refs processing, 0.0000245 secs][class unloading, 0.0044098 secs][scrub symbol table, 0.0029752 secs][scrub string table, 0.0006820 secs][1 CMS-remark: 19498K(32768K)] 36316K(62272K), 0.0104997 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]
[CMS-concurrent-sweep-start]
[CMS-concurrent-sweep: 0.007/0.007 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
[CMS-concurrent-reset-start]
[CMS-concurrent-reset: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]Copy to clipboardErrorCopied

CMS 日志分为两个 STW(stop the world),分别是 init remark(1)与 remark(7)两个阶段。一般耗时比 YGC 长约 10 倍:

  • [GC (CMS Initial Mark) [1 CMS-initial-mark: 19498K(32768K)] 36184K(62272K), 0.0018083 secs][times: user=0.01 sys=0.00, real=0.01 secs] 会 STO(Stop The World),这时候的老年代容量为 32768K,在使用到 19498K 时开始初始化标记。耗时短。
  • [CMS-concurrent-mark-start] 并发标记阶段开始
  • [CMS-concurrent-mark: 0.011/0.011 secs] [Times: user=0.02 sys=0.00, real=0.00 secs] 并发标记阶段花费时间。
  • [CMS-concurrent-preclean-start] 并发预清理阶段,也是与用户线程并发执行。虚拟机查找在执行并发标记阶段新进入老年代的对象(可能会有一些对象从新生代晋升到老年代,或者有一些对象被分配到老年代)。通过重新扫描,减少下一个阶段”重新标记”的工作,因为下一个阶段会 Stop The World。
  • [CMS-concurrent-preclean: 0.001/0.001 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 并发预清理阶段花费时间。
  • [CMS-concurrent-abortable-preclean-start] CMS: abort preclean due to time
  • [CMS-concurrent-abortable-preclean: 0.558/5.093 secs][times: user=0.57 sys=0.00, real=5.09 secs] 并发可中止预清理阶段,运行在并行预清理和重新标记之间,直到获得所期望的 eden 空间占用率。增加这个阶段是为了避免在重新标记阶段后紧跟着发生一次垃圾清除
  • [GC (CMS Final Remark) [YG occupancy: 16817 K (29504 K)][rescan (parallel), 0.0021918 secs][weak refs processing, 0.0000245 secs][class unloading, 0.0044098 secs][scrub symbol table, 0.0029752 secs][scrub string table, 0.0006820 secs][1 CMS-remark: 19498K(32768K)] 36316K(62272K), 0.0104997 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] 会 STW(Stop The World),收集阶段,这个阶段会标记老年代全部的存活对象,包括那些在并发标记阶段更改的或者新创建的引用对象
  • [CMS-concurrent-sweep-start] 并发清理阶段开始,与用户线程并发执行。
  • [CMS-concurrent-sweep: 0.007/0.007 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] 并发清理阶段结束,所用的时间。
  • [CMS-concurrent-reset-start] 开始并发重置。在这个阶段,与 CMS 相关数据结构被重新初始化,这样下一个周期可以正常进行。
  • [CMS-concurrent-reset: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 并发重置所用结束,所用的时间。