public static FileSystem get(final URI uri, final Configuration conf, final String user)
public static FileSystem get(URI uri, Configuration conf)
我们可以看到 FileSystem get (final URI uri, final Configuration conf, final String user) 方法最后是调用 FileSystem get (URI uri, Configuration conf) 方法的,区别在于 FileSystem get (URI uri, Configuration conf) 方法于缺少也就是缺少每次新建 Subject 的的操作。
本文主要介绍了由 FileSystem 类引起的一次线上内存泄漏导致内存溢出的问题分析解决全过程。
一、背景
周末小叶正在王者峡谷乱杀,手机突然收到大量机器 CPU 告警,CPU 使用率超过 80% 就会告警,同时也收到该服务的 Full GC 告警。该服务是小叶项目组非常重要的服务,小叶赶紧放下手中的王者荣耀打开电脑查看问题。
图 1.1 CPU 告警 Full GC 告警
二、问题发现
2.1 监控查看
因为服务 CPU 和 Full GC 告警了,打开服务监控查看 CPU 监控和 Full GC 监控,可以看到两个监控在同一时间点都有一个异常凸起,可以看到在 CPU 告警的时候,Full GC 特别频繁,猜测可能是 Full GC 导致的 CPU 使用率上升告警。
图 2.1 CPU 使用率
图 2.2 Full GC 次数
2.2 内存泄漏
从 Full Gc 频繁可以知道服务的内存回收肯定存在问题,故查看服务的堆内存、老年代内存、年轻代内存的监控,从老年代的常驻内存图可以看到,老年代的常驻内存越来越多,老年代对象无法回收,最后常驻内存全部被占满,可以看出明显的内存泄漏。
图 2.3 老年代内存
图 2.4 JVM 内存
2.3 内存溢出
从线上的错误日志也可以明确知道服务最后是 OOM 了,所以问题的根本原因是内存泄漏导致内存溢出 OOM,最后导致服务不可用。
图 2.5 OOM 日志
三、问题排查
3.1 堆内存分析
在明确问题原因为内存泄漏之后,我们第一时间就是 dump 服务内存快照,将 dump 文件导入至 MAT (Eclipse Memory Analyzer) 进行分析。Leak Suspects 进入疑似泄露点视图。
图 3.1 内存对象分析
图 3.2 对象链路图
打开的 dump 文件如图 3.1 所示,2.3G 的堆内存 其中 org.apache.hadoop.conf.Configuration 对象占了 1.8G,占了整个堆内存的 78.63%。
展开该对象的关联对象和路径,可以看到主要占用的对象为 HashMap,该 HashMap 由 FileSystem.Cache 对象持有,再上层就是 FileSystem。可以猜想内存泄漏大概率跟 FileSystem 有关。
3.2 源码分析
找到内存泄漏的对象,那么接下来一步就是找到内存泄漏的代码。
在图 3.3 我们的代码里面可以发现这么一段代码,在每次与 hdfs 交互时,都会与 hdfs 建立一次连接,并创建一个 FileSystem 对象。但在使用完 FileSystem 对象之后并未调用 close () 方法释放连接。
但是此处的 Configuration 实例和 FileSystem 实例都是局部变量,在该方法执行完成之后,这两个对象都应该是可以被 JVM 回收的,怎么会导致内存泄漏呢?
图 3.3
(1)猜想一:FileSystem 是不是有常量对象?
接下里我们就查看 FileSystem 类的源码,FileSystem 的 init 和 get 方法如下:
图 3.4
从图 3.4 最后一行代码可以看到,FileSystem 类存在一个 CACHE,通过 disableCacheName 控制是否从该缓存拿对象。该参数默认值为 false。也就是默认情况下会通过 CACHE 对象返回 FileSystem。
图 3.5
从图 3.5 可以看到 CACHE 为 FileSystem 类的静态对象,也就是说,该 CACHE 对象会一直存在不会被回收,确实存在常量对象 CACHE,猜想一得到验证。
那接下来看一下 CACHE.get 方法:
从这段代码中可以看出:
Cache.Key 的 hashCode 方法如下:
schema 和 authority 变量为 String 类型,如果在相同的 URI 情况下,其 hashCode 是一致。而 unique 该参数的值每次都是 0。那么 Cache.Key 的 hashCode 就由 ugi.hashCode() 决定。
由以上代码分析可以梳理得到:
(2)猜想二:FileSystem 同样 hdfs URI 是不是多次缓存?
FileSystem.Cache.Key 构造函数如下所示:ugi 由 UserGroupInformation 的 getCurrentUser () 决定。
继续看 UserGroupInformation 的 getCurrentUser () 方法,如下:
其中比较关键的就是是否能通过 AccessControlContext 获取到 Subject 对象。在本例中通过 get (final URI uri, final Configuration conf,final String user) 获取时候,在 debug 调试时,发现此处每次都能获取到一个新的 Subject 对象。也就是说相同的 hdfs 路径每次都会缓存一个 FileSystem 对象。
猜想二得到验证:同一个 hdfs URI 会进行多次缓存,导致缓存快速膨胀,并且缓存没有设置过期时间和淘汰策略,最终导致内存溢出。
(3)FileSystem 为什么会重复缓存?
那为什么会每次都获取到一个新的 Subject 对象呢,我们接着往下看一下获取 AccessControlContext 的代码,如下:
其中比较关键的是 getStackAccessControlContext 方法,该方法调用了 Native 方法,如下:
该方法会返回当前堆栈的保护域权限的 AccessControlContext 对象。
我们通过图 3.6 get(final URI uri, final Configuration conf,final String user) 方法可以看到,如下:
图 3.6
图 3.7
图 3.8
从图 3.8 标红的代码可以看到在 createRemoteUser 方法中,创建了一个新的 Subject 对象,并通过该对象创建了 UserGroupInformation 对象。至此,UserGroupInformation.getBestUGI 方法执行完成。
接下来看一下 UserGroupInformation.doAs 方法(FileSystem.get (final URI uri, final Configuration conf, final String user) 执行的最后一个方法),如下:
然后在调用 Subject.doAs 方法,如下:
最后在调用 AccessController.doPrivileged 方法,如下:
该方法为 Native 方法,该方法会使用指定的 AccessControlContext 来执行 PrivilegedExceptionAction,也就是调用该实现的 run 方法。即 FileSystem.get (uri, conf) 方法。
至此,就能够解释在本例中,通过 get (final URI uri, final Configuration conf,final String user) 方法创建 FileSystem 时,每次存入 FileSystem 的 Cache 中的 Cache.key 的 hashCode 都不一致的情况了。
小结一下:
(4)FileSystem 的正确用法
从上述分析,既然 FileSystem.Cache 都没有起到应起的作用,那为什么要设计这个 Cache 呢。其实只是我们的用法没用对而已。
在 FileSystem 中,有两个重载的 get 方法:
我们可以看到 FileSystem get (final URI uri, final Configuration conf, final String user) 方法最后是调用 FileSystem get (URI uri, Configuration conf) 方法的,区别在于 FileSystem get (URI uri, Configuration conf) 方法于缺少也就是缺少每次新建 Subject 的的操作。
图 3.9
没有新建 Subject 的的操作,那么图 3.9 中 Subject 为 null,会走最后的 getLoginUser 方法获取 loginUser。而 loginUser 是静态变量,所以一旦该 loginUser 对象初始化成功,那么后续会一直使用该对象。UserGroupInformation.hashCode 方法将会返回一样的 hashCode 值。也就是能成功的使用到缓存在 FileSystem 的 Cache。
图 3.10
四、解决方案
经过前面的介绍,如果要解决 FileSystem 存在的内存泄露问题,我们有以下两种方式:
(1)使用 public static FileSystem get(URI uri, Configuration conf):
(2)使用 public static FileSystem get(final URI uri, final Configuration conf, final String user):
基于我们已有的历史代码最小改动的前提下,我们选择了第二种修改方式。在我们每次使用完 FileSystem 之后都关闭 FileSystem 对象。
五、优化结果
对代码进行修复发布上线之后,如下图一所示,可以看到修复之后老年代的内存可以正常回收了,至此问题终于全部解决。
六、总结
内存溢出是 Java 开发中最常见的问题之一,其原因通常是由于内存泄漏导致内存无法正常回收引起的。在我们这篇文章中,详细介绍一次完整的线上内存溢出的处理过程。
总结一下我们在碰到内存溢出时候的常用解决思路:
(1)生成堆内存文件:
在服务启动命令添加
让服务在发生 oom 时自动 dump 内存文件,或者使用 jamp 命令 dump 内存文件。
(2)堆内存分析:使用内存分析工具帮助我们更深入地分析内存溢出问题,并找到导致内存溢出的原因。以下是几个常用的内存分析工具:
(3)根据堆内存分析定位到具体的内存泄漏代码。
(4)修改内存泄漏代码,重新发布验证。
内存泄漏是内存溢出的常见原因,但不是唯一原因。常见导致内存溢出问题的原因还是有:超大对象、堆内存分配太小、死循环调用等等都会导致内存溢出问题。
在遇到内存溢出问题时,我们需要多方面思考,从不同角度分析问题。通过我们上述提到的方法和工具以及各种监控帮助我们快速定位和解决问题,提高我们系统的稳定性和可用性。
原文转载:揭露 FileSystem 引起的线上 JVM 内存溢出问题
原文作者:vivo互联网技术-Ye Jidong