内存泄漏

一、什么是内存泄漏(Memory Leak)

概念:

内存泄漏是指程序中某些对象已经不再被使用,但仍然被引用着,GC 无法回收它们,导致内存长时间得不到释放

换句话说:对象本该死掉,却还“活着”。

举例:

List cache = new ArrayList<>();
public void add() {
    Object obj = new byte[1024 * 1024]; // 1MB
    cache.add(obj); // 添加后永远不移除
}
  • 每次调用 add() 都往 cache 加对象,却从不清理,哪怕用不到,引用仍存在,GC 无法回收,最终内存被耗尽。

常见内存泄漏场景:

场景 描述
静态集合(如 Map、List) 静态变量生命周期长,不主动清理会导致对象一直被引用
缓存不清理 自实现缓存逻辑,没有定期移除无用对象
监听器、回调未注销 比如事件监听器注册后不移除,导致对象长时间被引用
ThreadLocal 泄漏 线程未释放,而 ThreadLocalMap 中的 value 引用未清除
数据库连接/文件/IO 未关闭 导致资源句柄未释放,间接导致内存占用

解决策略:

  • 及时释放无用引用,例如从集合中移除、置为 null
  • 使用 WeakReference(如 WeakHashMap)减少强引用持有
  • 正确关闭资源、注销监听器
  • 使用内存分析工具(如 VisualVM、MAT、jProfiler)定位泄漏对象和引用链

补充:ThreadLocal 泄漏——最常被问到的场景

表里那一行值得单独展开,因为它的泄漏链条绕了两层引用:

  • 每个 Thread 对象内部有一张 ThreadLocalMap
  • Map 的 key 是 ThreadLocal弱引用,但 value 却是强引用
  • 线程池里的线程长期不死,key 被回收后 value 变成"永远到不了的孤儿"——GC 够不着它,因为它还被线程强引用着。

所以用线程池 + ThreadLocal,用完必须 remove()

static ThreadLocal<byte[]> buf = new ThreadLocal<>();

public void handle() {
    buf.set(new byte[1024 * 1024]);
    try {
        // 业务逻辑
    } finally {
        buf.remove();   // 不写这行,线程池场景下必然泄漏
    }
}

更系统的原理(引用链图、InheritableThreadLocal)见 并发容器与工具类

补充:泄漏和溢出的关系

两者经常一起出现,关系一句话说清:泄漏是慢性病,溢出是急性发作

对比 内存泄漏 内存溢出(OOM)
本质 对象该死没死,被引用拖住 内存彻底不够,分配失败
症状 缓慢恶化:老年代占用持续走高、Full GC 越来越频繁 直接抛 Error,程序崩溃
关系 持续泄漏 → 迟早引发堆溢出 但溢出不一定是泄漏(也可能是堆配太小、瞬时大对象)

排查时先看 GC 日志里老年代曲线:只涨不跌基本就是泄漏;锯齿状正常波动却偶发 OOM,更可能是堆容量或流量问题。

补充:泄漏排查的标准动作

  1. jps 找到进程号 → jmap -histo:live <pid> 看对象数量排行,异常多的大类先锁定;
  2. jmap -dump:live,format=b,file=heap.hprof <pid> 拉堆快照;
  3. 用 MAT(Eclipse Memory Analyzer)打开,看 Leak Suspects 报告和 GC Roots 引用链——MAT 会直接告诉你"谁引用着这堆不该活着的对象";
  4. 对照引用链改代码(静态集合清理、remove、注销监听器……)。

GC 底层为什么回收不掉这些对象(可达性分析、GC Roots 是什么),见 垃圾回收与GC算法


⬅️ 自动垃圾回收机制 🏠 00-Java ➡️ 内存溢出