第一章: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()。
知识体系总览
下面这张图是我自己画的,把四种泄漏场景的关系梳理清楚了。你可以保存下来,以后排查问题时对照着看:
总结一下
这四种场景,说白了就是“不该活的对象,被人为续命了”。排查时,我一般先用jmap dump堆,然后用MAT分析。重点看两个东西:
- 对象数量异常——比如HashMap里有几百万个Entry
- GC Root路径——看谁在引用这些对象
嗯,今天就先聊到这儿。记住一句话:写代码时多想想“这个对象什么时候能被回收”,很多泄漏就能避免。
避坑指南:我曾经在一个项目里,因为没注意ThreadLocal的remove(),导致线上服务每48小时OOM一次。后来加了remove(),系统稳定运行了半年。所以,别小看任何一行代码。