故障描述

像是Java进程出现了内存泄漏的情况,但我们的堆内存限制为4G。由于内存占用超过了4G,可以初步判断是JVM堆外内存泄漏。 确认了下当时服务进程的启动配置: -Xms4g -Xmx4g -Xmn2g -Xss1024K -XX:PermSize=256m -XX:MaxPermSize=512m -XX:ParallelGCThreads=20 -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -XX:+UseCMSCompactAtFullCollection -XX:CMSInitiatingOccupancyFraction=80尽管当天没有上线新代码,但在当天上午,我们正在使用消息队列推送历史数据的修复脚本。该任务会大量调用我们服务中的某一个接口,所以我们初步怀疑与该接口有关。 下图显示了该接口调用量在案发当天的变化情况:

image.png

可以看到,调用量在案发当时相比正常情况下的每分钟200+次大大增加到每分钟5000+次。 我们暂时停止了脚本发送消息,该接口的调用量下降到每分钟200+次,容器内存的上升速度也不再那么快,一切似乎恢复了正常。 接下来,我们需要排查是否这个接口发生了内存泄漏。

排查过程

首先,让我们回顾一下Java进程的内存分配方式,以便更好地阐述我们的排查思路。 以我们线上使用的JDK1.8版本为例,JVM内存分配可以划分为两个区域:堆区和非堆区。

  • • 堆区:包括新生代和老年代。

  • • 非堆区:包括元数据区和直接内存。

image.png

需要特别注意的是,永久代(JDK8中的原生去)存放JVM运行时使用的类,永久代的对象在进行full GC时进行垃圾收集。 经过回顾JVM内存分配的知识,让我们回到故障现场。

堆内存分析

虽然最初我们基本确认与堆内存无关,因为泄露的内存占用超过了堆内存限制4G,但为了保险起见,我们先检查一下堆内存是否有线索。

我们观察了新生代和老年代内存的占用曲线以及垃圾回收次数统计,和往常一样,并没有发现大问题。接下来,我们在事故现场的容器上生成了一份JVM堆内存的日志。

堆内存Dump

堆内存快照dump命令: jmap -dump:live,format=b,file=xxxx.hprof pid

你也可以使用jmap -histo:live pid直接查看堆内存存活的对象。

导出后,将Dump文件下载回本地,然后可以使用Eclipse的MAT(Memory Analyzer)或者JDK自带的JVisualVM打开日志文件。 使用MAT打开文件如图所示:

image.png

在堆内存中,可以看到一些与nio相关的大对象,比如正在接收消息队列消息的nioChannel,还有nio.HeapByteBuffer,但是数量并不多,不能作为判断的依据,先放着观察下。

接下来,我开始浏览该接口代码,接口内部的主要逻辑是调用集团的客户端,将数据库表中的数据查表后写入系统,没有其他额外逻辑。

发现没有什么特殊逻辑后,我开始怀疑客户端封装是否存在内存泄漏。我怀疑的理由是,客户端底层是由其他客户端封装的,作为RPC框架,其底层通讯传输协议有可能会申请直接内存。

我开始考虑是否是我的代码触发了WCS客户端的Bug,导致不断地申请直接内存的调用,最终导致内存被耗尽。

我联系上了值班人员,并将我们遇到的问题和他们进行了描述。他们回复说,会在他们本地执行写入操作的压力测试,看看能否复现我们的问题。

既然等待他们的反馈还需要时间,我们决定先自己思考可能的原因。

我将怀疑的目光停留在直接内存上,怀疑是由于接口调用量过大,客户端对nio的使用不当,导致使用ByteBuffer申请过多的直接内存。

最终结果证明,这个先入为主的思路导致我们在排查过程中走了弯路。在问题的排查过程中,用合理的猜测来缩小排查范围是可以的,但最好先把每种可能性都列清楚。当发现自己深入某个可能性无果时,要及时回头仔细审视其他可能性。

沙箱环境复现

为了还原当时的故障场景,我在测试环境中申请了一台压测机器,以确保和线上环境的一致性。

首先,我们模拟了内存溢出的情况,通过大量调用接口来触发故障。我们使用脚本不断推送数据,持续调用我们的接口,并观察内存的占用情况。

在开始调用接口后,我们注意到内存开始持续增长,并且没有受到限制(没有因为限制触发Full GC)。

image.png

接下来,我们模拟了平时正常调用量的情况。我们将该接口的正常调用量(相对较小,并且每10分钟进行一次批量调用)切换到压测机器上,并得到了以下老生代内存和物理内存的趋势图:

image.png问题来了:为什么内存会不断增长并最终耗尽呢?