对象存活判定:为什么引用计数法被淘汰
在深入垃圾回收算法之前,我们首先得解决一个根本问题:JVM 如何判断一个对象是“垃圾”还是“活物”?只有准确识别出死亡对象,后续的清理工作才有意义。
早期的一些语言或特定场景下,曾使用过引用计数法。它的逻辑非常直观:给每个对象维护一个计数器,每当有一个地方引用它,计数器就加 1;当引用失效时,计数器就减 1。任何时刻计数器为 0 的对象,理论上就是不可能再被使用的,可以回收。
这种方法实现简单,判定效率也高,但它有一个致命的缺陷:无法解决对象之间相互循环引用的问题。
想象这样一个场景:对象 A 有一个字段指向对象 B,而对象 B 也有一个字段指向对象 A。除此之外,没有任何其他对象引用它们。此时,虽然 A 和 B 实际上已经不可达,应该被回收,但因为它们互相引用,各自的计数器都不为 0。JVM 会误以为它们还在被使用,从而导致内存泄漏。
publicclassReferenceCountingDemo{// 定义一个成员变量用于互相引用Objectinstance=null;publicstaticvoidmain(String[]args){ReferenceCountingDemoobjA=newReferenceCountingDemo();ReferenceCountingDemoobjB=newReferenceCountingDemo();// 互相引用objA.instance=objB;objB.instance=objA;// 断开外部引用objA=null;objB=null;// 此时 objA 和 objB 的引用计数都不为 0// 如果使用引用计数法,这两个对象将无法被回收}}正因为这个无法回避的缺陷,主流的 Java 虚拟机(如 HotSpot)并没有采用引用计数法,而是选择了更严谨的可达性分析算法(Reachability Analysis)。
可达性分析的核心思想是从一组称为"GC Roots"的对象开始向下搜索。如果一个对象到 GC Roots 没有任何引用链相连,则证明该对象是不可用的。在 Java 中,可作为 GC Roots 的对象主要包括:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象。
通过这种“顺藤摸瓜”的方式,所有未被标记的对象都会被判定为垃圾,从而彻底解决了循环引用的难题。
四大核心回收算法详解
确定了哪些对象是垃圾后,下一步就是如何高效地清理它们。JVM 演化出了四种经典的垃圾回收算法,每种都有其独特的适用场景和优缺点。
标记 - 清除算法(Mark-Sweep)
这是最基础也是最直观的算法,分为两个阶段:
- 标记:从 GC Roots 开始,标记所有需要回收的对象。
- 清除:统一回收所有被标记的对象。
它的优点是实现简单,但缺点同样明显:效率不高(标记和清除两个过程都需要遍历),且会产生大量不连续的内存碎片。当后续需要分配较大对象时,可能因为找不到足够的连续内存空间而不得不提前触发另一次垃圾收集动作。
复制算法(Copying)
为了解决内存碎片问题,复制算法应运而生。它将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块用完了,就将还存活的对象复制到另外一块上,然后把已使用过的内存空间一次清理掉。
这种算法的优点非常明显:
- 高效:没有标记和清除过程,实现简单,运行高效。
- 无碎片:整理后的内存是连续的,分配新对象时只需移动指针即可。
但它的代价是内存利用率低,正常使用时只能利用一半的内存,这在内存宝贵的年代是难以接受的。不过,随着硬件发展,这种牺牲空间换时间的策略在特定区域变得非常可行。
标记 - 整理算法(Mark-Compact)
针对老年代对象存活率高、不适合复制的特点,标记 - 整理算法被提出。它的标记过程与“标记 - 清除”一样,但后续步骤不是直接清除,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
这样既避免了内存碎片的产生,又不像复制算法那样需要两倍的内存空间。当然,移动对象涉及大量的指针更新和对象搬移,时间成本相对较高。
分代收集算法(Generational Collection)
现代 JVM 并不单一地使用上述某种算法,而是根据对象存活周期的不同,将堆内存划分为新生代和老年代,针对不同年代采用最适合的算法,这就是分代收集理论。
- 新生代:对象“朝生夕死”,存活率极低。因此适合使用复制算法,只需付出少量存活对象的复制成本,就能高效清理大部分垃圾。
- 老年代:对象存活率高,没有额外担保机制。因此适合使用标记 - 清除或标记 - 整理算法。
新生代为何偏爱复制算法
在 HotSpot 虚拟机中,新生代的设计完美诠释了复制算法的优势。虽然理论上复制算法需要 1:1 的空间比例,但在实际的新生代应用中,并不需要这么奢侈。
新生代被细分为三个区域:Eden 区和两个大小相等的Survivor 区(From 和 To)。默认情况下,Eden 区与 Survivor 区的比例是8:1:1。这意味着,JVM 只需要预留 10% 的内存作为空闲缓冲,就能应对绝大多数情况。
对象流转的过程如下:
- 新创建的对象优先在Eden 区分配。
- 当 Eden 区满时,触发Minor GC。
- GC 发生时,将 Eden 区和当前 From Survivor 区中存活的对象,一次性复制到 To Survivor 区。
- 清理掉 Eden 区和 From Survivor 区。
- 交换 From 和 To 的角色,保持 From 区始终为空,等待下一次 GC。
在这个过程中,还有一个关键机制:对象年龄增长。
每次对象在 Survivor 区之间复制存活下来,其“年龄”就加 1。当年龄达到一定阈值(默认为 15)时,对象就会被晋升到老年代。这个阈值可以通过-XX:MaxTenuringThreshold参数调整。
此外,还有一种特殊情况:大对象直接进入老年代。
如果在 Eden 区分配一个大对象(如长字符串或大型数组),导致无法放入,或者即使放入也会在下次 GC 时因 Survivor 区装不下而失败,JVM 会策略性地直接将其分配到老年代。这避免了在大对象身上进行频繁的复制操作,提升效率。我们可以通过-XX:PretenureSizeThreshold参数来设定这个大对象的阈值(该参数仅对 Serial 和 ParNew 收集器有效)。
这种设计巧妙地利用了“大部分对象朝生夕死”的特性,用极小的内存代价换取了极高的回收效率。
老年代与内存碎片的博弈
如果说新生代是“快进快出”的游乐场,那么老年代就是“长期居住”的社区。这里的对象存活率极高,如果使用复制算法,不仅浪费空间,而且每次 GC 都要搬运大量数据,效率极低。因此,老年代通常采用标记 - 清除或标记 - 整理算法。
这里就引出了一个核心矛盾:内存碎片。
如果使用标记 - 清除算法,回收后会留下大量不连续的内存空洞。当程序需要分配一个稍大的对象时,可能剩余总内存足够,但没有一块连续的空間容纳它,从而被迫触发 Full GC 甚至抛出OutOfMemoryError。
为了解决这个问题,标记 - 整理算法成为了老年代的主流选择(如 CMS 收集器的备选方案 Serial Old,以及 G1 的部分场景)。它在回收前先进行一次“整理”,将存活对象紧凑地排列在一端。虽然这个过程比单纯的清除要慢,因为它涉及对象的移动和指针修正,但它保证了内存的连续性,避免了碎片化带来的分配失败风险。
在实际生产中,选择哪种策略往往取决于应用对停顿时间(STW)和吞吐量的敏感度:
- 如果应用对响应时间极其敏感,不能容忍长时间的停顿,可能会倾向于使用并发标记清除(如 CMS),哪怕忍受一定的碎片风险(通过参数
-XX:+UseCMSCompactAtFullCollection在 Full GC 时进行整理)。 - 如果应用更看重整体吞吐量,且能接受稍长的 GC 停顿,那么标记 - 整理算法(如 Parallel Old)通常是更稳妥的选择,因为它能保证内存分配的稳定性。