Java 应用内存溢出 (OOM) 故障排查实战

一、OOM 概述

1.1 什么是 OOM

内存溢出(Out Of Memory,OOM)是 Java 应用最常见的故障之一,指 JVM 无法为新对象分配内存,且无法通过垃圾回收释放足够空间时抛出的错误。

1.2 OOM 的常见类型

错误类型 触发原因 常见场景
Java heap space 堆内存不足 对象泄漏、大对象、内存配置过小
GC overhead limit exceeded GC 时间占比过高 内存接近耗尽,GC 频繁
Metaspace 元空间不足 类加载过多、动态代理、热部署
StackOverflowError 栈空间不足 递归过深、栈帧过大
Direct buffer memory 直接内存不足 NIO 使用、Netty 应用
Unable to create new native thread 无法创建线程 线程数过多、系统资源限制
Out of swap space 系统交换空间不足 物理内存 + 交换空间耗尽
Killed process 被 OOM Killer 杀死 系统级内存不足

二、故障现象识别

2.1 应用层症状

1
2
3
4
5
6
7
8
9
10
# 应用日志中出现
java.lang.OutOfMemoryError: Java heap space
java.lang.OutOfMemoryError: GC overhead limit exceeded
java.lang.OutOfMemoryError: Metaspace

# 应用表现
- 响应时间突然变长
- 请求超时增加
- 服务间歇性不可用
- 进程突然消失(被 OOM Killer 杀死)

2.2 系统层症状

1
2
3
4
5
6
7
8
9
10
11
12
# 检查进程是否存在
ps aux | grep java

# 查看系统内存
free -h

# 查看 OOM Killer 日志
dmesg | grep -i "killed process"
grep -i "out of memory" /var/log/messages

# 查看 JVM 进程内存使用
top -p <pid>

2.3 典型 OOM Killer 日志

1
2
[12345.678901] Out of memory: Kill process 12345 (java) score 900 or sacrifice child
[12345.678902] Killed process 12345 (java) total-vm:8234567kB, anon-rss:7123456kB, file-rss:12345kB

三、诊断工具准备

3.1 内置工具

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# jcmd - 多功能诊断工具
jcmd <pid> help
jcmd <pid> GC.heap_info
jcmd <pid> VM.flags

# jmap - 内存映射
jmap -heap <pid>
jmap -histo <pid>
jmap -dump:format=b,file=heap.hprof <pid>

# jstat - GC 统计
jstat -gc <pid> 1000 10
jstat -gcutil <pid> 1000 10

# jstack - 线程栈
jstack <pid> > thread_dump.txt

# jinfo - JVM 信息
jinfo -flags <pid>
jinfo -sysprops <pid>

3.2 第三方工具

工具 用途 特点
MAT (Memory Analyzer Tool) 堆转储分析 Eclipse 出品,功能强大
JVisualVM 实时监控 JDK 自带,图形化
Arthas 在线诊断 阿里巴巴开源,无需重启
GCEasy GC 日志分析 在线工具,可视化好
Async Profiler 性能分析 低开销,支持火焰图

3.3 工具安装

1
2
3
4
5
6
7
# 安装 MAT
wget https://www.eclipse.org/downloads/download.php?file=/mat/1.12.0/rcp/MemoryAnalyzer-1.12.0-rcp-linux.gtk.x86_64.tar.gz
tar -xzf MemoryAnalyzer-*.tar.gz

# 安装 Arthas
wget https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar

四、排查流程

4.1 标准排查流程图

1
2
3
4
5
发现 OOM → 收集现场 → 分析原因 → 定位代码 → 修复验证
↓ ↓ ↓ ↓ ↓
监控告警 堆转储 MAT 分析 源码审查 压测验证
GC 日志 线程分析 日志排查 灰度发布
系统日志 火焰图 复现测试 全量上线

4.2 第一步:保护现场

关键原则:先保留现场,再重启恢复

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 1. 如果进程还在,立即生成堆转储
jmap -dump:live,format=b,file=/tmp/heap_$(date +%Y%m%d_%H%M%S).hprof <pid>

# 2. 生成线程转储
jstack <pid> > /tmp/thread_$(date +%Y%m%d_%H%M%S).txt

# 3. 收集 GC 日志(如果配置了)
# GC 日志通常在应用日志目录或指定路径

# 4. 记录 JVM 参数
jinfo <pid> > /tmp/jinfo_$(date +%Y%m%d_%H%M%S).txt

# 5. 记录系统状态
top -b -n 1 > /tmp/top_$(date +%Y%m%d_%H%M%S).txt
free -h > /tmp/memory_$(date +%Y%m%d_%H%M%S).txt

4.3 第二步:分析堆转储

使用 MAT 分析:

  1. 打开 MAT,加载 heap.hprof 文件
  2. 查看 “Leak Suspects” 报告
  3. 分析 “Dominator Tree” 找出大对象
  4. 查看 “Histogram” 统计对象数量

关键指标:

1
2
3
- Shallow Heap: 对象自身占用内存
- Retained Heap: 对象及其引用对象总内存
- GC Roots: 对象引用链起点

常见泄漏模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 模式 1: 静态集合持续增长
public class CacheManager {
private static Map<String, Object> cache = new HashMap<>();

public void put(String key, Object value) {
cache.put(key, value); // 只增不减
}
}

// 模式 2: 未关闭的资源
public void processData() throws Exception {
InputStream is = new FileInputStream("large_file");
// 忘记关闭,导致内存泄漏
byte[] data = IOUtils.toByteArray(is);
}

// 模式 3: 监听器/回调未注销
public class EventListener {
public void register() {
eventBus.register(this); // 注册后未注销
}
}

// 模式 4: ThreadLocal 未清理
public class UserContext {
private static ThreadLocal<User> user = new ThreadLocal<>();

public void setUser(User u) {
user.set(u); // 请求结束后未 remove
}
}

4.4 第三步:分析 GC 日志

启用 GC 日志:

1
2
3
4
5
6
7
8
9
10
11
12
# JDK 8
-Xloggc:/var/log/gc.log
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=10
-XX:GCLogFileSize=100M

# JDK 11+
-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags
-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags:filecount=10,filesize=100M

分析要点:

1
2
3
4
5
6
7
8
# 使用 GCEasy.io 在线分析
# 上传 gc.log 文件,获取可视化报告

# 关键关注点:
1. GC 频率是否过高(每分钟超过 1 次 Full GC)
2. GC 耗时是否过长(单次 Full GC 超过 1 秒)
3. 老年代使用率是否持续增长
4. GC 后内存是否有效回收

典型 GC 问题日志:

1
2
3
4
5
6
7
8
9
10
# Full GC 频繁
2026-03-14T15:30:01.123+0800: [Full GC (Ergonomics) [PSYoungGen: 1024K->0K(76288K)]
[ParOldGen: 71680K->71500K(175616K)] 72704K->71500K(251904K), 1.234 secs]

# GC 时间占比过高
2026-03-14T15:30:05.456+0800: [GC (Allocation Failure) [PSYoungGen: 65536K->8192K(76288K)]
137216K->79872K(251904K), 0.567 secs]

# 触发 GC overhead limit
java.lang.OutOfMemoryError: GC overhead limit exceeded

4.5 第四步:使用 Arthas 在线诊断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 启动 Arthas
java -jar arthas-boot.jar

# 查看内存概况
dashboard

# 查看堆内存详情
memory

# 查看对象实例数量
vmtool --action getInstances --className java.lang.String --limit 10

# 查找占用内存最大的对象
vmtool --action getInstances --className com.example.MyClass --limit 5

# 监控方法调用
trace com.example.Service process '#cost > 100'

# 查看线程状态
thread

# 查看线程 CPU 占用
thread -n 5

五、典型场景案例分析

5.1 案例一:缓存无限增长

现象

  • 应用运行 3 天后开始频繁 Full GC
  • 第 5 天出现 OOM 崩溃

排查过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 1. 堆转储分析
jmap -dump:live,format=b,file=heap.hprof <pid>

# 2. MAT 分析发现
- HashMap 占用 4GB,占总堆 80%
- HashMap 中有 500 万个 Entry
- 键为 String,值为自定义对象

# 3. 定位代码
public class ProductCache {
private static Map<String, Product> cache = new HashMap<>();

public void cacheProduct(String productId, Product product) {
cache.put(productId, product); // 无过期、无上限
}
}

解决方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 方案 1: 使用 LRU 缓存
private static Map<String, Product> cache = new LinkedHashMap<String, Product>(10000, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Product> eldest) {
return size() > 10000; // 限制最大 1 万条
}
};

// 方案 2: 使用 Guava Cache
private static Cache<String, Product> cache = CacheBuilder.newBuilder()
.maximumSize(10000)
.expireAfterWrite(1, TimeUnit.HOURS)
.build();

// 方案 3: 使用 Caffeine(推荐)
private static Cache<String, Product> cache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(1, TimeUnit.HOURS)
.recordStats()
.build();

5.2 案例二:大对象直接加载

现象

  • 导入 Excel 文件时偶发 OOM
  • 小文件正常,大文件(>100MB)必现

排查过程

1
2
3
4
5
6
7
8
// 问题代码
public void importExcel(MultipartFile file) throws Exception {
// 一次性加载整个文件到内存
byte[] data = file.getBytes(); // 100MB 文件直接占用 100MB 堆
Workbook workbook = new XSSFWorkbook(new ByteArrayInputStream(data));

// 处理...
}

解决方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 方案 1: 流式处理(推荐)
public void importExcel(MultipartFile file) throws Exception {
try (InputStream is = file.getInputStream();
OPCPackage pkg = OPCPackage.open(is)) {

XSSFReader reader = new XSSFReader(pkg);
ReadOnlySharedStringsTable strings = new ReadOnlySharedStringsTable(pkg);

// 流式读取,不一次性加载
XMLReader sheetParser = XMLHelper.getXMLReader();
sheetParser.setContentHandler(new XSSFSheetXMLHandler(...));
sheetParser.parse(new InputSource(reader.getSheet("Sheet1")));
}
}

// 方案 2: 使用 EasyExcel(阿里巴巴开源)
public void importExcel(MultipartFile file) {
EasyExcel.read(file.getInputStream(), DataModel.class, new ReadListener() {
@Override
public void invoke(DataModel data, AnalysisContext context) {
// 逐行处理,内存占用极低
processRow(data);
}
}).sheet().doRead();
}

5.3 案例三:ThreadLocal 内存泄漏

现象

  • Tomcat 应用运行一段时间后 OOM
  • 重启后恢复正常

排查过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 问题代码
public class UserContextHolder {
private static ThreadLocal<User> currentUser = new ThreadLocal<>();

public static void setUser(User user) {
currentUser.set(user);
}

public static User getUser() {
return currentUser.get();
}
// 缺少 remove() 方法
}

// 使用场景
public class UserController {
public void handleRequest() {
User user = authenticate();
UserContextHolder.setUser(user); // 设置后未清理

// 处理业务...
// 请求结束,但 ThreadLocal 未清理
// Tomcat 线程复用,导致内存泄漏
}
}

解决方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 方案 1: 手动清理
public class UserContextHolder {
private static ThreadLocal<User> currentUser = new ThreadLocal<>();

public static void setUser(User user) {
currentUser.set(user);
}

public static User getUser() {
return currentUser.get();
}

public static void clear() {
currentUser.remove(); // 关键:请求结束后清理
}
}

// 使用拦截器统一清理
public class UserContextInterceptor implements HandlerInterceptor {
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
UserContextHolder.clear(); // 请求结束清理
}
}

// 方案 2: 使用 try-finally
public void handleRequest() {
try {
User user = authenticate();
UserContextHolder.setUser(user);
// 处理业务...
} finally {
UserContextHolder.clear(); // 确保清理
}
}

5.4 案例四:元空间溢出

现象

1
java.lang.OutOfMemoryError: Metaspace

原因分析

  • 动态生成类过多(CGLIB、Groovy)
  • 热部署频繁(DevTools、JRebel)
  • 类加载器泄漏

排查命令

1
2
3
4
5
6
7
8
# 查看元空间使用
jcmd <pid> VM.metaspace

# 查看加载的类
jcmd <pid> GC.class_histogram | head -50

# 查看类加载器
jcmd <pid> GC.classloader_stats

解决方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 1. 增加元空间(临时方案)
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m

# 2. 排查类泄漏
# 使用 MAT 分析类加载器引用链

# 3. 优化动态代理
// 避免重复创建代理类
private static final Map<Class<?>, Object> proxyCache = new ConcurrentHashMap<>();

public Object getProxy(Class<?> clazz) {
return proxyCache.computeIfAbsent(clazz, c -> createProxy(c));
}

5.5 案例五:被 OOM Killer 杀死

现象

  • 进程突然消失,无 OOM 错误日志
  • 系统日志显示被 kill

排查

1
2
3
4
5
6
7
8
# 查看 OOM Killer 日志
dmesg -T | grep -i "killed process"
grep -i "out of memory" /var/log/messages

# 查看进程被杀详情
# 输出示例:
# Out of memory: Kill process 12345 (java) score 901 or sacrifice child
# Killed process 12345 (java) total-vm:16234567kB, anon-rss:15123456kB

解决方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 1. 调整 JVM 堆大小(预留系统内存)
# 堆大小建议为物理内存的 60-70%
-Xms8g -Xmx8g # 16G 物理机器

# 2. 调整 OOM Score
# 降低 Java 进程被杀概率
echo -500 > /proc/<pid>/oom_score_adj

# 3. 增加系统交换空间
sudo fallocate -l 8G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile

# 4. 使用容器限制(Docker/K8s)
docker run -m 8g --memory-swap=10g ...

# K8s 资源配置
resources:
requests:
memory: "8Gi"
limits:
memory: "10Gi"

六、预防措施

6.1 JVM 参数优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 生产环境推荐配置
java -Xms8g -Xmx8g \
-XX:MetaspaceSize=256m \
-XX:MaxMetaspaceSize=512m \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:InitiatingHeapOccupancyPercent=45 \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/heapdump \
-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags:filecount=10,filesize=100M \
-XX:+UseGCLogFileRotation \
-XX:NumberOfGCLogFiles=10 \
-XX:GCLogFileSize=100M \
-jar application.jar

6.2 代码规范

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 1. 避免静态集合无限增长
// ❌ 错误
private static List<Data> dataList = new ArrayList<>();

// ✅ 正确:使用有界集合
private static final int MAX_SIZE = 10000;
private static BlockingQueue<Data> dataQueue = new ArrayBlockingQueue<>(MAX_SIZE);

// 2. 及时关闭资源
// ✅ 使用 try-with-resources
try (InputStream is = new FileInputStream(file);
BufferedReader reader = new BufferedReader(new InputStreamReader(is))) {
// 处理...
}

// 3. 大对象分片处理
public void processLargeData(List<Data> data) {
int batchSize = 1000;
for (int i = 0; i < data.size(); i += batchSize) {
int end = Math.min(i + batchSize, data.size());
processBatch(data.subList(i, end));
// 每批处理后释放引用
}
}

// 4. 使用软引用/弱引用缓存
private static Map<String, SoftReference<CacheEntry>> cache =
new ConcurrentHashMap<>();

// 5. ThreadLocal 使用规范
private static final ThreadLocal<DateFormat> dateFormat =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

// 使用后清理(如果在线程池场景)
dateFormat.remove();

6.3 监控告警

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# Prometheus 监控规则
groups:
- name: jvm-alerts
rules:
# 堆内存使用率过高
- alert: JvmHeapUsageHigh
expr: jvm_memory_used_bytes{area="heap"} / jvm_memory_max_bytes{area="heap"} > 0.85
for: 5m
labels:
severity: warning
annotations:
summary: "JVM 堆内存使用率超过 85%"

# Full GC 频繁
- alert: JvmFullGcFrequent
expr: rate(jvm_gc_collection_seconds_count{gc="G1 Old Generation"}[5m]) > 0.5
for: 5m
labels:
severity: warning
annotations:
summary: "Full GC 过于频繁"

# GC 时间占比过高
- alert: JvmGcTimeRatioHigh
expr: rate(jvm_gc_collection_seconds_sum[5m]) / 300 > 0.1
for: 5m
labels:
severity: critical
annotations:
summary: "GC 时间占比超过 10%"

6.4 压测验证

1
2
3
4
5
6
7
8
# 使用 JMeter 进行压力测试
# 逐步增加并发,观察内存变化

# 使用 Chaos Engineering 注入故障
# 模拟内存压力,验证系统恢复能力

# 长期稳定性测试
# 运行 72 小时以上,观察内存趋势

七、应急响应清单

7.1 故障发生时的操作

1
2
3
4
5
6
□ 1. 确认故障现象(查看日志、监控)
□ 2. 保留现场(堆转储、线程转储、GC 日志)
□ 3. 评估影响范围(受影响服务、用户)
□ 4. 临时恢复(重启服务,如有必要)
□ 5. 通知相关人员(开发、测试、业务方)
□ 6. 记录故障时间线

7.2 恢复后的操作

1
2
3
4
5
6
7
8
□ 1. 分析堆转储文件
□ 2. 定位根本原因
□ 3. 制定修复方案
□ 4. 代码修复 + 单元测试
□ 5. 压测验证
□ 6. 灰度发布
□ 7. 全量上线
□ 8. 故障复盘 + 文档沉淀

八、总结

OOM 故障排查的核心要点:

  1. 保护现场优先:先 dump 堆,再重启恢复
  2. 工具链完备:熟练掌握 jmap、MAT、Arthas 等工具
  3. 分析有方法:从堆转储→GC 日志→代码定位
  4. 预防胜于治疗:合理配置 JVM 参数 + 代码规范 + 监控告警
  5. 持续优化:定期压测 + 代码审查 + 故障复盘

记住:每一次 OOM 故障都是优化系统的机会,做好记录和复盘,避免同类问题再次发生。