本文详细记录了一次真实的线上OutOfMemoryError故障排查全过程。从收到监控报警到登录服务器取证,从使用JDK命令行工具快速定位到使用MAT进行深度内存分析,最终找到内存泄漏的根本原因并修复。这是一次宝贵的实战经验,让我对JVM内存管理有了刻骨铭心的认识。
1. 事故现象:监控系统报警
周五晚上10点,收到监控系统告警:
- 应用服务器内存使用率超过90%
- Full GC频率异常升高,从几分钟一次到几秒钟一次
- 应用响应时间急剧增加
不久后,应用日志中出现错误:
java.lang.OutOfMemoryError: Java heap space2. 应急响应:保存现场证据
第一步:立即保存堆转储文件
# 1. 找到Java进程PID
jps -l
# 或
ps -ef | grep java
# 2. 生成堆转储文件(重要:这会暂停应用,生产环境需谨慎)
jmap -dump:format=b,file=/tmp/heapdump.hprof <pid>
# 3. 同时保存线程快照,排查是否有死锁
jstack <pid> > /tmp/threaddump.txt第二步:观察实时GC情况
# 每秒输出一次GC统计信息
jstat -gcutil <pid> 1000输出示例:
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 100.00 85.43 99.87 94.23 91.83 1520 32.417 130 98.104 130.521关键发现: 老年代(O)使用率99.87%,Full GC(FGC)后回收效果极差,说明有对象无法被GC回收,存在内存泄漏。
3. 深度分析:使用MAT挖掘根本原因
将heapdump.hprof文件下载到本地,使用Eclipse Memory Analyzer Tool(MAT)进行分析。
第一步:查看泄漏疑点报告(Leak Suspects)
MAT自动分析后生成报告,显示:
- 85%的内存被
java.lang.ThreadLocal$ThreadLocalMap$Entry实例占用 - 怀疑点:
com.example.UserContext类中的ThreadLocal
第二步:分析Dominator Tree(支配树)
在Dominator Tree中按Retained Heap排序,发现有几个巨大的ThreadLocalMap实例。
第三步:查看GC Root引用链
右键可疑对象 → Path to GC Roots → exclude weak/soft references(排除弱/软引用,因为ThreadLocalMap的Key是弱引用,我们关心的是Value为什么没被回收)。
引用链分析结果:
Thread Thread-1
↓
java.lang.ThreadLocal$ThreadLocalMap (属于某个线程)
↓
java.lang.ThreadLocal$ThreadLocalMap$Entry[] (数组)
↓
Entry对象(其中Value很大)
↓
com.example.UserSession (用户会话对象,包含大量数据)
↓
... 业务数据4. 定位问题代码
根据引用链找到问题代码:
有问题的UserContext类:
@Component
public class UserContext {
private static final ThreadLocal<UserSession> currentSession = new ThreadLocal<>();
public static void setCurrentSession(UserSession session) {
currentSession.set(session);
}
public static UserSession getCurrentSession() {
return currentSession.get();
}
// 致命问题:没有提供清理方法!
// public static void clear() {
// currentSession.remove();
// }
}拦截器中使用但未清理:
@Component
public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 从请求中获取用户信息
UserSession session = extractSessionFromRequest(request);
UserContext.setCurrentSession(session); // 设置到ThreadLocal
return true;
}
// 问题:没有实现afterCompletion来清理ThreadLocal
// @Override
// public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
// UserContext.clear(); // 应该在请求完成后清理
// }
}5. 问题根源分析
为什么会导致内存泄漏?
- Tomcat线程池机制: Web服务器使用线程池处理请求,线程在处理完请求后不会被销毁,而是返回线程池重用。
- ThreadLocal生命周期: ThreadLocal的值与线程生命周期绑定。
泄漏过程:
- 线程A处理请求1,在ThreadLocal中设置了1MB的UserSession
- 请求1处理完成,线程A返回线程池,但ThreadLocal中的值没有被清理
- 线程A被重用来处理请求2,又设置了新的UserSession,旧值失去引用但仍在内存中
- 随着时间推移,每个线程的ThreadLocal中可能积累多个废弃的UserSession
6. 解决方案
方案1:在拦截器中确保清理
@Component
public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
UserSession session = extractSessionFromRequest(request);
UserContext.setCurrentSession(session);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
// 确保在请求处理完成后清理ThreadLocal
UserContext.clear();
}
}方案2:使用try-finally确保清理
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
UserSession session = extractSessionFromRequest(request);
UserContext.setCurrentSession(session);
// 将request设置为需要清理的标志
request.setAttribute("NEED_CLEANUP", true);
return true;
}
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
if (request.getAttribute("NEED_CLEANUP") != null) {
UserContext.clear();
}
}方案3:使用Filter包装器(更可靠)
@Component
public class ThreadLocalCleanupFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
try {
chain.doFilter(request, response);
} finally {
// 无论请求处理成功还是异常,都会执行清理
UserContext.clear();
}
}
}7. 预防措施
代码规范:
- 强制要求:使用ThreadLocal必须配套try-finally进行清理
- 代码审查:将ThreadLocal的使用作为审查重点
- 工具检测:使用SonarQube等工具检测潜在的资源泄漏
监控预警:
- JVM内存使用率监控
- GC频率和耗时监控
- 定期生成堆转储进行分析
8. 验证修复效果
修复后部署到测试环境,使用JMeter进行压力测试,通过jstat观察:
- 老年代内存使用率在80%左右稳定波动
- Full GC频率恢复正常(几小时一次)
- 没有出现内存持续增长的现象
总结:
这次OOM事故是我职业生涯中的重要一课。它让我深刻理解到:
- ThreadLocal使用必须谨慎,一定要配套清理机制
- 理解Web容器的线程池模型对排查这类问题至关重要
- 掌握JDK命令行工具和MAT是后端开发的必备技能
- 内存问题最好的解决方式是预防,而不是事后补救
这次经历让我从"只会写业务代码"的程序员,成长为"具备系统思维"的工程师。