跳到主要内容

内存泄漏排查

问题

服务运行一段时间后内存持续增长不释放,如何定位内存泄漏?

答案

内存泄漏 vs 内存溢出

内存泄漏(Memory Leak)内存溢出(OOM)
定义对象不再使用但无法被 GC 回收内存不足无法分配新对象
关系泄漏积累到一定程度 → 溢出可能是泄漏导致,也可能是单次大分配

排查流程

堆转储对比分析

多次 dump 对比
# 第一次 dump
jmap -dump:format=b,file=/tmp/heap1.hprof <pid>
# 等待 30 分钟
# 第二次 dump
jmap -dump:format=b,file=/tmp/heap2.hprof <pid>

在 MAT 中:Histogram → 右键 → Compare with another Heap Dump → 找到数量持续增加的类。

六种常见泄漏场景

场景 1:集合只加不删

❌ 泄漏:static 集合无限增长
public class EventManager {
// 只 add 不 remove,随着时间推移 listeners 越来越大
private static final List<EventListener> listeners = new ArrayList<>();

public void register(EventListener listener) {
listeners.add(listener);
}
// 缺少 unregister 方法!
}
✅ 修复
public void unregister(EventListener listener) {
listeners.remove(listener);
}
// 或使用 WeakReference
private static final List<WeakReference<EventListener>> listeners = new ArrayList<>();

场景 2:ThreadLocal 未清理

❌ 线程池 + ThreadLocal = 泄漏
private static final ThreadLocal<UserContext> USER_CTX = new ThreadLocal<>();

public void handleRequest() {
USER_CTX.set(new UserContext(userId));
// 处理业务...
// 线程池复用线程,ThreadLocal 不清理 → 泄漏
}
✅ 修复:finally 中 remove
public void handleRequest() {
try {
USER_CTX.set(new UserContext(userId));
// 处理业务...
} finally {
USER_CTX.remove(); // 必须清理
}
}

场景 3:连接/流未关闭

❌ 连接未关闭
public String query(String sql) {
Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql);
// 异常时 conn 不会关闭 → 连接泄漏
String result = rs.getString(1);
conn.close();
return result;
}
✅ 修复:try-with-resources
public String query(String sql) {
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql)) {
return rs.getString(1);
}
}

场景 4:内部类持有外部类引用

❌ 非静态内部类持有外部类导致泄漏
public class Outer {
private byte[] data = new byte[10 * 1024 * 1024]; // 10MB

// 非静态内部类隐式持有 Outer 引用
public class InnerTask implements Runnable {
@Override
public void run() { /* ... */ }
}

public void start() {
executor.execute(new InnerTask());
// InnerTask → Outer → data,Outer 无法回收
}
}
✅ 修复:使用静态内部类
private static class InnerTask implements Runnable {
@Override
public void run() { /* ... */ }
}

场景 5:缓存无过期策略

详见 OOM 定位 中的本地缓存案例,使用 Caffeine 设置大小限制和过期时间。

场景 6:监听器/回调未注销

❌ 注册后未注销
public class DataProcessor {
public void init() {
// 注册监听器,但 destroy 时没有注销
eventBus.register(this);
}
// 缺少 destroy() { eventBus.unregister(this); }
}

Arthas 在线排查

Arthas 内存相关命令
# 查看堆内存概况
memory

# 查看对象实例数 Top N
dashboard
# 然后按 m 查看内存

# Heap Dump(不需要 jmap)
heapdump /tmp/heap.hprof

# 在线搜索对象实例
vmtool --action getInstances --className com.example.UserContext --limit 10

常见面试问题

Q1: 如何判断是不是内存泄漏?

答案

  • 监控堆内存使用量,如果随时间持续上升不回落(Full GC 后仍然很高),大概率是内存泄漏
  • 使用 jstat -gcutil <pid> 1000 持续观察 Old 区使用率

Q2: ThreadLocal 为什么会内存泄漏?

答案

ThreadLocalMap 的 key 是 WeakReference<ThreadLocal>,但 value 是强引用。当 ThreadLocal 被回收后,key 变成 null,但 value 无法回收。线程池场景下线程长期不销毁,导致越积越多。

详见 ThreadLocal

Q3: 生产环境如何做内存泄漏的预防?

答案

  • ThreadLocal 使用 try-finally 确保 remove
  • IO 资源使用 try-with-resources
  • 集合类注意 remove / 设置上限
  • 内部类优先用 static
  • Caffeine / Guava Cache 设置大小和过期
  • 监控 + 告警:老年代使用率持续上升告警

相关链接