常见的垃圾回收算法有几种类型(详解标记清除复制整理优缺点及适用场景)

在Java虚拟机的演进史上,垃圾回收(Garbage Collection, GC)始终是最核心的话题之一。很多开发者对GC的理解仅停留在“自动内存管理”的层面,认为只要写了Java代码,内存问题就会自动消失。然而,当生产环境出现频繁的Full GC导致系统卡顿,或者因为内存碎片化导致大对象分配失败时,深入理解底层的垃圾回收算法就显得尤为关键。不同的回收算法针对不同的内存区域和对象生命周期设计,它们各有千秋,没有绝对的“最好”,只有“最合适”。本文将深入剖析四种主流的垃圾回收算法,拆解它们的运作机理、优缺点以及在实际虚拟机中的落地应用,帮助你在面对调优难题时能做出精准的判断。

标记 – 清除算法:最基础却最脆弱的逻辑

标记 – 清除(Mark-Sweep)算法是垃圾收集领域最古老、最基础的算法,后续许多高级算法都是基于它的思想改进而来的。它的执行过程非常直观,分为两个阶段:首先是“标记”阶段,从根集合(GC Roots)开始遍历,所有能被引用到的对象都会被标记为“存活”;其次是“清除”阶段,扫描整个堆内存,将那些没有被标记的对象直接回收,同时释放其占用的内存空间。

算法缺陷与性能瓶颈

虽然逻辑简单易懂,但标记 – 清除算法存在两个致命的缺点,这使得它很难单独应用于现代高性能的JVM中。
第一个问题是效率低下。在标记和清除两个阶段,都需要遍历大量的内存对象。如果堆内存很大,而存活对象又很多,那么标记过程的耗时将会非常可观。更糟糕的是,在清除阶段,如果需要回收的对象分散在内存的各个角落,回收器需要频繁地访问不连续的内存地址,这对CPU缓存极其不友好,会导致大量的缓存失效(Cache Miss),进一步拖慢执行速度。

第二个问题更为严重,那就是内存碎片化。标记 – 算法在回收对象后,只是简单地腾出了空间,而不会移动任何存活对象。随着时间的推移,堆内存中会留下大量不连续的小空闲块。当程序需要分配一个大对象时,即使总的空闲内存足够,也可能因为找不到一块连续的内存空间而触发一次新的垃圾收集动作。这种“假性”内存不足会显著降低系统的吞吐量。在早期的JVM实现中,为了解决碎片问题,不得不引入复杂的空闲链表维护机制,但这又增加了管理的开销。

复制算法:以空间换时间的极致策略

为了解决标记 – 清除算法的效率问题和碎片化问题,复制(Copying)算法应运而生。这种算法的核心思想非常激进:它将可用的内存空间按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完了,就将还存活的对象一次性全部复制到另外一块空白的内存上,然后把已经使用过的这块内存空间一次性清理掉。

高效运行与空间浪费的博弈

复制算法的最大优势在于高效且无碎片。由于存活对象是被整体移动到新的内存区域,所以新区域的内存是绝对连续的,完全消除了内存碎片。分配新对象时,只需要维护一个指针(通常称为“碰撞指针”),指向空白区域的起始位置,每次分配只需移动指针即可,速度极快。此外,回收时不需要逐个扫描死亡对象,只需忽略旧区域即可,清理动作瞬间完成。

然而,这种高效的代价是昂贵的空间成本。理论上,复制算法需要将内存利用率强行降低到50%,因为在任何时刻,都有一半的内存是闲置的,专门用来做备份。这对于内存资源紧张的服务器来说,无疑是一种巨大的浪费。
不过,现代研究表明,新生代中的对象绝大多数都是“朝生夕死”的,存活率极低(通常不到10%)。基于这一“弱分代假说”,我们可以优化复制算法:不再将内存划分为严格的1:1比例,而是将新生代划分为一块较大的Eden区和两块较小的Survivor区(通常为8:1:1的比例)。每次垃圾回收时,将Eden区和其中一块Survivor区中存活的少量对象复制到另一块空的Survivor区,然后清空前两块。这样,空间利用率就提升到了90%,既保留了复制算法的高效和无碎片特性,又极大地减少了空间浪费。这也是目前HotSpot虚拟机新生代默认采用的回收策略(Serial Copying, ParNew等)。

标记 – 整理算法:老年代的终极解决方案

复制算法在对象存活率较高的场景下显得力不从心。如果对象存活率很高(例如在老年代),我们需要进行大量的复制操作,这不仅浪费时间,而且因为要移动大量数据,效率反而不如直接清除。为了解决老年代回收的问题,标记 – 整理(Mark-Compact)算法被提了出来。

兼顾碎片整理与空间利用

标记 – 整理算法的标记阶段与标记 – 清除算法完全一致,都是从GC Roots开始标记存活对象。但在后续阶段,它不是直接清除死亡对象,而是让所有存活的对象都向内存空间的一端移动,然后直接清理掉端边界以外的内存。
这种算法完美地结合了前两种算法的优点:它像标记 – 清除一样,不需要预留额外的备用内存,空间利用率高;又像复制算法一样,整理后的内存是连续的,避免了碎片化问题。

当然,天下没有免费的午餐。标记 – 整理算法的缺点在于移动成本高。在整理阶段,如果存活对象非常多,就需要进行大量的内存复制操作。更复杂的是,移动对象后,所有指向这些对象的引用地址都必须更新,否则程序就会出错。这个“更新引用”的过程(Relocation)涉及到大量的指针修正,计算量巨大,会导致较长的停顿时间(Stop-The-World)。因此,标记 – 整理算法通常用于对象存活率高、变动频率低的老年代区域,如Serial Old和CMS(在某些模式下)收集器就采用了类似的思路(CMS主要用标记 – 清除,但在碎片严重时退化使用标记 – 整理)。

分代收集算法:因地制宜的综合实战方案

严格来说,分代收集(Generational Collection)并不是一种独立的、具体的算法实现,而是一种基于对象生命周期理论的组合策略。它根据对象存活周期的不同,将堆内存划分为新生代(Young Generation)和老年代(Old Generation),甚至永久代/元空间,然后针对不同年代的特点,采用最合适的回收算法。

新生代的快速迭代

如前所述,新生代的特点是对象创建快、死亡也快。因此,新生代非常适合使用复制算法。通过Eden和Survivor区的配合,以极小的空间代价换取了极高的回收速度和零碎片。大多数商用虚拟机(如HotSpot)在新生代都默认采用这种策略。只有在极端情况下(如存活对象超过Survivor区容量),才会启用“分配担保”机制,将对象直接晋升到老年代。

老年代的稳健清理

老年代的特点是对象存活率高,很少发生死亡。在这里使用复制算法会造成大量的无效搬运,因此标记 – 整理算法或标记 – 清除算法是更好的选择。

  • 标记 – 整理:适合对内存碎片敏感的场景,保证大对象能顺利分配,但单次GC停顿时间较长。
  • 标记 – 清除:适合对停顿时间敏感的场景(如CMS收集器),它尽量不打断用户线程,但长期运行后可能产生碎片,导致不得不进行一次的整理(Full GC)。

现代垃圾收集器(如G1、ZGC)虽然在实现细节上更加复杂,引入了Region分区、并发标记、染色指针等技术,但其底层核心依然离不开这几种基础算法的组合与变种。例如,G1收集器在整体上依然遵循分代思想,但在局部Region的回收上,灵活运用了复制和标记 – 整理算法,实现了可预测的停顿时间模型。

理解这些算法的优缺点,是进行JVM调优的前提。如果你的应用产生了大量短生命周期对象,应关注新生代参数(如-XX:NewRatio, -XX:SurvivorRatio)以优化复制效率;如果应用中存在大量长生命周期的大对象,则需重点关注老年代的整理策略,避免频繁的Full GC。没有银弹,只有对场景的深刻洞察和对算法特性的精准匹配,才能构建出高性能的Java应用。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
小码农的头像小码农认证作者

相关推荐

返回顶部