← 返回题目列表

Java 有 GC 为什么还会内存泄漏?常见的内存泄漏场景有哪些?

高频 中等 第 6 / 34 题 更新于 2026/07/26
内存泄漏ThreadLocal静态集合GC

简化版

Java 有 GC,但内存泄漏照样发生——GC 只回收「不可达」的对象,而内存泄漏的本质是**「本该无用的对象,却因为还被某个存活对象引用着,导致 GC 无法回收」**。常见场景:① 静态集合(static 的 Map/List 一直往里放不清理,元素永远可达);② ThreadLocal 用完不 remove(线程池里线程长期存活,ThreadLocalMap 的 value 一直被引用);③ 监听器/回调注册后不注销④ 资源未关闭(连接、流、Session);⑤ 内部类/匿名类隐式持有外部类引用⑥ 用可变对象做 HashMap 的 key 后改了它。核心特征:对象「逻辑上没用了,但引用链上还连着 GC Root」。

详细版

内存泄漏 vs 内存溢出

内存泄漏(Memory Leak):对象没用了但还被引用 → GC 回收不了 → 慢慢累积
内存溢出(OOM):内存不够用了 → 抛 OutOfMemoryError
关系:泄漏是"因",长期泄漏累积 → 内存被占满 → 溢出是"果"

六大常见泄漏场景

场景原因解决
静态集合static Map/List 只增不删,元素永远可达及时移除、用有界缓存/弱引用
ThreadLocal 不 remove线程池线程长存,value 一直被 ThreadLocalMap 引用用完 finally 里 remove()
监听器/回调不注销注册后忘记 removeListener及时反注册,用弱引用
资源未关闭Connection/Stream/Session 没 closetry-with-resources
内部类持有外部引用非静态内部类隐含 Outer.this用静态内部类 + 弱引用
可变对象作 HashMap key改了 key 的字段导致 hash 变化,取不出也删不掉key 用不可变对象
// 典型泄漏:ThreadLocal 在线程池里不 remove
static ThreadLocal<BigObject> tl = new ThreadLocal<>();
void handle() {
    tl.set(new BigObject());
    try { doWork(); }
    finally { tl.remove(); }   // ★ 必须 remove,否则线程池复用线程时 value 泄漏
}

⚠️ 内存泄漏的隐蔽之处在于「缓慢」——每次泄漏一点,短期看不出,跑几天几周后内存逐渐被占满,最后 OOM。所以线上表现常是「运行一段时间后 Full GC 越来越频繁、老年代持续增长回收不掉、最终 OOM」,而不是启动就崩。

完整版教学

一、有 GC 为什么还泄漏:可达 ≠ 有用

很多人以为「Java 有 GC 就不会内存泄漏」,这是根本误解。GC 的判断标准是「对象是否可达」(从 GC Root 出发能否引用到),而不是「对象是否还有用」:

GC 回收的条件:对象不可达(没有任何 GC Root 引用链能到它)
内存泄漏的本质:对象"逻辑上无用了",但"引用链上还连着 GC Root"
             → GC 认为它可达 → 不回收 → 泄漏

举例:你把一个对象放进 static Map,用完后「逻辑上不需要它了」,但只要没从 Map 里 remove,这个 Map(被类持有,是 GC Root)就一直引用着它,GC 永远不回收。GC 能防「野指针/悬空引用」,但防不了「你自己一直攥着不放」。所以 Java 的内存泄漏,本质是程序员在逻辑上「忘了松手」——对象没用了却还留着引用。理解「可达≠有用」,才理解所有泄漏场景的共性。

二、场景一:静态集合——最经典的泄漏

static 修饰的集合是泄漏重灾区,因为静态字段属于类、生命周期和 JVM 一样长,是 GC Root:

public class Cache {
    private static final Map<String, Object> CACHE = new HashMap<>();
    public static void put(String k, Object v) {
        CACHE.put(k, v);   // 只放不删 → 元素永远被 CACHE 引用 → 永不回收
    }
}

问题:CACHE 作为静态字段永远可达,放进去的每个 value 也就永远可达,哪怕业务早就不用了。随着不断 put,Map 无限增长直到 OOM。解决:① 用完及时 remove;② 用有界缓存(如 Guava Cache、Caffeine,设最大容量和过期时间,自动淘汰);③ 用 WeakHashMap(key 无强引用时自动回收条目)。核心是「别让静态集合无限增长」。

三、场景二:ThreadLocal 不 remove(线程池下最坑)

ThreadLocal 泄漏是高频考点,且在线程池场景下最危险。原理要结合 ThreadLocalMap 的结构:

每个线程有一个 ThreadLocalMap,Entry 的 key 是 ThreadLocal(弱引用),value 是你存的对象(强引用)
  key(弱引用):ThreadLocal 对象没有外部强引用时会被 GC 回收 → key 变 null
  value(强引用):即使 key 被回收成 null,value 仍被 Entry 强引用着!

普通线程:线程结束 → 整个 ThreadLocalMap 被回收 → 无所谓
线程池线程:线程长期复用、永不结束 → ThreadLocalMap 一直在
  → 一堆 key=null 但 value 还在的"僵尸 Entry" → value 永远泄漏

所以铁律:ThreadLocal 用完必须在 finally 里 remove(),尤其在线程池、Tomcat 请求线程里。不 remove 的后果:线程复用时 value 一直累积,且下一个请求可能读到上一个请求残留的 value(数据串味 + 泄漏双重问题)。这也是 Spring、MyBatis 等框架用 ThreadLocal 后都强调「请求结束清理」的原因。

四、场景三、四:监听器不注销与资源未关闭

两个「注册了/打开了却忘了释放」的场景:

// 场景三:监听器/回调注册后不注销
eventBus.register(this);   // 注册了
// ... 忘了 eventBus.unregister(this)
// → eventBus(可能是单例/静态)一直持有 this → this 及其引用的对象泄漏

// 场景四:资源未关闭
Connection conn = dataSource.getConnection();
// ... 用完忘了 conn.close()
// → 连接不归还连接池 → 连接池耗尽 + 连接持有的缓冲区泄漏

这两类的共性是「成对操作缺了后半截」:register 要配 unregister,open/getConnection 要配 close。解决:① 资源用 try-with-resources(自动 close);② 监听器用弱引用注册(观察者被回收时自动失效),或在生命周期结束(如页面销毁、Bean 销毁)时主动注销。框架里常见的坑是「组件销毁了但它注册到全局事件总线的监听没摘掉」,导致组件无法回收。

五、场景五、六:内部类引用与可变 key

两个更隐蔽的泄漏:

// 场景五:非静态内部类隐式持有外部类引用
class Outer {
    private byte[] bigData = new byte[100 * 1024 * 1024];  // 100MB
    Runnable createTask() {
        return new Runnable() {          // 匿名内部类,隐含持有 Outer.this
            public void run() { ... }     // 即使没用 bigData,也拴住了整个 Outer
        };
    }
}
// 若这个 Runnable 被长期持有(如提交给长存的调度器)→ 整个 Outer(含 100MB)无法回收
// 解决:用 static 内部类,需要外部数据就用弱引用或只传必要字段

// 场景六:可变对象作 HashMap 的 key
Map<Point, String> map = new HashMap<>();
Point p = new Point(1, 2);
map.put(p, "v");
p.setX(99);          // 改了 key 参与 hashCode 的字段
map.get(p);          // null!hash 变了,定位到别的桶
map.remove(p);       // 也删不掉 → 这个 entry 泄漏(取不出也删不掉)

场景五的解法呼应「内部类优先用静态」——非静态内部类的隐含 Outer.this 引用会拴住整个外部对象。场景六的解法是「HashMap 的 key 用不可变对象」(如 String、Integer),避免 put 后 key 变化导致条目「取不出也删不掉」的泄漏。

六、如何发现和定位内存泄漏

内存泄漏的排查有固定套路:

① 现象识别:老年代持续增长、Full GC 越来越频繁却回收不掉、最终 OOM
   (不是启动就崩,而是"跑一段时间后"逐渐恶化)
② 抓 dump:jmap -dump:format=b,file=heap.hprof <pid>
           或 -XX:+HeapDumpOnOutOfMemoryError 自动在 OOM 时导出
③ 分析 dump:用 MAT(Memory Analyzer)看:
   - Histogram:哪类对象数量/占用异常多
   - Dominator Tree:谁"支配"了大量内存
   - "Path to GC Roots":这个本该回收的对象,被谁的引用链拴住了 ★关键
④ 定位:找到"引用链"就找到了"谁忘了松手"

排查的核心是第③步的「Path to GC Roots」——它告诉你「这个泄漏对象是被哪条引用链、最终被哪个 GC Root 拴住的」,顺着这条链就能找到代码里「忘了 remove/close/unregister」的地方。这和「怎么用工具查」是配套的:知道有哪些常见泄漏场景(本题),才能快速对照 dump 里的引用链定位到具体 bug。

记忆钩子:「有 GC 也泄漏,因为可达≠有用;六大场景:静态集合只增不删、ThreadLocal 不 remove(线程池下最坑)、监听器不注销、资源不 close、内部类拴外部对象、可变对象作 key;排查靠 dump + MAT 的 Path to GC Roots 找谁忘了松手」

七、常见误区与追问

  • 误区:Java 有 GC 就不会内存泄漏。 GC 只回收「不可达」对象,而泄漏是「对象逻辑无用但引用链还连着 GC Root」,GC 认为可达就不回收。
  • 误区:内存泄漏和内存溢出是一回事。 泄漏是「对象该回收却回收不了、缓慢累积」;溢出是「内存不够抛 OOM」;长期泄漏累积会导致溢出,泄漏是因、溢出是果。
  • 误区:ThreadLocal 会自动清理不用管。 key 是弱引用会被回收,但 value 是强引用;线程池线程长存时会残留「key=null 但 value 还在」的僵尸 Entry,必须手动 remove。
  • 误区:用完对象设为 null 就能防泄漏。 只对「局部变量指向大对象且方法长时间不结束」有点用;根本办法是切断「让对象一直可达」的那条引用链(remove/close/unregister)。
  • 追问:ThreadLocal 为什么在线程池下容易泄漏? 线程池线程长期复用不销毁,其 ThreadLocalMap 一直存在,用完不 remove 会残留 value(强引用)无法回收,还可能导致下个任务读到脏数据。
  • 追问:怎么定位内存泄漏? 抓 heap dump(jmap 或 OOM 自动导出),用 MAT 看 Dominator Tree 找占用大户,再看该对象的「Path to GC Roots」找出被哪条引用链拴住,对应到代码里忘了释放的地方。
  • 追问:WeakHashMap 为什么能防泄漏? 它的 key 是弱引用,当 key 没有其他强引用时会被 GC 回收,对应的 entry 自动移除,适合做「跟随 key 生命周期」的缓存,避免手动清理遗漏。

八、加强记忆

Java 有 GC 仍会内存泄漏,根源是「可达 ≠ 有用」——GC 只回收不可达对象,而泄漏是「对象逻辑上没用了,却还被某个 GC Root 的引用链拴着」,本质是程序员「忘了松手」。六大高频场景要背熟:① 静态集合只增不删(static 是 GC Root,元素永远可达,用有界缓存/WeakHashMap);② ThreadLocal 用完不 remove(线程池线程长存,ThreadLocalMap 残留 key=null 但 value 还在的僵尸 Entry,必须 finally 里 remove);③ 监听器/回调注册后不注销④ 资源(连接/流/Session)未 close(用 try-with-resources);⑤ 非静态内部类隐含持有 Outer.this 拴住整个外部对象(用静态内部类);⑥ 可变对象作 HashMap key 后改字段(取不出也删不掉,key 用不可变对象)。泄漏是「因」、长期累积导致 OOM 是「果」,线上表现是「跑一段时间后老年代持续增长、Full GC 频繁却回收不掉」。排查靠 heap dump + MAT 的「Path to GC Roots」 找出「谁的引用链拴住了本该回收的对象」。一句话「可达非有用、六场景忘松手、ThreadLocal 线程池下最坑、排查看 Path to GC Roots」。