前言
通过《JVM垃圾回收》理解JVM GC的工作机构,但不够,还要懂如何优化
GC日志
这里先要明确Minor GC、Major GC和Full GC的区别:
- Minor GC:针对年轻代的垃圾回收
- Major GC:针对老年代的垃圾回收。
- Full GC:针对整个堆(包括年轻代和老年代)的垃圾回收,而且会将年轻代存活的对象全部转移到老年代。
// 以下面参数启动程序
-XX:+UseConcMarkSweepGC
-verbose:gc
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
Minor GC日志格式如下
2021-07-17T13:57:07.826-0800: 10.755: [GC (Allocation Failure) 2021-07-17T13:57:07.826-0800: 10.755: [ParNew: 34944K->4351K(39296K), 0.0050114 secs] 34944K->4774K(126720K), 0.0050517 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
- 2021-07-17T13:57:07.826-0800: 10.755表示GC发生的日期花间,后面的10.755表示本次gc与JVM启动时的相对时间,单位为秒。
- [GC (Allocation Failure)这里的GC表示这是一次垃圾回收,但并不能单凭这个就判断这是依次Minor GC,括号中的Allocation Failure表示gc的原因,新生代内存不足而导致新对象内存分配失败。
- [ParNew: 34944K->4351K(39296K), 0.0050114 secs] 垃圾收集器为ParNew,我们知道ParNew是针对新生代的垃圾收集器,从这可以看出本次gc是Minor GC。后面紧跟着的34944K->4352K(39296K)的含义是GC前该内存区域已使用容量 -> GC后该内存区域已使用容量(该内存区域总容量),再后面的0.0050114 secs表示该内存区域GC所占用的时间,单位为秒。
- 34944K->4774K(126720K), 0.0050517 secs]表示收集前后整个堆的使用情况,0.0050517 secs表示本次GC的总耗时,包括把年轻代的对象转移到老年代的时间。
- [Times: user=0.01 sys=0.00, real=0.01 secs],表示GC事件在不同维度的耗时,单位为秒。这里面的user、sys和real与Linux的time命令所输出的时间含义一致,分别表示用户态消耗的CPU时间、内核态消耗的CPU时间和操作从开始到结束所经过的等待耗时(例如等待磁盘I/O、等待线程阻塞)。
MinorGC 触发条件
- Eden区域满了
- 新创建的对象大小 > Eden所剩空间
- Full GC的时候会先触发Minor GC
Major GC日志格式如下
cms模式下日志为
// 初始标记
2021-07-17T13:57:18.280-0800: 21.209: [GC (CMS Initial Mark) [1 CMS-initial-mark: 5844K(87424K)] 9223K(126720K), 0.0005914 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
// 并发标记
2021-07-17T13:57:18.281-0800: 21.210: [CMS-concurrent-mark-start]
2021-07-17T13:57:18.308-0800: 21.237: [CMS-concurrent-mark: 0.027/0.027 secs] [Times: user=0.05 sys=0.02, real=0.02 secs]
// 标记预清理
2021-07-17T13:57:18.308-0800: 21.237: [CMS-concurrent-preclean-start]
2021-07-17T13:57:18.309-0800: 21.238: [CMS-concurrent-preclean: 0.001/0.001 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
// 并发可中止的预清理阶段
2021-07-17T13:57:18.309-0800: 21.238: [CMS-concurrent-abortable-preclean-start]
// 这里有一次 Minor GC
2021-07-17T13:57:18.475-0800: 21.404: [GC (Allocation Failure) 2021-07-17T13:57:18.475-0800: 21.404: [ParNew: 37887K->2905K(39296K), 0.0036855 secs] 43732K->9796K(126720K), 0.0037165 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
2021-07-17T13:57:18.703-0800: 21.632: [CMS-concurrent-abortable-preclean: 0.086/0.394 secs] [Times: user=0.43 sys=0.03, real=0.40 secs]
// 重新标记
2021-07-17T13:57:18.704-0800: 21.632: [GC (CMS Final Remark) [YG occupancy: 23192 K (39296 K)]2021-07-17T13:57:18.704-0800: 21.632: [Rescan (parallel) , 0.0064188 secs]2021-07-17T13:57:18.710-0800: 21.639: [weak refs processing, 0.0000702 secs]2021-07-17T13:57:18.710-0800: 21.639: [class unloading, 0.0048281 secs]2021-07-17T13:57:18.715-0800: 21.644: [scrub symbol table, 0.0026840 secs]2021-07-17T13:57:18.718-0800: 21.646: [scrub string table, 0.0003091 secs][1 CMS-remark: 6890K(87424K)] 30082K(126720K), 0.0147064 secs] [Times: user=0.03 sys=0.00, real=0.01 secs]
// 并发清理 - 清除那些没有标记的无用对象并回收内存
2021-07-17T13:57:18.718-0800: 21.647: [CMS-concurrent-sweep-start]
2021-07-17T13:57:18.721-0800: 21.650: [CMS-concurrent-sweep: 0.003/0.003 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
// 并发清理 - 重新设置CMS算法内部的数据结构,准备下一个CMS生命周期的使用
2021-07-17T13:57:18.722-0800: 21.650: [CMS-concurrent-reset-start]
2021-07-17T13:57:18.724-0800: 21.653: [CMS-concurrent-reset: 0.003/0.003 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
下面对上图中各个阶段的日志进行分析
初始标记阶段(CMS initial mark)
- [GC (CMS Initial Mark) [1 CMS-initial-mark: 5844K(87424K)]表示这是CMS开始对老年代进行垃圾圾收集的初始标记阶段,5844K(87424K)表示当前老年代的容量为87424K,在使用了5844K时开始进行CMS垃圾回收。可以计算下这个比例,5844 / 87424K约等于0.67,可以大概推算出CMS收集器的启动内存使用阈值。
- 9223K(126720K), 0.0005914 secs]表示当前整个堆的内存使用情况和本次初始标记耗费的时间
- [Times: user=0.00 sys=0.00, real=0.00 secs] 参考上文
并发标记阶段(CMS concurrent mark)
- [CMS-concurrent-mark-start] 表示并发标记阶段开始
- [CMS-concurrent-mark: 0.027/0.027 secs] 0.027/0.027 secs表示遍历整个年老代并且标记活着的对象该阶段持续的时间和时钟时间,耗时0.027秒
- [Times: user=0.05 sys=0.02, real=0.02 secs] 参考上文
阶段时间和时钟时间的区别:
当阶段时间< 时钟时间, 表示任务快速做完了,在等待。
当阶段时间>时钟时间, 一些工作是并行完成的。
标记预清理阶段(concurrent-preclean)
- [CMS-concurrent-preclean-start] 参考上文
- [CMS-concurrent-preclean: 0.001/0.001 secs] 参考上文
- [Times: user=0.00 sys=0.00, real=0.00 secs] 参考上文
并发可中止的预清理阶段
- [CMS-concurrent-abortable-preclean-start] 参考上文
- [GC (Allocation Failure) 参考Minor GC,为什么会触发Minor GC,请参考《JVM垃圾回收》
- [CMS-concurrent-abortable-preclean: 0.086/0.394 secs] 参考上文
- [Times: user=0.43 sys=0.03, real=0.40 secs]参考上文
重新标记阶段(CMS remark)
- [GC (CMS Final Remark) 表示重新标记阶段,会STW
- [YG occupancy: 23192 K (39296 K)]表示年轻代当前的内存占用情况
- [Rescan (parallel) , 0.0064188 secs]这是整个final remark阶段扫描对象的用时总计,该阶段会重新扫描堆中剩余的对象,重新从“根对象”开始扫描,并且也会处理对象关联。本次扫描共耗时 0.0064188 s。
- [weak refs processing, 0.0000702 secs]第一个子阶段,表示对弱引用的处理耗时为0.0000702。
- [class unloading, 0.0048281 secs]第二个子阶段,表示卸载无用的类的耗时为0.0048281s。
- [scrub symbol table, 0.0026840 secs]第三个子阶段,表示清理字符串的符号耗时
- [scrub string table, 0.0003091 secs]第四个子阶段,表示清理字符串表的耗时
- [1 CMS-remark: 6890K(87424K)] 表示经历了上面的阶段后老年代的内存使用情况
- 30082K(126720K), 0.0147064 secs] 表示final remark后整个堆的内存使用情况和整个final remark的耗时
- [Times: user=0.03 sys=0.00, real=0.01 secs] 参考上文
并发清除阶段(CMS concurrent sweep)
- CMS-concurrent-sweep第一个子阶段,任务是清除那些没有标记的无用对象并回收内存。后面的参数是耗时,不再多提。
- CMS-concurrent-reset第二个子阶段,作用是重新设置CMS算法内部的数据结构,准备下一个CMS生命周期的使用。
Major GC 触发条件
- 老年代使用总量到达阀值, 初始标记阶段可以算出来
Full GC日志格式如下
2021-07-17T14:35:20.140-0800: 2303.028: [Full GC (System.gc()) 2021-07-17T14:35:20.140-0800: 2303.028: [CMS: 130006K->94027K(159388K), 0.7367636 secs] 153960K->94027K(198684K), [Metaspace: 91204K->91204K(1132544K)], 0.7380303 secs] [Times: user=0.38 sys=0.32, real=0.73 secs]
- [Full GC (System.gc()) 参考上文
- [CMS: 130006K->94027K(159388K), 0.7367636 secs] 表示老年代用的是cms,从原来的1300060->94027K,159388K是老年代总量,用时0.7367636 s;
- 153960K->94027K(198684K), 全堆的情况,从差值来看年代值也进行了回收
- [Metaspace: 91204K->91204K(1132544K)], 0.7380303 secs] 元空间的使用情况和用时
- [Times: user=0.38 sys=0.32, real=0.73 secs] 参考上文
Full GC触发条件:
- 调用System.gc时,系统建议执行Full GC,但是不必然执行
- 老年代空间不足
- 元空间不足
- 通过Minor GC后进入老年代的平均大小大于老年代的可用内存
- 由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小。
HotSpot VM的GC里,除了CMS的concurrent collection之外,其它能收集old gen的GC都会同时收集整个GC堆,包括young gen,所以不需要事先触发一次单独的young GC
调优案例
频繁的GC可能会引起cpu,内存的一系列问题。
案例1:由于元空间内存不足而引发的Full GC
- 通过[Full GC (Metadata GC Threshold)我们知道这是一次针对整个堆(包括年轻代和老年代)的Full GC,
- 括号中的Metadata GC Threshold说明了gc发生的原因,是因为元空间内存不足够而产生扩容导致的GC
- 同样的我们还可以通过后面的[CMS: 0K->19938K(1048576K)看出在老年代内存使用为0的时候就发生了Full GC,所以可以确定不是因为老年代内存不够用而发生的gc。
- 再后面的[Metaspace: 20674K->20674K(1069056K)]表示这次gc前后的元空间(Metaspace)内存变化,元空间的最大容量为1069056K,约等于1G
我们只要根据实际情况将元空间的初始值设置的-XX:MetaspaceSize大一点就可以避免这种Full GC。
案例2: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% 。
优化目标:降低TP99、TP90时间。
- 首先优化Minor GC频繁问题。通常情况下,由于新生代空间较小,Eden区很快被填满,就会导致频繁Minor GC,因此可以通过增大新生代空间来降低Minor GC的频率。例如在相同的内存分配率的前提下,新生代中的Eden区增加一倍,Minor GC的次数就会减少一半。
这时很多人有这样的疑问,扩容Eden区虽然可以减少Minor GC的次数,但会增加单次Minor GC时间么?
- Minor GC时间 =T1(扫描新生代)+T2(复制存活对象到Survivor区)
- 扩容前:新生代容量为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复制时间。
- 总结,扩大Eden区,Minor GC时增加了T1(扫描时间),但省去T2(复制对象)的时间,更重要的是对于虚拟机来说,复制对象的成本要远高于扫描成本;单次Minor GC时间更多取决于GC后存活对象的数量,而非Eden区的大小。
- 再通过日志查看到Major GC后老年代使用空间为300M+,意味着此时绝大多数(86% = 2G/2.3G)的对象已经不再存活,也就是说生命周期长的对象占比很小。
- 通过日志查看晋升年龄阈值为2,而JVM参数设置了MaxTenuringThreshold=15,证明大多对象是由于Eden区满了被晋升到老年代
由此可见,服务中存在大量短期临时对象,扩容新生代空间后,Minor GC频率降低,对象在新生代得到充分回收,只有生命周期长的对象才进入老年代。这样老年代增速变慢,Major GC频率自然也会降低。
案例3:请求高峰期发生GC,导致服务可用性下降
GC日志显示,高峰期CMS在重标记(Remark)阶段耗时1.39s。Remark阶段是Stop-The-World(以下简称为STW)的,即在执行垃圾回收时,Java应用程序中除了垃圾回收器线程之外其他所有线程都被挂起,意味着在此期间,用户正常工作的线程全部被暂停下来,这是低延时服务不能接受的。本次优化目标是降低Remark时间。
- 根据GC日志红色标记2处显示,可中断的并发预清理执行了5.35s,超过了设置的5s被中断,期间没有等到Minor GC ,所以Remark时新生代中仍然有很多对象
- 对于这种情况,CMS提供CMSScavengeBeforeRemark参数,用来保证Remark前强制进行一次Minor GC。
案例4: 发生Stop-The-World的GC
根据GC日志可知本次Full GC耗时1.23s。这个在线服务同样要求低时延高可用。
首先,什么时候可能会触发Full GC呢?
- 如果是CMS GC时出现promotion failed和concurrent mode failure中两种情况,日志中会有特殊标识,目前没有。(排除)
- Young GC晋升到老年代的平均大小大于老年代的剩余空间,根据GC日志,当时老年代使用量仅为20%,也不存在大于2G的大对象产生。(排除)
- 主动触发Full GC,因为当时没有相关命令执行
- 根据日志发现Full GC后,Perm区变大了,推断是由于永久代空间不足容量扩展导致的。