目录
- 什么是 Java 内存泄漏?
- 为什么在 Linux 上排查?Linux 的优势
- 排查内存泄漏的完整流程
- 第一步:确认问题
- 第二步:生成内存快照
- 第三步:分析内存快照
- 第四步:定位问题代码
- 第五步:修复与验证
- 常见的 Java 内存泄漏场景与案例
- 预防内存泄漏的最佳实践
什么是 Java 内存泄漏?
内存泄漏 指的是程序在申请内存后,无法被垃圾回收器 自动回收,导致这块内存被长期占用,直到耗尽所有可用内存。

重要区别:内存溢出 vs. 内存泄漏
- 内存泄漏:指 “该回收的没回收”,内存被占用,但程序可能还能运行一段时间,只是可用内存越来越少,最终可能导致内存溢出。
- 内存溢出:指 “需要的内存不够用了”,程序申请的内存超出了 JVM 可用的最大内存(
-Xmx设置的值),直接抛出OutOfMemoryError。
内存泄漏是导致内存溢出的最常见原因之一。
为什么在 Linux 上排查?Linux 的优势
Java 程序通常在 Linux 服务器上长期运行,因此排查工作也主要在 Linux 环境下进行,Linux 提供了强大的工具,使得内存排查非常高效:
jps(Java Virtual Machine Process Status Tool):快速查看当前运行的 Java 进程 ID。jstat(JVM Statistics Monitoring Tool):实时监控 JVM 的堆内存、垃圾回收等运行时数据。jmap(Memory Map Tool):生成堆内存转储文件,这是分析内存泄漏的核心。jstack(Stack Trace Tool):生成 Java 线程的堆栈跟踪,用于分析死锁或线程问题。jcmd(Diagnostic Command Tool):一个更强大的多功能工具,可以执行jmap,jstack等功能,并且不需要 JVM 暂停。top/htop:系统级进程监控,可以直观地看到进程的内存和 CPU 占用情况。/proc文件系统:一个虚拟文件系统,提供了关于进程和系统信息的详细数据,/proc/<pid>/maps和/proc/<pid>/smaps。
排查内存泄漏的完整流程
这是一个经典的排查流程,从发现到解决。

第一步:确认问题
当服务器变慢或频繁 Full GC 时,需要怀疑是内存泄漏。
-
使用
top或htop观察 Java 进程的内存使用情况。# 按下 'M' 键,按内存使用排序 top -p <pid>
观察内存占用是否持续增长,并且在 GC 后不能完全回落,正常情况下,内存使用会在一个范围内波动。
-
使用
jstat监控 GC 情况。
(图片来源网络,侵删)# 每 1 秒打印一次 GC 统计信息 jstat -gcutil <pid> 1000
关键看
Old区(或Old Gen)的使用率是否持续上升。Old Gen快被占满,会触发 Major GC(或 Full GC),如果每次 Major GC 后,Old Gen的使用率都不能回到一个较低的水平,并且整体趋势是上升的,那基本可以确定是内存泄漏。
第二步:生成内存快照
一旦确认疑似内存泄漏,需要生成堆内存的快照(Heap Dump)进行分析,快照是一个 .hprof 文件,包含了某个时刻堆中所有对象的信息。
最佳实践:在内存泄漏发生时,自动生成多个快照。
-
使用
jcmd(推荐):jcmd是现代 JDK 的首选,功能强大且无需暂停 JVM。# 在另一个终端执行 jcmd <pid> GC.heap_info # 先查看堆信息 # 触发一次 GC 并生成堆快照 # 文件会生成在 /tmp/ 目录下 jcmd <pid> GC.heap_dump /tmp/heapdump_<pid>.hprof
-
使用
jmap(传统方式):jmap在生成快照时需要暂停 JVM,可能导致应用卡顿,在生产环境需谨慎使用。# 生成堆快照 jmap -dump:format=b,file=/tmp/heapdump_<pid>.hprof <pid>
自动生成快照的脚本:为了捕获泄漏现场,可以编写一个脚本,当内存占用超过阈值时自动触发 jcmd。
第三步:分析内存快照
生成的 .hprof 文件需要使用专门的工具来分析,最常用的是 Eclipse MAT (Memory Analyzer Tool)。
使用 Eclipse MAT 分析步骤:
- 打开
.hprof文件:MAT 会自动开始分析。 - 查看 Leak Suspects Report(泄漏嫌疑报告):MAT 会自动生成一个报告,它会根据算法(如支配树分析)找出最可疑的内存泄漏点,这个报告通常能直接告诉你问题所在。
- 打开 Histogram(直方图)视图:
- 功能:列出堆中所有类的实例数量和总大小。
- 操作:
- 点击
Group by > Class Name按类名分组。 - 关键操作:选中一个或多个类,右键选择
List Objects -> with outgoing references(查看持有这些对象引用的外部对象)或List Objects -> with incoming references(查看被哪些对象引用)。
- 点击
- 目标:找到那些数量巨大且本不该存在的对象,在一个用户登录的系统中,发现
HashMap<UserSession, ...>中有数百万个UserSession对象,这就是一个强烈的信号。
- 打开 Dominator Tree(支配树)视图:
- 功能:这个视图显示的是对象的支配关系,如果一个对象支配了其他对象,那么只要它不被回收,被它支配的对象也无法被回收。
- 目标:找到“大”的父节点(支配者),查看它下面有哪些“孩子”,如果一个父节点非常大,并且它持有大量不应该存在的子对象,那么这个父节点就是泄漏的根源,这比直方图更能体现“谁阻止了垃圾回收”。
第四步:定位问题代码
分析工具会告诉你哪个类的对象太多了,接下来就是根据这个线索回到代码中。
MAT 提供的路径追踪功能非常关键:
- 在直方图或支配树视图中,找到可疑的对象。
- 右键选择
Path to GC Roots(追踪到 GC 根的路径)。 - MAT 会展示一条引用链,从 GC 根(如一个静态变量、一个正在运行的线程栈中的变量)一直到你找到的那个可疑对象。
- 这条路径会清晰地告诉你,是哪个静态变量、哪个类的哪个字段、在哪个方法中,持有了这些无法回收的对象。
常见场景举例:
- 路径指向一个
static final Map,说明这个静态 Map 无限增长。 - 路径指向一个
ThreadLocal,说明线程没有正确清理ThreadLocal。 - 路径指向一个静态的
List,说明元素被添加后从未被移除。
第五步:修复与验证
- 修复代码:根据定位到的问题,修改代码,在不再需要时手动移除
Map中的元素、正确清理ThreadLocal、使用弱引用等。 - 重新部署:将修复后的代码部署到测试环境或生产环境。
- 回归验证:重复第一步和第二步的操作,监控内存使用情况和 GC 行为,确认内存泄漏已经解决,内存占用恢复正常。
常见的 Java 内存泄漏场景与案例
静态集合类
这是最经典的场景,静态集合的生命周期与类相同,只要类被加载,它就会一直存在。
// 错误示例
public class Cache {
private static final Map<String, Object> CACHE = new HashMap<>();
public static void put(String key, Object value) {
CACHE.put(key, value);
}
// 缺少 remove() 方法,或者调用方忘记 remove
}
分析:CACHE 是静态的,只要应用不重启,它就会一直存在,如果不断向 CACHE 中添加元素而不移除,它的大小会无限增长。
监听器、回调未注销
许多框架(如 GUI、Web、消息队列)都使用监听者模式,如果注册了监听器但没有在适当的时候注销,监听器对象会一直被框架持有,导致它和它引用的所有对象都无法被回收。
// 错误示例
public class EventManager {
private List<EventListener> listeners = new ArrayList<>();
public void register(EventListener listener) {
listeners.add(listener);
}
// 缺少 unregister() 方法,或者调用方忘记 unregister
}
分析:EventManager 可能是一个单例,listeners 列表会持有所有注册的监听器,即使监听器所在的业务对象已经不再使用,它也无法被 GC。
不当使用 ThreadLocal
ThreadLocal 为每个线程都创建一个副本,每个线程的副本存储在线程自己的 ThreadLocalMap 中。问题在于 ThreadLocalMap 的 key 是 ThreadLocal 对象的弱引用,而 value 是强引用。
ThreadLocal 对象被回收了,ThreadLocalMap 中的 key 就变成了 null,但 value 仍然存在,并且因为 key 是 null,这个 value 永远无法被访问,也无法被清理,除非线程被销毁。
// 错误示例
public class SessionManager {
private static final ThreadLocal<Session> sessionHolder = new ThreadLocal<>();
public void createSession() {
Session session = new Session();
sessionHolder.set(session);
// ... 业务逻辑 ...
// 如果忘记调用 sessionHolder.remove()
}
}
分析:在 Web 服务器中,线程通常会被池化复用,如果每次请求后不调用 remove(),那么之前请求的 Session 对象会一直在线程的 ThreadLocalMap 中堆积,造成内存泄漏。
正确做法:使用 try-finally 确保 remove() 一定会被调用。
try {
sessionHolder.set(new Session());
// ...
} finally {
sessionHolder.remove(); // 必须调用!
}
缓存使用不当
缓存(如 Guava Cache)虽然强大,但如果配置不当(如设置了过期的容量或时间但没有正确回收),或者业务逻辑中缓存了不该缓存的数据(如缓存了所有查询结果),也会导致内存泄漏。
数据库连接、IO流未关闭
虽然 JVM 的 finalize 方法理论上会关闭这些资源,但这是一个不可靠的过程,如果显式地没有关闭 Connection, Statement, ResultSet, FileInputStream 等,它们会一直被持有,不仅可能造成内存泄漏,更会耗尽数据库连接或文件句柄。
预防内存泄漏的最佳实践
- 代码审查:在代码审查时,重点关注静态变量、集合类、监听器和
ThreadLocal的使用。 - 编写单元测试:针对可疑的代码路径(如缓存、会话管理)编写单元测试,模拟长时间运行和大量数据,使用工具(如 JMockit 的
Mockit.tearDown()或手动检查内存)来验证是否存在泄漏。 - 利用弱引用:对于缓存场景,可以考虑使用
WeakHashMap或 Guava Cache 的弱引用配置,当内存紧张时,GC 会自动回收这些条目。 - 遵循“谁创建,谁负责”原则:对于需要显式释放的资源,要确保创建它的代码逻辑负责在最后释放它。
- 监控先行:在生产环境中部署应用前,确保有完善的监控(如 Prometheus + Grafana 监控 JVM 指标),以便在问题发生时能快速报警。
- 熟悉工具:熟练掌握
jps,jstat,jcmd,jmap,jstack等命令,以及 Eclipse MAT 或 VisualVM 等分析工具,这些工具是排查问题的“利器”。
希望这份详细的指南能帮助你有效地在 Linux 环境下排查和解决 Java 内存泄漏问题!
