ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Java内存泄漏的隐形元凶:线程池中的匿名内部类

2026/9/7 23:09:21 拓冰建站 浏览量
Java内存泄漏的隐形元凶:线程池中的匿名内部类 先看下面这段代码你感受一下有没有问题public class TaskService { private final ExecutorService pool Executors.newFixedThreadPool(8); private final MapString, Object cache new ConcurrentHashMap(); public void submitTask(String taskId) { pool.execute(new Runnable() { Override public void run() { // 模拟处理任务 String data cache.get(taskId); process(data); } }); } }我见过不少线上服务的内存就是这样一点一点涨上去的直到某个深夜触发Full GC老年代回收不掉直接OOM。很多人第一反应是去看缓存、看大对象、看SQL很少有人会怀疑这段人畜无害的匿名内部类代码。但问题恰恰就藏在new Runnable() {}这个写法里——它不是一个普通对象它是一个持有外部类引用的定时炸弹。这篇文章想把这件事讲透为什么非静态内部类会持有外部类的引用为什么它一旦进了线程池就被放大成内存泄漏以及线上出了问题之后怎么一步步把证据链找出来。1. 一段看上去没什么问题的代码如何让服务在深夜OOM1.1 任务提交的完整链路里谁在长期存活我先说结论new Runnable() {}创建的匿名内部类对象会隐式持有TaskService实例的引用。当你把它提交到线程池这个Runnable会被封装成Worker任务放进线程池的任务队列里。如果任务很快执行完Runnable从队列中移除引用链断开GC可以正常回收一切无事发生。但问题出在两个场景场景一任务执行很慢。比如process(data)里有一段耗时的RPC调用或者数据量大导致处理时间很长。这时候Worker线程正拿着这个Runnable执行外部类实例TaskService被它牢牢拽住。如果TaskService本身又持有cache这种大对象那这坨内存就全部无法回收。场景二任务排队很长。固定线程池的队列是无界的LinkedBlockingQueue默认容量是Integer.MAX_VALUE。当提交速度大于处理速度任务会在队列里堆积。堆积多久取决于队列消费速度可能几十秒、几分钟甚至一直堆积。每一个堆积的Runnable都携带着外部类引用积少成多老年代就被这些看不见的引用撑满了。1.2 为什么用new Thread()不会泄漏用线程池会这里有个很关键的区别。如果我把上面的代码改成public void submitTask(String taskId) { new Thread(() - { String data cache.get(taskId); process(data); }).start(); }就算这个线程执行得再慢最多也就是这个线程自己占着TaskService引用不释放。但线程执行完run()方法后线程对象和Runnable对象的引用就断了GC能回收。即使执行期间外部类引用被持有也只是单个线程的暂存引用不会造成持续累积。线程池则完全不同。池里的线程是长期存活的它们不会被回收。也就是说任务对象一旦被提交进线程池就进入了一个永生容器。只要任务没有被执行并移除它和它引用的所有对象都算作GC Roots可达哪怕业务上这个任务已经没有意义了GC也动不了它。一句话总结内部类提供隐形的强引用线程池提供永生容器两者凑在一起就是内存泄漏的完美温床。2. 非静态内部类为什么是隐形的强引用持有者2.1 编译器的小动作为每个构造方法偷偷加参数很多Java开发者知道非静态内部类持有外部类引用但不知道为什么也没亲眼验证过。我建议你亲手做一次实验十秒钟就懂。写一个最简单的类public class Outer { private int value; class Inner { public Inner() { System.out.println(inner created); } } }编译之后在class文件所在目录执行javap -p Outer$Inner.class你会看到类似这样的输出class Outer$Inner { final Outer this$0; public Outer$Inner(Outer); }注意两个信息编译器自动给内部类加了一个字段this$0类型是Outer修饰符是final——强引用不可变。构造方法不是无参的而是Outer$Inner(Outer)参数就是外部类实例。所以你写new Inner()的时候编译器在背后做的是new Inner(outerInstance)外部类引用被强有力地塞进了内部类对象里。从字节码层面看非静态内部类对象必然持有外部类实例没有任何例外。2.2 匿名内部类也一样别侥幸有人觉得自己从不用这种内部类只用匿名内部类。抱歉匿名内部类本质上就是内部类的一种特殊形式编译器同样会生成this$0字段同样持有外部类引用。用上面TaskService的例子验证编译后看TaskService$1.classjavap -p TaskService$1.class输出照样是class TaskService$1 implements java.lang.Runnable { final TaskService this$0; TaskService$1(TaskService); }我见过不少项目组排查内存泄漏查了半天缓存和大集合最后把Class文件拖进反编译工具才发现——罪魁祸首是一堆匿名Runnable、匿名Comparator、匿名回调。它们每一个都默默攥着外部类实例的强引用。2.3 可达性分析GC判定活还是不活说到GC为什么回收不掉就必须提一下JVM的可达性分析算法。简单说JVM从GC Roots线程栈的局部变量、静态变量、JNI引用等出发沿着对象引用链往下走所有走得到的对象都是可达的不会被回收走不到的就可以回收。那么一个匿名Runnable被提交到线程池之后它的引用链是这样的GC Roots (Worker线程) └─ Thread对象 (线程池里的工作线程) └─ Worker (ThreadPoolExecutor.Worker) └─ firstTask / 队列中的Runnable └─ this$0 (TaskService实例) └─ cache (Map) └─ 大量缓存对象这条链每一环都是强引用没有一个环节能断掉。所以TaskService、cache、以及cache里的所有对象全都变成了老而不死的常驻对象。你以为任务执行完就完事了实际上只要Worker线程还活着线程池不关它会一直活着这条链就一直存在。3. 线程池的角色一个长生命周期容器如何让泄漏变成必然3.1 线程池线程与普通线程的宿命差异第1节提到了线程池和new Thread()的区别这里我再往深挖一层因为很多讲内存泄漏的文章都把这层含糊带过了。new Thread()创建的线程执行完run()方法后线程自动结束JVM会清理掉线程栈、线程对象以及线程持有的所有局部引用。它是一个短生命周期容器任务执行完毕引用链随之断裂。线程池里的Worker线程不一样。ThreadPoolExecutor的Worker会进入一个while (task ! null || (task getTask()) ! null)的循环不断从队列里取任务执行。取不到任务时线程会阻塞在getTask()上等待但它本质上还活着还停留在任务队列的等待逻辑里。这个线程从创建到线程池关闭全程都在GC Roots的可达路径上。所以线程池里的任务对象生命周期不是任务执行完就结束而是一直延续到线程池关闭。只要你不调用shutdown()它就一直在。3.2 发生在任务队列里的慢速泄漏有些场景任务明明很快就执行完了为什么还会OOM答案是泄漏不需要任务永远存活只需要任务存活的时长大于内存分配的速度。假设你的接口每秒接收1000个任务每个任务在队列里排队5秒、执行2秒那么任意时刻队列里都有几千个任务。每个任务Runnable都引用着一个TaskService实例及其内部的缓存Map。假如Map里存的是几十MB甚至上百MB的数据几千个任务就是几十GB的引用囤积量。而老年代的空间有限撑不了几轮就满了。更隐蔽的是慢速这个词。它不是一次性爆发的而是随着时间推移每天涨一点涨了七八天才触发OOM。这中间可能有无数次的Young GC把新建对象清掉但队列里这些任务对象因为尚且存活会逐渐从新生代晋升到老年代。一旦进入老年代它们和外部类、缓存Map一起构成一个巨大的对象图Full GC扫过它们的时候发现全都被Worker线程引用着只能放弃回收。于是每次Full GC之后老年代使用量几乎不降这就是典型的GC后内存纹丝不动的泄漏特征。3.3 从热词线程池参数合理配置带出的思考顺带说一个和泄漏相关的热门问题线程池参数怎么配才合理。很多人的配置标准是吞吐量高不高队列长不长但很少有人从GC角度考虑过。根据我的经验参数配置至少要影响两个和内存泄漏直接相关的点队列长度。LinkedBlockingQueue无界队列看似不会拒绝任务实际上等于允许任务无限堆积。任务堆积不仅仅是业务延迟问题更是内存占用问题——每一个排队任务都携带引用。如果你用的是有界队列任务堆积到上限就会触发拒绝策略反而不会让内存无限膨胀。核心线程数和最大线程数。如果核心线程数设置得很小而提交量又大线程池会自动走先入队队列满再开新线程的流程。在任务没有消费完之前所有入队对象都处于长期存活状态。当然线程池参数是个大话题这里不展开。我只想说配置线程池时除了看吞吐和响应时间也应该把持有引用的任务对象是否可能堆积作为考虑因素之一。4. 现场排查从GC日志到MAT的完整取证链路如果真的遇到了线上OOM或者老年代持续增长我建议按下面这条链路来排查。每一步都有明确目的不是瞎翻堆。4.1 第一步开GC日志看老年代曲线排查内存问题第一步永远不是直接抓堆而是先看GC日志。因为GC日志能告诉你内存增长的节奏、GC的频率、Full GC之后老年代是否回落。JVM参数里加上以JDK8为例-Xloggc:/path/to/gc.log -XX:PrintGCDetails -XX:PrintGCDateStamps如果是JDK11可以换成-Xlog:gc*:file/path/to/gc.log:time,uptime,level,tags观察老年代Old Gen的曲线。正常情况下Full GC之后老年代使用量会明显下降如果每次Full GC之后老年代几乎不降甚至持续走高基本可以判定存在留驻内存泄漏——对象始终被引用GC无法回收。4.2 第二步抓堆转储文件别等进程死掉确认疑似泄漏后用jmap抓堆转储。注意避免在OOM边缘去抓因为进程可能马上挂掉或者抓dump的过程本身占用内存导致更快OOM。建议在内存使用率稳定偏高的时段抓jmap -dump:live,formatb,file/path/to/heap.bin pid提示如果不知道进程PID用jps -l找Java主类对应的PID。如果是容器环境注意看是容器内PID还是宿主机PID。如果进程已经OOM了可以让JVM在OOM时自动输出堆转储-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/path/to/heap.bin这样至少能留下案发现场。4.3 第三步MAT里查Dominator Tree快速锁定大对象拿到堆转储文件后用Eclipse MAT打开。我个人用得最多的是Dominator Tree视图——它会按支配对象大小排序让你一眼看到哪些对象占据了堆内存大头。打开方式Open Heap Dump→ 选择Dominator Tree。在这个视图里你会很可能会看到一个非常显眼的类比如java.util.concurrent.ConcurrentHashMap或者某个业务Service对象占着几百MB甚至几个GB。但注意——不要只看它占了多少要右键它选择Merge Shortest Paths to GC Roots→exclude weak/soft references。这个操作会展示从GC Roots到该对象的最短引用链。如果引用链里出现了ThreadPoolExecutor$Worker或者Thread那证据链基本就闭环了这个业务对象是被线程池的Worker线程以强引用方式拽住的而拽住它的源头就是某个Runnable/内部类对象。4.4 第四步用OQL确认内部类实例的数量MAT还提供了一个OQL查询功能可以直接统计某个类的实例数量。比如我想确认TaskService$1这种匿名Runnable到底有多少个实例SELECT count(*) FROM com.example.TaskService$1执行结果如果显示的实例数量很大比如上万个那基本可以实锤有大量匿名Runnable对象堆积在线程池里。继续查它们的引用关系指向的正是同一个TaskService实例。这一步很重要因为只看到一个大对象还不够你得能说明它为什么还活着而OQL能帮你数出有多少个内部类实例在排队这个精确证据。4.5 第五步用jstack对照线程状态有时堆转储文件里看不到任务排队数量因为dump那一刻队列可能已经被消费掉了。这时候可以配合jstack看线程栈jstack pid重点看线程名含pool-前缀的线程观察它们停在哪个方法上。如果大量线程停在某个业务process()方法上说明业务处理耗时过长导致任务堆积——这时候即使内部类引用链没问题你也得优先解决处理慢的问题。把GC日志、堆转储、线程栈三份数据放在一起排查结论就有了支撑不管是自己改代码还是跟团队汇报都有理有据。5. 修复方案与取舍静态内部类、弱引用与Lambda的边界5.1 方案一最推荐把内部类改成静态内部类这是最直观也最彻底的方案。静态内部类不持有外部类引用它和外部类只在代码组织层面有关系在对象引用层面没有任何关系。上面的TaskService改成这样public class TaskService { private final ExecutorService pool Executors.newFixedThreadPool(8); private final MapString, Object cache new ConcurrentHashMap(); public void submitTask(String taskId) { pool.execute(new TaskRunnable(this, taskId)); } private static class TaskRunnable implements Runnable { private final TaskService service; private final String taskId; TaskRunnable(TaskService service, String taskId) { this.service service; this.taskId taskId; } Override public void run() { String data service.cache.get(taskId); process(data); } } }注意静态内部类还是要通过构造参数拿到TaskService引用但只要不传它就不持有。这里有个设计上的关键点如果你的任务真的不需要访问外部类的成员那就不要传外部类引用进去让Runnable变成完全独立的任务描述对象和外部类彻底切断关系。有人会问我改成静态内部类但run方法里还是要用外部类的cache成员怎么办 那就按上面写法把需要的数据通过构造参数传进来不要整体传this。最合适的做法是只传递执行任务所需的那些数据比如taskId、必要的配置值。任务对象尽可能轻它在线程池里存活多久就只占用多少内存。5.2 方案二用Lambda替换匿名内部类但别踩捕获的坑Java 8之后我们可以用Lambda替代很多匿名内部类场景。上面的submitTask可以改成pool.execute(() - { String data cache.get(taskId); process(data); });Lambda表达式不会像匿名内部类那样无条件持有外部类引用它只有在捕获外部变量的时候才会持有这些变量。这里捕获了cache、taskId所以仍然隐式持有了TaskService实例的引用因为cache是它的成员变量本质上还是需要通过this去访问。所以Lambda也不是万能的。它的优点是不会产生额外的Class文件TaskService$1.class这种但引用捕获的规则和匿名内部类类似——只要用到了外部类的成员变量就逃不开持有外部类实例的宿命。用Lambda最安全的方式是在Lambda内部完全不引用外部类的成员变量和方法只依赖外部传入的参数。比如pool.execute(new TaskRunnable(taskId, cache));或者更干脆pool.execute(() - processTask(cache, taskId));把对成员cache的访问改成从方法参数传入这样Lambda只捕获参数不捕获外部类实例。5.3 方案三弱引用能解决吗聊聊适用边界看到内部类持有外部类引用有人会想到用WeakReference把外部类包一层private static class TaskRunnable implements Runnable { private final WeakReferenceTaskService serviceRef; TaskRunnable(TaskService service) { this.serviceRef new WeakReference(service); } Override public void run() { TaskService service serviceRef.get(); if (service ! null) { // 使用service } } }这个方案不是万能的甚至很多场景下不推荐。原因有两个第一任务还在队列里排队时你没法保证外部类一定不被回收。如果外部类被回收了任务执行时serviceRef.get()返回null任务逻辑就跪了。对于任务必须执行成功的场景这是不可接受的。第二WeakReference适合的场景是有它更好没它也能活——典型的如缓存、监听器列表。但线程池里的任务通常是业务必须执行的你不能用弱引用顺便指一下外部类这种方向来设计否则任务执行时引用失效业务受损。所以我的立场是弱引用更多是面试里的讨论题实际业务代码里能用静态内部类解决的就别绕远路。如果确实需要弱引用那一定要处理get()返回null时的兜底逻辑并且要想明白外部类已被回收这个事实是否影响任务执行结果。5.4 方案四任务执行完主动断链不推荐但值得知道有人可能想着在Runnable的run()方法最后把this$0置null。但因为this$0是final的你改不了。退一步你可以把外部类引用变成自己的字段执行完后置nullprivate TaskService service; Override public void run() { try { // 业务逻辑 String data service.cache.get(taskId); process(data); } finally { service null; } }注意这个方案的问题在于任务执行完之前service强引用依然存在。如果任务长时间排队泄漏照旧。而且如果任务抛异常导致finally没执行不太可能但并发场景下逻辑复杂引用还是断不掉。所以它只适合当最终手段来用不是根治方案。5.5 各方案对比方案是否解决隐患编码成本适用场景静态内部类不引用外部类彻底解决低但要重构run方法绝大多数业务场景最推荐静态内部类传必要参数解决但看参数设计中任务需要访问外部数据时推荐Lambda只捕获参数解决低Java 8项目推荐Lambda捕获外部类成员不解决低不推荐容易踩坑WeakReference包一层部分缓解可能失效中监听器/缓存类场景业务任务不推荐run结束后手动断链部分缓解排队期无效低不推荐作为根治手段6. 同类隐患会隔空持有外部引用的其他场景排查完内部类线程池这个坑之后你会发现一个更普遍的规律只要是长生命周期容器里放了短生命周期对象这个短生命周期对象又持有大对象引用就很容易变成内存泄漏。线程池只是其中一种容器下面这些场景同样要警惕。6.1 FutureTask的幽灵持有ExecutorService.submit(Callable)会返回一个FutureTask。如果你把FutureTask对象妥善保存比如存进某个List或Map并且迟迟不调用get()那么FutureTask内部的Callable对象会一直存活。这个Callable如果也是匿名内部类写的同样持有外部类引用。尤其做异步编排的时候容易把一堆Future收集到一个集合里等待结果。如果任务一直不完成集合里的FutureTask、Callable、以及它们引用的对象都会被长期持有。我见过一个批量处理的服务就是因为Future集合销毁不及时内存被撑爆。6.2 ThreadLocal的线程绑定问题ThreadLocal本身不持有外部类实例但线程池场景下ThreadLocal变量在线程池线程上有个经典问题线程池的线程是复用的一个线程处理完任务A后如果没有显式remove()ThreadLocal里的值就还留在线程上。下一个任务B执行时如果代码里用同一个ThreadLocal的Key拿到的可能是任务A留下的旧值。放到内存泄漏语境里问题在于线程池线程长期存活ThreadLocal里的值也会跟着长期存活。如果你在ThreadLocal里放了一个大对象比如某个业务上下文、某个大集合、甚至某个内部类实例那这个对象就等同于被线程永续持有GC完全收不掉。业界流传的用ThreadLocal一定要remove根源就在这。它和内部类泄漏是两类不同机制但都指向同一个教训长生命周期容器里的共享状态必须自己管理生命周期不能指望GC。6.3 监听器/观察者模式的忘记解绑注册监听器也是一个典型场景。很多框架的事件监听器、回调接口注册时传的是匿名内部类或Lambda。如果这个监听器被注册进了全局单例的事件总线里而外部类实例本身应该被销毁那么只要事件总线还活着监听器就一直持有外部类引用。最典型的例子是GUI程序窗口关闭了但因为监听器还注册在某个全局对象上窗口对象永远不能被回收。这个场景的修复思路和线程池类似在不需要监听的时候务必调用removeListener解绑或者在事件总线里用WeakReference存监听器引用。后者的可行性比线程池场景高因为监听器被回收后最多是收不到事件不影响系统核心功能。7. 写在最后关于排查工具和代码习惯的几个经验这篇文章的核心就一句话内部类会隐形持有外部类引用线程池会让这个引用无限期存活两者叠加就是内存泄漏。但我最后想分享的不只是这个结论本身而是我在排查这些问题时养成的一些代码习惯。第一写线程池任务之前先问自己一句这个任务对象会活多久 如果答案是不确定或者可能要排队那就要谨慎使用匿名内部类。改成静态内部类或者只捕获必要参数的Lambda成本极低但能避免线上事故。第二排查内存问题别一上来就翻堆。先看GC日志确认是不是Full GC后不回收的形态再看线程栈确认有没有任务积压最后用MAT/Dominator Tree去定位具体引用链。按这个顺序每一步都能缩小范围不会把自己淹在几千个类里。第三排查到最后发现元凶往往不是某个巨大的对象而是一大群不起眼的小对象——比如堆里躺着几万个匿名Runnable。这类问题最难发现因为单个Runnable只占几十字节但乘上时间、乘上排队数量数量级就完全变了。这也是为什么我强调一定要做OQL统计用数字说服自己。第四GC日志和堆转储的开关最好一直开着尤其是压测环境和预发环境。线上出了内存问题再临时加参数往往已经错过最佳取证时机。养成习惯每次上线前检查JVM参数把-XX:HeapDumpOnOutOfMemoryError当成标配。这个坎跨过去之后你会对Java的引用机制、JVM的可达性分析、线程池的底层模型有一个更立体的认知。以后再看到别人代码里随手写一个new Runnable() {}往线程池里丢你就知道这颗雷埋在哪了。