OOM 及性能优化案例与解决方案
常见问题的根源与解法
这一节通过真实案例,展示常见 OOM 和性能问题的诊断与解决思路。每个案例都遵循「症状 → 诊断 → 根因 → 方案」的结构。
案例一:堆内存溢出(Heap Space)
症状
java.lang.OutOfMemoryError: Java heap space
at com.example.Cache.put(Cache.java:45)
...诊断
bash
# 1. 查看 GC 日志
# 如果 Full GC 频繁且内存不下降 → 内存泄漏
# 如果内存持续增长直到 OOM → 对象分配过快
# 2. 生成堆转储
jmap -dump:live,format=b,file=heap.hprof <pid>
# 3. 用 MAT 分析
# Dominator Tree → 找出 Retained Heap 最大的对象
# Histogram → 按内存排序找出最多的类根因
常见原因:
1. 内存泄漏
- 静态集合无限增长(HashMap 作为缓存无清理)
- Listener/Callback 未移除
- ThreadLocal 未清理
- 数据库连接池泄漏
2. 分配过快
- 一次性加载过多数据到内存
- 大对象(GB 级别的文件、XML)
- 循环创建大量对象解决方案
内存泄漏修复:
1. 定位泄漏对象(MAT Dominator Tree)
2. 追溯 GC Root(Path to GC Roots)
3. 修复代码
分配过快修复:
1. 分批加载数据(Stream/Lazy)
2. 使用堆外内存处理大文件
3. 增加堆大小(如果确实需要)java
// 修复示例:静态 Map 缓存无清理
// Before
public class Cache {
private static Map<String, Object> cache = new HashMap<>();
public void put(String key, Object value) {
cache.put(key, value); // 无限增长!
}
}
// After:使用 WeakHashMap 或限制大小
public class Cache {
// 方案一:WeakHashMap(无引用时自动回收)
private static Map<String, Object> cache = new WeakHashMap<>();
// 方案二:限制大小的 LRU 缓存
private static Map<String, Object> cache =
new LinkedHashMap<String, Object>(1000, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > 10000; // 超过 10000 条时淘汰最老的
}
};
}案例二:元空间溢出(Metaspace)
症状
java.lang.OutOfMemoryError: Metaspace
at java.lang.ClassLoader.defineClass1(Native Method)
...诊断
bash
# 1. 查看元空间使用
jstat -gc <pid>
# MC(元空间容量)和 MU(元空间使用)列
# 2. 查看类加载情况
jstat -class <pid>
# Loaded:已加载类数量
# Unloaded:已卸载类数量
# 如果 Unloaded = 0 → 类加载器泄漏
# 3. 生成堆转储分析类加载器
jmap -dump:live,format=b,file=heap.hprof <pid>
# MAT → Histogram → Group by ClassLoader根因
元空间溢出原因:
1. 类加载器泄漏(Tomcat/插件框架常见)
- 每次部署创建新的类加载器,但旧的不卸载
- 旧类加载器被其他对象引用无法回收
2. 动态类生成过多
- ORM 框架(Hibernate/JPA)
- 动态代理
- 脚本引擎(Groovy/BeanShell)
3. 参数设置过小
- JDK 8 永久代替代方案,但 MetaspaceSize 默认很小解决方案
bash
# 增加元空间
java -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m -jar myapp.jar
# 如果是类加载器泄漏:
# 1. 排查类加载器引用链
# MAT → Path to GC Roots → 查看什么引用了类加载器
# 2. 修复引用链
# 3. 确保使用 WeakReference 持有类加载器java
// 修复示例:类加载器泄漏
// Before:类加载器被静态集合持有
public class PluginManager {
private static Map<String, ClassLoader> loaders = new HashMap<>();
// ↑ loaders 持有类加载器引用,永不卸载
public static void loadPlugin(String name, ClassLoader loader) {
loaders.put(name, loader); // 泄漏!
}
}
// After:使用 WeakReference
public class PluginManager {
private static Map<String, WeakReference<ClassLoader>> loaders = new HashMap<>();
public static void loadPlugin(String name, ClassLoader loader) {
loaders.put(name, new WeakReference<>(loader));
}
public static ClassLoader getLoader(String name) {
WeakReference<ClassLoader> ref = loaders.get(name);
return ref == null ? null : ref.get();
}
}案例三:直接内存溢出(Direct Memory)
症状
java.lang.OutOfMemoryError: Direct buffer memory诊断
bash
# 1. 查看直接内存使用
jinfo -flag MaxDirectMemorySize <pid>
# 2. 确认是 NIO 使用
# 如果使用了 ByteBuffer.allocateDirect()根因
直接内存溢出原因:
1. NIO 操作使用直接内存
- ByteBuffer.allocateDirect()
- Netty 等 NIO 框架
2. 堆外内存未释放
- 主动释放 ByteBuffer:DirectByteBuffer = null
- 依赖 GC 回收(需要 Full GC 才回收)
3. 直接内存设置过小解决方案
bash
# 增加直接内存
java -XX:MaxDirectMemorySize=1g -jar myapp.jar
# 或不限制
java -XX:MaxDirectMemorySize=0 -jar myapp.jarjava
// 主动释放直接内存
public class DirectBufferDemo {
private static final Map<Long, ByteBuffer> buffers = new ConcurrentHashMap<>();
public void allocateBuffer(long id, int size) {
ByteBuffer buffer = ByteBuffer.allocateDirect(size);
buffers.put(id, buffer);
}
// 主动释放
public void releaseBuffer(long id) {
buffers.remove(id); // 让 GC 回收
}
// 或者手动触发 Cleaner(不推荐)
public void forceRelease(ByteBuffer buffer) throws Exception {
if (buffer.isDirect()) {
Cleaner cleaner = ((DirectByteBuffer) buffer).cleaner();
if (cleaner != null) {
cleaner.clean(); // 强制释放
}
}
}
}案例四:线程栈溢出(Stack Overflow)
症状
java.lang.StackOverflowError
at com.example.Recursive.calculate(Recursive.java:25)
...诊断
bash
# 1. 查看线程栈大小
jinfo -flag ThreadStackSize <pid>
# 2. 定位溢出方法
# 堆栈信息中会显示调用链
# 找到最深的方法根因
线程栈溢出原因:
1. 无限递归(最常见)
- 方法 A 调用方法 B,方法 B 调用方法 A
- 没有正确终止的递归
2. 线程栈设置过小
- -Xss256k 设置过小
3. 递归调用中创建大量对象
- 每个栈帧都消耗栈空间解决方案
bash
# 增加线程栈大小
java -Xss1m -jar myapp.jarjava
// 修复无限递归
// Before
public int sum(int n) {
return n + sum(n - 1); // 没有终止条件!
}
// After:尾递归优化或循环
public int sum(int n) {
// 循环替代递归
int result = 0;
for (int i = 1; i <= n; i++) {
result += i;
}
return result;
}
// 或者尾递归(需要编译器支持)
public int sum(int n, int acc) {
if (n <= 0) return acc;
return sum(n - 1, acc + n); // 尾递归形式
}案例五:GC 开销限制(GC Overhead Limit)
症状
java.lang.OutOfMemoryError: GC overhead limit exceeded
# 或日志中出现:
# GC overhead limit exceeded诊断
bash
# 1. 查看 GC 日志
# 大量 GC,CPU 消耗在 GC 上
# 2. 查看 GC 频率
jstat -gcutil <pid> 1000 10
# 如果 YGC/FGC 频繁且耗时很长根因
GC 开销限制原因:
1. 堆太小
- 分配速率 > 回收速率
- GC 不断运行但释放不了多少内存
2. 内存泄漏
- 同案例一
3. 大量小对象
- 分配速率过高解决方案
bash
# 增加堆大小
java -Xms4g -Xmx4g -jar myapp.jar
# 或关闭开销限制检查(不推荐)
java -XX:-UseGCOverheadLimit -jar myapp.jar案例六:请求超时 GC 停顿
症状
监控系统报警:P99 延迟 > 500ms
用户反馈:接口偶发性卡顿
# GC 日志:
# 2026-03-22T10:00:00.123+0800: [Full GC (Allocation Failure) ... 500ms]
# 2026-03-22T10:00:05.456+0800: [Full GC (Allocation Failure) ... 450ms]诊断
bash
# 1. 分析 GC 日志
# 查看停顿时间和频率
# 2. 用 GCViewer/GCEasy 分析
# 3. 查看 GC 原因
# Allocation Failure → 分配失败(正常)
# Ergonomics → 自适应调整
# System.gc() → 代码调用了 System.gc()
# Allocation Failure + CMS → CMS 老年代空间不足解决方案
java
// 调优方案
// 方案一:切换到 G1 GC
java -XX:+UseG1GC \
-XX:MaxGCPauseMillis=100 \
-jar myapp.jar
// 方案二:减少对象分配
// - 使用对象池
// - 减少中间对象
// - 使用基本类型
// 方案三:增加堆大小
java -Xms4g -Xmx4g -jar myapp.jar
// 方案四:减少 System.gc() 调用
// 搜索代码中的 System.gc()
grep -r "System.gc()" src/
// 方案五:使用 ZGC(极低停顿)
java -XX:+UseZGC \
-XX:+ZGenerational \
-jar myapp.jar性能优化 Checklist
性能优化清单:
内存优化:
□ 堆大小是否合理(-Xms = -Xmx)
□ 对象分配速率是否过高
□ 是否有内存泄漏
□ 元空间是否充足
□ 是否使用合适的数据结构
GC 优化:
□ GC 选择是否合适(场景匹配)
□ GC 参数是否调优
□ System.gc() 调用是否过多
□ Full GC 频率是否过高
代码优化:
□ 循环中是否创建过多对象
□ 是否使用对象池复用对象
□ 是否正确关闭资源
□ 是否使用流式处理代替一次性加载
监控:
□ GC 日志是否开启
□ OOM 时是否生成堆转储
□ 是否有健康检查接口
□ 性能基线是否建立本节小结
OOM 和性能问题速查:
| 问题类型 | 症状 | 根因 | 解决方案 |
|---|---|---|---|
| 堆溢出 | OutOfMemoryError: heap | 泄漏/分配过快 | MAT 分析 + 修复代码 |
| 元空间溢出 | OutOfMemoryError: Metaspace | 类加载器泄漏 | 增加 MetaspaceSize |
| 直接内存溢出 | OutOfMemoryError: Direct buffer | NIO 使用过多 | 增加 MaxDirectMemorySize |
| 线程栈溢出 | StackOverflowError | 无限递归 | 修复递归/增加栈 |
| GC 开销 | GC overhead limit | 堆太小/泄漏 | 增加堆 + 修复泄漏 |
| GC 停顿 | P99 延迟高 | GC 参数不当 | 调优 GC 参数 |
排查 OOM 的标准流程:
1. 查看错误信息(堆/元空间/直接内存)
2. 开启 GC 日志和堆转储
3. 生成堆转储(-XX:+HeapDumpOnOutOfMemoryError)
4. MAT 分析堆转储
5. 追溯 GC Root
6. 修复代码