1. Full GC (Ergonomics)
1.1 Java 进程一直进行 Full GC
例行检查线上运行的 Java 服务,通过 jstat -gcutil < pid >
命令检查 gc 情况的时候发现一个服务有点异常。可以看到以下打印的 gc 情况中,只有 FGC
的次数一直在变化,而YGC
维持不变,也就是说这个服务一直在进行 Full GC
,显而易见是有问题的
S0 | S1 | E | O | M | CCS | YGC | YGCT | FGC | FGCT | GCT |
---|---|---|---|---|---|---|---|---|---|---|
0.00 | 0.00 | 100.00 | 99.97 | 90.48 | 88.34 | 17498 | 91.739 | 1452 | 570.310 | 662.049 |
0.00 | 0.00 | 13.47 | 99.97 | 90.48 | 88.34 | 17498 | 91.739 | 1453 | 571.179 | 662.918 |
0.00 | 0.00 | 39.33 | 99.97 | 90.48 | 88.34 | 17498 | 91.739 | 1454 | 571.879 | 663.118 |
1.2 Full GC 的原因
检查 gc 日志,发现有以下 log,可以看到发生 Full GC
的原因是 Ergonomics
,并且年老代 Full GC 前后占用的内存几乎不变。查找资料,发现当使用 Server 模式下的ParallelGC
收集器组合(Parallel Scavenge + Serial Old)
时,会在 Minor GC
前进行一次判断,也就是 内存空间分配担保机制:
- Eden 空间不足发生
Minor GC
之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么Minor GC
可以确认是安全的。如果不成立的话虚拟机查看HandlePromotionFailure
的值是否允许担保失败,如果HandlePromotionFailure
的值不允许冒险,那么就要进行一次Full GC
。如果允许则检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于将继续尝试进行Minor GC
,小于的话就要进行Full GC
以保证Minor GC
的空间担保成功
这个 Java 服务没有通过启动参数配置垃圾收集器,查看堆配置发现是使用的默认 Parallel GC
。显然,合理的推测是空间分配担保失败导致了Full GC
[Full GC (Ergonomics) [PSYoungGen: 82944K->8916K(84992K)] [ParOldGen: 175101K->175101K(175104K)] 258045K->184018K(260096K), [Metaspace: 106250K->106166K(1153024K)], 0.4203767 secs] [Times: user=1.29 sys=0.00, real=0.42 secs]
1.3 检查堆占用
-
使用
jmap -heap 32006
命令查看堆内存使用情况,发现老年代的使用已经达到了99.97697796737938%
,只剩下了0.03M
,是可以和之前的推测相佐证的PS Old Generation capacity = 179306496 (171.0MB) used = 179265216 (170.96063232421875MB) free = 41280 (0.03936767578125MB) 99.97697796737938% used
- 1
- 2
- 3
- 4
- 5
-
使用命令
jmap -histo 32006 | head -n 30
查看堆内存中占用内存最多的前 30 个类实例,发现可疑的类有以下 3 个。其中SpringValue
为携程开源框架 apollo 的内部类,LinkedListMultimap
是 apollo 引用的 google 开源的集合类,像这种开源的代码如果有内存泄露的问题估计早就被曝出来修复掉了,所以唯一的疑点就是项目内部自己实现的MessageProcessor
这个类了- com.ctrip.framework.apollo.spring.property.SpringValue --53万个对象,占用 23M
- com.google.common.collect.LinkedListMultimap$Node --53万个对象,占用 21M
- com.service.task.client.MessageProcessor – 53万个对象,占用 17M
2. 代码检查
检查代码,发现 MessageProcessor
类通过注解 @Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
修饰,每次使用的时候都会新建一个对象,其内部还通过注解 @Value
引用了 apollo 配置。这个类的功能是从消息队列中拉取消息,然后将其分发给处理函数,从而完成一次消息处理。这个类之所以被设计成多实例,可以参考Spring 多实例注入,没错,就是笔者自己写的,因此原因也很清楚了
- 脚本每被调度一次,
MessageProcessor
就创建一个新实例用于从指定的消息队列中拉消息。这样时间一长,MessageProcessor
对象大量被创建,堆积在堆内存年轻代中,触发Minor GC
。本来这些只使用一次的对象理应在多次 Minor GC 中慢慢被回收掉,但是 JVM 的动态年龄机制是如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半, 则年龄大于或等于该年龄的对象可以直接进入老年代,这样大量的MessageProcessor
对象就跳过了年龄限制,直接进入老年代,导致老年代对象占用的内存居高不下。这种情况下当Minor GC
触发时,由于老年代剩余内存空间不足,空间担保必然失败,就触发了Full GC (Ergonomics)
3. 解决方式
- 使用多实例注入的思路是没错的,错误在于笔者的使用方式。之前的使用方式在脚本启动的时候就会新建
MessageProcessor
对象,造成大量的重复对象被创建,不仅浪费了内存,还会在一定程度上影响性能。基于此解决的方法很简单,只要通过@Bean(name = "xxxx")
注解为每条队列单独配置好一个processor
消息处理者,再使用@Resource(name = "xxxx")
引用指定的MessageProcessor
对象,避免大量相同的MessageProcessor
对象被创建出来就可以了 - 使用多实例注入的实质是为了解决
MessageProcessor
对象中某些属性的线程隔离问题,故也可以使用单例MessageProcessor
对象,同时将需要隔离的属性存入ThreadLocal
的解决方法。最后,最简单粗暴的解决方式是,将需要隔离的属性直接方法入参,这样就不需要考虑线程隔离问题了