Java 有 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 没 close | try-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」。