"匿名内部类会隐式持有外部类引用,容易导致内存泄漏"是老生常谈,但这篇真实实验挖出了一个更容易被忽视、也更违反直觉的真相:即便按套路"清空持有它的集合",外部对象也不一定立刻能被回收——这和有没有被匿名类捕获,其实是两件事。
上一篇:数组协变真实验证
非静态内部类(包括匿名内部类)为了能访问外部实例的成员,编译器会自动给它加一个隐藏字段。用反射把这个字段真实打印出来。
AnonymousClassLeakDemo.java Runnable innerTask = new Outer().makeInner(); Field[] fields = innerTask.getClass().getDeclaredFields(); for (Field f : fields) { System.out.println(f.getName() + " : " + f.getType().getSimpleName()); }
匿名类字段: this$0 类型: Outer
真实结果确认了这个说法不是猜测:非静态内部类(包括这里的匿名 Runnable 实现)在编译后,class 文件里真实多出了一个名叫 this$0 的合成字段,类型就是外部类 Outer。它是编译器自动插入并在构造时自动赋值的,Java 源码里完全看不到,但它是一个货真价实的强引用字段——只要这个匿名类的实例还活着,Outer 实例就不可能被 GC 回收。
用 WeakReference 追踪 Outer 实例,把它的匿名内部类实例塞进一个静态 List,验证 GC 是否真的回收不了它。
AnonymousClassLeakDemo.java static List<Runnable> holder = new ArrayList<>(); Outer o = new Outer(); holder.add(o.makeInner()); WeakReference<Outer> ref1 = new WeakReference<>(o); System.gc();
场景1 - Outer 被匿名内部类间接持有,holder 未清空时 GC 后仍存活: true
这一步符合预期:静态集合 holder 强引用着匿名内部类实例,匿名内部类实例又通过 this$0 强引用着 Outer,构成一条完整的强引用链,GC 真实无法回收——这正是"匿名内部类持有引用导致内存泄漏"这个说法最常被举例的场景。
按常见的说法,清空 holder 之后强引用链断了,Outer 应该能被回收。真实实验的结果并不这么干脆。
AnonymousClassLeakDemo.java holder.clear(); System.gc(); System.out.println(ref1.get() == null); // 期望 true(已回收)
场景2 - 清空 holder 后 GC,Outer 是否已被回收(get()==null): false 场景3 - lambda 不捕获 Outer,即使 holder2 未清空,Outer 是否已被回收: false holder2.size()=1
把两组真实数据放在一起,才是这篇实验真正的价值。
匿名内部类隐式持有外部引用(this$0)这件事本身是真实的,它确实是很多内存泄漏案例的根源——尤其是当这个内部类实例被塞进一个生命周期比外部对象长得多的静态集合、缓存、监听器列表时,泄漏是真实发生的。但这篇实验同时真实证明了另一件容易被混淆的事:仅仅"源码上的代码块结束了"或者"清空了持有它的集合",并不能保证对象立刻能被回收——只要当前方法还没执行完,而且局部变量在后续字节码里理论上还可能被访问到,它就仍然是一个活跃的 GC 根。这两个现象经常被搅在一起归因,真实实验把它们拆开了。
每一行都是真实反射或真实运行这段代码得到的结果。