ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

线上老年代缓慢增长导致的内存泄漏排障全记录

线上老年代缓慢增长导致的内存泄漏排障全记录 在微服务稳定性治理中最棘手的故障往往不是一上线就立刻崩溃的急症而是表现为“慢刀子割肉”的隐蔽性内存泄漏。我们曾遇到过一起典型的线上故障一个负责履约状态同步的核心应用在发版后平稳运行各项业务指标正常。但随着时间推移Prometheus 监控面板上的 JVM 老年代Old Generation使用率呈现出近乎恒定的斜率缓慢爬升。大约每运行 4 天老年代使用率就会触及 92% 的阈值随后引发高频且无效的 Full GC。每次 Full GC 仅能释放不到 3% 的空间导致单次 STWStop-The-World停顿达 2.8 秒最终触发 Kubernetes 存活探针超时并被强制重启容器。老年代内存占用趋势4天周期 100% | /! [Full GC 频繁触发 容器重启] 80% | /------/ 60% | /------/ 40% | /------/ 20% | /----------/ 0% -------------------------------------------------- 运行天数 (Day 1 - Day 4)现象复查与现场数据采集当监控系统再次发出老年代水位预警达到 85%时我们决定在服务发生严重 STW 之前保留一个完整的排查现场。1. 运行时 GC 状态快速观测通过连接到目标 Pod 执行jstat命令连续采样观察垃圾收集特征# 每秒输出一次 GC 统计信息 jstat -gcutil $(pgrep java) 1000 10输出样本片段显示S0 S1 E O M CCS YGC YGCT FGC FGCT GCT 0.00 98.42 45.12 88.75 96.12 92.30 4512 38.452 142 398.120 436.572 0.00 98.42 82.31 88.76 96.12 92.30 4512 38.452 142 398.120 436.572老年代占用率O持续稳定在 88% 以上且经历 Full GCFGC后几乎毫无回落说明大量对象存在强引用无法被 GC Roots 触达回收。2. 导出堆 Dump 文件在将该实例从网关负载均衡中平滑下线后通过jcmd快速导出堆内存快照# 生成堆快照 jcmd $(pgrep java) GC.heap_dump /tmp/dump_leak_service.hprofMATMemory Analyzer Tool深度溯源我们将 Dump 文件导入 Eclipse MAT 进行支配树Dominator Tree与内存泄漏疑点Leak Suspects分析。1. 支配树定位最大持有者在 MAT 的 Dominator Tree 视图中一个名为com.example.trade.context.UserSessionContextHolder的类以及伴随的java.lang.ThreadLocal$ThreadLocalMap占据了高达 68% 的堆内存空间。从支配树的持有层级往下剥引用关系非常清晰也极其致命外层的ThreadPoolExecutor作为核心常驻组件长期持有其内部的 Worker 工作线程数组而在高并发调用下这些工作线程被反复调度复用生命周期几乎与 JVM 进程等长。每个活跃线程内部持有的ThreadLocalMap因此常驻不灭由于业务处理完毕后漏掉了清理动作Map 底层的Entry[] table便死死咬住了对应的UserSessionContext包含用户 ID 与巨大的上下文载荷集合。整条链路层层强引用导致本应在单次 HTTP 请求结束时就消亡的临时对象被长生不老的工作线程牢牢拽在老年代中GC Roots 无论如何也无法判定其可回收。顺着这个线索我们进一步展开了详细的 GC Roots 溯源2. 追踪 Path to GC Roots展开ThreadLocalMap的引用链发现ThreadLocal$ThreadLocalMap$Entry中的value指向了大量堆积的业务上下文对象。进一步通过“Path to GC Roots (excluding weak references)”排查确认线程池使用的是 Tomcat 默认的工作线程池Tomcat-exec-*以及框架内部的ForkJoinPool.commonPool()。线程在处理完请求后被放回线程池复用并没有被销毁。虽然ThreadLocal本身是弱引用 Key但由于线程长久存活且其对应的value包含用户的复杂权限模型、附加元数据 Map被当前活跃线程强引用无法释放。根因代码与漏洞复现在审查涉及UserSessionContextHolder的业务代码时发现了致命的代码漏洞// 存在泄漏隐患的代码 public class UserSessionFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest (HttpServletRequest) request; String token httpRequest.getHeader(Authorization); if (token ! null) { UserSession session parseAndBuildSession(token); UserSessionContextHolder.set(session); // 绑定到 ThreadLocal } // 隐患当业务逻辑抛出未捕获的 RuntimeException 时后续代码直接中断 chain.doFilter(request, response); // 如果上面抛出异常这里的 remove() 永远不会被执行 UserSessionContextHolder.clear(); } }由于下游某个 RPC 服务偶尔返回业务异常如支付超时或序列化失败导致chain.doFilter直接抛出未捕获的异常。请求在没有执行到UserSessionContextHolder.clear()的情况下退出了过滤器残留的 Session 对象永久留在长生命周期的 Tomcat 线程的ThreadLocalMap中。随着成千上万次不同 Token 的请求被各线程处理所有工作线程的ThreadLocalMap最终全部被无效的大对象填满。生产修复与防御规范1. 严格使用 try-finally 保障生命周期针对线程上下文必须强制遵循try-finally模式确保无论发生何种异常资源都能被百分之百清理package com.example.trade.filter; import com.example.trade.context.UserSessionContextHolder; import jakarta.servlet.*; import jakarta.servlet.http.HttpServletRequest; import java.io.IOException; public class SafeUserSessionFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest (HttpServletRequest) request; try { String token httpRequest.getHeader(Authorization); if (token ! null) { UserSessionContextHolder.set(parseAndBuildSession(token)); } chain.doFilter(request, response); } finally { // 确保在请求结束时无条件清理 ThreadLocal UserSessionContextHolder.clear(); } } private Object parseAndBuildSession(String token) { // 解析业务 Token return new Object(); } }2. 启用本地缓存的有界淘汰策略如果业务中必须使用本地内存缓存临时数据严禁直接使用无界ConcurrentHashMap作为全局静态变量必须换用 Caffeine 等具备容量上限与过期淘汰机制的组件package com.example.trade.cache; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import java.util.concurrent.TimeUnit; public class LocalSessionCache { // 显式指定最大容量与写入后过期时间杜绝内存无界膨胀 private static final CacheString, Object CACHE Caffeine.newBuilder() .maximumSize(50_000) .expireAfterWrite(30, TimeUnit.MINUTES) .recordStats() .build(); public static void put(String key, Object value) { CACHE.put(key, value); } public static Object get(String key) { return CACHE.getIfPresent(key); } }3. JVM 防御参数配置在生产环境的 JVM 启动脚本中必须配置 OOM 自动 Dump 参数以便在发生意外时留下第一案发现场JAVA_OPTS -Xms4g -Xmx4g -XX:UseG1GC -XX:MaxGCPauseMillis200 -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/heapdump.hprof -XX:ExitOnOutOfMemoryError排障经验结语排查老年代缓慢泄露的核心在于看趋势重于看瞬态关注多次 Full GC 之后的老年代最低水位线Watermark。若最低水位线随时间线性抬升内存泄漏几乎不可避免。防范线程池污染线程复用是现代服务端提高吞吐量的基石但一切基于ThreadLocal的上下文绑定都必须伴随强约束的释放闭环。
返回列表