Java 应用 OOM 内存泄漏排查实战一次 Undertow 引发的血案生产环境 java.lang.OutOfMemoryError: Java heap space 的完整排查与修复过程从 MAT 堆分析到代码溯源一步步揪出跨线程传递 HttpServletRequest 的隐藏问题生产环境告警服务大面积超时CPU 飙升 40%堆内存爆满。56,356 个 SSL 引擎对象堆积在堆里占用了 73% 的内存——这究竟是天灾还是人祸本文将带你走入一次真实的 Java 内存泄漏排查现场从 MAT 堆分析到代码溯源一步步揭开 Undertow 背后的真相。 目录灾难降临——问题现象收集证据——获取堆转储解剖现场——MAT 分析定位顺藤摸瓜——代码排查溯源对症下药——修复方案效果验证——数据说话复盘反思——经验教训一、灾难降临——问题现象那是一个再普通不过的下午。运维同事的一条消息打破了平静“生产环境服务出现大量请求超时麻烦看一下。”查看应用日志红色的报错信息映入眼帘Exception in thread AsyncAppender-Worker-async info java.lang.OutOfMemoryError: Java heap space Exception in thread SimpleAsyncTaskExecutor-1 java.lang.OutOfMemoryError: Java heap spacejava.lang.OutOfMemoryError: Java heap space——这是 JVM 堆内存耗尽的典型信号。更令人不安的是在测试环境复现问题时发现了一个诡异的现象某两个复杂列表页面以每秒 1 次 F5 的频率刷新单台客户机就能把服务器 CPU 推高到 40% 以上。一个前端页面 F5 刷新竟然能吃掉服务器 40% 的 CPU这背后一定隐藏着更深的问题。二、收集证据——获取堆转储2.1 配置 JVM 参数排查内存问题第一件事就是拿到案发现场——堆转储文件Heap Dump。如果没有提前配置OOM 发生时证据就灰飞烟灭了。在应用启动参数中添加-XX:HeapDumpOnOutOfMemoryError-XX:HeapDumpPath/path/to/dumps/这两个参数的意思是当 OOM 发生时自动将堆内存快照保存到指定目录。2.2 OOM 再次触发配置上线后不久OOM 再次发生。指定目录下生成了一个约4GB的堆转储文件java_pid2796.hprof (约 4GB) 4GB 的 hprof 文件意味着堆已经被撑到极限了。这里面到底塞了什么东西2.3 选择分析武器——Eclipse MAT面对 4GB 的堆转储需要趁手的工具。选择了Eclipse MAT (Memory Analyzer Tool)原因有三自动生成 Leak Suspects Report— 一键定位嫌疑对象不需要手动翻查Dominator Tree 分析— 可视化对象引用链谁持有谁一目了然OQL 查询语言— 像 SQL 一样精确检索堆中的对象小插曲MAT 启动时报 JDK 版本兼容错误需要 JDK21环境是 JDK17。解决方法很简单——修改MemoryAnalyzer.ini文件指定 JDK 路径即可。如果 GUI 模式加载 4GB 的大文件卡死可以使用命令行模式java-Xmx6g-cpmat/plugins/org.eclipse.equinox.launcher_*.jar\org.eclipse.equinox.launcher.Main\-applicationorg.eclipse.mat.api.parse\java_pid2796.hprof org.eclipse.mat.api:suspects三、解剖现场——MAT 分析定位3.1 Leak Suspects Report触目惊心MAT 加载完成后Leak Suspects Report 给了我一记重击。嫌疑对象实例数量占用内存堆占比io.undertow.protocols.ssl.SNISSLEngine56,356 个3,130,057,240 bytes73.12%io.undertow.server.HttpServerExchange56,324 个618,397,648 bytes14.45%两张表加起来吃掉了堆内存的 87.57%。56,356 个 SNISSLEngine 对象一个 SSL 引擎对象大约 55KB5.6 万个堆在一起就撑爆了 3GB 内存。3.2 关键发现数量惊人一致注意两个数字——56,356 和 56,324几乎相等。这说明什么每一个SNISSLEngine对应一个HttpServerExchange。换句话说服务器同时积压了超过 5.6 万个正在处理中的 HTTPS 请求每个请求都带着一个 SSL 引擎对象谁也无法释放。3.3 深挖引用链谁锁住了它们MAT 的“Path to GC Roots”功能是破案的关键。它像侦探一样从每个嫌疑对象出发逆向追踪到 GC Root——找到谁在拽着这些对象不让回收。第一条引用链ConcurrentHashMap$Node[] (GC Root) ↓ 持有 HttpServerExchange (56,324 个) ↓ 持有 SslConduit / SSLConduit ↓ 持有 SNISSLEngine (56,356 个)第二条引用链从 Worker 线程出发WorkerThread (8 个) ↓ WEPollSelectorImpl ↓ HashMap$Node[] (9,159 个) ↓ SelectionKeyImpl (9,033 个) ← 活跃的 NIO 连接 ↓ NioSocketConduit ↓ SslConduit / SSLConduit3.4 推理真凶不是 SNISSLEngine⚡关键推理SNISSLEngine不是问题的**“因”而是果**。SNISSLEngine是 Undertow 处理 HTTPS 连接时正常创建的 SSL 引擎对象。它们之所以堆积在那里是因为真正的问题是那 56,324 个HttpServerExchange对象被某处代码锁住无法释放HttpServerExchange不释放它引用的SNISSLEngine自然也滞留堆中锁住它们的容器MAT 报告中指向了一个ConcurrentHashMap——这是 Undertow 内部用于管理活跃请求的数据结构所以推理链是代码锁住了 Exchange → Exchange 无法标记完成 → Undertow 的 Map 不放 → SSL 引擎跟着滞留 → 堆内存被撑爆真凶在业务代码里不在 Undertow 里。四、顺藤摸瓜——代码排查溯源4.1 全局搜索ConcurrentHashMap根据 MAT 报告的关键词ConcurrentHashMap、HttpServerExchange开始在代码库中地毯式搜索。业务代码中没有直接new ConcurrentHashMap()来缓存 Exchange 的地方。但这并不意味着安全——还可能有一种更隐蔽的方式跨线程传递。4.2 发现危险的操作在登录监听器UserActionListener中一段看似人畜无害的代码引起了注意publicclassUserActionListenerimplementsSaTokenListener{OverridepublicvoiddoLogin(StringloginType,ObjectloginId,StringtokenValue,SaLoginModelloginModel){// ... 获取用户信息// 危险操作将 HttpServletRequest 对象塞进异步事件LoginInforEventloginInforEventnewLoginInforEvent();loginInforEvent.setRequest(ServletUtils.getRequest());// ← 罪魁祸首SpringUtils.context().publishEvent(loginInforEvent);}}ServletUtils.getRequest()获取的是当前请求线程的HttpServletRequest对象然后把它塞进了一个异步事件中发布出去。这意味着请求线程结束了但HttpServletRequest还被事件对象拽着不放。4.3 还原完整泄漏链路┌─────────────────────────────────────────────────────────────────┐ │ 第 1 步用户登录成功 │ │ 监听器 doLogin() 获取当前请求的 HttpServletRequest │ └─────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 第 2 步创建事件塞入 Request 对象 │ │ loginInforEvent.setRequest(HttpServletRequest) │ │ ⚠️ HttpServletRequest 内部持有 HttpServerExchange │ └─────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 第 3 步发布异步事件 │ │ publishEvent() → 事件进入 Async 线程池队列 │ │ ⚠️ 事件对象持有 Request → 间接持有 Exchange │ └─────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 第 4 步Undertow 无法释放 Exchange │ │ Undertow 的 ConcurrentHashMap 追踪活跃 Exchange │ │ ⚠️ Exchange 被事件锁住 → 无法标记完成 → 无法释放 │ └─────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────┐ │ 第 5 步高并发下灾难性积压 │ │ 登录请求越多 → 积压事件越多 → Exchange 堆积 → │ │ SNISSLEngine 堆积 → 最终 OOM │ └─────────────────────────────────────────────────────────────────┘这就是完整的泄漏链条——一个看似无害的setRequest()调用引发了一场内存雪崩。4.4 澄清为什么其他配置不是主因排查过程中检查了多个疑似配置但逐一排除了配置项结论解释undertow.max-http-post-size: -1⚠️ 放大器无限制接收大请求体确实不好但不是 OOM 根因undertow.buffer-size: 512 CPU 相关缓冲区过小导致频繁系统调用只影响 CPU 不导致 OOMAsyncAppender queueSize 优化项调小能减少点内存但治标不治本SimpleAsyncTaskExecutor 加剧因素线程无限增长加剧了内存压力但不是源头结论配置优化只能缓解症状无法根治。真正的病灶在业务代码中。五、对症下药——修复方案5.1 核心手术停止跨线程传递 Request修复前错误代码// 危险将 Web 容器对象跨线程传递LoginInforEventloginInforEventnewLoginInforEvent();loginInforEvent.setRequest(ServletUtils.getRequest());// ❌SpringUtils.context().publishEvent(loginInforEvent);修复后正确代码// ✅ 1. 在同步线程中提取所有必要数据只取基础类型/值对象StringipServletUtils.getClientIP();Stringusername(String)loginModel.getExtra(LoginHelper.USER_NAME_KEY);StringtenantId(String)loginModel.getExtra(LoginHelper.TENANT_KEY);UserAgentuserAgentUserAgentUtil.parse(ServletUtils.getRequest().getHeader(User-Agent));// ✅ 2. 只传递纯数据绝不传递 Request 对象LoginInforEventloginInforEventnewLoginInforEvent();loginInforEvent.setTenantId(tenantId);loginInforEvent.setUsername(username);loginInforEvent.setIp(ip);loginInforEvent.setUserAgent(userAgent);// ✅ 不再调用 setRequest() ← 关键修复点SpringUtils.context().publishEvent(loginInforEvent);同步修改LoginInforEvent类publicclassLoginInforEventextendsApplicationEvent{privateStringtenantId;privateStringusername;privateStringip;privateUserAgentuserAgent;// ✅ 删除private HttpServletRequest request;}同步修改异步监听器AsyncpublicvoidrecordLogininfor(LoginInforEventevent){// ✅ 只使用纯数据不再依赖任何 Request 对象Stringipevent.getIp();Stringusernameevent.getUsername();// ... 直接入库}核心原则异步事件只传递数据不传递容器对象。HttpServletRequest/HttpServletResponse/HttpServerExchange这些 Web 容器对象只能在请求线程中使用跨线程传递就是给自己埋雷。5.2 重构线程池给异步任务套上缰绳Async默认使用SimpleAsyncTaskExecutor这个执行器每次执行都会创建一个新线程没有上限。在 OOM 场景下它就是一个无底洞。替换为有界线程池ConfigurationEnableAsyncpublicclassAsyncPoolConfigimplementsAsyncConfigurer{OverridepublicExecutorgetAsyncExecutor(){ThreadPoolTaskExecutorexecutornewThreadPoolTaskExecutor();executor.setCorePoolSize(10);executor.setMaxPoolSize(50);executor.setQueueCapacity(200);executor.setThreadNamePrefix(MyAsync-);executor.setRejectedExecutionHandler(newThreadPoolExecutor.CallerRunsPolicy()// 溢出时由调用线程执行);executor.initialize();returnexecutor;}}⚠️注意如果项目中已有AsyncConfigurer实现只保留一个否则会报Only one AsyncConfigurer may exist错误。5.3 架构优化将 SSL 卸载到 NginxMAT 数据显示 73% 的内存被SNISSLEngine占用——这意味着后端应用承担了 HTTPS 加密解密的全部开销。对于高并发场景SSL 握手和加密是非常昂贵的操作。最佳实践是将 SSL 处理卸载到反向代理层Nginx后端只用 HTTPNginx 配置server { listen 443 ssl; server_name your-domain.com; ssl_certificate /path/to/cert.pem; ssl_certificate_key /path/to/key.pem; location / { proxy_pass http://your-backend-app:8080; # ✅ 后端只用 HTTP proxy_set_header X-Forwarded-Proto $scheme; } }后端 Spring Boot 配置server:ssl:enabled:false# ✅ 关闭 HTTPSport:80805.4 Undertow 参数调优辅助server:undertow:max-http-post-size:10485760# 从 -1 改为 10MB限制请求体大小buffer-size:16384# 从 512 改为 16KB减少系统调用次数no-request-timeout:5000# 请求无活动超时及时清理僵尸连接⏱ 这些优化虽然只是辅助治疗但对于高并发场景依然很有价值。六、效果验证——数据说话6.1 内存数据天壤之别验证项修复前修复后改善幅度SNISSLEngine实例数56,356 个 10 个↓ 99.98%堆内存使用趋势持续上升直至 OOMGC 后呈锯齿状回落✅ 健康Full GC 频率频繁几乎为零✅ 正常6.2 CPU 数据恢复正常测试场景修复前修复后每秒 F5 刷新单客户机CPU40%CPU 5%100 并发登录压测响应超时频繁 GC ❌响应正常GC 平稳✅七、复盘反思——经验教训7.1 根因一句话总结异步事件跨线程传递HttpServletRequest→ 锁住HttpServerExchange→ Undertow 内部 Map 无法释放 →SNISSLEngine大量积压 →堆内存溢出7.2 四大核心原则原则说明不要跨线程传递容器对象HttpServletRequest/HttpServletResponse/HttpServerExchange等对象只能在请求线程中使用跨线程传递必然导致泄漏异步事件只传递数据使用基础类型String、int或纯 DTO绝不传递 Web 容器对象线程池必须有界永远不要让线程数无限制增长 ——SimpleAsyncTaskExecutor就是定时炸弹善用 MAT 分析工具MAT 的 Leak Suspects Report 能快速定位引用链是排查 OOM 的神器7.3 四点深刻教训教训一日志中早已留下线索日志里SimpleAsyncTaskExecutor-1这个线程名其实已经暴露了异步任务的问题。但在当时团队只关注了日志配置本身忽略了业务代码中的隐患。线程名是最好的诊断标签——如果你的日志里出现了SimpleAsyncTaskExecutor、DefaultMessageListenerContainer这样的默认线程名前缀请多留一个心眼。教训二MAT 报告要结合代码阅读SNISSLEngine占内存最多73%但它只是**“受害者”。真正的凶手**是代码中跨线程传递 Request 对象的逻辑。不要让最大的数字迷惑了你——分析 MAT 报告时要从引用链入手追到根因。教训三配置优化不能代替代码修复调整日志队列大小、修改 Undertow 参数、增加 JVM 内存——这些都只是止痛药。真正的治疗是代码层面的修复。配置可以优化但代码必须正确。教训四压测是验证修复的唯一标准修复完成后不能仅凭单次请求判断。必须用 JMeter 等工具进行压测验证观察内存曲线是否稳定锯齿状 VS 持续上升GC 频率和停顿时间高并发下的响应时间和错误率写在最后这次 OOM 事故排查从接到告警到完成修复经历了发现问题 → 获取堆转储 → MAT 分析 → 代码排查 → 修复验证的完整闭环。回想起来问题的本质其实很简单一行setRequest()的调用违反了一个基本原则——不要跨线程传递容器对象。但在百万级并发压力下这行代码就像一颗被踩中的地雷引爆了整个系统的内存。希望这篇记录能给你在排查类似问题时带来一些启发。如果你也遇到过类似的隐蔽内存泄漏欢迎留言交流 相关阅读Eclipse MAT 官方文档Undertow 项目官网Spring Async 官方文档本文首发于技术博客转载请注明出处。