2.3 内部类与内存泄漏 本节摘要:非静态内部类天生携带一份外部类实例引用。本节从 Android 的 Handler 经典泄漏讲到服务端定时任务里匿名 Runnable 拖住整个应用上下文的事故,说清四种内部类的引用结构、泄漏链分析方法,以及静态化 + 弱引用的修复模式。 事故现场:老年代每十分钟涨一格 一个部署在长生命周期进程里的规则引擎,监控显示老年代占用呈锯齿状缓慢抬升,每次 Full GC 后的"谷底"都比上一轮高一截——典型的内存泄漏曲线。堆 dump 用 MAT 打开,支配树(Dominator Tree)顶部的可疑对象是一个 ,它持有的队列里躺着几百个 ,每个 wrapper 都指向一个 30MB 的 。
本节摘要:非静态内部类天生携带一份外部类实例引用。本节从 Android 的 Handler 经典泄漏讲到服务端定时任务里匿名 Runnable 拖住整个应用上下文的事故,说清四种内部类的引用结构、泄漏链分析方法,以及静态化 + 弱引用的修复模式。
一个部署在长生命周期进程里的规则引擎,监控显示老年代占用呈锯齿状缓慢抬升,每次 Full GC 后的"谷底"都比上一轮高一截——典型的内存泄漏曲线。堆 dump 用 MAT 打开,支配树(Dominator Tree)顶部的可疑对象是一个 ThreadPoolExecutor,它持有的队列里躺着几百个 TaskWrapper,每个 wrapper 都指向一个 30MB 的 RuleContext。
代码里的问题只有一行结构:
public class RuleEngine { private final Context context = new Context(); // 30MB 规则数据 public void schedule(String ruleId) { scheduler.scheduleAtFixedRate(new Runnable() { @Override public void run() { apply(ruleId, context.get(ruleId)); // 匿名类默认捕获外部 this } }, 0, 10, TimeUnit.MINUTES); } }
这个匿名 Runnable 是非静态的,编译器给它生成了一个指向 RuleEngine.this 的隐藏字段。定时任务周期十分钟,队列里同时存在多个待执行任务,每个都通过 this$0 拖着整个 RuleEngine,而 RuleEngine 又拖着 30MB 的 RuleContext。任务不断堆积,泄漏就呈线性增长。
Java 的内部类家族有四种,引用关系各不相同:
| 种类 | 语法位置 | 持有外部引用 | 典型用途 | 泄漏风险 |
|---|---|---|---|---|
| 静态嵌套类 | static class | 否 | 工具/构建器 | 低 |
| 内部类(成员级) | class | 是 | 迭代器、视图 | 中 |
| 局部类 | 方法内 | 是(捕获实例) | 临时辅助 | 中 |
| 匿名类 | new 接口() {...} | 是(除静态上下文) | 回调、监听 | 高 |
规则只有一句话:编译器为每一个非静态内部类实例生成指向外部实例的隐藏引用(字节码里叫 this$0)。只要内部类实例活着,外部实例就别想被回收。Android 那个著名事故——Handler 匿名内部类持有 Activity,消息在队列里排队十分钟,Activity 早就该销毁却回收不了——和服务端这个定时任务是同一个骨架:短命对象被长命对象引用,长命对象又被匿名类拖住。
确认手段是堆分析三步:dump 堆 → MAT 支配树找大对象 → 沿引用链(Reference Chain)向上找 GC Root。看到链上有 this$0 或 val$(匿名类捕获的局部变量也会被生成为合成字段)就基本定案。
修复模式是"静态化 + 显式传参 + 必要时弱引用":
public class RuleEngine { private final Context context = new Context(); public void schedule(String ruleId) { scheduler.scheduleAtFixedRate(new ApplyTask(ruleId, context), 0, 10, TimeUnit.MINUTES); } // 静态嵌套类:不捕获外部实例 只持有真正需要的字段 private static final class ApplyTask implements Runnable { private final String ruleId; private final Context context; ApplyTask(String ruleId, Context context) { this.ruleId = ruleId; this.context = context; } @Override public void run() { /* apply(ruleId, context) */ } } }
注意这个改法没有消除泄漏,只是把隐式依赖变成显式依赖:任务依旧引用 Context,但引用宽度从"整个 RuleEngine"缩到"真正需要的 Context"。如果任务生命周期可能超过数据生命周期(比如缓存刷新任务拿着旧配置跑),就要用弱引用断开强链,或改成任务执行时按 id 重新查最新数据——不持有,而是每次取,这是治本。

Lambda 是否有同样问题?有,但形式不同:Lambda 捕获的是用到的局部变量/字段,不用 this 就不捕获 this。但 engine -> engine.apply() 这类写法捕获方法所属实例,效果与非静态匿名类等价。所以判断标准不看语法糖,看到底捕获了什么。
⚠️ 常见坑:把"取消注册"写在被持有对象的清理方法里。回调注册在全局调度器上,清理方法却依赖对象自己被回收后调用——顺序反了,永远走不到。
💡 关键直觉:内存泄漏的本质是生命周期错配,内部类只是让错配看不见。评审长生命周期组件里的回调注册时,先问一句"这个回调会活多久,它又拖住了谁"。
this$0/val$ 是铁证this$0 隐藏引用,拖住整个外部实例第 2 章收束。下一章进入高级特性区——异常、泛型、反射、Stream,每个都自带暗面。