揭露 FileSystem 引起的线上 JVM 内存溢出问题
作者:来自 vivo 互联网大数据团队-Ye Jidong
本文主要介绍了由 FileSystem 类引起的一次线上内存泄漏导致内存溢出的问题分析解决全过程。
内存泄漏定义(memory leak):一个不再被程序使用的对象或变量还在内存中占有存储空间,JVM 不能正常回收改对象或者变量。一次内存泄漏似乎不会有大的影响,但内存泄漏堆积后的后果就是内存溢出。
内存溢出(out of memory):是指在程序运行过程中,由于分配的内存空间不足或使用不当等原因,导致程序无法继续执行的一种错误,此时就会报错 OOM,即所谓的内存溢出。
一、背景
周末小叶正在王者峡谷乱杀,手机突然收到大量机器 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 类内部维护了一个 Map,该 Map 用于缓存已经连接好的 FileSystem 对象,Map 的 Key 为 Cache.Key 对象。每次都会通过 Cache.Key 获取 FileSystem,如果未获取到,才会继续创建的流程。
在 Cache 类内部维护了一个 Set(toAutoClose),该 Set 用于存放需自动关闭的连接。在客户端关闭时会自动关闭该集合中的连接。
每次创建的 FileSystem 都会以 Cache.Key 为 key,FileSystem 为 Value 存储在 Cache 类中的 Map 中。那至于在缓存时候是否对于相同 hdfs URI 是否会存在多次缓存,就需要查看一下 Cache.Key 的 hashCode 方法了。
Cache.Key 的 hashCode 方法如下:
schema 和 authority 变量为 String 类型,如果在相同的 URI 情况下,其 hashCode 是一致。而 unique 该参数的值每次都是 0。那么 Cache.Key 的 hashCode 就由 ugi.hashCode()决定。
由以上代码分析可以梳理得到:
业务代码与 hdfs 交互过程中,每次交互都会新建一个 FileSystem 连接,结束时并未关闭 FileSystem 连接。
FileSystem 内置了一个 static 的 Cache,该 Cache 内部有一个 Map,用于缓存已经创建连接的 FileSystem。
参数 fs.hdfs.impl.disable.cache,用于控制 FileSystem 是否需要缓存,默认情况下是 false,即缓存。
Cache 中的 Map,Key 为 Cache.Key 类,该类通过 schem,authority,ugi,unique 4 个参数来确定一个 Key,如上 Cache.Key 的 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) 方法可以看到,如下:
先通过 UserGroupInformation.getBestUGI 方法获取了一个 UserGroupInformation 对象。
然后在通过 UserGroupInformation 的 doAs 方法去调用了 get(URI uri, Configuration conf)方法
图 3.7 UserGroupInformation.getBestUGI 方法的实现,此处关注一下传入的两个参数 ticketCachePath,user。ticketCachePath 是获取配置 hadoop.security.kerberos.ticket.cache.path 的值,在本例中该参数未配置,因此 ticketCachePath 为空。user 参数是本例中传入的用户名。
ticketCachePath 为空,user 不为空,因此最终会执行图 3.7 的 createRemoteUser 方法
图 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 都不一致的情况了。
小结一下:
在通过 get(final URI uri, final Configuration conf,final String user)方法创建 FileSystem 时,由于每次都会创建新的 UserGroupInformation 和 Subject 对象。
在 Cache.Key 对象计算 hashCode 时,影响计算结果的是调用了 UserGroupInformation.hashCode 方法。
UserGroupInformation.hashCode 方法,计算为:System.identityHashCode(subject)。即如果 Subject 是同一个对象则返回相同的 hashCode,由于在本例中每次都不一样,因此计算的 hashCode 不一致。
综上,就导致每次计算 Cache.key 的 hashCode 不一致,便会重复写入 FileSystem 的 Cache。
(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):
该方法是能够使用到 FileSystem 的 Cache 的,也就是说对于同一个 hdfs URI 是只会有一个 FileSystem 连接对象的。
通过 System.setProperty("HADOOP_USER_NAME", "hive")方式设置访问用户。
默认情况下 fs.automatic.close=true,即所有的连接都会通过 ShutdownHook 关闭。
(2)使用 public static FileSystem get(final URI uri, final Configuration conf, final String user):
该方法如上分析,会导致 FileSystem 的 Cache 失效,且每次都会添加至 Cache 的 Map 中,导致不能被回收。
在使用时,一种方案是:保证对于同一个 hdfs URI 只会存在一个 FileSystem 连接对象。
另一种方案是:在每次使用完 FileSystem 之后,调用 close 方法,该方法会将 Cache 中的 FileSystem 删除。
基于我们已有的历史代码最小改动的前提下,我们选择了第二种修改方式。在我们每次使用完 FileSystem 之后都关闭 FileSystem 对象。
五、优化结果
对代码进行修复发布上线之后,如下图一所示,可以看到修复之后老年代的内存可以正常回收了,至此问题终于全部解决。
六、总结
内存溢出是 Java 开发中最常见的问题之一,其原因通常是由于内存泄漏导致内存无法正常回收引起的。在我们这篇文章中,详细介绍一次完整的线上内存溢出的处理过程。
总结一下我们在碰到内存溢出时候的常用解决思路:
(1)生成堆内存文件:
在服务启动命令添加
让服务在发生 oom 时自动 dump 内存文件,或者使用 jamp 命令 dump 内存文件。
(2)堆内存分析:使用内存分析工具帮助我们更深入地分析内存溢出问题,并找到导致内存溢出的原因。以下是几个常用的内存分析工具:
Eclipse Memory Analyzer:一款开源的 Java 内存分析工具,可以帮助我们快速定位内存泄漏问题。
VisualVM Memory Analyzer:一个基于图形化界面的工具,可以帮助我们分析 java 应用程序的内存使用情况。
(3)根据堆内存分析定位到具体的内存泄漏代码。
(4)修改内存泄漏代码,重新发布验证。
内存泄漏是内存溢出的常见原因,但不是唯一原因。常见导致内存溢出问题的原因还是有:超大对象、堆内存分配太小、死循环调用等等都会导致内存溢出问题。
在遇到内存溢出问题时,我们需要多方面思考,从不同角度分析问题。通过我们上述提到的方法和工具以及各种监控帮助我们快速定位和解决问题,提高我们系统的稳定性和可用性。
版权声明: 本文为 InfoQ 作者【vivo互联网技术】的原创文章。
原文链接:【http://xie.infoq.cn/article/f8bdc47a88b9c0eb82964a80b】。文章转载请联系作者。
评论