内存泄漏 vs 内存溢出:常见场景与高效排查指南

8 阅读3分钟

🚫 一、内存泄漏 ≠ 内存溢出

很多开发者容易混淆这两个概念,但它们本质不同:

概念定义类比
内存泄漏(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)

  1. 打开 .hprof 文件

  2. 查看 Dominator Tree:找出占用内存最多的对象

  3. 右键 → Path To GC Roots → exclude weak/soft references

    • 定位“阻止回收”的强引用链

💡 技巧:关注 char[], byte[], HashMap$Entry 等高频大对象。

步骤 4️⃣:代码审查清单

  •  静态集合是否合理?是否有清理机制?
  •  ThreadLocal 是否在 finally 中 remove?
  •  资源是否用 try-with-resources 关闭?
  •  自定义类作为 Map key 时是否重写了 equals/hashCode
  •  监听器注册后是否能注销?

✅ 四、预防优于排查:最佳实践

  1. 避免滥用 static:静态变量生命周期 = JVM,慎用缓存。

  2. 优先使用局部变量:作用域越小,GC 越快。

  3. 使用现代工具库

    • 缓存 → Caffeine / Guava Cache
    • 资源管理 → try-with-resources
    • 线程上下文 → TransmittableThreadLocal(阿里开源,支持线程池传递+自动清理)
  4. 压测 + 监控:上线前做内存压力测试,配合 Prometheus + Grafana 监控堆内存。


📚 五、总结

  • 内存泄漏是“温水煮青蛙”式问题,初期无感知,后期直接 OOM。
  • 90% 的泄漏源于:静态集合、ThreadLocal、未关闭资源、监听器未注销
  • 排查核心:堆转储 + GC Roots 引用链分析
  • 最佳策略:代码规范 + 自动化检测(如 SonarQube) + 定期压测