Java 内存泄漏高频场景——我排查过的5种内存泄漏,每种都有代码示例
Java 内存泄漏高频场景——我排查过的5种内存泄漏,每种都有代码示例
适读人群:有生产经验的 Java 开发者,遇到过内存问题或想提前预防的同学 | 阅读时长:约 16 分钟 | 核心价值:5 种高频内存泄漏场景的完整分析,现象→原因→解法,每种附代码
2020 年的一个周五下午 4 点,我们的服务开始慢慢变慢,然后 OOM Killed,然后 Pod 重启,然后又慢,然后又 OOM。
我当时有点慌,因为这是重要业务,而且规律是大约 6 小时重启一次,看起来是内存缓慢泄漏。
那次排查花了整整两天,最终定位到是一个 ThreadLocal 没有在线程归还线程池之前 remove,导致线程池里的线程一直持有大对象的引用,GC 无法回收。
这次排查经历让我把 Java 内存泄漏的高频场景系统研究了一遍。这篇文章写的是我亲自排查过的 5 种,不是抄书。
怎么确认是内存泄漏
先说排查方法,不是直接猜。
第一步:看 GC 日志趋势
如果是内存泄漏,你会看到:
- Old Gen 占用持续增长,Full GC 之后也回收不了多少
- 随时间推移,每次 Full GC 之后的 Old Gen 基线越来越高
第二步:heap dump 分析
内存泄漏确认之后,用 heap dump 找到到底是什么对象占了内存:
# 不停止服务,生成 heap dump
jmap -dump:format=b,file=/tmp/heap-$(date +%Y%m%d%H%M%S).hprof <pid>
# 或者配置 JVM 在 OOM 时自动 dump
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/heapdump.hprof然后用 Eclipse Memory Analyzer (MAT) 打开 hprof 文件,看"Leak Suspects"报告,它会告诉你哪个对象占了最多内存,以及它是被谁持有的。
泄漏类型一:ThreadLocal 未清理
这就是我开头提到的那次事故。
问题代码:
// 有内存泄漏的代码
public class UserContextHolder {
private static final ThreadLocal<UserContext> CONTEXT = new ThreadLocal<>();
public static void setContext(UserContext ctx) {
CONTEXT.set(ctx);
}
public static UserContext getContext() {
return CONTEXT.get();
}
// 没有 remove 方法,或者没有在合适的地方调用
}在线程池场景下,线程会被复用。如果请求处理完之后不调用 CONTEXT.remove(),这个线程对象就一直持有上一个请求的 UserContext,GC 无法回收 UserContext(以及它引用的所有对象)。
如果 UserContext 里有数据库连接、大的数据集合,泄漏量会很大。
正确写法——在 Filter 或 AOP 里保证每次请求结束后清理:
package com.example.context;
import javax.servlet.*;
import java.io.IOException;
/**
* Servlet Filter:在每次请求结束时清理 ThreadLocal
* 这是保证 ThreadLocal 不泄漏的标准做法
*/
public class UserContextClearFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
try {
// 设置用户上下文
UserContext ctx = buildUserContext(request);
UserContextHolder.setContext(ctx);
chain.doFilter(request, response);
} finally {
// 无论请求是否正常结束,finally 保证一定清理
// 这一行是防止泄漏的关键
UserContextHolder.clearContext(); // 内部调用 CONTEXT.remove()
}
}
}
// UserContextHolder 的完整实现
class UserContextHolder {
private static final ThreadLocal<UserContext> CONTEXT = new ThreadLocal<>();
public static void setContext(UserContext ctx) { CONTEXT.set(ctx); }
public static UserContext getContext() { return CONTEXT.get(); }
// 必须有这个方法,并且必须在请求结束后调用
public static void clearContext() { CONTEXT.remove(); }
}泄漏类型二:静态集合持续增长
静态集合(static Map、static List)里添加了对象,但没有移除逻辑,或者移除逻辑有缺陷。
// 典型的内存泄漏:缓存只进不出
public class UserCache {
// 这个 Map 会一直增长,永远不清空
private static final Map<Long, UserInfo> CACHE = new HashMap<>();
public static void cache(Long userId, UserInfo user) {
CACHE.put(userId, user); // 只 put,没有 evict 机制
}
public static UserInfo get(Long userId) {
return CACHE.get(userId);
}
}我在一个消息处理服务里见过类似的代码,用来缓存已处理消息的 ID(防重复处理)。但消息 ID 的集合是无界增长的,运行几天之后内存就耗尽了。
解法一:用 WeakHashMap(key 被 GC 回收时自动移除,但只适合特定场景)。
解法二:用带过期时间的缓存(Guava Cache 或 Caffeine):
package com.example.cache;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.util.concurrent.TimeUnit;
/**
* 使用 Caffeine 做有界缓存——有大小上限和过期时间,不会无限增长
*/
public class BoundedUserCache {
private static final Cache<Long, UserInfo> CACHE = Caffeine.newBuilder()
.maximumSize(10_000) // 最多缓存 1 万个
.expireAfterWrite(30, TimeUnit.MINUTES) // 写入后 30 分钟过期
.recordStats() // 可以监控命中率
.build();
public static void put(Long userId, UserInfo user) {
CACHE.put(userId, user);
}
public static UserInfo get(Long userId) {
return CACHE.getIfPresent(userId);
}
}踩坑实录一:监听器注册后没有反注册
这是我在做一个桌面应用(Java Swing)时遇到的,但在服务端也会出现(比如事件总线)。
// 有泄漏的监听器注册
public class DataService {
private final EventBus eventBus;
public DataService(EventBus eventBus) {
this.eventBus = eventBus;
// 注册监听器
eventBus.register(this); // EventBus 持有 DataService 的引用
}
@Subscribe
public void onEvent(SomeEvent event) {
// 处理事件
}
// 没有 unregister 方法!DataService 实例即使不用了,也会被 EventBus 持有,无法被 GC
}如果 DataService 每次请求都创建一个新实例,并注册到一个全局的 EventBus,这些实例就永远不会被 GC 回收。
正确做法:
package com.example.event;
import com.google.common.eventbus.EventBus;
import com.google.common.eventbus.Subscribe;
/**
* 必须实现 close 方法,在不使用时反注册
*/
public class DataService implements AutoCloseable {
private final EventBus eventBus;
public DataService(EventBus eventBus) {
this.eventBus = eventBus;
eventBus.register(this);
}
@Subscribe
public void onEvent(Object event) {
// 处理事件
}
@Override
public void close() {
// 不用了就反注册,释放引用
eventBus.unregister(this);
}
}
// 使用时配合 try-with-resources
// try (DataService service = new DataService(eventBus)) {
// service.doWork();
// } // 自动调用 close(),自动 unregister踩坑实录二:内部类隐式持有外部类引用
这是一个很隐蔽的泄漏场景,特别是在 Android 开发里很常见,Java 服务端也有。
// 有内存泄漏:非静态内部类持有外部类引用
public class OuterService {
private final byte[] hugeData = new byte[10 * 1024 * 1024]; // 10MB 数据
public Runnable createTask() {
// 非静态匿名类/内部类会隐式持有 OuterService 的引用
// 即使 OuterService 不再需要,只要 task 还活着,OuterService 就不能被 GC
return new Runnable() {
@Override
public void run() {
// 这个任务可能被提交到线程池,长期存活
System.out.println("执行任务");
}
};
}
}如果把这个 Runnable 提交到长期运行的线程池,OuterService 对象(以及它持有的 10MB 数据)就会一直无法被 GC。
解法:用静态内部类或 lambda(lambda 不会自动捕获 this,除非你明确用了外部类的字段):
package com.example.inner;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class SafeOuterService {
private final byte[] hugeData = new byte[10 * 1024 * 1024];
public Runnable createTask() {
// 使用 lambda,只捕获必要的数据,不持有 SafeOuterService 的引用
// 注意:如果 lambda 里引用了 this.xxx,还是会持有引用
return () -> System.out.println("执行任务");
}
// 或者用静态内部类
// private static class MyTask implements Runnable {
// @Override
// public void run() { System.out.println("执行任务"); }
// }
}踩坑实录三:连接池泄漏——借出后没有归还
这是生产中实际遇到过的问题,排查起来比较麻烦。
// 有泄漏的连接使用
public void queryUser(long userId) throws Exception {
Connection conn = dataSource.getConnection(); // 从连接池借出
PreparedStatement stmt = conn.prepareStatement("SELECT * FROM users WHERE id = ?");
stmt.setLong(1, userId);
ResultSet rs = stmt.executeQuery();
if (rs.next()) {
// 处理结果...
}
// BUG:没有 close!如果这个方法正常返回,conn 不会自动归还到连接池
// 如果抛异常更惨,直接泄漏
}这种情况严格来说不是内存泄漏(泄漏的是连接资源),但效果类似:连接池里可用的连接越来越少,最终获取连接超时,服务不可用。
正确写法:
package com.example.db;
import javax.sql.DataSource;
import java.sql.*;
/**
* 正确的 JDBC 使用:try-with-resources 保证连接一定归还
*/
public class SafeUserRepository {
private final DataSource dataSource;
public SafeUserRepository(DataSource dataSource) {
this.dataSource = dataSource;
}
public String findUserName(long userId) {
// try-with-resources:无论正常还是异常,都会调用 close()
// Connection.close() 会把连接归还到连接池,不是真正关闭
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(
"SELECT name FROM users WHERE id = ?")) {
stmt.setLong(1, userId);
try (ResultSet rs = stmt.executeQuery()) {
return rs.next() ? rs.getString("name") : null;
}
} catch (SQLException e) {
throw new RuntimeException("查询用户失败: userId=" + userId, e);
}
}
}泄漏类型五:PermGen/Metaspace 泄漏——类加载器泄漏
这种泄漏在动态加载代码的场景里出现,比如插件系统、脚本引擎、热部署。
每次创建一个新的 ClassLoader 来加载类,如果旧的 ClassLoader 没有被 GC(比如有其他对象持有了被这个 ClassLoader 加载的类的引用),ClassLoader 就无法被 GC,它加载的所有类的元数据也无法被 GC,Metaspace 就会一直涨。
排查方式:
# 监控 Metaspace 使用情况
jstat -gc <pid> 1000
# 在 GC 日志里看 Metaspace 的变化
-XX:+PrintGCDetails如果 Metaspace 持续增长,而且 Full GC 也回收不了,大概率是 ClassLoader 泄漏。
预防方式: 显式清理对动态加载类的引用,使用 WeakReference 持有 ClassLoader。这个场景比较复杂,具体排查步骤可以单独写一篇。
这 5 种是我实际见过频率最高的。其中 ThreadLocal 未清理和静态集合无界增长是最常见的,建议每个 code review 里都专门检查这两点。
