本文原载于Elastic中文社区: https://elasticsearch.cn/publish/article/178
近期公司某个线上JAVA应用出现内存泄漏问题,整个排查过程颇费周折,前后耗费了近2周才定位到问题根源并予以修复。排查问题过程中在网上翻查了大量的资料,发现国内几乎没有文章能对同类问题做透彻的分析和找到问题真正根源的。即使国外的各类博客和文章,也少有正确的分析。因此感觉有必要对问题根源和相关案例做一个总结,希望能为国内开发者避免踩上同类陷阱提供一些帮助。
开门见山,先列一下收集到的同类问题案例集:
- Debugging Java Native Memory Leaks
- Tracking Down Native Memory Leaks in Elasticsearch
- CompressingStoredFieldsFormat should reclaim memory more aggressively
- Close InputStream when receiving cluster state in PublishClusterStateAction
- Kafka OOM During Log Recovery Due to Leaked Native Memory
这些案例涉及到的不乏一些流行的开源软件如Lucene, Elasticsearch, Kafka,并且某些Bug版本在大量公司有线上部署。这些案例的问题根源都惊人的一致,即在JAVA里使用GZIP库进行数据流的压缩/解压之后,忘记调用流的close()方法,从而造成native memory的泄漏。
关于这类问题的分析方法和工具,上面收集的案例集里有非常详尽的描述,这里就不再班门弄斧一一赘述了。只结合我们自己的案例,做一个比较简短的介绍和总结。
我们公司这个案例的排查之所以花了近2个礼拜,其中一个重要原因是这个应用是通过Docker部署的。应用上线运行一段时间后,会被Docker的OOM killer给Kill掉,查看JVM监控数据却发现Heap使用得很少,甚至都没有old GC发生过,top里看这个JAVA进程的RSS内存占用远高于分配的Heap大小。很自然的,研发人员第一反应是底层系统的问题,注意力被转移到研究各种Docker内存相关的参数上。 而我知道ElasticCloud曾经也被某些版本的linux内核bug困扰,docker可能会误杀JVM (参见Memory Issues We'll Remember),bug的内核版本和docker版本和我们线上部署的又很接近,因此这个内核bug也被加入到了怀疑列表中。 事后证明这个方向是错误的,浪费了一些时间。
在一段时间排查无果后,为了缩小排查范围,我们决定将这个应用部署到VM上做对比测试。结果内存泄漏问题依然存在,因而排除掉了Linux内核和docker本身的问题。
同期也参考过一篇关于DirectByteByffer造成堆外内存泄漏问题的分析博客,JVM源码分析之堆外内存完全解读,考虑到问题现象和我们类似,我们的应用也有用到netty,DBB泄漏也被列为怀疑对象。然而在JVM启动里参数里对MaxDirectMemorySize做了限制后,经过一段时间对外服务,JAVA进程的RSS仍然会远超过HEAP + MDM设置的大小。
这期间我们也使用过NMT工具分析HEAP内存占用情况,然而这个工具报告出来的内存远小于RSS,也就是说这多出来的内存并没有被JVM本身用到,泄漏的是native memory。 JAVA应用产生native memory泄漏通常是在使用某些native库时造成的,因此注意力转移到JNI。
最终帮助我们找到正确方向的是开头列的 Debugging Java Native Memory Leaks 这篇由Twitter工程师写的博客。 博客里介绍了如何使用jemalloc来替换glibc的malloc,通过拦截和追踪JVM对native memory的分配申请,从而可以分析出HEAP以外的内存分配由哪些方法调用产生的。博客里提到产生泄漏的原因是忘记关闭GZIPOutputStream,巧合的是我们线上应用也使用了gzip压缩服务请求数据,于是查看了一下相关的代码,果然发现有忘记关闭的stream。 找到根源后,解决问题就简单了,一行代码修复。
对于ElasticSearch用户,要注意的是某些版本存在这个泄漏问题,对于小内存机器上运行的ES服务可能会有较大的影响。 可是官方没有明确列出所有受影响的版本,只在博客里提到5.2.1修复了这些问题。 因此如果你有顾虑的话,可以用top命令看一下ES JAVA进程的RSS消耗,如果大大于分配的HEAP,有可能就是中招啦。