背景

一日,线上的某个服务的某个节点突然告警,频繁地FullGC,这个服务已经有半个月没有进行过发布和容器重启了。

处理步骤

  • 首先将告警的容器隔离,从网关上摘掉这个节点,让流量不再进入到这个节点。
  • 通过jmap将节点的内存快照dump下来。
  • 通过jprofiler分析hprof文件。

分析原因

有很多工具可以方便地分析hprof文件,如MAT、JProfiler。
我们通过JProfiler分析,在JProfiler的启动中心打开单个快照。
在这里插入图片描述
选择下载到本地的hprof文件,等待一会解析,然后直奔“最大对象”(因为频繁fullgc基本上都是因为有回收不掉的大对象,导致fullgc没效果,才频繁触发的):
在这里插入图片描述
发现一个static字段占用了1851MB大小,差不多2GB!!
在这里插入图片描述
我们的JVM启动参数中有如下关于内存的设置:

-Xms8192m 
-Xmx8192m 
-Xmn5120m 
-XX:MetaspaceSize=384m 
-XX:MaxMetaspaceSize=512m 

即,java堆内存分配8GB,新生代堆内存5GB,那么留给老年代的就只剩3GB,然后那一个ConcurrentHashMap类型的static字段,就占了将近2GB(这个大对象很显然是放在老年代的)。
我们知道JVM垃圾回收的时候,是通过可达性分析来判断一个对象是否存活的,凡是能从GCRoots到达的对象都不会被回收,而static字段就是GCRoots的一种,也就是说我们的这个static字段是没办法被回收的。所以当发生FullGC的时候总是无法在老年代腾出空间,这样JVM很快又要进行FullGC,但是每次FullGC都回收不到足够的空间,从而陷入恶性循环。

解决问题

通过查看代码中的那个static ConcurrentHashMap,发现是中间件的一个类中的字段,用于存放流量的灰度标识,只有往里面放内容,却没有删除的代码。那么如果map的key基本上都不一样,就会导致map中的内容随着时间的流逝而不断地变大,最终导致FullGC的时候,回收不掉老年代中的内存。
这个问题,在前一段时间已经有别的服务也遇到了,后来反馈给中间件,中间件那边给了一个解决了该问题的版本让升级。
那么为什么以前没升级的时候没有出现问题?我猜测可能是上游网关更改了流量灰度标识的规则,导致往map里面放的内容的key突然变得花样多了起来,这样map就不能很好滴去重,导致map不断变大。而当时写中间件这块代码的人可能因为当时认为不会有那么多样的key,认为map不会变得太大,所以就没有删除map中的内容。
查看了中间件升级的版本,采用了guava的cache,设定了一个maxsize,这样map中的元素数量就不会超过maxsize,也就不会导致老年代被占用那么大。这其实就是一种缓存淘汰策略,只保留最近使用的maxsize条记录。

反思

  1. JVM的内存问题,很多的时候并不是简单地把堆内存设置大一点就可以解决的,如果代码有问题,总会把内存耗尽,这个时候就得通过修改代码来解决问题了。
  2. 有些内存泄露,不是很快就出现问题,而是很久以后才出问题的,比如我们的这个服务,半个月没重新启动,导致老年代被慢慢占用到无法回收。看来提高发布的频率也是有可能将一些隐藏的内存泄露问题掩盖住的。
  3. 本地缓存可以提高速度,但是要小心,看看是否需要清除不用的内容,比如采用guava的cache,设定一个固定的大小可以有效避免内存泄露。
Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐