🚫 一、内存泄漏 ≠ 内存溢出
很多开发者容易混淆这两个概念,但它们本质不同:
| 概念 | 定义 | 类比 |
|---|---|---|
| 内存泄漏(Memory Leak) | 对象已不再使用,但因被强引用而无法被 GC 回收,导致堆内存持续增长 | 水桶有裂缝,水慢慢漏不出去,越积越多 |
| 内存溢出(OutOfMemoryError) | JVM 堆空间不足,无法为新对象分配内存 | 水桶装满后继续倒水,水溢出来 |
💡 关键关系:长期未处理的内存泄漏 → 堆内存耗尽 → 触发
java.lang.OutOfMemoryError: Java heap space
🔍 二、内存泄漏的 5 大典型场景(附可复现代码)
场景 1️⃣:ThreadLocal 未清理(线程池中高危!)
问题:在线程池中使用 ThreadLocal,任务执行完未调用 remove(),导致 ThreadLocalMap 中的 Entry 无法释放。
Java
编辑
1private static final ThreadLocal<BigObject> threadLocal = new ThreadLocal<>();
2
3public void processRequest() {
4 threadLocal.set(new BigObject()); // 占用大量内存
5 // ❌ 忘记 threadLocal.remove();
6}
✅ 修复方案:
try {
threadLocal.set(new BigObject());
} finally {
threadLocal.remove(); // 必须清理!
}
📌 原理:
ThreadLocalMap的 key 是弱引用,但 value 是强引用。若不 remove,value 会一直被持有。
场景 2️⃣:资源未关闭(文件、连接、流等)
public void readFile() throws IOException {
FileInputStream fis = new FileInputStream("large.log");
// ❌ 忘记 fis.close()
}
✅ 推荐写法(try-with-resources) :
try (FileInputStream fis = new FileInputStream("large.log")) {
// 自动关闭,即使异常也会释放资源
}
支持自动关闭的类需实现
AutoCloseable接口(如InputStream,Connection,Statement)。
场景 3️⃣:监听器/回调未注销
public class EventManager {
private final List<EventListener> listeners = new ArrayList<>();
public void addListener(EventListener listener) {
listeners.add(listener);
}
// ❌ 缺少 removeListener 方法
}
✅ 解决方案:
- 提供
removeListener()方法 - 使用
WeakReference<EventListener>存储(谨慎使用,可能提前回收)
场景 4️⃣:静态集合滥用
private static final List<Object> cache = new ArrayList<>();
// 静态 = 生命周期 = JVM
public void addToCache(Object obj) {
cache.add(obj); // 永不清理 → 内存爆炸
}
✅ 优化建议:
-
改用
WeakHashMap(key 弱引用) -
或使用 Guava 的
CacheBuilder设置过期策略:Cache<String, Object> cache = CacheBuilder.newBuilder() .maximumSize(1000) .expireAfterWrite(10, TimeUnit.MINUTES) .build();
场景 5️⃣:未重写 equals() 和 hashCode()
Map<Person, Integer> map = new HashMap<>();
for (int i = 0; i < 1000; i++) {
map.put(new Person("Alice"), 1); // 每次都是新对象!
}
若 Person 未重写 equals/hashCode,HashMap 会认为每个 new Person("Alice") 都是不同 key,导致 Map 无限膨胀。
✅ 修复:
public class Person {
private String name;
@Override
public boolean equals(Object o) { /* ... */ }
@Override
public int hashCode() { return Objects.hash(name); }
}
🛠️ 三、内存泄漏排查四步法
步骤 1️⃣:监控内存趋势
-
使用 JConsole 或 VisualVM(JDK 自带)观察:
- 堆内存是否持续上升?
- Full GC 后内存是否回落?(若不回落,极可能泄漏)
步骤 2️⃣:生成堆转储(Heap Dump)
# 方式1:jmap(生产环境慎用,会暂停应用)
jmap -dump:format=b,file=heap.hprof <pid>
# 方式2:启动时加参数(推荐)
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/logs/
步骤 3️⃣:分析堆转储(推荐 Eclipse MAT)
-
打开
.hprof文件 -
查看 Dominator Tree:找出占用内存最多的对象
-
右键 → Path To GC Roots → exclude weak/soft references
- 定位“阻止回收”的强引用链
💡 技巧:关注
char[],byte[],HashMap$Entry等高频大对象。
步骤 4️⃣:代码审查清单
- 静态集合是否合理?是否有清理机制?
-
ThreadLocal是否在 finally 中 remove? - 资源是否用 try-with-resources 关闭?
- 自定义类作为 Map key 时是否重写了
equals/hashCode? - 监听器注册后是否能注销?
✅ 四、预防优于排查:最佳实践
-
避免滥用 static:静态变量生命周期 = JVM,慎用缓存。
-
优先使用局部变量:作用域越小,GC 越快。
-
使用现代工具库:
- 缓存 →
Caffeine/Guava Cache - 资源管理 → try-with-resources
- 线程上下文 →
TransmittableThreadLocal(阿里开源,支持线程池传递+自动清理)
- 缓存 →
-
压测 + 监控:上线前做内存压力测试,配合 Prometheus + Grafana 监控堆内存。
📚 五、总结
- 内存泄漏是“温水煮青蛙”式问题,初期无感知,后期直接 OOM。
- 90% 的泄漏源于:静态集合、ThreadLocal、未关闭资源、监听器未注销。
- 排查核心:堆转储 + GC Roots 引用链分析。
- 最佳策略:代码规范 + 自动化检测(如 SonarQube) + 定期压测。