凌晨三点,手机震动,Prometheus 告警:「服务器可用内存低于 10%」。你睡眼惺忪地打开电脑,心里默念:「别是 OOM Killer,别是 OOM Killer……」然后 dmesg 一看——果然,Java 进程已经被内核一枪毙了。

本文记录一套完整的 Linux + Java 内存占用高排查流程:从 freetop,从 jstat 到 MAT,一步步定位内存泄漏的完整 SOP。不求背下来,但求下次被叫醒时,能少骂两句。


一、怎么发现内存高了

大部分人发现内存高,无非三种途径:

  1. Prometheus + Grafana 告警:监控大盘的 node_memory_MemAvailable_bytes 跌破阈值,钉钉/飞书/微信群开始轰炸。
  2. 巡检发现free -h 一看,used 99%,心跳加速。
  3. 进程被杀:最惨的情况——OOM Killer 已经替你做了决定,dmesg | grep -i oom 看到一行冰冷的日志:Out of memory: Killed process 12345 (java)

但请注意一个关键常识:Linux 下 free 命令显示的高内存使用率,不一定是问题

$ free -h
              total        used        free      shared  buff/cache   available
Mem:            15G         12G        512M        256M        2.8G        2.5G
Swap:          2.0G        128M        1.9G

很多新手看到 used 12G,立刻慌了。但实际上 Linux 会把空闲内存拿来做文件缓存(buff/cache),这是好事,不是泄漏。真正要看的是 available 这一列——它才代表「应用程序还能申请到多少内存」。

记住:free 很小不可怕,available 很小才可怕。Linux 的哲学是「内存闲着也是闲着,不如拿来当缓存」。


二、Linux 层定位:谁在吃内存

确认 available 确实低了之后,下一步就是找出那个「大胃王」进程。

1. free -h 整体视图

先搞清楚 free -h 每一列的含义:

字段 含义 说明
total 总物理内存 服务器硬件配置
used 已使用内存 包含了 buff/cache,数值偏大
free 完全空闲内存 没被任何人碰过的内存
shared 共享内存 主要是 tmpfs 使用
buff/cache 缓冲区/缓存 内核用来加速 I/O 的,可回收
available 可用内存 真正的剩余可用量,看这个

一句话总结:别看 free,看 available。available = free + 可回收的 buff/cache。

2. top 按内存排序

找到是哪个进程在吃内存:

# 启动 top 后按 M 键,按内存使用排序
top
# 进入后按 Shift + M

关注三列内存指标:

列名 全称 含义
VIRT Virtual Memory 虚拟内存,进程申请的总量(包含未实际分配的)
RES Resident Memory 常驻内存,实际占用的物理内存,重点看这个
SHR Shared Memory 共享内存,如共享库 .so 文件占用的部分

VIRT 大不要慌,Java 的 VIRT 天生就大(虚拟地址空间预留)。RES 才是真正吃掉的物理内存。如果某个 Java 进程 RES 占了 10G+,那它就是嫌疑犯。

3. /proc/PID/status 精确查看

锁定了可疑 PID 之后,精确查看它的内存使用:

# 替换 PID 为实际进程 ID
cat /proc/PID/status | grep -E 'VmRSS|VmSize|VmSwap|Name'

输出示例:

Name:   java
VmSize:  8234560 kB    # 虚拟内存大小
VmRSS:   6123456 kB    # 常驻物理内存(约 5.8G)
VmSwap:    12340 kB    # 被换出到 swap 的部分
指标 说明
VmRSS 实际物理内存占用,最重要的指标
VmSize 虚拟内存,通常比 RSS 大很多,不用太紧张
VmSwap 被换到磁盘的内存,如果这个值很大,说明内存真的不够了

4. smaps 查看内存映射

如果需要更细粒度地了解内存分布:

# 查看进程的内存映射摘要
cat /proc/PID/smaps | grep -i rss | awk '{sum+=$2} END {print sum/1024 " MB"}'

# 或者用 smaps_rollup(内核 4.14+)更方便
cat /proc/PID/smaps_rollup

这一步通常用于排查「非堆内存」泄漏,比如 Native 内存、mmap 映射等场景。


三、Java 层定位:JVM 内存到底花在哪

到这一步,我们已经确认了是某个 Java 进程在吃内存。接下来要进入 JVM 内部,看看钱(内存)花在了哪里。

1. jstat 看 GC 和堆使用

jstat 是 JDK 自带的轻量级监控工具,线上可以放心用,开销极小

# 每秒打印一次 GC 统计,持续观察
jstat -gc PID 1000

输出列含义速查:

含义 单位
S0C / S1C Survivor 0/1 容量 KB
S0U / S1U Survivor 0/1 已使用 KB
EC / EU Eden 区容量 / 已使用 KB
OC / OU Old 区容量 / 已使用 KB
MC / MU Metaspace 容量 / 已使用 KB
YGC / YGCT Young GC 次数 / 耗时 次 / 秒
FGC / FGCT Full GC 次数 / 耗时 次 / 秒

关键判断逻辑

  • OU(Old 区使用量)持续上升,Full GC 后也不下降 --> 大概率堆内存泄漏
  • FGC 频繁,FGCT 耗时越来越长 --> 堆快满了,GC 在做垂死挣扎
  • MU 持续上升 --> 可能是 Metaspace 泄漏(动态类加载过多)
# 也可以看百分比,更直观
jstat -gcutil PID 1000

如果你看到 Old 区使用率 99%,Full GC 次数几百次——恭喜,你找到了一个正在被内存泄漏慢慢掐死的 JVM。

2. jmap 导出堆转储

确认堆内存有问题后,需要导出堆转储文件(Heap Dump)做离线分析:

# 导出堆转储
jmap -dump:format=b,file=/tmp/heap.hprof PID

WARNING:线上执行 jmap dump 会触发 STW(Stop The World),期间所有业务线程暂停。堆越大,暂停时间越长。4G 堆大概暂停几秒到十几秒,8G+ 可能更久。

最佳实践:不要等出事了再手动 dump,在 JVM 启动参数里提前配置:

# JVM 启动参数中加入,OOM 时自动 dump
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/logs/heap-dump.hprof

这样 OOM 的时候会自动生成 dump 文件,不用你凌晨三点爬起来手动操作——毕竟你的手可能在抖

3. MAT 分析堆转储

拿到 .hprof 文件后,用 Eclipse MAT(Memory Analyzer Tool)打开分析。MAT 是 Java 内存分析的瑞士军刀,免费且强大。

分析三板斧

第一斧:Leak Suspects Report(泄漏嫌疑报告)

打开 hprof 文件后,MAT 会自动生成泄漏嫌疑报告。它会告诉你:

  • 哪个对象占了最多内存
  • 从 GC Root 到该对象的引用链
  • 嫌疑对象的类名和大小

大部分情况下,看完这个报告就能定位问题了。

第二斧:Dominator Tree(支配树)

Dominator Tree 按「支配关系」排列对象,从大到小。谁持有的内存最多,谁就排在最前面。

操作路径:MAT 菜单 --> Open Dominator Tree

通常你会看到某个 HashMapArrayList 或者自定义缓存类霸占了 80% 以上的堆内存。点进去看它持有的元素数量——如果一个 HashMap 里有几百万条数据,那它就是「凶手」。

第三斧:Top Consumers(大对象排行榜)

按类或包维度,展示内存消耗 Top N。适合快速定位是哪个模块、哪个包在吃内存。

4. Arthas 在线诊断

如果不方便做堆 dump(比如堆太大,dump 会影响业务),可以用阿里开源的 Arthas 做在线诊断:

# 启动 Arthas,附加到目标 Java 进程
java -jar arthas-boot.jar

# 查看 JVM 整体状态
dashboard

# 导出堆转储(比 jmap 更安全,支持只 dump 存活对象)
heapdump --live /tmp/heap.hprof

# 查看某个类的实例数量和占用内存
vmtool --action getInstances --className java.util.HashMap --limit 10

# 查看某个对象的详细信息
vmtool --action getInstances --className com.xxx.CacheManager --express 'instances[0].cache.size()'

Arthas 的 dashboard 命令堪称「JVM 体检报告」,一个命令看到堆使用、GC 情况、线程状态,非常适合第一时间快速摸底。


四、常见 5 大原因 + 解法

排查了这么多次内存问题,总结下来逃不出这五大类:

1. 堆内泄漏:HashMap / List 只增不减

典型场景

  • static Map 做本地缓存,只 put 不 remove,数据越攒越多
  • ThreadLocal 用完没有 remove(),线程池复用导致数据一直留着
  • 事件监听器注册了但从不注销
// 反面教材:静态 Map 当缓存,永不过期
private static final Map<String, Object> cache = new HashMap<>();

public void addToCache(String key, Object value) {
    cache.put(key, value);  // 只进不出,内存迟早爆
}

解决方案

  • 本地缓存用 CaffeineGuava Cache,设置最大容量和过期时间
  • ThreadLocal 用完一定要 remove()
  • 使用 WeakHashMapWeakReference 让 GC 能回收
// 正面教材:用 Caffeine 做有界缓存
Cache<String, Object> cache = Caffeine.newBuilder()
    .maximumSize(10_000)           // 最多 1 万条
    .expireAfterWrite(10, TimeUnit.MINUTES)  // 10 分钟过期
    .build();

2. 大对象:一次查询返回百万行

典型场景

  • SELECT * FROM t_order 不加 LIMIT,百万行数据全部加载到内存
  • 读取整个大文件到 byte[]
  • 一次 RPC 调用返回的列表没有分页

解决方案

  • 数据库查询必须加 LIMIT,或使用流式查询(fetchSize
  • 大文件用 BufferedReader 逐行读取,不要 Files.readAllBytes()
  • 接口返回数据做分页,设置合理的单页上限
// 反面教材
List<Order> orders = orderMapper.selectAll();  // 100 万条全加载

// 正面教材:流式查询
try (Cursor<Order> cursor = orderMapper.selectAllWithCursor()) {
    cursor.forEach(order -> processOrder(order));
}

3. 堆外泄漏:DirectByteBuffer / Netty

典型场景

  • NIO 的 DirectByteBuffer 分配后没有显式释放
  • Netty 的 ByteBuf 忘记调用 release()
  • 使用了 JNI / Native 库存在内存泄漏

解决方案

# 限制 Direct Memory 最大值,避免无限膨胀
-XX:MaxDirectMemorySize=512m

# Netty 开启泄漏检测(测试环境用 PARANOID,生产用 SIMPLE)
-Dio.netty.leakDetection.level=PARANOID

堆外泄漏的麻烦之处在于:jmap 看不到,MAT 也分析不出来,因为这些内存不在 JVM 堆里。需要用 NMT(Native Memory Tracking)或 pmap 来排查:

# 启用 NMT(JVM 启动参数)
-XX:NativeMemoryTracking=detail

# 运行时查看 Native 内存分布
jcmd PID VM.native_memory detail

4. 元空间溢出:Metaspace

典型场景

  • 大量使用动态代理(CGLib、Javassist),每次都生成新的类
  • Groovy / JSP / 脚本引擎频繁编译,类加载器不断创建
  • OSGi 或热部署框架的类加载器泄漏

报错信息

java.lang.OutOfMemoryError: Metaspace

解决方案

# 限制 Metaspace 大小,避免无限增长
-XX:MaxMetaspaceSize=256m

# 监控已加载的类数量
jstat -gc PID 1000  # 看 MC/MU 列
jcmd PID GC.class_stats  # 查看类统计

5. GC 参数不合理

典型场景

  • 堆设置太小,业务量上来后频繁 Full GC
  • 使用了不适合当前场景的 GC 收集器
  • 新生代和老年代比例不合理

GC 选择参考

场景 推荐 GC JVM 参数
小堆(< 4G),低延迟 G1 -XX:+UseG1GC
大堆(4G~16G),吞吐优先 G1 -XX:+UseG1GC -XX:MaxGCPauseMillis=200
超大堆(16G+),极低延迟 ZGC -XX:+UseZGC(JDK 17+)
批处理/离线任务,吞吐优先 Parallel -XX:+UseParallelGC

堆大小经验公式

# 一般建议:堆大小 = 存活对象大小 × 3~4 倍
# 如果 Full GC 后老年代还剩 1G 存活对象,堆至少给 3~4G
-Xms4g -Xmx4g  # 最小值和最大值设一样,避免动态扩缩容的开销

五、Java 内存区域速查表

JVM 的内存不只有堆,出问题的时候要知道是哪个区域炸了:

内存区域 存放内容 OOM 报错信息 排查工具
堆(Heap) 对象实例 java.lang.OutOfMemoryError: Java heap space jmap + MAT
元空间(Metaspace) 类元数据、方法信息 java.lang.OutOfMemoryError: Metaspace jstat -gc
直接内存(Direct) NIO 缓冲区 java.lang.OutOfMemoryError: Direct buffer memory NMT / jcmd
线程栈(Stack) 方法调用栈帧 StackOverflowError jstack
Native 内存 JNI、C/C++ 库 进程被 OOM Killer 杀死 pmap、smaps

最阴险的是 Native 内存泄漏——JVM 自己的监控工具看不到,只有 Linux 层面才能发现。进程 RES 持续涨,但 JVM 堆使用率不高?十有八九是堆外泄漏。


六、排查 SOP 速查表

下次被叫醒时,照着这个表一步步来:

步骤 命令 目的
1 free -h 确认是否真的内存不足(看 available)
2 top(按 M 排序) 找到吃内存最多的进程 PID
3 cat /proc/PID/status 确认进程的 RSS、Swap 使用
4 jstat -gcutil PID 1000 查看 JVM 堆各区使用率和 GC 频率
5 jmap -dump:format=b,file=/tmp/heap.hprof PID 导出堆转储(注意 STW 风险)
6 MAT 打开 hprof 分析 Leak Suspects + Dominator Tree
7 jcmd PID VM.native_memory detail 排查堆外内存(如 NMT 已开启)

快捷版(一行命令快速摸底):

# 看内存总览
free -h && echo "---" && top -b -o %MEM -n 1 | head -20

# 看 Java 进程的 GC 情况(替换 PID)
jstat -gcutil PID 1000 5

# 看进程内存详情(替换 PID)
cat /proc/PID/status | grep -E 'Name|VmRSS|VmSize|VmSwap|Threads'

七、总结

内存泄漏就像家里的水管漏水——滴滴答答的时候你根本注意不到,等你发现的时候,地板已经泡烂了。

核心要点回顾

  1. Linux 层:看 available 而不是 free,用 top + /proc/PID/status 定位吃内存的进程
  2. JVM 层jstat 看 GC 趋势,jmap 导出堆转储,MAT 分析泄漏嫌疑
  3. 堆外内存:NMT + pmap 是唯一的救命稻草,堆监控看不到的泄漏最危险
  4. 预防大于治疗:启动参数加上 -XX:+HeapDumpOnOutOfMemoryError,监控加上内存告警,本地缓存用有界的 Caffeine/Guava
  5. 五大常见原因:堆内泄漏、大对象、堆外泄漏、Metaspace 溢出、GC 参数不合理

最后的忠告:每一个线上 OOM 的背后,都有一个程序员曾经说过「这个 Map 不会很大的」。


如果这篇文章帮你少被叫醒了一次,那它就值了。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐