一次内存泄漏问题排查和分析,小坑
做后端开发这些年,内存泄漏这种问题遇到不少,但大部分时候是那种一上来就OOM崩溃的大事故,好查也好修。真正让人头疼的,反而是那种内存悄悄上涨、看起来没毛病、跑个两三天就GG的小坑。最近我就踩了一个这样的坑,排查过程倒不算特别曲折,但里面有几个点挺典型的,写出来给大伙儿做个参考。
先交代一下背景:一个标准的Java微服务,跑在K8s集群里,堆内存设了4G,用的G1垃圾回收器。某天监控报警,说服务的内存使用率持续走高,从早上的30%一路爬到晚上90%,眼看就要触及容器限额了。但诡异的是,CPU和GC都很平稳,既没有频繁Full GC,也没有大量的GC日志报错,就是内存像滴水一样一点一点往上涨。重启之后内存能回落,但跑几天又会爬上来,典型的缓慢内存泄漏特征。
这次排查我踩的坑其实不算特别深,但过程挺有代表性:从现象定位到堆内存泄漏,一步步追到线程池里的ThreadLocal,最后发现是一个特别不起眼的业务代码细节。整个过程我可以拆成几个阶段,每个阶段都有一些值得记录的细节,今天一并说清楚。
1. 排查前期的思路:先分清“内存涨”和“内存泄漏”
很多人一看到内存上涨就急着dump堆,这其实是个误区。你要先搞清楚一个问题:当前的内存增长,到底是不是真的泄漏?
Java应用的内存占用曲线本来就应该是锯齿状的,因为Young GC和Full GC会周期性回收对象。如果你看到的内存曲线是平滑上升、垃圾回收之后也不回落,那才叫泄漏。如果曲线是锯齿状但整体水平在缓慢抬高,那也有两种情况:一种是确实有对象被人为持有,另一种是老年代正常晋升,但GC的阈值没触发Full GC,导致老年代被渐渐填满。
我第一步做的,就是先看监控曲线确认形态。这台机器的曲线非常典型:每天上午10点左右开始爬坡,凌晨2点有波谷,但波谷的高度一天比一天高。3天之后,波谷都比最初的峰值还高。这说明确实有东西没被回收,不是自然波动。
确认了泄漏方向之后,我开始盯着两样东西看:堆内存的使用率和GC情况。如果是对象持续增长,通常你会看到Young GC频率越来越高、单次GC后存活对象越来越多、或者Old Gen持续膨胀。我看了一下GC曲线,发现一个问题:Young GC频率其实没有明显变化,但每次GC之后存活下来的对象比例在变大,Old Gen的占用从开始的1.2G一路涨到2.8G。这个信息很重要,它说明有对象从新生代晋升到了老年代,而且一旦到了老年代就再也没被回收过。
提示:排查内存泄漏,第一步永远是“看一眼内存曲线和GC曲线”,别急着dump。曲线本身就能告诉你大量的信息,能少走很多弯路。
2. 抓现场:用jstat和jmap锁定泄漏方向
确认了堆内存在持续膨胀之后,第二步就是抓现场。所谓抓现场,就是拿到一个“能说明问题”的堆快照。这里面有个细节:dump的时机非常关键。
我犯过的一个小错误是,第一次dump之前没有做任何处理,结果dump出来的文件里全是正常业务对象,根本分辨不出谁在泄漏。后来我学乖了,先把应用跑上一个小时,确认内存曲线还在爬坡,然后手动触发一次Full GC(用jcmd或者jmap -histo先触发一次GC),等曲线降下来之后再记录一个“基线”,然后再跑一段时间,观察哪些对象在持续增长。
具体来说,我的操作顺序是这样的:
- 用
jstat -gcutil <pid> 1000看实时GC情况,确认老年代使用率在持续上涨; - 用
jmap -histo:live <pid>触发一次Full GC并打印存活对象直方图,记录下来; - 过5分钟再执行一次同样的命令,对比两次直方图里哪些类的实例数在增长;
- 找出增长最明显的那个类,再用
jcmd <pid> GC.heap_dump /tmp/heap.hprof生成堆快照,用MAT分析。
第一次jmap -histo:live出来的时候,我看到最前面的都是些正常的业务对象、byte数组和String,这些没啥参考价值,因为一个大对象会拆成很多数组。第二次对比时,我注意到一个名叫com.xxx.wrapper.UserContext的实例数从几百涨到了几千,而且每个实例内部都带着一大坨Map结构。直觉告诉我,疑点就在这个类身上。
这里插一句,很多人惯用的jmap -dump:live其实有个隐藏风险:它会先触发Full GC,在流量高峰期这么做容易造成业务停顿。我当时用的是jcmd的GC.heap_dump,它默认不触发GC,dump出来的文件更接近当前真实状态,对比分析起来更准。
注意:用
jmap -histo:live排查对象增长虽然好用,但它会触发Full GC,线上操作要挑低峰期,或者干脆分两次执行、中间留出足够时间,别在高峰硬刚。
3. MAT分析:顺着引用链找到“钉子户”
拿到heap dump之后,我用的工具是Eclipse MAT。这个工具在排查内存泄漏方面真的YYDS,尤其是它的Leak Suspects报告,能直接帮你圈出嫌疑对象。
我打开hprof文件(2.1G,花了一两分钟,还能接受),第一时间看的就是Leak Suspects。报告里给出了几个“可疑点”,第一个就直接指向了java.lang.Thread内部持有的ThreadLocal.ThreadLocalMap,里面挂着大量的UserContext对象。看到“ThreadLocalMap”这几个字,我心里就有数了——典型的ThreadLocal用法不当。
但光知道是ThreadLocal还不够,你得搞清楚是谁往里塞东西、什么时候塞的、为什么没清。我用MAT的Open Query Browser > Paths to GC Roots从UserContext实例出发,一步一步往上找引用链。
链路大概是这样的:
UserContext -> ThreadLocal.ThreadLocalMap.Entry -> ThreadLocal.ThreadLocalMap -> Thread在MAT的Dominator Tree里,我能看到某个线程下面挂着几百个Entry,每个Entry的value都是一个新的UserContext实例。换句话说,这个线程处理过的每一次请求,都往它自己的ThreadLocalMap里塞了一个对象,而因为线程池里的线程是长期存活的,这些对象就永远被线程对象强引用着,GC一直回收不掉。
这里有个很微妙的点:一般ThreadLocal的key是WeakReference,所以如果ThreadLocal对象本身没被强引用,key会被回收、value也能跟着被清掉。但这个案例里,key是某个静态工具类里的静态ThreadLocal<UserContext>变量,它是强引用,永远不会被回收,所以每个Entry都活得好好的,value只会增加不会减少。这就是这个坑的“小”所在——代码看着完全没问题,一个静态变量、一个set、一个get,谁能想到会漏呢。
再看堆里的对象分布,UserContext内部挂着一个HashMap,里面装着用户信息、登录态、请求参数,一个对象撑个几KB到几十KB不等。单个对象倒不大,问题是线程池有20个线程,每个线程每天要处理上万个请求,两天下来就是几十万个对象被强引用,堆内存不涨才怪。
4. 层层深入到修复:问题出在过滤器里
顺着引用链往下追,我找到了泄漏的源头——一个基于OncePerRequestFilter的过滤器,它做了这么个事情:
@Component public class UserContextFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 从请求头/Token解析出用户信息,塞到ThreadLocal里 UserContext context = parseUser(request); UserContextHolder.set(context); // 问题就出在这里:没有在finally里做清理 try { filterChain.doFilter(request, response); } finally { // 某些情况下这里不会执行到,或者根本没写这一步 } } }UserContextHolder的实现也很简单:
public class UserContextHolder { private static final ThreadLocal<UserContext> CONTEXT = new ThreadLocal<>(); public static void set(UserContext ctx) { CONTEXT.set(ctx); } public static UserContext get() { return CONTEXT.get(); } public static void remove() { CONTEXT.remove(); } }你发现问题了没?set()之后,过滤器的finally块里压根儿没调用remove()。在普通的同步请求里问题不大,因为请求走完线程就归还线程池,ThreadLocalMap还留在Thread上,但下一个请求复用这个线程时会再次 set,覆盖掉旧值,所以一个线程最多挂一个实例,看起来不严重。但这个服务有个特殊的业务路径:请求在过滤器中解析完用户信息之后,会异步提交到另一个自定义线程池去处理,也就是说,过滤器所在的Tomcat线程并没有执行完整流程就返回了,而真正干活的是业务线程池里的常驻线程。
这就要命了。
因为在异步线程池里执行任务时,代码里也调用了UserContextHolder.get()去取用户信息,也就是说,异步任务的执行线程(业务线程池里的线程)也会被 set 一次UserContext,但异步任务的结尾同样没有调用remove()。而且一个业务线程会处理成千上万个任务,每次set进去的都是一个新对象,这个线程的ThreadLocalMap就会疯狂膨胀。
排查到这一步,我基本上已经能确认完整的泄漏链路了:
- 请求进来,过滤器解析用户上下文,set到线程池A的线程上;
- 异步任务被提交到线程池B,线程B执行任务时,从ThreadLocal里get到上下文,经过业务处理,又set了新的上下文;
- 线程B是常驻线程,处理完任务后被归还给线程池,但ThreadLocalMap上的entry没有被清理;
- 下一个任务复用线程B,重新set一个对象,覆盖到map里,但之前set进去的对象并没有被覆盖掉(因为ThreadLocalMap的entry是以key为索引的,同一个key的set操作会覆盖value,注意这里会被覆盖),但那些在业务执行过程中new出来并塞进同一个key的对象,如果当时没remove,旧值就被新值覆盖了,理论上不会膨胀才对。
等等,这里我总结的时候才发现,如果只是同一个ThreadLocal key反复set,旧value会被新value覆盖,为什么实例数还在涨?
我回头仔细翻了MAT的对象分布,发现更精确的现象:膨胀的并不是同一个ThreadLocal key下的 entry,而是每次异步任务都创建了新的 ThreadLocal 对象,比如业务代码里某个工具类自己定义了ThreadLocal<UserContext>,然后set进去但没remove。这种情况下,每次set都是一个新的key,Entry就增多了。找到这个工具类之后,事情就更加清楚了:
public class TaskContext { // 这里定义了一个非静态内部类的ThreadLocal实例? // 或者每次创建了一个新的ThreadLocal对象 private final ThreadLocal<Map<String, String>> localMap = new ThreadLocal<>(); public void run() { localMap.set(new HashMap<>()); // ... 业务逻辑 // 没有remove() } }如果TaskContext是被每个任务new出来的,那么localMap就是一个新的ThreadLocal,每次set都会往线程池线程的ThreadLocalMap里新增一个Entry。于是,每处理一个任务,线程的ThreadLocalMap就多一个条目。业务线程池里有几十个线程,每个线程处理几万个任务,那就是几十万个Entry,每个Entry里还带着一个永远不会被回收的HashMap。
最终修复方案其实很简单:在异步任务执行完毕之后,显式调用localMap.remove(),或者在finally块里清理。我给这个工具类加了finally清理逻辑,同时给过滤器也补上了finally { UserContextHolder.remove(); },双保险。
修复之后的验证也很直接:重启服务,观察两天,内存曲线稳定在30%左右,调峰也有,但整体不再爬坡,老年代占用稳定,问题解决。
5. 复盘这次“小坑”背后的三个技术细节
排查完之后,我坐那儿仔细想了想,这个坑为什么会这么隐蔽,有三点值得展开聊一聊。
5.1 ThreadLocal的弱引用和强引用容易混淆
很多人有个误解,说ThreadLocal的key是WeakReference,所以ThreadLocal理论上不会泄漏。这个说法不够准确。弱引用只保证“ThreadLocal对象自身”可以被回收,但value是强引用,如果key对应的ThreadLocal被回收了,value还在,等到下一次set或get时才会把key为null的entry清理掉(ThreadLocalMap的expungeStaleEntry机制)。
但这个机制有个前提:你得继续对这个ThreadLocalMap进行get/set操作。如果这个线程后面再也不访问这个ThreadLocal了,那些value会一直挂在Thread上,直到线程销毁。如果线程是线程池里的常驻线程,那基本等于永久泄漏。
所以ThreadLocal的正确用法就一句话:凡是set过的地方,不管正常返回还是异常抛出,都要remove。
5.2 线程池复用放大了泄漏
如果这个功能用的是普通的HTTP请求线程,请求结束、线程回收,ThreadLocalMap也会被回收,泄漏根本没有放大机会。但问题是异步线程池的线程是常驻的,一次泄漏,会在同一个线程内反复累计。例子:一个线程处理5000个任务,每个任务往ThreadLocalMap里塞一个对象,这个线程就会多挂5000个对象,20个线程就是10万个。摊上大对象,内存直接就顶不住了。
所以排查方向很大程度上取决于你应用的并发模型:如果服务大量使用线程池、异步任务、xxl-job这种调度组件,那ThreadLocal水平泄漏的概率极高。
5.3 为什么复现难、前期测不出来
这个坑最坑爹的地方在于:本地跑、单测、低并发预发环境,全都看不出问题。只有并发量上去了、线程池循环使用到一定次数之后,内存才会肉眼可见地涨。因为单测时一个线程通常只处理一个任务,线程就结束了,ThreadLocalMap跟着线程一起销毁,自然没泄漏。等你上了高并发,问题才浮出来。
这也解释了为什么线上问题往往比测试环境高一到两个档次。测试环境没有足够大的线程复用压力,很多类似的“静态状态污染”问题都会被掩盖。
6. 常见问题和排查技巧实录
下面整理几个这次排查过程中问过自己、也经常被同事问到的问题,算是速查表性质的东西。
6.1 内存泄漏和内存溢出是一回事吗?
不是。内存泄漏是“该回收的对象没被回收”,内存还在慢慢被占;内存溢出是“内存确实不够用,直接抛OutOfMemoryError”。内存泄漏积累到一定程度会引发溢出,但反过来,溢出还有一种情况是某个瞬间峰值过大,比如一个大批量查询,跟泄漏无关。排查时先看曲线:平滑爬坡、不回落,基本就是泄漏;尖峰冲高、GC之后恢复,那就是瞬时压力问题。
6.2 dump文件太大,MAT打不开怎么办?
heap dump动辄好几个G,MAT默认给的内存不够会直接报An unexpected error occurred。我给个经验值:-Xmx给到dump文件大小的1.2到1.5倍,比如6G的dump,MAT启动参数改成-Xmx8192m。修改方式是改MemoryAnalyzer.ini,在文件尾部加一行-Xmx8192m。要是还不行,那就别加载全部对象了,先用jmap -histo:live拿到对象直方图粗筛一次,或者用MAT的OQL直接跑查询,只提取你关心的类路径,省的把整个堆都load进内存。
6.3 jmap执行时线上服务卡顿了怎么办?
jmap -histo:live和jmap -dump:live都会触发Full GC,如果线上是高峰期,轻则GC耗时长、重则接口超时。更安全的做法是:
- 先用
jstat -gcutil确认当前GC状态正常,再选低峰期执行; - 用
jcmd <pid> GC.heap_dump代替jmap -dump:live,它不强制Full GC; - 如果必须抓现场,按顺序:一次histo、间隔五分钟、一次dump,中间别做其他操作。
6.4 排查时发现好几种对象都在涨,怎么快速缩小范围?
我的做法是拍两轮“对比快照”。先记录此时哪些类实例数最多,过十分钟或半小时再记录一次,把两次直方图做差,涨得最快的那个类往往是泄漏点。具体可以配合jmap -histo:live | sort输出排序结果,或者写个简短的shell脚本做diff。这个办法比直接拿MAT分析大海捞针高效得多。
6.5 有没有别的容易漏掉的“小坑”,可以提前防?
结合我个人的经验,除了ThreadLocal不remove之外,还有几个高频小坑也容易造成缓慢内存泄漏:
- 静态集合当缓存用:static Map里 put 了数据就没人清理,key还得看情况,value永远被强引用;
- Socket/IO流没关:连接池里的空闲连接、未释放的流,积累起来也很要命;
- 事件监听器没反注册:像Spring的ApplicationListener,每次操作往里add一个,从不remove;
- 第三方库的内部缓存:比如某些HTTP客户端、RPC框架,会缓存路由表、类信息、响应数据,你得查它们的缓存配置和过期策略。
这类问题共性很突出:代码里每一处小疏忽,在长期运行和高并发下都会被无限放大。排查这类问题,靠的是一个系统性的认知和工具链,而不是碰运气。
这次打完收工之后,我给团队立了个小规矩:所有用ThreadLocal的代码,Code Review时重点看有没有在finally里remove;所有新增的异步任务,统一要求自定义线程池,并且在线程池的名字里带上业务标识,方便排查堆栈时一眼定位。以后再遇到内存曲线偷偷爬坡的问题,至少能少走两小时弯路。