内存泄漏排查:堆Dump分析、强软弱虚引用与监听器泄漏
内存泄漏排查:堆Dump分析、强软弱虚引用与监听器泄漏
适读人群:Java中高级开发工程师 | 阅读时长:约18分钟 | 适用JDK版本:JDK 8 / 11 / 17 / 21
开篇故事
2019年,我接手一个运行了三年的遗留系统,一个基于Spring MVC的后台管理系统。这个系统有个奇怪的特性:每隔大约7天,就必须重启一次,否则会OOM。运维同学甚至写了自动定时重启脚本。
接手后我觉得这不能忍,必须找出根因。
首先排除了正常的堆增长——7天才OOM说明增长很慢,真正的业务对象应该都被GC回收了。这种缓慢增长的特征是典型的内存泄漏。
我在应用上配置了-XX:+HeapDumpOnOutOfMemoryError,等待下一次OOM(等了五天)。拿到dump文件后,用MAT分析,Leak Suspects第一行就是:
"com.example.config.SystemConfig$ConfigChangeListener - 2,847个实例,占用堆1.4GB"
ConfigChangeListener?这是一个配置变更监听器。进一步分析路径,发现这些监听器被注册到了一个单例的ConfigManager里,ConfigManager维护了一个List<ConfigChangeListener>。
问题在于:ConfigChangeListener是一个匿名内部类,每个HTTP请求处理时都会new一个新的匿名监听器注册到ConfigManager,但从来没有remove过。系统运行2个月,处理了数百万个请求,每个请求都留下了一个监听器对象,这些对象因为被ConfigManager(单例)持有引用,永远不会被GC回收,慢慢把堆撑满。
修复非常简单:在请求处理完成后remove监听器,或者改用观察者模式的弱引用版本。修复后系统稳定运行两年,再也没有出现过内存泄漏问题。
一、内存泄漏的本质与分类
Java的内存泄漏与C/C++的定义不同。C/C++里,内存泄漏是申请了内存但忘记释放。Java有GC,不存在"忘记释放"的问题,但存在一种特殊情况:对象不再被业务逻辑使用,但仍然被某个引用持有,GC无法回收。
这就是Java内存泄漏的本质:不需要的对象持有了不该有的引用。
内存泄漏的分类:
类型一:集合泄漏。最常见。往集合(List、Map、Set)中添加对象,但没有相应的remove操作。静态集合泄漏最危险,因为静态对象的生命周期与进程相同。
类型二:监听器泄漏。将监听器(Observer、Listener、Handler)注册到事件源,但忘记在对象销毁时取消注册。这是"开篇故事"的根因。
类型三:内部类持有外部类引用。非静态内部类隐式持有外部类的引用。如果内部类的实例生命周期比外部类长,外部类就无法被GC回收。
类型四:ThreadLocal泄漏。ThreadLocal没有remove,在线程池场景下,线程被复用,ThreadLocal的value永远不会被清理。
类型五:ClassLoader泄漏。自定义ClassLoader加载了很多类,但ClassLoader本身没有被GC回收,导致其加载的所有类的元数据也无法释放。
类型六:连接/流资源泄漏。Connection、InputStream、OutputStream等资源没有关闭,虽然GC会回收对象,但底层的OS资源(文件描述符、Socket)可能已经耗尽。
二、原理深度解析
2.1 四种引用类型的GC行为
强引用(Strong Reference):普通的对象引用。只要强引用存在,GC永远不会回收该对象,即使OOM。
Object obj = new Object(); // 强引用
obj = null; // 解除强引用,对象可以被GC软引用(SoftReference):在内存充足时不回收,在GC决定需要更多内存时才回收。适合做内存敏感的缓存。
SoftReference<byte[]> softRef = new SoftReference<>(new byte[1024 * 1024 * 10]); // 10MB
// 内存充足时,softRef.get()返回对象
// 内存不足时,JVM会回收软引用指向的对象,softRef.get()返回null
byte[] data = softRef.get();
if (data == null) {
// 重新加载数据
data = loadData();
softRef = new SoftReference<>(data);
}弱引用(WeakReference):只要发生GC(不论内存是否充足),弱引用指向的对象就会被回收(前提是没有其他强/软引用指向它)。
// ThreadLocal的Entry就是We弱引用key
// WeakHashMap的key也是弱引用
WeakReference<Object> weakRef = new WeakReference<>(someObject);
someObject = null; // 解除强引用
// 下次GC后,weakRef.get()返回null虚引用(PhantomReference):最弱的引用,无法通过虚引用获取到对象。只用于与ReferenceQueue配合,在对象被GC回收后收到通知,用于做资源清理。
ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> phantomRef = new PhantomReference<>(object, queue);
// 当object被GC回收后,phantomRef会被加入queue
// 直接内存的Cleaner就是用虚引用实现的2.2 ThreadLocal泄漏的原理
ThreadLocal泄漏是面试高频题,也是实际生产中很常见的问题,原理值得深入了解:
关键点:ThreadLocalMap的key(ThreadLocal实例)是弱引用,但value是强引用。
泄漏路径:
- 方法结束,ThreadLocal局部变量出了作用域
- ThreadLocal对象的弱引用被GC回收(key变成null)
- 但Entry对value的强引用还在!ThreadLocalMap里有一个key为null的Entry,value永远无法被GC
- 线程池的线程不会死,ThreadLocalMap也不会死,这些key=null的Entry永久驻留
防止泄漏:
// 必须在finally块中调用remove
ThreadLocal<RequestContext> requestContext = new ThreadLocal<>();
try {
requestContext.set(buildContext(request));
// 业务处理
doProcess();
} finally {
requestContext.remove(); // 关键!
}2.3 监听器泄漏的模式分析
// 泄漏模式:注册了但没有取消注册
public class EventDrivenProcessor {
private EventBus eventBus; // 单例EventBus
public void processRequest(Request req) {
// 每次请求都创建新的监听器并注册
// 这个匿名内部类会隐式持有EventDrivenProcessor的引用!
eventBus.register(new EventListener() {
@Override
public void onEvent(Event e) {
handleEvent(req, e); // 通过捕获的req持有引用
}
});
// 但从来没有eventBus.unregister()!
}
}修复方案:
public class EventDrivenProcessor {
private EventBus eventBus;
private List<EventListener> registeredListeners = new ArrayList<>();
public void processRequest(Request req) {
EventListener listener = e -> handleEvent(req, e);
registeredListeners.add(listener);
eventBus.register(listener);
// 请求处理完成后取消注册
try {
doProcess();
} finally {
eventBus.unregister(listener);
registeredListeners.remove(listener);
}
}
}三、诊断工具与命令
3.1 初步判断是否内存泄漏
# 连续监控堆使用率
jstat -gcutil <pid> 5000 60
# 观察O列(老年代)
# 如果Old Gen在Full GC后每次都比上次高,且持续增长,是内存泄漏的强烈信号
# 对比不同时间点的对象分布
jmap -histo:live <pid> > /tmp/hist1.txt
# 等待30分钟
jmap -histo:live <pid> > /tmp/hist2.txt
# 对比两次结果,找出实例数持续增长的类
diff /tmp/hist1.txt /tmp/hist2.txt | grep ">"3.2 MAT深度分析流程
# Step 1: 生成heap dump
jmap -dump:live,format=b,file=/tmp/heap.hprof <pid>
# Step 2: 用MAT打开(命令行方式)
# 下载MAT:https://eclipse.dev/mat/downloads.php
./mat.sh
# Step 3: MAT关键操作
# File → Open Heap Dump → 选择文件
# 等待分析完成(大文件需要几分钟)
# 点击"Leak Suspects Report"(最快找到问题)
# 查看Dominator Tree(找到Retained Heap最大的对象)
# 查看Histogram,按Retained Heap排序
# MAT OQL查询(强大的对象查询语言)
# 查询特定类的所有实例
SELECT * FROM com.example.ConfigChangeListener
# 查询内存占用超过1MB的对象
SELECT * FROM java.lang.Object o WHERE o.@retainedHeapSize > 1048576
# 查询某个集合的大小
SELECT s.size FROM java.util.ArrayList s WHERE s.size > 100003.3 实战排查ThreadLocal泄漏
# 用MAT或jmap查找key为null的ThreadLocal Entry
# jmap -histo:live可以看到ThreadLocalMap$Entry数量
jmap -histo:live <pid> | grep ThreadLocalMap
# 使用Arthas诊断ThreadLocal
java -jar arthas-boot.jar
# 查看线程的ThreadLocal信息
> vmtool --action getInstances --className java.lang.Thread --limit 5
# 结合ognl表达式分析ThreadLocalMap
# 检查线程池中的ThreadLocal泄漏
> thread | grep "pool-" # 找线程池线程
> thread <thread_id> # 查看特定线程的详细信息3.4 监控内存趋势
# 使用Prometheus + JVM Exporter监控
# jvm_memory_used_bytes{area="heap"} 趋势持续上升 → 内存泄漏嫌疑
# 用JFR持续记录内存分配
jcmd <pid> JFR.start duration=3600s filename=/tmp/leak.jfr settings=profile
# JMC分析JFR文件
# Memory → Live Objects → 按Class排序
# 找出随时间增长的对象类型四、完整调优方案
4.1 消除静态集合泄漏
// 危险:无界静态Map
public class CacheManager {
private static final Map<String, Object> CACHE = new HashMap<>();
public static void put(String key, Object value) {
CACHE.put(key, value); // 只放,不清
}
}
// 安全:使用有界缓存(Caffeine推荐)
public class CacheManager {
private static final Cache<String, Object> CACHE = Caffeine.newBuilder()
.maximumSize(10_000) // 最多10000个元素
.expireAfterWrite(30, TimeUnit.MINUTES) // 30分钟过期
.weakValues() // value使用弱引用
.recordStats() // 记录统计信息(便于监控)
.build();
}
// 或者使用LinkedHashMap实现LRU缓存
Map<String, Object> lruCache = Collections.synchronizedMap(
new LinkedHashMap<>(16, 0.75f, true) {
private static final int MAX_SIZE = 1000;
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > MAX_SIZE; // 超过上限时删除最老的
}
}
);4.2 解决Spring Bean的监听器泄漏
// Spring中,如果一个短生命周期的Bean注册到了长生命周期的Bean
@Component
@Scope("prototype") // 每次请求创建新实例
public class RequestProcessor implements ApplicationListener<ApplicationEvent> {
// 问题:Spring会把这个Listener注册到ApplicationContext
// 每次创建新实例都会注册,但prototype bean不会被Spring自动销毁
// 导致大量RequestProcessor实例泄漏
@Override
public void onApplicationEvent(ApplicationEvent event) {
// 处理事件
}
}
// 修复:避免prototype bean实现ApplicationListener
// 或者使用SmartApplicationListener + 显式取消注册
@Component
@Scope("prototype")
public class RequestProcessor implements SmartApplicationListener, DisposableBean {
@Autowired
private ApplicationEventMulticaster eventMulticaster;
@PostConstruct
public void registerListener() {
eventMulticaster.addApplicationListener(this);
}
@Override
public void destroy() {
eventMulticaster.removeApplicationListener(this); // 关键!
}
}4.3 防止ClassLoader泄漏
// 正确实现自定义ClassLoader
public class PluginClassLoader extends URLClassLoader {
public PluginClassLoader(URL[] urls, ClassLoader parent) {
super(urls, parent);
}
// 插件卸载时必须调用close()
// URLClassLoader实现了Closeable接口
public void unload() {
try {
close(); // 释放所有加载的类和资源
} catch (IOException e) {
log.error("Failed to close ClassLoader", e);
}
}
}
// 使用时
PluginClassLoader loader = new PluginClassLoader(urls, parent);
try {
Class<?> pluginClass = loader.loadClass("com.plugin.Main");
// 使用插件...
} finally {
loader.unload(); // 卸载时关闭ClassLoader
}五、踩坑实录
坑一:以为WeakHashMap可以解决所有泄漏问题
有次写了个缓存,用WeakHashMap存储,以为key没有强引用时会自动清理。结果运行一段时间后缓存一直在增长,WeakHashMap根本没有清理。
原因:缓存的key是用户ID(Long对象),而JVM对-128到127的Integer/Long会缓存(Integer.valueOf的缓存机制),所以这些key对象一直有强引用(来自Integer缓存池),弱引用的清理条件不满足。对于超出范围的ID,确实会被清理,但99%的测试数据都是小数字的ID,所以测试环境看不出问题,只有生产环境的真实数据才能触发。
教训:WeakHashMap的key不能是会被池化的对象。业务上更推荐用Caffeine的明确过期策略。
坑二:内部类持有外部类,外部类无法回收
有个定时任务系统,任务是非静态内部类:
public class TaskManager {
private List<Runnable> tasks = new ArrayList<>();
public void addTask() {
tasks.add(new Runnable() { // 非静态匿名内部类
@Override
public void run() {
// TaskManager被隐式持有!
System.out.println("Task executed");
}
});
}
}每次addTask后,tasks里新增一个持有TaskManager引用的对象。虽然任务执行完了,但tasks列表里一直保留着这些对象,而这些对象又持有TaskManager,TaskManager又持有tasks,形成了一个循环引用环(虽然现代GC能处理循环引用,但这里问题是tasks没有被清理)。
解决方案:改成静态内部类,或者任务执行后从tasks里remove。
坑三:日志框架的MDC未清理导致ThreadLocal泄漏
使用SLF4J的MDC(Mapped Diagnostic Context)追踪请求链路,但在请求结束时没有清理MDC。
MDC内部使用ThreadLocal存储Map,线程池线程复用后,前一个请求的MDC数据会"残留"在下一个请求中,不只是内存泄漏,还会导致日志数据混乱(新请求的日志里混入了旧请求的trace_id)。
解决方案:在AOP或Filter的finally块中调用MDC.clear()。
坑四:Spring @Async的BeanFactory泄漏
有个项目大量使用了@Async注解,发现每次触发异步方法,Metaspace都会轻微增长,运行数小时后触发Metaspace OOM。
排查发现:每次@Async方法执行,Spring都会通过CGLIB为这个Bean生成一个新的代理类(因为某个配置问题导致代理没有被缓存复用),每次生成都会加载新的类到Metaspace。
解决方案:检查@Async的配置,确保CGLIB代理类被缓存复用,不要每次重新生成。
六、总结
内存泄漏的排查思路是:持续观察老年代GC后的内存基线是否上升 → 确认是泄漏后dump堆 → MAT分析Dominator Tree和Leak Suspects → 找到持有大量内存的对象 → 分析其GC Roots引用链 → 定位代码中的泄漏点。
四种引用类型是防止泄漏的利器:强引用能hold住对象,弱引用让GC可以回收(下次GC),软引用内存不足时回收(做缓存),虚引用用于监听回收时机(做资源清理)。理解它们的生命周期,在合适的场景用合适的引用类型。
最常见的泄漏模式:静态集合无上限增长、监听器注册后未移除、ThreadLocal未remove、非静态内部类持有外部类引用。这四类问题在code review时要重点关注。
生产环境一定要配置-XX:+HeapDumpOnOutOfMemoryError,并且留出足够的磁盘空间存放dump文件(与堆大小相同)。内存泄漏问题通常要在接近OOM时才最明显,这个时机自动捕获的dump文件最有价值。
