置空引用并不能帮助垃圾回收
将引用置空无助于垃圾回收
我经常遇到的一个误解是:在Java中将引用置空有助于垃圾回收。这种观点尤其常见于那些习惯了C/C++的开发者,他们习惯于将delete ptr变成ref = null。公平地说,这是一种合理的想法。
但这种想法也是错误的,而理解它错在哪里,就能让你了解追踪式垃圾回收器实际工作原理的大部分重要内容。
也就是说,在追踪式垃圾回收运行时中分配的数据,绝大多数在变量超出作用域的那一刻就变得可回收了。
回收器并不会删除任何东西
让我们来看看"垃圾回收"这个名称,因为问题就出在这里。"垃圾回收"这个词给人留下的心理模型是:一旦某个对象不再被使用,垃圾回收就会释放该对象占用的内存,使其可被重新利用。
虽然这确实是垃圾回收器所做的事,但它完成这件事的方式并非名称所暗示的那样。现代JVM的回收器被称为复制回收器。复制回收器会追踪存活引用,将存活数据从一个内存池复制到另一个内存池。
当所有存活数据都从"from"内存池复制到"to"内存池后,"from"内存池就被视为已清空并可重新使用。我更愿意将这个过程称为"存活对象收割"而非"垃圾回收"。"存活对象收割"更准确地描述了垃圾回收器实际在做的事情。
一旦你开始将垃圾回收理解为存活对象收割,你就会迅速意识到回收器并不会删除任何东西。相反,它通过追踪存活引用来保留所有它能触及的数据。数据作用域对垃圾回收的影响,是理解为什么ref = null没有帮助的关键。
大多数对象"早夭"
当应用程序消耗的内存累积达到某个阈值时,就会触发垃圾回收。一旦触发,垃圾回收器首先找到所有GC根(垃圾回收根),然后开始清理内存。
GC根是指向被回收内存池之外、但指向该内存池内数据的一个指针。符合这种描述的指针被称为外部指针,并且根据定义,它是一个活跃的GC根。在JVM中查找外部指针的过程被称为"根扫描"。
为完整起见,内部指针是指完全存在于某个内存池内部的指针。图1展示了外部指针和内部指针。
虽然GC根存在于多个JVM数据结构中,但本例中感兴趣的数据结构是Java栈。让我们深入探究Java栈的工作原理,以理解它们在对象存活判定中的作用。下面是一段供参考的代码。
清单1. 用于探索主线程栈的示例代码
在JVM中,栈是每个线程私有的。相比之下,Java堆中的数据由所有线程共享。当JVM的主线程执行上述代码时,它会为每个方法调用在栈上压入一个栈帧。
栈帧会保存传递给方法的参数、所有局部变量以及返回值。只有Java原始值或引用会被压入栈中。
上面的栈包含一个单独的栈帧。帧内有一个局部变量数组和一个操作数栈;其中的每个槽位都存放一个Java原始值或一个引用。
引用是指向Java堆上数组或对象的指针,可以被传递、赋值或比较,但永远不会被检查或直接操作。上面的帧处于初始状态:局部变量数组为args和stackExample各保留了一个槽位。args槽持有对堆上String[]的引用,而stackExample尚未设置。
随着代码执行,new StackExample() 将被执行。在将新实例赋值给stackExample之前,该实例的引用会存在于操作数栈上。至关重要的是,必须始终在栈中保留这些引用,即使引用被复制,也不能丢失它们。
在图3中,我们可以看到调用链 main() -> runit() -> call1() 的栈状态。call1 中的代码已全部执行完毕,返回值(对 output 的引用)已被压入操作数栈。
从作用域的角度看,栈帧中的所有变量都是创建该栈帧的方法的局部变量。因此,当该方法返回时,栈顶将被重新定位到调用链中上一个方法的顶部,所有局部变量都将超出作用域。
从垃圾回收的角度看,栈中的所有引用都是外部指针,根据定义就是GC根。因此,当垃圾回收器扫描栈时,它需要知道当前栈顶的位置。所有位于栈顶之下的引用都会被加入GC根集合,而位于栈顶之上的引用则不会被访问。
在典型的Java应用程序中,绝大多数引用都属于局部作用域。考虑到栈每秒会变化数百万次,绝大多数局部引用从未被垃圾回收器看到过。实际上,这正是观察到大多数对象"早夭"的原因,也是"弱分代假设"的基础。
你可能会想:既然栈帧每秒变化数百万次,垃圾回收器如何获得一个稳定的GC根集合?答案是,它利用一种称为安全点的机制短暂暂停执行。对于并发回收器,这种稳定视图不会持续太久,但这并不重要。
重要的是要知道,已经超出作用域的引用不可能神奇地重新回到作用域,而且我们可以允许并发回收器在单个GC周期中将某些对象视为存活,同时知道这些内存会在未来的周期中被回收。由于这些数据通常只占堆的一小部分,考虑到好处,这种"浪费"是可以接受的。
ref = null 是死代码
既然我们已经简要探讨了垃圾回收器如何与线程栈和局部变量交互,那么显而易见:当一个引用即将超出作用域时,将其置为null是没有必要的。这对垃圾回收器没有任何帮助。内存泄漏并不是忘记释放,而是垃圾回收器仍然可以到达的对象——一个不断增长的静态集合、一个注册后从未注销的监听器、一个从未被淘汰的缓存条目。对象之所以存活,是因为根据可达性规则,它确实仍然存活。找到引用链以及导致数据保留时间超过语义有用时间的逻辑,才是修复的第一步。
简而言之,ref = null 是对一个更深层次问题的创可贴式修复,而这个问题本不应出现在应用程序的代码库中。你可能会问,这是真的吗?因为"从不"是一段很长的时间,在这段时间里你很可能会遇到一些例外情况。还记得前面提到过的弱分代假设吗?事实证明,基于这个假设来优化垃圾回收是如此强大,以至于不容忽视。
何时 ref = null 有帮助
弱分代假设告诉我们,大多数对象的存活时间非常短暂。这就是现代Java堆被组织成不同分代空间的基础。也就是说,这些空间被设计成按数据年龄来组织。
通常情况下,每个内存池是单独GC的,但在某些情况下,它们可能会被合并在一起同时回收。在前一种常见情况下,其他每个空间都会成为被回收空间的一个根来源。在极少数情况下,这会导致一种称为"对象裙带关系"的退化状态。
当一个碎片化的数据结构(如LinkedList)的各个部分跨越多个内存空间时,就会发生对象裙带关系。在这种情况下,将引用置空可能对垃圾回收产生积极影响。话虽如此,除非存在某种病理状态阻止内存回收,否则最终所有内存都会被回收。
如果你发现内存消耗随时间增长,那么你的应用程序正遭受某种数据生命周期逻辑错误,我们通常称之为内存泄漏。虽然 ref = null 可能是一个容易的战术性破解方法,但最好还是修复导致泄漏的错误逻辑。在对象裙带关系中,ref = null 是一个信号,表明:我已经用完这个碎片化数据结构的这一部分了。让我们考虑LinkedList的情况。
LinkedList是一个碎片化的数据结构,由一个头节点和一系列节点组成,这些节点通过链连接在一起。如果我们使用LinkedList作为FIFO队列,添加节点会添加到尾部,但取出节点会从头部取出。如果我们先构建队列再取出,那么头节点很可能最终进入老年代,而最近添加的节点可能仍然留在新生代。如果我们执行一系列取出操作,就有可能发生这样的情况:LinkedList的头部指向新生代中的一个节点,而取出的节点也指向一个节点链,该链最终又指向新生代中的那个节点。这会导致老年代中的两个节点成为新生代回收的GC根。
因此,垃圾回收器将到达新生代中的所有僵尸节点。最终,这种状况会在老年代被回收时自行解除。然而,这可能导致回收器执行比预期更多的老年代回收。在这种情况下,添加 next = null 可以打破裙带关系,使一切得到清理。但如前所述,这是一个特殊情况,你不太可能遇到。
最后一点:所有节点都指向另一个对象,通常正是那个对象消耗了大量内存,而节点本身只是一个薄包装器,并不消耗太多内存。
结论
要点很简单。作为方法返回的一部分,栈顶会被调整,使得当前栈帧不再处于作用域内。通过这一调整,所有指向存储在该栈帧中数据的局部引用都会丢失。具有讽刺意味的是,这种引用的丢失——在C/C++中你会极力避免的事情——正是Java运行时需要发生的。正是这种引用的丢失,使得回收器能够在不依赖你提供 ref = null 的情况下回收内存。
鉴于在大多数应用程序中,绝大多数数据都是局部作用域的,什么都不做就是最好的行动方案。另外,当内存消耗上升时,那并不是回收器失败;而是回收器忠实地保留着你的应用程序仍然持有的某些东西,修复方法是找到那个引用链并纠正数据生命周期,而不是将null当作症状来处理。
希望这篇文章能帮助你理解JVM是多么精妙和令人惊叹,以及它承担了多少认知负荷,从而让你几乎不必去思考那些会偷走你对应用程序逻辑注意力的事情。
如果你遇到了持久的内存问题,或者你想知道如何减少内存使用,我可以提供帮助。我的堆分析工具设计用于处理任何大小的堆。此外,它是可配置的,可以针对你的应用问题进行定制。
本文首发于 foojay。