第一章:Java堆内存泄漏实战——四大经典场景深度剖析

大家好,我是你们的老朋友。今天咱们聊聊Java堆内存泄漏。说实话,这玩意儿我干了十几年架构,几乎每个项目都遇到过。你想想看,一个系统跑着跑着就OOM了,重启后又正常,过几天又挂了——这种“幽灵般”的问题,十有八九就是内存泄漏。

我个人习惯把内存泄漏分成四类:静态集合类、未关闭资源、内部类引用、ThreadLocal误用。这四类占了实际生产问题的80%以上。今天咱们一个一个拆解。

核心观点:内存泄漏的本质是“该回收的对象,因为被错误引用而无法回收”。说白了,就是GC想收,但有人拽着不放。

1.1 静态集合类——最常见的“内存黑洞”

先看一个我踩过的坑。几年前做一个电商后台,用户登录后会把Session信息放到一个静态Map里。代码大概长这样:

public class SessionManager {
    private static Map<String, UserSession> sessionCache = new HashMap<>();
    
    public static void addSession(String token, UserSession session) {
        sessionCache.put(token, session);
    }
}

看起来没问题对吧?但问题在于——这个Map永远不会被清理。用户登出、Session过期,Map里的对象依然存在。随着时间推移,Map越来越大,最终OOM。

为什么会这样?因为静态变量的生命周期和ClassLoader一样长,只要JVM不退出,它就一直在。你想想看,一个HashMap里塞了几百万个对象,GC每次扫描都要遍历它们,性能能不崩吗?

我的建议:能用WeakHashMap就别用HashMap。或者,至少加个定时清理机制。我在项目中后来改成了Guava Cache,设置过期时间,问题就解决了。

1.2 未关闭的资源——连接、流、文件

这个场景太经典了。我记得有一次排查一个报表系统,每天下午4点准时OOM。查了半天,发现是JDBC连接没关闭。

public void queryData() {
    Connection conn = null;
    Statement stmt = null;
    try {
        conn = dataSource.getConnection();
        stmt = conn.createStatement();
        // 执行查询...
    } catch (SQLException e) {
        e.printStackTrace();
    }
    // 注意:没有finally块,没有关闭连接!
}

这段代码的问题很明显——连接对象没有被释放。每个连接都会占用堆内存和系统资源。虽然Connection对象本身不大,但每个连接背后可能关联着Socket、缓冲区等。日积月累,堆内存就被吃光了。

嗯,这里要注意:try-with-resources是Java 7引入的救星。用上它,代码简洁又安全:

try (Connection conn = dataSource.getConnection();
     Statement stmt = conn.createStatement()) {
    // 执行查询...
} // 自动关闭

我曾经踩过的坑:有一次用Apache HttpClient,以为调用close()就完事了。结果发现它内部有连接池,close()只是归还连接,不是真正关闭。后来查文档才知道要调用close() + shutdown()。所以,别只看API名字,要理解底层实现。

1.3 内部类与外部类引用——隐形的“强引用链”

这个知识点比较隐蔽。很多新手不知道,非静态内部类会持有外部类的隐式引用。什么意思呢?看个例子:

public class OuterClass {
    private byte[] bigData = new byte[1024 * 1024 * 10]; // 10MB
    
    public class InnerClass {
        public void doSomething() {
            System.out.println("Hello");
        }
    }
    
    public InnerClass createInner() {
        return new InnerClass();
    }
}

如果你这样用:

OuterClass outer = new OuterClass();
InnerClass inner = outer.createInner();
outer = null; // 把外部引用置空
// 但inner还在用,它持有outer的引用,所以outer无法被GC回收

说白了,只要内部类对象存活,外部类对象就永远无法回收。那10MB的bigData就一直占着堆内存。

怎么解决?两个办法:

  • 把内部类改成静态内部类——静态内部类不持有外部类引用
  • 或者,用完后显式置空内部类引用

我个人习惯:只要内部类不需要访问外部类的实例变量,一律写成static。这不仅是性能问题,更是设计问题。

1.4 ThreadLocal使用不当——线程池的“定时炸弹”

ThreadLocal是个好东西,但用不好就是灾难。我见过一个案例:一个Web应用用了线程池处理请求,每个请求往ThreadLocal里塞了用户信息。结果呢?线程池里的线程是复用的,上一个请求的数据还留在ThreadLocal里,下一个请求就拿到了脏数据。

更严重的是内存泄漏。看这段代码:

private static ThreadLocal<byte[]> threadLocal = new ThreadLocal<>();

public void handleRequest() {
    byte[] data = new byte[1024 * 1024]; // 1MB
    threadLocal.set(data);
    // 处理业务...
    // 忘记调用 threadLocal.remove()
}

线程池里的线程长期存活,ThreadLocal引用的对象就一直存在。如果线程池有100个线程,每个线程都塞1MB数据,那就是100MB的泄漏。而且,ThreadLocalMap的key是弱引用,但value是强引用——这意味着即使key被回收,value也不会被回收,直到线程死亡。

我曾经犯过的错:在一个批处理系统里,用ThreadLocal缓存了数据库连接。结果跑了三天后OOM。排查发现,每个线程都持有一个连接对象,而连接对象内部又引用了大量缓存数据。从那以后,我给自己定了个规矩:ThreadLocal用完后,必须在finally块里调用remove()。

知识体系总览

下面这张图是我自己画的,把四种泄漏场景的关系梳理清楚了。你可以保存下来,以后排查问题时对照着看:

Java堆内存泄漏四大场景 堆内存泄漏 静态集合类 未关闭资源 内部类引用 ThreadLocal误用 典型场景 • HashMap/ArrayList静态持有 • 缓存无过期策略 • 生命周期与JVM同长 典型场景 • JDBC连接未关闭 • 文件流未释放 • Socket/Http连接泄漏 典型场景 • 非静态内部类实例 • 匿名内部类回调 • 外部类无法GC 典型场景 • 线程池+ThreadLocal • 未调用remove() • Value强引用泄漏 排查思路总结 1. 用MAT或JProfiler分析堆转储文件 2. 找GC Root路径,看谁引用了泄漏对象 3. 对照四大场景,定位问题代码

总结一下

这四种场景,说白了就是“不该活的对象,被人为续命了”。排查时,我一般先用jmap dump堆,然后用MAT分析。重点看两个东西:

  • 对象数量异常——比如HashMap里有几百万个Entry
  • GC Root路径——看谁在引用这些对象

嗯,今天就先聊到这儿。记住一句话:写代码时多想想“这个对象什么时候能被回收”,很多泄漏就能避免。

避坑指南:我曾经在一个项目里,因为没注意ThreadLocal的remove(),导致线上服务每48小时OOM一次。后来加了remove(),系统稳定运行了半年。所以,别小看任何一行代码。

可可精品资料库