记一次内存泄漏问题排查

背景

事情起源于新接手的某一个服务在其中一套线下环境中,大概每四天会出现一次重启。
由于应用是通过systemd启动的,首先自然想到找sre同学查看日志,看下应用是为何被杀。经过sre同学的反馈,得知是因为内存超过了限制,触发了oom-killer。
需要解决的问题就是找出oom-killer的触发原因,以解决这个问题。也就引起引出了下文中的排查过程,欲知后事,请看官慢慢观看。

环境介绍

出现问题的服务是一个基于springboot框架的Java服务。该服务的主要职责是消费kafka数据,进行O(n)复杂度的处理后,同步发送给下游API并记录offset到kafka broker。由于和其他进程混布在同一台物理服务器上,采用cgroup限制了内存大小,限制为3G。同时其启动参数中,jvm限制为2g,对外内存最大使用限制为 -XX:MaxDirectMemorySize=512M 512M。

排查过程

从Java应用的内存模型看起

想必每个Java程序猿在工作(准备面试)中,都或多或少看过周志明大神的 深入理解Java虚拟机。在此书中,我们了解到了JVM的内存分区(此处不采用内存模型描述,以与JMM相区分)。在内存分区中,最重要也最长打交道的便是堆部分。我们“精通”于通过jmap、jstat两把梭分析堆内的世界,也可能在这个过程中逐渐在潜意识里将java内存与堆内存划了个等号。
在出现问题时,我首先通过 jstat -gccapacity 命令查看了当前进程的jvm各分区占用内存情况。查看的结果是整个堆大小确实是2g的限制,也就是说,触发oom-killer不是堆内存引起的,也就应该着眼于堆外,开始进行非堆内存的分析。

在发现不是JVM问题后,首先先尝试把内存限制从3G改为了6G,后发现虽然重启间隔长了一些,但是还是会出现该现象。因此可以初步判断该问题并非内存使用突然增长,而是逐渐增加占用,最后触发oom-killer。

堆外的精彩世界

抛开Java不谈,在linux下,我们可以通过top命令查看主机上进程的内存使用情况。其中跟内存有关的主要有以下指标。

1
2
3
4
5
6
7
%MEM 进程使用的物理内存百分比
VIRT 进程使用的虚拟内存总量,单位kb。VIRT=SWAP+RES
SWAP 进程使用的虚拟内存中,被换出的大小,单位kb。
RES 进程使用的、未被换出的物理内存大小,单位kb。RES=CODE+DATA
CODE 可执行代码占用的物理内存大小,单位kb
DATA 可执行代码以外的部分(数据段+栈)占用的物理内存大小,单位kb
SHR 共享内存大小,单位kb

本例中在重启后每天定时观察RES指标,发现会不断增加,最后一次查看的时候已经达到了5.1g,再增长下去即可达到内存限制6G,触发oom-killer。
在JVM中,我们可以通过 jmap 命令来快照堆内对象占用情况,在堆外,我们同样可以使用 pmap 命令来查看内存占用情况。

1
pmap -x <pid>

结果示例如下:

1
2
3
4
5
6
7
8
9
Address Kbytes RSS Dirty Mode Mapping
0000000000400000 4 0 0 r-x-- java
0000000000600000 4 4 4 rw--- java
0000000000be9000 1652 1524 1524 rw--- [ anon ]
0000000080000000 2114432 2114344 2114344 rw--- [ anon ]
00000001010e0000 1031296 0 0 ----- [ anon ]
00007f3c7ee82000 12 0 0 ----- [ anon ]
00007f3c7ee85000 1016 16 16 rw--- [ anon ]
00007f3c7ef83000 12 0 0 ----- [ anon ]

具体mmap解读在此不展开。
从此次mmap结果,发现有大量Mapping为 [anon] (已分配内存)的内存段,且大小(Kbytes)均为1024。
由于之前初步怀疑内存是缓慢增长的,所以可以充分怀疑是过多的小内存块分配,导致了oom。因此,我设置了定时任务,重启进程,并在连续三天内多次dump mmap结果。
经对比发现,问题果然出现在这些小的内存块上,在三天的持续dump中,大小为1024Kb的内存块在逐渐增加。

到这里,已经判断到了内存增加的元凶——小内存块。但是具体原因还是未知,同时,到这里也就没有进一步思考的思路了。

“昂贵的资源”—线程

真正考虑到线程其实源于一个偶然,由于之前遇到过 Unable to create new native thread 的错误,在五思路的时候,执行了 jstack <pid> 查看了下线程栈。不看不知道一看吓一跳,通过查看发现线程有几千个。这一数目虽然没达到程序挂掉,但是整体看起来十分不合理。此时脑海突然灵光一现,想起了学习Java(准备面试)时候重温的Java基础知识:

1
Java中,对于线程的创建,jvm都是向操作系统请求new一个本地线程,这个时候操作系统为线程分配内存是分配在非JVM的内存里的,线程占用的是堆外内存。

想到这,突然感觉已经水落石出了,大量的小内存块+线程多,这肯定是因为线程过度创建导致了。
后续通过对jstack的结果进行简易分析,查明了是由于程序bug导致一直创建kafka的消费线程,由于创建的线程数过多,导致进程占用内存过多,最终触发oom-killer。

小结

解决这次问题的过程中,其实主要是一个思维转换的过程。
在最开始定位问题的时候,根本没考虑线程的问题。得知堆外内存泄漏的时候,也丝毫没有把线程作为考量的一点。偶然执行了jstack,才开启了新的定位路径,最终解决问题。
虽然说是偶然,也有很多的必然,假设我连线程会占用堆外内存都不知道,那么就算看到了线程多,也不会关联到内存问题,所以八股也是重要的;同时,如果我不觉得上千的线程数在应用进程中不正常,可能也无法定位到问题,经验同样也有些重要。
本文仅是一次问题定位的记录,意在给所有能读到这篇文章的人提供一个思路,愿大家都能牢记八股,并化身名侦探柯南ヾ(◍°∇°◍)ノ゙。
如有交流欢迎发email到 gaodongyan.jayden@gmail.com,大家一起进步。