运维知识
悠悠
2026年4月13日

Linux 内存泄漏排查全攻略:从现象到根因,一文讲透生产环境实战经验

做运维这么多年,内存泄漏这事儿真是没少碰。有些时候程序跑着跑着内存就蹭蹭往上涨,重启一下好了,过两天又不行了。这种问题最烦人,不像CPU高那样容易定位,也不像磁盘满了那样好处理。内存泄漏就像个隐形杀手,藏在代码深处,等你发现的时候往往已经酿成事故了。

今天咱们不聊虚的,直接上干货,把Linux下排查内存泄漏的方法系统梳理一遍。从基础的命令行工具到高级的调试技巧,能用的都给大家整理出来。

内存泄漏到底是咋回事

说白了就是申请了内存用完不释放。程序向系统申请了一块内存空间,用完了却没告诉系统"我不用了",这块内存就被标记为占用状态。时间一长,这种"僵尸内存"越来越多,可用内存越来越少,最后系统扛不住了,触发OOM Killer把进程干掉,或者整个系统卡死。

Linux的内存管理机制本身是没问题的,问题出在应用程序层面。C/C++这种需要手动管理内存的语言是重灾区,Java、Go这种有垃圾回收的语言理论上好点,但也不代表不会泄漏。比如Java里的对象引用没释放,或者堆外内存用多了,照样会出问题。

内存泄漏有个特点:渐进性。刚启动的时候没事,运行几天甚至几周才开始显现。所以排查起来特别费劲,你得知道是哪个时间点开始泄漏的,做了什么操作触发的。

怎么发现内存泄漏

监控是第一道防线。我们生产环境用的是Prometheus + Grafana,内存使用率曲线一目了然。正常的内存使用应该是波动的,有升有降,垃圾回收或者缓存清理后内存会下来。如果看到一条斜向上的直线,那八成有问题。

命令行下最常用的是free命令:

free -h
              total        used        free      shared  buff/cache   available
Mem:           15Gi       8.5Gi       2.1Gi       256Mi       4.9Gi       6.2Gi
Swap:         8.0Gi       1.2Gi       6.8Gi

重点看available这列,这才是真正可用的内存。free那列看起来少,但很多是缓存,系统需要的时候可以回收。如果available持续下降,那就要警惕了。

top命令更直观,能看到每个进程的内存占用:

top - 14:30:25 up 10 days,  3:45,  2 users,  load average: 0.52, 0.58, 0.59
Tasks: 156 total,   1 running, 155 sleeping,   0 stopped,   0 zombie
%Cpu(s):  3.2 us,  1.5 sy,  0.0 ni, 94.8 id,  0.5 wa,  0.0 hi,  0.0 si,  0.0 st
MiB Mem :  16384.0 total,   2150.5 free,   8720.3 used,   5513.2 buff/cache
MiB Swap:   8192.0 total,   6800.2 free,   1391.8 used.   6352.1 avail Mem

  PID USER      PR  NI    VIRT    RES    SHR    S  %CPU  %MEM     TIME+ COMMAND
12345 app       20   0   4.2g   3.5g   123m   S   3.2  22.1  12:34.56 java

M可以按内存排序。VIRT是虚拟内存,RES是实际物理内存,SHR是共享内存。排查内存泄漏主要看RES,这个才是真正占用的物理内存。

还有个命令vmstat,能看内存变化趋势:

vmstat 1 10
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 1  0 1425400 2150352 451232 5060992    0    0     2    15   12   15  3  1 95  0  0
 0  0 1425400 2148320 451240 5061124    0    0     0     0  1234  2156  3  1 95  0  0

free列是不是在持续减少。siso是swap in和swap out,如果这两个值很大,说明系统在频繁换页,内存已经紧张了。

定位问题进程

发现系统内存不够了,下一步就是找出是谁在搞鬼。

top命令能看到进程级别的内存占用,但有时候进程下面有很多线程,想知道具体是哪个线程占的多,可以用:

top -H -p <pid>

这样能显示进程下所有线程的资源占用情况。

还有一个很好用的命令是ps,可以按内存排序:

ps aux --sort=-%mem | head -10

这会列出内存占用最高的10个进程。

有时候进程名都一样,比如Java程序都叫java,怎么区分?看命令行参数:

ps aux | grep java

或者看/proc/<pid>/cmdline

cat /proc/12345/cmdline | tr '\0' ' '

找到嫌疑进程后,就要深入分析了。

深入分析进程内存

使用 /proc 文件系统

Linux的/proc目录是个宝藏,每个进程的信息都在这里。

/proc/<pid>/status里有进程的基本信息:

cat /proc/12345/status
Name:   java
Umask:  0022
State:  S (sleeping)
...
VmSize:   4325432 kB    # 虚拟内存
VmRSS:    3521344 kB    # 物理内存
VmData:   1234567 kB    # 数据段
VmStk:       136 kB    # 栈
VmExe:      4832 kB    # 代码段
VmLib:     32456 kB    # 共享库

VmRSS就是进程实际占用的物理内存,这个值最关键。

/proc/<pid>/maps记录了进程的内存映射:

cat /proc/12345/maps
00400000-00483000 r-xp 00000000 fd:00 123456 /usr/lib/jvm/java-11/bin/java
...
7f3c40000000-7f3c40200000 rw-p 00000000 00:00 0
7f3c40200000-7f3c48000000 ---p 00000000 00:00 0

能看到进程加载了哪些库,有哪些内存区域。如果发现有很多匿名映射(没有对应文件),可能是堆外内存或者内存映射文件。

更详细的信息在/proc/<pid>/smaps

cat /proc/12345/smaps | head -30
7f3c40000000-7f3c40200000 rw-p 00000000 00:00 0
Size:               2048 kB
Rss:                1024 kB
Pss:                1024 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:      1024 kB
Referenced:         1024 kB
Anonymous:          1024 kB
AnonHugePages:         0 kB
ShmemHugePages:        0 kB
ShmemPmdMapped:        0 kB
Swap:                  0 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Locked:                0 kB

Rss是实际占用的物理内存,Pss是按比例分摊的共享内存。Anonymous是匿名内存,通常是堆和栈。Private_Dirty是私有脏页,被进程修改过还没写入磁盘的内存。

可以写个脚本统计各类内存的总量:

cat /proc/12345/smaps | grep -E "^Size|^Rss|^Pss" | awk '{arr[$1]+=$2} END {for(k in arr) print k, arr[k]" kB"}'

使用 pmap 命令

pmap其实就是把/proc下的信息整理了一下,看起来更方便:

pmap -x 12345
Address           Kbytes     RSS   Dirty Mode  Mapping
0000000000400000    4832    4832       0 r-x-- java
00000000010b3000      68      68      68 rw--- java
...
00007f3c40000000   65536   65532   65532 rw---   [ anon ]
...
total kB         4325432 3521344 3521344

-x参数显示扩展信息。Address是内存起始地址,Kbytes是区域大小,RSS是实际物理内存,Dirty是脏页。

最后一行是汇总。如果发现某个内存区域特别大,或者[ anon ]类型的区域特别多,就要注意了。[ anon ]通常是堆内存或者堆外内存。

pmap -X能显示更详细的信息,包括内存区域的保护属性、设备号等。

使用 strace 追踪系统调用

如果想知道进程在做什么内存操作,可以用strace

strace -p 12345 -e trace=mmap,munmap,brk

这会追踪进程的内存分配系统调用。mmap是映射内存,munmap是解除映射,brk是调整堆大小。

输出类似这样:

mmap(NULL, 1048576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f3c41000000
brk(0x56789000)                         = 0x56789000
munmap(0x7f3c41000000, 1048576)         = 0

如果看到大量的mmap但没有对应的munmap,那很可能就是泄漏了。

strace的缺点是会影响性能,生产环境慎用。而且输出量大,分析起来比较费劲。

内存泄漏检测工具

Valgrind

Valgrind是排查内存问题的神器,不仅能检测泄漏,还能检测非法访问、未初始化变量等问题。

用法很简单:

valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes ./your_program

程序退出后会输出详细报告:

==12345== HEAP SUMMARY:
==12345==     in use at exit: 1,024 bytes in 1 blocks
==12345==   total heap usage: 100 allocs, 99 frees, 10,240 bytes allocated
==12345==
==12345== 1,024 bytes in 1 blocks are definitely lost in loss record 1 of 1
==12345==    at 0x4C2AB80: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==12345==    by 0x400544: main (test.c:10)
==12345==
==12345== LEAK SUMMARY:
==12345==    definitely lost: 1,024 bytes in 1 blocks
==12345==    indirectly lost: 0 bytes in 0 blocks
==12345==      possibly lost: 0 bytes in 0 blocks
==12345==    still reachable: 0 bytes in 0 blocks
==12345==         suppressed: 0 bytes in 0 blocks

definitely lost就是确定泄漏的内存,possibly lost是可能泄漏,still reachable是程序结束时还没释放但可能是有意为之。

Valgrind的原理是在程序和CPU之间加了一层虚拟化,拦截所有内存操作。这导致程序运行速度会变慢10-100倍,所以生产环境基本没法用,只能在测试环境跑。

对于长期运行的服务,可以让它运行一段时间后正常退出,Valgrind会输出报告。或者用kill -SIGTERM让它优雅退出。

AddressSanitizer (ASan)

ASan是编译器级别的内存检测工具,GCC和Clang都支持。编译时加上参数:

gcc -fsanitize=address -g -O1 test.c -o test

运行时如果检测到内存问题,会直接报错:

=================================================================
==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x60700000eff8
READ of size 4 at 0x60700000eff8 thread T0
    #0 0x400544 in main test.c:15
    #1 0x7f3c4a0e082f in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2082f)
...

ASan比Valgrind快很多,性能损耗大概只有2倍左右,可以在生产环境开启(如果性能要求不是特别高的话)。

ASan还能检测很多Valgrind检测不到的问题,比如栈缓冲区溢出、全局缓冲区溢出等。

GDB 调试

GDB不仅能调试崩溃,排查内存问题也很好用。

先attach到进程:

gdb -p 12345

查看内存映射:

(gdb) info proc mappings
Start           End             Size            Offset          Name
0x400000        0x408000        0x8000          0x0             /path/to/binary
...

查看堆信息:

(gdb) call malloc_info(0, stdout)

这会输出malloc的状态信息(如果glibc版本支持的话)。

还可以在malloc和free上设置断点:

(gdb) break malloc
Breakpoint 1 at 0x7f3c4a123456
(gdb) commands
Type commands for breakpoint(s) 1, one per line.
End with a line saying just "end".
>backtrace
>continue
>end

这样每次malloc都会打印调用栈。但这会让程序变得很慢,因为malloc调用非常频繁。

更好的方法是条件断点,比如只追踪大于1MB的分配:

(gdb) break malloc if $rdi > 1048576

$rdi是第一个参数(x86_64架构),也就是分配的大小。

glibc 内存分配调试

glibc提供了一些环境变量来调试内存分配:

MALLOC_TRACE=/tmp/malloc.log ./your_program

这会记录所有的内存分配和释放操作。然后用mtrace命令分析:

mtrace your_program /tmp/malloc.log

输出类似:

Memory not freed:
-----------------
           Address     Size     Caller
0x0000000000602010    0x400  at 0x400544: main (test.c:10)

还有MALLOC_CHECK_环境变量:

MALLOC_CHECK_=3 ./your_program

值可以是0-3:

  • 0:不输出错误
  • 1:输出错误到stderr
  • 2:立即abort
  • 3:输出错误并abort

这对检测double free、堆溢出等问题很有用。

custom malloc hooks

如果想要更精细的控制,可以自己实现malloc钩子:

#include <malloc.h>

static void *(*old_malloc)(size_t) = NULL;

static void *my_malloc(size_t size, const void *caller) {
    void *ptr = old_malloc(size);
    fprintf(stderr, "malloc(%zu) = %p from %p\n", size, ptr, caller);
    return ptr;
}

void __attribute__((constructor)) init_hook() {
    old_malloc = dlsym(RTLD_NEXT, "malloc");
    __malloc_hook = my_malloc;
}

编译成动态库,用LD_PRELOAD加载:

gcc -shared -fPIC -ldl hook.c -o hook.so
LD_PRELOAD=./hook.so ./your_program

这样就能追踪所有的malloc调用了。可以记录调用栈、分配大小等信息,后期分析。

Java 程序内存泄漏排查

Java程序有垃圾回收,理论上不应该泄漏。但实际上还是会有问题。

区分堆内和堆外内存

先用jstat看堆内存:

jstat -gc 12345 1000
 S0C    S1C    S0U    S1U      EC       EU        OC         OU       MC     MU    CCSC   CCSU   YGC     YGCT    FGC    FGCT    GCT
10240  10240  0.0    8192.0  81920.0  65536.0   204800.0   102400.0  51200  48000  6144   5800   1234   12.345  12     3.456   15.801

如果老年代(OC/OU)使用率不高,Full GC(FGC)次数也不多,那堆内存应该没问题。

jmapdump堆内存分析:

jmap -histo:live 12345 | head -20

这会输出对象统计,看哪种对象数量多、占用大。

如果堆内存没问题,但进程RSS还是很高,那可能是堆外内存。Java的堆外内存包括:

  • DirectBuffer:NIO用的直接内存
  • Metaspace:类元数据
  • 线程栈:每个线程1MB左右
  • native内存:JNI调用分配的

Native Memory Tracking (NMT)

NMT是JDK自带的工具,能追踪JVM内部内存使用。启动时加参数:

-XX:NativeMemoryTracking=summary

或者-XX:NativeMemoryTracking=detail更详细。

运行时用jcmd查看:

jcmd 12345 VM.native_memory summary

输出:

Native Memory Tracking:

Total: reserved=2048MB, committed=1024MB
-                 Java Heap (reserved=512MB, committed=512MB)
-                     Class (reserved=256MB, committed=128MB)
-                    Thread (reserved=256MB, committed=256MB)
-                      Code (reserved=128MB, committed=64MB)
-                        GC (reserved=128MB, committed=64MB)
-                  Internal (reserved=256MB, committed=128MB)

能清楚看到各类内存的占用情况。如果Thread那项很大,可能是线程太多;如果Internal很大,可能是DirectBuffer。

DirectBuffer 泄漏

DirectBuffer是堆外内存,不受GC管理。如果用完不释放,会一直占用。

用JMX可以查看DirectBuffer使用情况:

import java.lang.management.ManagementFactory;
import java.lang.reflect.Method;

public class DirectBufferInfo {
    public static void main(String[] args) throws Exception {
        Class<?> clz = Class.forName("sun.misc.SharedSecrets");
        Method method = clz.getDeclaredMethod("getJavaNioAccess");
        method.setAccessible(true);
        Object nioAccess = method.invoke(null);
      
        Method method2 = nioAccess.getClass().getDeclaredMethod("getDirectBufferPool");
        method2.setAccessible(true);
        Object pool = method2.invoke(nioAccess);
      
        Method getCount = pool.getClass().getDeclaredMethod("getCount");
        Method getTotal = pool.getClass().getDeclaredMethod("getTotalCapacity");
        Method getUsed = pool.getClass().getDeclaredMethod("getMemoryUsed");
      
        System.out.println("Count: " + getCount.invoke(pool));
        System.out.println("Total: " + getTotal.invoke(pool));
        System.out.println("Used: " + getUsed.invoke(pool));
    }
}

Netty框架有内置的泄漏检测,启动参数:

-Dio.netty.leakDetection.level=PARANOID

会在日志里输出泄漏报告,包括创建ByteBuf的调用栈。

常见内存泄漏场景

排查了这么多案例,总结下来常见的泄漏场景就那么几类。

缓存没有清理

往静态Map里加数据,用完不删:

public class Cache {
    private static Map<String, Object> map = new HashMap<>();
  
    public static void put(String key, Object value) {
        map.put(key, value);
    }
}

这种最坑,因为静态变量生命周期和程序一样长,里面的对象永远不会被回收。解决办法是用WeakHashMap,或者定期清理。

监听器没有注销

注册了监听器,忘了注销:

button.addActionListener(listener);
// 窗口关闭时忘了 removeActionListener

被监听的对象持有监听器的引用,监听器无法回收。

线程没有停止

创建了线程,没有停止机制:

new Thread(() -> {
    while (true) {
        // do something
    }
}).start();

线程一直在运行,线程对象和它引用的对象都无法回收。

连接没有关闭

数据库连接、网络连接用完没关:

Connection conn = dataSource.getConnection();
// 忘了 conn.close()

连接池里的连接会被耗尽,导致后续请求失败。

堆外内存泄漏

使用DirectBuffer或者JNI调用native代码,分配的内存不受GC管理,需要手动释放。

预防措施

与其事后排查,不如事前预防。

代码层面

  • 使用try-with-resources,确保资源释放
  • 缓存设置上限和过期时间
  • 使用WeakReference、SoftReference处理可选缓存
  • 线程池不要无限制创建线程

测试层面

  • 压测时监控内存,长时间运行观察趋势
  • 使用内存分析工具做静态检查
  • 单元测试覆盖资源释放逻辑

运维层面

  • 设置内存告警阈值
  • 定期重启服务(治标不治本,但能争取时间)
  • 使用cgroups限制进程内存上限

排查思路总结

最后总结一下排查思路:

  1. 确认是内存泄漏还是正常波动:看趋势,泄漏是持续上涨不会下降
  2. 定位问题进程:用top、ps找出内存占用高的进程
  3. 分析进程内存分布:用pmap、/proc/smaps看是哪类内存
  4. 区分堆内堆外:Java程序先用jstat看堆内存
  5. 选择合适工具:开发环境用valgrind/ASan,生产环境用gdb/pmap
  6. 分析代码:根据工具报告定位问题代码

排查内存泄漏确实需要耐心,有时候要反复测试才能定位。但只要方法对路,总能找到问题根源。


公众号:运维躬行录
个人博客:躬行笔记

如果觉得文章有用,帮忙点个赞、转发一下,让更多人看到。你的支持是我持续输出的动力!有问题也可以在公众号后台留言,看到会回复的。

文章目录

博主介绍

热爱技术的云计算运维工程师,Python全栈工程师,分享开发经验与生活感悟。
欢迎关注我的微信公众号@运维躬行录,领取海量学习资料

微信二维码