本文详细记录了一次真实的线上OutOfMemoryError故障排查全过程。从收到监控报警到登录服务器取证,从使用JDK命令行工具快速定位到使用MAT进行深度内存分析,最终找到内存泄漏的根本原因并修复。这是一次宝贵的实战经验,让我对JVM内存管理有了刻骨铭心的认识。

1. 事故现象:监控系统报警

周五晚上10点,收到监控系统告警:

  • 应用服务器内存使用率超过90%
  • Full GC频率异常升高,从几分钟一次到几秒钟一次
  • 应用响应时间急剧增加

不久后,应用日志中出现错误:

java.lang.OutOfMemoryError: Java heap space

2. 应急响应:保存现场证据

第一步:立即保存堆转储文件

# 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. 问题根源分析

为什么会导致内存泄漏?

  1. ​Tomcat线程池机制:​​ Web服务器使用线程池处理请求,线程在处理完请求后不会被销毁,而是返回线程池重用。
  2. ​ThreadLocal生命周期:​​ ThreadLocal的值与线程生命周期绑定。
  3. 泄漏过程:

    • 线程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事故是我职业生涯中的重要一课。它让我深刻理解到:

  1. ThreadLocal使用必须谨慎​,一定要配套清理机制
  2. 理解Web容器的线程池模型对排查这类问题至关重要
  3. 掌握JDK命令行工具和MAT是后端开发的必备技能
  4. 内存问题最好的解决方式是预防​,而不是事后补救

这次经历让我从"只会写业务代码"的程序员,成长为"具备系统思维"的工程师。

如果觉得我的文章对你有用,请随意赞赏