2023 年的一个凌晨两点,我被一条告警电话叫醒:一个核心 Java 服务被系统 OOM Killer 杀掉了。我迷迷糊糊登上去把服务重启,看了眼监控觉得没事,就回去睡了。结果四个小时后,同一个服务又挂了,告警又把我叫起来。这次我没敢再睡,盯着监控看了一会儿,发现这个服务的堆内存像一条几乎笔直的斜线一样往上爬,涨到顶就崩,重启归零,然后再次开始爬。我第一反应是"流量涨了内存不够",准备申请扩容加机器。幸好在提工单之前,我先把它的堆 dump 下来用工具打开看了一眼——那一眼让我彻底改了主意:堆里 80% 的空间被一个 static 的 HashMap 占着,里面塞了几百万个对象,从服务启动到现在一个都没被清理过。这根本不是内存不够,是内存泄漏,加多少机器都是往一个漏的桶里倒水。那次之后我才认真补课:JVM 的内存到底分成哪几块、对象怎么被回收、GC 日志怎么读、OOM 有几种、内存泄漏怎么定位。这篇文章就是把这套排查 JVM 内存问题的方法,从头到尾梳理一遍。
问题背景
先把那次事故的现象和我的误判摆出来,因为后面所有的排查思路,都是冲着纠正这个误判去的。
现象:一个 Java 服务,堆内存从启动开始持续单调上涨,像一条几乎笔直的斜线,涨到 -Xmx 上限就触发 OOM 崩溃;重启后内存归零,几个小时后又重复同样的过程。
我当时的错误判断:"内存不够用了,加机器或者把 -Xmx 调大就行。"
真相:内存持续单调上涨、而且 Full GC 也压不下来,是典型的内存泄漏特征。泄漏的根因是某些对象本该被回收,却一直被某个 GC Root 引用着。加机器、调大堆,只是让它崩得慢一点,泄漏的速度一点没变——这是往一个漏的桶里多倒水,不是补桶。
要真正定位这类问题,需要几块底层认知:
- JVM 内存分成哪几块,每一块溢出时的报错信息都不一样;
- 对象怎么进堆、怎么被判定成"垃圾"、怎么被回收;
- GC 日志怎么读,哪些是健康信号、哪些是危险信号;
- OOM 有四五种类型,报错关键字会直接指向不同的根因;
- 内存泄漏怎么用堆 dump 加分析工具,定位到具体是哪行代码在攒对象。
排查用的工具大多是 JDK 自带、无需额外安装的:jps、jstat、jmap、jstack、jcmd,再加一个图形化的堆分析工具 MAT(Memory Analyzer Tool)。
一、先认清 JVM 内存结构:堆只是其中一块
很多人一提"Java 内存"就只想到堆,但 JVM 的内存其实分成好几块,每一块满了报的错都不一样。认错了块,排查方向从一开始就是错的。
JVM 运行时内存大致分成这几部分:
- 堆 Heap——所有对象实例都在这里,是 GC 的主战场;内部再分新生代(Eden 加两个 Survivor)和老年代。溢出报错是
OutOfMemoryError: Java heap space。 - 虚拟机栈——每个线程一个,存方法调用的栈帧。递归太深会
StackOverflowError;线程开太多则会报unable to create new native thread。 - 元空间 Metaspace——存类的元信息,JDK 8 起从堆里挪到了本地内存。动态生成大量类时可能
OutOfMemoryError: Metaspace。 - 直接内存 Direct Memory——NIO 的
ByteBuffer.allocateDirect用的,不受 -Xmx 管,受-XX:MaxDirectMemorySize管,溢出报Direct buffer memory。 - 程序计数器——每个线程一个,记录执行到哪条字节码,极小,可忽略。
这里有一个最容易被忽视、却最关键的认知:-Xmx 只管堆。元空间、直接内存、线程栈都在堆之外,你把 -Xmx 调得再大,也救不了 Metaspace 或 Direct buffer 的 OOM。所以排查的第一步永远是看清 OOM 报错里的那个关键字——它直接告诉你是哪一块出了问题。
下面这段代码用 MemoryMXBean 把堆和非堆的用量分别打出来,跑一次就能直观感受到"堆只是 JVM 内存的一部分":
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.MemoryUsage;
public class MemoryInspect {
public static void main(String[] args) {
MemoryMXBean mx = ManagementFactory.getMemoryMXBean();
MemoryUsage heap = mx.getHeapMemoryUsage();
MemoryUsage nonHeap = mx.getNonHeapMemoryUsage();
// 堆:存放所有对象实例,是 -Xmx 管控的部分,也是 GC 的主战场
System.out.printf("堆 已用=%dMB 已提交=%dMB 上限=%dMB%n",
heap.getUsed() >> 20, heap.getCommitted() >> 20,
heap.getMax() >> 20);
// 非堆:元空间、代码缓存等;JDK8 后元空间走本地内存,不归 -Xmx 管
System.out.printf("非堆 已用=%dMB%n", nonHeap.getUsed() >> 20);
// Runtime 视角的堆数据,适合在代码里做内存自检 / 埋点
Runtime rt = Runtime.getRuntime();
long usedMB = (rt.totalMemory() - rt.freeMemory()) >> 20;
System.out.printf("Runtime 已用=%dMB total=%dMB max=%dMB%n",
usedMB, rt.totalMemory() >> 20, rt.maxMemory() >> 20);
}
}
二、对象的一生:它怎么进堆,又怎么被判死刑
要看懂内存泄漏,得先知道一个对象从生到死会经历什么。
分配与晋升:对象优先在新生代的 Eden 区分配;Eden 满了就触发一次 Minor GC(只回收新生代,很快);Minor GC 后还活着的对象被挪进 Survivor 区,年龄加一;之后每熬过一次 Minor GC 年龄再加一,到达阈值(默认 15)就晋升进老年代;特别大的对象会跳过 Eden 直接进老年代。老年代满了,就触发 Full GC——回收整个堆,慢,停顿长。
怎么判定一个对象是垃圾:JVM 用的是可达性分析。从一组叫 GC Roots 的"根"出发,顺着引用链往下走,能走到的对象算"存活",走不到的就是"垃圾",可以回收。
哪些东西算 GC Roots——这一点是理解内存泄漏的钥匙:
- 虚拟机栈里,正在执行的方法所引用的局部变量;
- 静态变量(static 字段引用的对象)——内存泄漏的头号重灾区;
- 常量引用的对象;
- JNI(本地方法)引用的对象。
顺带说一句为什么 JVM 不用更简单的"引用计数法":因为它解决不了循环引用——A 引用 B、B 引用 A,但 A 和 B 其实都没人用了,各自的计数永远不为零,于是永远回收不掉。可达性分析没有这个毛病:只要 A、B 从任何一个 GC Root 都走不到,照样判死。
一句话记住内存泄漏的本质:一个对象只要还能从某个 GC Root 走到,它就永远不会被回收。下面这段代码就是泄漏的最小模型——局部变量短命且健康,被静态集合收着的对象则永远赖在堆里:
import java.util.ArrayList;
import java.util.List;
public class ObjectLifecycle {
// 静态字段是 GC Root:它引用的对象,从 Root 永远可达,GC 碰都不能碰
private static final List<byte[]> STATIC_HELD = new ArrayList<>();
public static void main(String[] args) {
for (int i = 0; i < 2000; i++) {
// 局部变量 shortLived:下一次循环就不可达了,
// Minor GC 一来就被回收,它"短命"且健康
byte[] shortLived = new byte[256 * 1024];
consume(shortLived);
// 而这些被 STATIC_HELD 收着的对象,从 GC Root 一直可达,
// 不管发生多少次 GC 都回收不掉 —— 这就是内存泄漏的最小模型
if (i % 50 == 0) {
STATIC_HELD.add(new byte[256 * 1024]);
}
}
// 这个数字只增不减,STATIC_HELD 持有的内存就是"泄漏"掉的部分
System.out.println("静态集合持有对象数 = " + STATIC_HELD.size());
}
private static void consume(byte[] data) {
// 仅使用,不保存引用 —— 方法返回后 data 即不可达
int sum = 0;
for (byte b : data) sum += b;
}
}
三、读懂 GC 日志:健康的 GC 和危险的 GC 长什么样
GC 日志是判断"内存到底有没有问题"最直接的证据。它默认是关的,生产环境建议常态开启,开销很小。开启 GC 日志的启动参数,JDK 8 和 JDK 9+ 写法不同:
# JDK 9 及以后(统一日志框架)
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime:filecount=5,filesize=20m
# JDK 8 及以前
-XX:+PrintGCDetails -XX:+PrintGCDateStamps \
-Xloggc:/var/log/app/gc.log \
-XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=20m
开起来之后,日志里大致长这样(节选,并在右侧加了批注):
[2023-08-12T02:14:33] GC(128) Pause Young (G1 Evacuation Pause)
512M->48M(1024M) 12.3ms <- Minor GC:回收前 512M,回收后 48M,停顿 12ms,健康
[2023-08-12T02:51:07] GC(417) Pause Full (G1 Compaction)
980M->870M(1024M) 1840ms <- Full GC:停顿 1.8s,且回收后还剩 870M 下不去,危险
读 GC 日志,主要盯这几个点:
- GC 类型——Young / Minor 是回收新生代,快;Full 是回收整个堆,慢。Full GC 频繁出现就是危险信号。
- 回收前后的堆大小——格式是"回收前→回收后(总大小)"。健康的 GC 回收后能降到一个低水位;如果每次 Full GC 之后老年代都降不下去、水位线一路抬高,基本就是内存泄漏。
- 停顿时间 Pause——这段时间业务线程是停的(STW)。Minor GC 几毫秒到几十毫秒正常;Full GC 动辄上百毫秒甚至几秒,次数一多接口就会超时。
一句话总结健康基线:Minor GC 频率不高、停顿短,Full GC 极少发生、且每次都能把老年代显著回收下去。偏离这条基线,就该往下深挖了。
四、OOM 的几种类型:报错关键字直接指向根因
OutOfMemoryError 不是一种错,而是一类错。报错信息后面跟的那个关键字,几乎能直接定位问题出在哪一块内存上:
- Java heap space——堆放不下了。要么真的是对象太多、堆太小,要么是内存泄漏。最常见,也是本文的主线。
- GC overhead limit exceeded——JVM 花了超过 98% 的时间做 GC,却只回收回不到 2% 的内存。本质还是堆要爆了,只是死法更"挣扎"。
- Metaspace——类的元信息太多。常见于反复热部署、或框架动态生成海量类。可以调大
-XX:MaxMetaspaceSize,但要先怀疑类加载器泄漏。 - Direct buffer memory——堆外的直接内存爆了,通常和 NIO、Netty 有关。-Xmx 调再大也没用,要调
-XX:MaxDirectMemorySize并排查 ByteBuffer 没释放。 - unable to create new native thread——线程太多,每个线程的栈都要占内存。多半是线程池没复用、或者在疯狂 new Thread。
- StackOverflowError——严格说不是 OOM,是单个线程的栈太深,几乎总是递归没有正确的终止条件。
下面用代码直观地把两种最典型的情况触发出来——堆溢出和栈溢出。跑的时候建议加上 -Xmx64m 让它快一点崩:
import java.util.ArrayList;
import java.util.List;
public class OomDemo {
// 触发堆溢出:不断分配并强引用,GC 回收不掉,堆迟早放不下
static void heapOom() {
List<byte[]> hold = new ArrayList<>();
while (true) {
// 每次 1MB,且一直被 hold 引用,从 GC Root 可达
hold.add(new byte[1024 * 1024]);
}
// 结果:OutOfMemoryError: Java heap space
}
// 触发栈溢出:递归没有终止条件,栈帧无限叠加
static int stackOom(int depth) {
// 每调一层就压一个栈帧,永远不返回
return stackOom(depth + 1);
// 结果:StackOverflowError
}
public static void main(String[] args) {
try {
stackOom(0);
} catch (StackOverflowError e) {
System.out.println("栈溢出:递归深度耗尽了线程栈");
}
// 建议加 -Xmx64m 启动,几秒内即可看到堆 OOM
heapOom();
}
}
五、内存泄漏排查:从堆 dump 一路定位到那行代码
回到开头那次事故。确认了内存单调上涨之后,排查就是一套固定的动作:先看进程,再看 GC 和堆的实时状态,最后把堆 dump 下来用工具剖开。这一套命令全是 JDK 自带的,可以直接在生产机上用:
# 1. 找到 Java 进程的 PID
jps -l
# 2. 实时看 GC 状况,每 1 秒打一行,共打 10 行
# 重点看 OU(老年代用量)和 FGC(Full GC 次数)是不是一直涨
jstat -gc <pid> 1000 10
# 3. 看堆里对象的数量排行(histogram),谁占内存多一目了然
# :live 会先触发一次 Full GC,只统计存活对象
jmap -histo:live <pid> | head -30
# 4. 把整个堆 dump 成文件,拿回本地用 MAT 分析
jmap -dump:live,format=b,file=/tmp/heap.hprof <pid>
# 5. JDK 11+ 推荐用 jcmd,功能更全
jcmd <pid> GC.heap_info
jcmd <pid> GC.class_histogram | head -30
jcmd <pid> GC.heap_dump /tmp/heap.hprof
# 6. 让 JVM 在 OOM 那一刻自动 dump,事后不用复现(加到启动参数里)
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/heapdump.hprof
拿到 .hprof 文件后,用 MAT 打开,它的两个功能基本能直接破案:
- Leak Suspects 报告——MAT 会自动算出"谁占了大头",通常一打开就直接点名某个对象;那次事故里它点的就是一个 static 的 HashMap。
- Dominator Tree(支配树)——按"对象实际撑着多少内存"排序,层层展开,就能看到是哪个集合、被哪个类的哪个字段引用着。
顺着支配树往上看引用链(path to GC Roots),它会一直指到某个 GC Root——那次就是某个工具类里的一个 private static final Map。代码翻过去一看:这个 Map 被当成缓存用,只往里 put,从来没有 remove,也没有任何容量上限。根因就这么定位到了某一行。
六、工程坑:缓存、ThreadLocal、堆大小、容器内存
定位到根因之后,真正要做的是"以后别再犯"。下面这几个坑,是我见过的 Java 内存事故里最高频的几个。
坑 1:把普通 Map 当缓存用。一个只 put、不 remove、没有容量上限的 Map,本身就是一个匀速增长的内存泄漏。要做本地缓存,就用带淘汰能力的结构——一个 LRU,或者直接上 Caffeine、Guava Cache,给它设上最大容量和过期时间。
坑 2:ThreadLocal 在线程池里不 remove。线程池的线程是复用的、生命周期极长,ThreadLocal 往里塞的对象如果用完不 remove,就会一直挂在那个线程上,既泄漏内存,又会让下一个复用该线程的任务读到脏数据。铁律:ThreadLocal 用完,在 finally 里 remove。
坑 3:-Xmx 拍脑袋设置。-Xmx 太小会频繁 Full GC,太大则单次 Full GC 停顿长。建议把 -Xms 和 -Xmx 设成相等,避免堆反复伸缩;并且一定要配上 -XX:+HeapDumpOnOutOfMemoryError,留好"第一现场"。
坑 4:容器里 JVM 看错了内存上限。老版本 JDK 在容器里会读到宿主机的总内存,而不是容器的 limit,于是 JVM 以为自己有很多内存,实际一超过容器 limit 就被 cgroup 直接 kill——就是开头那种 OOM Killer。JDK 8u191+ 和 JDK 11+ 默认支持感知容器,务必用够新的版本,或显式用 -XX:MaxRAMPercentage 按比例设置堆。
下面把"普通 Map 当缓存"这个头号坑的错误写法和正确写法放在一起对照:
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
public class CacheCorrectly {
// 错误:静态 Map 当缓存,只增不减,这是一个匀速增长的内存泄漏
static final Map<String, byte[]> BAD_CACHE = new HashMap<>();
static void badPut(String key, byte[] value) {
BAD_CACHE.put(key, value); // 永远没人 remove,也没有上限
}
// 正确:基于 LinkedHashMap 实现一个有容量上限的 LRU 缓存
static class LruCache<K, V> extends LinkedHashMap<K, V> {
private final int maxSize;
LruCache(int maxSize) {
super(16, 0.75f, true); // accessOrder=true,按访问顺序排
this.maxSize = maxSize;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
// 超过容量就自动淘汰最久未使用的那个,内存就有了天花板
return size() > maxSize;
}
}
public static void main(String[] args) {
// 生产环境更推荐直接用 Caffeine:
// Caffeine.newBuilder().maximumSize(10_000)
// .expireAfterWrite(Duration.ofMinutes(10)).build();
Map<String, byte[]> cache = new LruCache<>(1000);
for (int i = 0; i < 100_000; i++) {
cache.put("k" + i, new byte[1024]);
}
// 不管塞多少,条目数恒定在上限,内存有天花板
System.out.println("LRU 缓存条目数 = " + cache.size()); // 1000
}
}
命令速查
| 目的 | 命令 / 参数 |
|---|---|
| 列出 Java 进程 | jps -l |
| 实时看 GC 与各代用量 | jstat -gc <pid> 1000 10 |
| 看堆对象数量排行 | jmap -histo:live <pid> |
| dump 整个堆 | jmap -dump:live,format=b,file=heap.hprof <pid> |
| 看堆概况(JDK 11+) | jcmd <pid> GC.heap_info |
| 看线程栈 | jstack <pid> |
| OOM 时自动 dump | -XX:+HeapDumpOnOutOfMemoryError |
| 指定 dump 路径 | -XX:HeapDumpPath=/path/heap.hprof |
| 开 GC 日志(JDK 9+) | -Xlog:gc*:file=gc.log |
| 开 GC 日志(JDK 8) | -XX:+PrintGCDetails -Xloggc:gc.log |
| 固定堆大小 | -Xms 与 -Xmx 设为相等 |
| 容器内按比例设堆 | -XX:MaxRAMPercentage=75.0 |
避坑清单
- 内存持续单调上涨、Full GC 也压不下来,要先怀疑内存泄漏,而不是直接加机器或调大 -Xmx。
- 排查 OOM 第一步是看报错关键字:heap space、Metaspace、Direct buffer memory、native thread 指向完全不同的内存区域和根因。
- 记住 -Xmx 只管堆,元空间、直接内存、线程栈都在堆之外,调大 -Xmx 救不了这几类 OOM。
- 内存泄漏的本质是对象被某个 GC Root 一直引用着回收不掉,排查时重点盯静态字段引用的集合。
- 生产环境常态开启 GC 日志,关注 Full GC 频率、回收后老年代是否降得下去、单次停顿时长。
- 给服务加 -XX:+HeapDumpOnOutOfMemoryError,让它在 OOM 那一刻自动 dump,省去复现的麻烦。
- 普通 HashMap 当缓存只 put 不 remove 就是泄漏,本地缓存一律用带容量上限和过期淘汰的结构。
- 线程池里的 ThreadLocal 用完必须在 finally 里 remove,否则既泄漏内存又会让复用线程读到脏数据。
- -Xms 和 -Xmx 设成相等,避免堆反复伸缩带来的额外停顿和不稳定。
- 容器部署务必用 JDK 8u191+ 或 JDK 11+,让 JVM 正确感知容器内存 limit,否则会被 cgroup 直接 kill。
总结
回头看那次凌晨两点的告警,真正值钱的不是最后改的那几行代码,而是我差一点就提交的那张扩容工单。如果当时我没有先 dump 一眼堆,而是直接加了机器、调大了 -Xmx,会发生什么?服务确实会"稳"几天——因为漏的桶变大了,要更久才会装满。然后某个深夜它还是会崩,而那时我多半会再加一次机器。内存泄漏最阴险的地方就在这里:它给你一种"加资源就能解决"的错觉,而每一次加资源,都在把真正的根因往后拖、把账单往上推。
所以排查 JVM 内存问题,真正的第一步不是动手,而是先做一个判断:这到底是"不够用",还是"在泄漏"。这两者的现象很像,处理方式却完全相反。判断的依据也很朴素——看内存曲线和 GC 日志:如果内存涨上去之后,每次 Full GC 都能显著回落,那大概率只是堆偏小,调参数或优化对象就行;如果它单调上涨、Full GC 之后水位线一路抬高下不来,那就是泄漏,加多少资源都没用,必须 dump 堆、找到那个攥着对象不放的 GC Root。
这篇文章的几块内容,其实是围着这个判断一层层展开的。先认清 JVM 内存分成堆、栈、元空间、直接内存几块,是为了在看到 OOM 报错时,能凭那个关键字立刻锁定方向;搞懂对象的分配、晋升和可达性分析,是为了理解"泄漏"在底层到底意味着什么——无非是某个对象一直能从 GC Root 走到;学会读 GC 日志,是为了不靠猜、用证据来区分"不够用"和"在泄漏";而 jmap、MAT 这套工具,是把模糊的"内存有问题"一路收敛到"某个类的某个静态字段"的最后一段路。
把这套方法走通一遍之后你会发现,JVM 内存其实没那么神秘。它不像很多人想的那样是一个需要"调参玄学"才能驯服的黑盒——绝大多数线上内存事故,根因都朴素得令人意外:一个忘了清理的静态缓存、一个没 remove 的 ThreadLocal、一段没有终止条件的递归、一个在容器里没设对的堆参数。难的从来不是这些根因本身,而是你有没有那套"先判断、再用日志取证、最后用工具定位"的章法,不被表象带着走、不靠加机器去掩盖问题。养成了这套章法,下一次凌晨两点的告警,你就能在提工单之前,先把那一眼堆看明白。
—— 别看了 · 2026