一次线上 OOM 排查:Java 堆外内存泄漏分析与解决

线上服务突然 OOM(OutOfMemoryError),堆内存还有空闲,但进程内存不断增长直到被 kill。这是典型的堆外内存泄漏问题。本文记录完整的排查过程和解决方案。

OOM 异常分类

OOM 不只是 heap 满了,JVM 内存分为多个区域:

区域 OOM 原因 表现
Java Heap 对象分配超过 -Xmx java.lang.OutOfMemoryError: Java heap space
Metaspace 类加载过多 java.lang.OutOfMemoryError: Metaspace
Direct Memory NIO 直接内存 java.lang.OutOfMemoryError: Direct buffer memory
Stack 线程栈过深 java.lang.StackOverflowError
Native Heap JNI/native 代码 系统级 OOM

这次遇到的是 Direct Buffer Memory 问题。

问题现象

1
2
3
4
5
6
7
8
9
10
# 监控告警
[告警] 服务内存使用率超过 90%
进程: java (pid 12345)
内存: RSS 8GB / 8GB

# k8s 事件
Warning Evicted Pod was evicted due to node memory pressure

# dmesg
Out of memory: Kill process 12345 (java) score 861 or sacrifice child

服务启动后内存逐渐增长,GC 正常但进程 RSS 持续上升,最终被 OOM Killer 杀掉。

heap dump 获取方法

情况1:服务未崩溃

1
2
3
4
5
6
7
8
# 使用 jmap 获取 heap dump(生产慎用,可能 STW)
jmap -dump:format=b,file=heap.hprof <pid>

# 推荐:使用 gdb 附带的 gcore(不影响 Java 进程)
gcore <pid>

# 然后用 jmap 转换
jmap -dump:format=b,file=heap.hprof <pid> heap.hprof

情况2:设置自动 dump

1
2
3
# JVM 参数:OOM 时自动生成 dump
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/logs/heap.hprof

情况3:服务已崩溃

如果进程被 kill,来不及 dump,查看崩溃日志:

1
2
# 查看 hs_err 日志
ls -la /data/logs/hs_err_*.log

MAT 工具分析

安装与启动

1
2
# 下载 MAT:https://eclipse.dev/mat/downloads.php
./MemoryAnalyzer -vm /path/to/java

关键视图

1. Histogram(直方图)

查看对象数量和内存占用:

1
2
3
4
5
6
Class Name                                    | Objects | Shallow Heap
----------------------------------------------|---------|------------
java.lang.String | 1,234,567 | 49,382,680
java.util.HashMap$Node | 2,345,678 | 74,261,000
java.util.ArrayList | 500,000 | 12,000,000
...

2. Dominator Tree(支配树)

找出占用内存最多的对象路径:

1
2
3
4
5
Path to GC Roots: 524,288,000 (42.5%)
|
+--- com.example.CacheManager
| +--- Map<CacheKey, CacheEntry> map = 500MB
| | +--- 100万个 CacheEntry 对象

3. Leak Suspects(泄漏怀疑)

MAT 自动分析可能的内存泄漏点:

1
2
3
4
5
6
7
One instance of "com.example.CacheManager" 
loaded by "sun.misc.Launcher$AppClassLoader"
occupies 524,288,000 (42.5%) bytes.

Keywords:
com.example.CacheManager
sun.misc.Launcher$AppClassLoader

常见泄漏场景

场景1:Cache 无限增长

1
2
3
4
5
6
7
8
9
10
11
12
// 问题代码
@Service
public class UserService {
private Map<Long, User> userCache = new HashMap<>();

public User getUser(Long id) {
if (!userCache.containsKey(id)) {
userCache.put(id, userMapper.selectById(id));
}
return userCache.get(id);
}
}

问题userCache 只增不删,内存持续增长。

解决:使用带过期时间的缓存:

1
2
3
4
5
6
7
8
@Service
public class UserService {
// 使用 WeakHashMap 或 LRUCache
private LoadingCache<Long, User> userCache = Caffeine.newBuilder()
.maximumSize(10_000) // 最大条目
.expireAfterWrite(10, TimeUnit.MINUTES) // 写入后过期
.build(id -> userMapper.selectById(id));
}

场景2:静态集合持有对象

1
2
3
4
5
6
7
8
// 问题代码
public class StaticHolder {
public static List<Object> list = new ArrayList<>();

public static void add(Object obj) {
list.add(obj); // 永不清理
}
}

解决:单例模式需要清理机制,或使用弱引用:

1
2
3
4
5
6
public class StaticHolder {
// 使用 WeakHashMap:key 不再被引用时可回收
public static Map<Object, Object> cache = Collections.synchronizedMap(
new WeakHashMap<>()
);
}

场景3:监听器未注销

1
2
3
4
5
6
7
8
9
10
// 问题代码
@Service
public class EventService {
@PostConstruct
public void init() {
eventBus.register(this); // 注册监听器
}

// 忘记注销!
}

解决:使用 @PreDestroy 注销:

1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class EventService {
@PostConstruct
public void init() {
eventBus.register(this);
}

@PreDestroy
public void destroy() {
eventBus.unregister(this);
}
}

NIO 直接内存泄漏

这次问题的真正原因。

问题代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Service
public class FileUploadService {

@Autowired
private FileMapper fileMapper;

public String upload(MultipartFile file) throws IOException {
// 问题:每次上传都创建新的 FileChannel
try (RandomAccessFile raf = new RandomAccessFile(file.getOriginalFilename(), "rw");
FileChannel channel = raf.getChannel()) {

MappedByteBuffer buffer = channel.map(
FileChannel.MapMode.READ_WRITE,
0,
file.getSize()
);

// 处理文件...
// 问题:MappedByteBuffer 持有文件映射,不会立即释放
}
}
}

问题分析

MappedByteBuffer 使用了 Direct Memory(堆外内存),映射了操作系统的文件。问题:

  1. 文件过大(几个 GB)
  2. 多次上传,累积大量 Direct Buffer
  3. GC 不管理 Direct Memory
  4. FileChannel.close() 不会立即释放映射

解决方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Service
public class FileUploadService {

public String upload(MultipartFile file) throws IOException {
// 方案1:使用传统 IO,减小 buffer
try (InputStream is = file.getInputStream();
OutputStream os = new FileOutputStream(targetPath)) {

byte[] buffer = new byte[8192]; // 8KB 小缓冲区
int bytesRead;
while ((bytesRead = is.read(buffer)) != -1) {
os.write(buffer, 0, bytesRead);
}
}

// 方案2:使用 chunked 处理超大文件
// 方案3:使用内存映射库(如 java-unwind)
}
}

监控 Direct Memory

1
2
3
4
5
6
7
8
9
10
11
12
# 添加 JVM 参数
-XX:MaxDirectMemorySize=512m
-XX:+PrintGCDetails
-XX:+PrintGCApplicationStoppedTime

# 代码中监控
MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
MemoryMXBean memory = ManagementFactory.getMemoryMXBean();
MemoryUsage heapUsage = memory.getHeapMemoryUsage();
MemoryUsage directUsage = memory.getNonHeapMemoryUsage();

System.out.println("Direct Memory: " + directUsage.getUsed());

实战:解决第三方 SDK 泄漏

问题定位

1
2
3
4
5
6
7
8
9
10
11
12
// 业务代码
@Service
public class ThirdPartyService {

public void callApi(String param) {
// 每次调用都创建新客户端
ThirdPartyClient client = new ThirdPartyClient();
client.connect();
// 使用...
// 问题:client 没有关闭
}
}

ThirdPartyClient 内部使用了 NIO:

1
2
3
4
5
6
7
8
9
10
// 第三方 SDK 简化
public class ThirdPartyClient {
private ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024); // 1MB

public void connect() {
// 建立连接...
}

// 没有 close 方法!
}

解决方案

  1. 复用客户端
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Service
public class ThirdPartyService {

private final ThirdPartyClient client;

@PostConstruct
public void init() {
client = new ThirdPartyClient();
client.connect();
}

public void callApi(String param) {
// 复用单例 client
client.invoke(param);
}

@PreDestroy
public void destroy() {
if (client != null) {
client.close();
}
}
}
  1. 如果 SDK 没有提供 close:使用反射或包装:
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 ThirdPartyClientWrapper implements AutoCloseable {

private final ThirdPartyClient client;
private final Cleaner cleaner;

public ThirdPartyClientWrapper() {
this.client = new ThirdPartyClient();
// 使用 Cleaner 延迟清理
this.cleaner = Cleaner.create(client, this::cleanup);
}

public void invoke(String param) {
client.call(param);
}

private void cleanup() {
// 清理 Direct Memory
// 调用 SDK 的清理方法(如果有)
}

@Override
public void close() {
cleaner.clean();
}
}

总结

堆外内存泄漏排查要点:

  1. 确认类型:是 Heap OOM 还是 Direct Memory OOM
  2. heap dump:使用 MAT 分析支配树
  3. 常见场景:Cache、静态集合、监听器未注销
  4. NIO 问题MappedByteBuffer、Direct ByteBuffer
  5. 监控-XX:MaxDirectMemorySize、JMX MBean

解决方案优先级:

  1. 修复代码问题(根本解决)
  2. 限制内存大小(止血)
  3. 重启服务(临时方案)
  4. 增加节点(缓解)