2.3 内部类与内存泄漏


文档摘要

2.3 内部类与内存泄漏 本节摘要:非静态内部类天生携带一份外部类实例引用。本节从 Android 的 Handler 经典泄漏讲到服务端定时任务里匿名 Runnable 拖住整个应用上下文的事故,说清四种内部类的引用结构、泄漏链分析方法,以及静态化 + 弱引用的修复模式。 事故现场:老年代每十分钟涨一格 一个部署在长生命周期进程里的规则引擎,监控显示老年代占用呈锯齿状缓慢抬升,每次 Full GC 后的"谷底"都比上一轮高一截——典型的内存泄漏曲线。堆 dump 用 MAT 打开,支配树(Dominator Tree)顶部的可疑对象是一个 ,它持有的队列里躺着几百个 ,每个 wrapper 都指向一个 30MB 的 。

2.3 内部类与内存泄漏

本节摘要:非静态内部类天生携带一份外部类实例引用。本节从 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$0val$(匿名类捕获的局部变量也会被生成为合成字段)就基本定案。

修复模式是"静态化 + 显式传参 + 必要时弱引用":

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() 这类写法捕获方法所属实例,效果与非静态匿名类等价。所以判断标准不看语法糖,看到底捕获了什么

⚠️ 常见坑:把"取消注册"写在被持有对象的清理方法里。回调注册在全局调度器上,清理方法却依赖对象自己被回收后调用——顺序反了,永远走不到。

💡 关键直觉:内存泄漏的本质是生命周期错配,内部类只是让错配看不见。评审长生命周期组件里的回调注册时,先问一句"这个回调会活多久,它又拖住了谁"。

防坑清单

  • 长生命周期调度器/事件总线里注册的回调,一律静态嵌套类或静态工厂 Lambda,显式传最小依赖
  • MAT 支配树 + 引用链是标准诊断路径,this$0/val$ 是铁证
  • 数据可能被刷新的任务不持有数据,改按 id 现查
  • 弱引用只在生命周期确实交叠不了时用,且配合同步清理解除注册
  • 监控上盯"Full GC 后老年代谷底"的趋势线,锯齿抬升即立项排查

本节要点回顾

  • 机理:非静态内部类实例携带 this$0 隐藏引用,拖住整个外部实例
  • 高发位:匿名 Runnable/Listener 注册进长生命周期容器(调度器、事件总线、线程池)
  • 修复:静态化 + 显式最小依赖;治本是"不持有,现查取"
  • 诊断:堆 dump → 支配树 → 引用链,找合成字段
  • Lambda 同理:看捕获了什么,不看语法糖

第 2 章收束。下一章进入高级特性区——异常、泛型、反射、Stream,每个都自带暗面。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U