Java 内存泄漏:常见场景排查与解决方案全攻略
Java 内存泄漏:常见场景排查与解决方案全攻略
内存泄漏是 Java 应用的"慢性杀手"——它不会立刻致命,却会在深夜流量高峰时,用一记
OutOfMemoryError送你的服务归西。 2024年阿里双十一技术复盘显示,通过精确内存治理,核心交易系统性能提升了40%。今天,我们就把这套"查出病因、对症下药"的方法论,一次性讲透。
一、先分清:内存泄漏 ≠ 内存溢出
很多人把这两个概念混为一谈,这是排查方向错误的根源。
| 内存泄漏(Memory Leak) | 内存溢出(OOM) | |
|---|---|---|
| 本质 | 无用对象无法被 GC 回收,持续占用堆内存 | 内存不够用了,JVM 抛出异常 |
| 关系 | 是因 | 是果 |
| 表现 | 堆内存持续增长,Full GC 后也无法回落 | java.lang.OutOfMemoryError: Java heap space |
| 特征 | 渐进式恶化,重启后短时间复现 | 突发性崩溃 |
一句话总结:内存泄漏是"慢性病",OOM 是它的"并发症"。
🔍 快速判定标准(满足2点即可确认泄漏)
- 堆内存使用率持续走高,Full GC 后仍无法回落至合理区间,呈现「只增不减」趋势
- 日志频繁出现
GC overhead limit exceeded,且重启后短时间复现 - 老年代占比长期 >90%,Full GC 频率越来越高,单次 GC 耗时 >1s
- 服务卡顿、接口超时与 GC 频率强关联
二、十大高频内存泄漏场景(附代码对比)
🔥 场景一:静态集合无限增长(泄漏之王,占比90%)
烂代码:
java
1public class Cache {
2 private static final Map<String, Object> map = new HashMap<>();
3
4 public static void add(String key, Object value) {
5 map.put(key, value); // 只存不取,永不清理
6 }
7}
8
静态变量的生命周期与 JVM 一致。你往里塞一百万个对象,GC 想收也收不了——因为 GC Roots(静态变量)一直攥着引用不放。
✅ 解决方案:
java
1// 方案1:使用 WeakHashMap,key 不被强引用时自动回收
2private static final Map<String, Object> cache = new WeakHashMap<>();
3
4// 方案2:使用成熟缓存框架(Guava Cache / Caffeine),设置容量+过期策略
5Cache<String, Object> cache = Caffeine.newBuilder()
6 .maximumSize(10_000)
7 .expireAfterWrite(10, TimeUnit.MINUTES)
8 .build();
9
10// 方案3:必须用静态集合时,提供清理方法
11public static void clearCache() { map.clear(); }
12
🔥 场景二:资源未关闭(IO 流 / 数据库连接 / Socket)
烂代码:
java
1public void readFile(String path) throws IOException {
2 FileInputStream fis = new FileInputStream(path);
3 // 读取操作...
4 // 忘了关闭!底层文件句柄一直被占用
5}
6
✅ 解决方案——try-with-resources(Java 7+):
java
1public void readFile(String path) throws IOException {
2 try (FileInputStream fis = new FileInputStream(path)) {
3 // 业务逻辑
4 } // 自动关闭,无需 finally
5}
6
凡是实现了
AutoCloseable的资源,都该用 try-with-resources。这不是建议,是铁律。
🔥 场景三:ThreadLocal 未清理(线程池场景下的定时炸弹)
烂代码:
java
1private static final ThreadLocal<BigObject> local = new ThreadLocal<>();
2
3// 线程池复用线程,但 local 从不清理
4public void handleRequest() {
5 local.set(new BigObject()); // 每次请求都塞一个大对象
6 // ... 请求结束,没有 remove()!
7}
8
Tomcat 等容器的线程池会复用线程。你不 remove(),这个大对象就跟着线程活到天荒地老。
✅ 解决方案:
java
1public void handleRequest() {
2 try {
3 local.set(new BigObject());
4 // 业务逻辑
5 } finally {
6 local.remove(); // ⚠️ 必须清理!
7 }
8}
9
🔥 场景四:监听器/回调未注销
烂代码:
java
1public class Activity {
2 public void onCreate() {
3 eventBus.register(this); // 注册了
4 }
5 // onDestroy 时忘了 unregister!Activity 永远无法被回收
6}
7
✅ 解决方案:
java
1@Override
2protected void onDestroy() {
3 eventBus.unregister(this); // 注册和注销必须成对出现
4 super.onDestroy();
5}
6
或者使用弱引用持有监听器:
java
1eventBus.register(new WeakReference<>(this));
2
🔥 场景五:非静态内部类持有外部类引用
烂代码:
java
1public class OuterClass {
2 private String data = new byte[1024 * 1024]; // 1MB 大对象
3
4 private class InnerClass { // 非静态内部类,隐式持有 OuterClass 引用
5 public void doSomething() {
6 System.out.println(data);
7 }
8 }
9}
10
只要 InnerClass 实例还活着,OuterClass 就永远不能被回收。这是 Android 内存泄漏的头号元凶。
✅ 解决方案:
java
1// 改为静态内部类 + 弱引用
2private static class SafeHandler {
3 private final WeakReference<OuterClass> ref;
4 SafeHandler(OuterClass outer) {
5 this.ref = new WeakReference<>(outer);
6 }
7}
8
🔥 场景六:HashMap Key 未正确实现 equals/hashCode
java
1class Person {
2 String name;
3 int age;
4 // 没有重写 equals 和 hashCode!
5}
6
7Map<Person, String> map = new HashMap<>();
8while (true) {
9 Person p = new Person("zhangsan", 18);
10 map.put(p, "value"); // 每次都是新对象,永远可以 put 进去
11}
12
对象属性被修改后,hash 值改变,remove() 找不到它——内存就这么漏了。
✅ 解决方案: 永远重写 equals() 和 hashCode(),且保证一致性。
🔥 场景七:单例持有短生命周期对象
java
1public class Singleton {
2 private User currentUser; // 单例生命周期 = JVM 生命周期
3
4 public void setUser(User user) {
5 this.currentUser = user; // 用户用完了,但单例还攥着不放
6 }
7}
8
✅ 解决方案: 改用弱引用,或提供 clear() 方法主动置空。
🔥 场景八:String.intern() 滥用
对大量动态字符串调用 intern(),会把它们永久塞进字符串常量池,无法回收。
✅ 解决方案: 仅对高频复用的固定字符串使用 intern(),动态字符串绝不调用。
🔥 场景九:循环引用(两个对象互相持有)
java
1class A { B b; }
2class B { A a; }
3A a = new A(); B b = new B();
4a.b = b; b.a = a;
5// a = null; b = null; // 即使置空,两个对象仍互相引用,GC 无法回收
6
现代 JVM 的 GC 已能处理大多数循环引用,但如果引用链上存在 GC Roots(如静态变量),照样泄漏。
🔥 场景十:ThreadLocal + 线程池 = 灾难组合
这是场景三的延伸,也是生产环境最高频的泄漏源:
| 组件 | 问题 |
|---|---|
| 线程池 | 线程复用,不会销毁 |
| ThreadLocal | 不清理就一直跟着线程 |
| 结果 | 每次请求泄漏一个大对象,积少成多 |
铁律:ThreadLocal 用完必 remove(),没有例外。
三、排查方法论:监控 → 分析 → 定位 → 解决 → 验证
📊 第一步:监控发现异常(线上快速响应)
JDK 自带命令(零成本):
bash
1# 1. 找到 Java 进程 PID
2jps -l
3
4# 2. 实时监控 GC 情况(每秒采样,共10次)
5jstat -gc <PID> 1000 10
6
7# 重点关注:
8# FGCT — Full GC 总耗时(持续增大 = 频繁 Full GC)
9# OU — 老年代已使用内存(持续走高不回落 = 泄漏铁证)
10
JVM 启动参数(生产必配):
bash
1java -Xms2g -Xmx2g \
2 -XX:+PrintGCDetails \
3 -XX:+PrintGCTimeStamps \
4 -Xloggc:/logs/gc.log \
5 -XX:+HeapDumpOnOutOfMemoryError \
6 -XX:HeapDumpPath=/logs/heapdump.hprof \
7 -jar app.jar
8
-XX:+HeapDumpOnOutOfMemoryError是救命参数——OOM 时自动导出堆快照,避免手动导出不及时。
🔍 第二步:生成堆快照(精准取证)
bash
1# 手动导出(会触发 STW,建议低峰期执行)
2jmap -dump:format=b,file=/logs/heap.hprof <PID>
3
4# 或用 Arthas(无侵入,推荐线上用)
5heapdump /logs/heap.hprof
6
🔬 第三步:MAT 分析(定位元凶)
这是排查的核心环节。用 Eclipse MAT 打开 .hprof 文件:
| MAT 功能 | 用途 |
|---|---|
| Leak Suspects Report | 一键生成泄漏嫌疑报告,直接告诉你谁在漏 |
| Dominator Tree | 按 Retained Heap 排序,找到占用内存最多的对象 |
| Path to GC Roots | 追溯引用链,找到"谁在攥着泄漏对象不放" |
核心操作:
1Dominator Tree → 右键泄漏对象 → Path to GC Roots → exclude weak references
2
这条引用链的终点,就是你要修的代码。
🛠️ 第四步:对症下药
| 泄漏类型 | 解决策略 |
|---|---|
| 静态集合/缓存无限增长 | 限制容量 + LRU 淘汰 + WeakReference |
| 监听器/回调未移除 | 注册/注销成对出现 + WeakReference 保存 |
| 线程池 / Timer / Executor | 应用关闭时调用 shutdown() |
| 非关闭资源 | try-with-resources 自动关闭 |
| 对象引用链过长 | 清理引用链,避免全局容器持有局部对象 |
| ThreadLocal 泄漏 | finally 块中必调 remove() |
✅ 第五步:验证与预防
| 手段 | 说明 |
|---|---|
| 压力测试 | JMeter 高并发压测,对比 GC 前后堆大小变化 |
| 持续监控 | Prometheus + Grafana 实时监控 JVM 内存,设置 90% 告警 |
| APM 工具 | SkyWalking / Pinpoint / Arthas,生产环境无侵入监控 |
| 代码规范 | 禁止原始类型的静态集合,缓存必须有过期策略 |
四、工具选型速查表
| 工具 | 用途 | 场景 | 成本 |
|---|---|---|---|
jps / jstat / jmap |
监控 + 快照 | 线上快速排查 | 免费 |
| VisualVM | 可视化监控 + 堆分析 | 开发/测试 | 免费(JDK自带) |
| MAT | 堆快照深度分析 | 精准定位泄漏源 | 免费 |
| Arthas | 线上无侵入诊断 | 生产应急 | 免费(阿里开源) |
| JProfiler / YourKit | 内存+CPU联合分析 | 复杂场景 | 商用 |
| Prometheus + Grafana | 长期趋势监控 | 生产环境 | 免费 |
五、一张图总结排查流程
1发现内存异常(jstat 监控)
2 │
3 ▼
4生成堆快照(jmap / HeapDumpOnOutOfMemoryError)
5 │
6 ▼
7MAT 分析(Leak Suspects → Dominator Tree → GC Roots 引用链)
8 │
9 ▼
10定位泄漏代码(静态集合?ThreadLocal?监听器?资源未关?)
11 │
12 ▼
13修复代码 + 压力测试验证
14 │
15 ▼
16APM 持续监控,预防复发
17
写在最后
内存泄漏的本质只有一句话:长生命周期对象,持有了短生命周期对象的引用,导致 GC 收不掉。
所有的排查工具、所有的最佳实践,都是围绕这一句话展开的。记住这十大场景,掌握 MAT + jstat 的组合拳,你就能在 OOM 找到你之前,先找到它。
别等服务崩了才想起查内存——那叫救火,不叫排查。 🔥
更多推荐


所有评论(0)