JVM垃圾回收算法主要有标记-清除、复制和标记-整理三种,分别适用于不同内存区域。标记-清除易产生碎片,复制算法以空间换时间,适合新生代,标记-整理则解决碎片问题,适合老年代。JVM结合多种算法,基于对象生命周期差异实现分代回收,提升性能。现代GC器如G1、ZGC、Shenandoah通过区域化管理、并发处理和读屏障等技术,在大堆场景下实现低延迟与高吞吐的平衡。选择合适的GC器需根据应用类型、堆大小、对象分配速率和硬件资源综合考量,并通过日志分析与调优持续优化。

JVM的垃圾回收算法主要有三种基本类型:标记-清除(Mark-Sweep)、复制(Copying)和标记-整理(Mark-Compact)。它们各自有其优缺点,并且在实际的JVM实现中,往往会结合使用,以适应不同的内存区域和回收需求。
解决方案
要说JVM的垃圾回收,首先得明白它核心的那几个套路。在我看来,所有复杂的GC器,骨子里都离不开这三种基本算法的影子,只是它们被包装得更精巧,或者说,有了更高级的优化策略。
标记-清除(Mark-Sweep)算法 这是最基础的一种,理解起来也直观。它分两步走:
- 标记(Mark):从根对象(GC Roots)开始,遍历所有可达的对象,把它们标记出来。这些被标记的对象就是“活”的,不能被回收。
- 清除(Sweep):遍历整个堆,把所有没有被标记的对象(也就是不可达的“死”对象)清除掉。 这种算法有个明显的优点,它不需要移动对象,所以效率相对高,而且不会占用额外的空间。但缺点也挺突出:它会产生大量的内存碎片。想想看,一块大内存被回收后,可能会留下很多小块的“坑”,如果后续需要分配一个大对象,即使总内存是够的,也可能因为没有连续的足够空间而触发另一次GC,甚至导致OOM。这就像你的硬盘,删了很多文件,但碎片化严重,找个大文件都费劲。
复制(Copying)算法 这个算法的思路就完全不同了,它更像是一种“以空间换时间”的策略,特别适合那些生命周期短的对象。它将可用内存分成大小相等的两块,每次只使用其中一块。当这块内存用完了,就将还“活着”的对象复制到另一块空闲的内存上,然后把当前使用的这块内存全部清理掉。
- 复制(Copy):将当前已用空间中的存活对象,全部复制到另一块未使用的空间。
- 清空(Empty):将当前已用空间直接清空。 它的优点是显而易见的:不会产生内存碎片,而且复制过程中,只要移动堆顶指针就能分配内存,效率很高。但它的缺点也很明显:内存利用率只有50%,因为你总要留一块备用空间。这在寸土寸金的生产环境里,有时候是不能接受的。所以,它通常只用于新生代(Young Generation),因为新生代的对象大部分都是朝生夕死的,存活率很低,复制的成本相对可控。
标记-整理(Mark-Compact)算法 标记-整理是标记-清除的升级版,它在标记之后,并没有直接清除,而是多了一步“整理”。
- 标记(Mark):同样,从GC Roots开始,标记出所有存活的对象。
- 整理(Compact):将所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。 这种算法解决了标记-清除的内存碎片问题,而且内存利用率高,因为它不需要像复制算法那样预留空间。但它的缺点也很明显:对象移动的成本很高,尤其是在老年代这种对象数量多、存活率高的区域,每次GC都移动大量对象,会造成较长时间的STW(Stop-The-World),也就是应用暂停。这对于那些对响应时间要求高的应用来说,是致命的。
为什么JVM垃圾回收需要多种算法?它们如何协同优化性能?
你可能会问,既然有这么多算法,为什么不选一个最好的用到底?我的理解是,没有“最好”的算法,只有“最合适”的算法。JVM之所以需要多种垃圾回收算法,并让它们协同工作,核心原因在于对象的生命周期差异和性能目标的多样性。
设想一下,你有一个繁忙的办公室,有些文件(对象)刚用完就扔了,有些文件要长期保存。如果用同一种方式处理,效率肯定不高。JVM正是基于这种“分代假设”(Generational Hypothesis)来设计的:绝大多数对象都是朝生夕死的,而少数对象会长期存活。
因此,JVM把堆内存分成了不同的区域,最常见的就是新生代(Young Generation)和老年代(Old Generation)。
- 新生代:这里的对象生命周期短,GC频繁。为了追求高效率和避免碎片,通常会使用复制算法。因为存活对象少,复制成本低,而且能够快速回收大量空间。新生代通常还会细分为一个Eden区和两个Survivor区(From和To),这就是复制算法的具体实现。对象先在Eden区分配,GC时,存活对象被复制到其中一个Survivor区,下次GC再复制到另一个,经过几次GC仍存活的对象才会被晋升到老年代。
- 老年代:这里的对象生命周期长,GC不那么频繁,但每次GC涉及的对象数量多。如果用复制算法,成本太高,而且老年代的内存利用率要求更高。所以,老年代通常会选择标记-整理算法来解决碎片问题,或者使用标记-清除算法(如果对碎片不那么敏感,或者后续有整理的机制)来降低STW时间。当然,现代的GC器,比如CMS、G1,对老年代的回收做了大量优化,试图在吞吐量和延迟之间找到更好的平衡。
这种分代协同的策略,本质上是一种优化:用最适合的算法处理最合适的区域。新生代用复制,追求高吞吐量和低延迟;老年代用标记-整理或更复杂的算法,解决碎片和长期存活对象的回收问题。它们就像是流水线上的不同工位,各司其职,共同完成了垃圾回收这个大任务,以最小的代价维持了应用的持续运行。
现代JVM垃圾回收器:从CMS到ZGC,技术演进与核心特性解析
如果说前面讲的是GC算法的“基本功”,那么现代JVM的垃圾回收器就是这些基本功的集大成者,并且加入了大量创新,旨在解决传统GC器面临的痛点。我们追求的无非是两点:高吞吐量(单位时间内处理更多任务)和低延迟(响应时间快)。但这两者往往是鱼和熊掌,难以兼得。
-
CMS(Concurrent Mark Sweep)收集器: CMS是HotSpot JVM中第一个真正意义上的并发收集器,它的目标是降低GC时的停顿时间(latency),特别适用于对响应时间敏感的Web应用。它主要用于老年代。 它的核心思想是:在标记和清除阶段,尽可能地让GC线程和应用线程并发执行,减少STW时间。它有几个关键步骤:初始标记(STW,但很快)、并发标记(与应用并发)、重新标记(STW,修正并发标记期间对象的变化)、并发清除(与应用并发)。 听起来很美,但CMS也有它的问题:
- 浮动垃圾(Floating Garbage):在并发清除阶段,应用还在运行,可能会产生新的垃圾,这部分垃圾只能等到下次GC才能清理,这就是浮动垃圾。
- 内存碎片:CMS是基于标记-清除算法的,所以它会产生内存碎片。当碎片过多时,如果需要分配大对象,可能无法找到连续空间,导致提前触发Full GC,而Full GC是STW的。
- 对CPU资源敏感:并发执行意味着需要更多的CPU核心来支持GC线程。
-
G1(Garbage-First)收集器: G1是Oracle在JDK 7中推出的,旨在取代CMS,成为下一代低延迟、高吞吐量的收集器。它的设计理念非常独特:它将整个Java堆划分为多个大小相等的独立区域(Region)。每个Region都可以独立地作为Eden、Survivor或者Old区。 G1的核心优势在于:
- 可预测的停顿时间:G1允许用户指定一个GC停顿的目标时间(例如,不超过200毫秒)。G1会根据这个目标,选择回收价值最高(垃圾最多)的Region进行回收,这就是“Garbage-First”的由来。
- 避免内存碎片:G1在回收Region时,采用的是复制和标记-整理算法的混合模式。它会把存活对象从一个或多个Region复制到新的Region中,这本身就带有整理的效果,因此碎片问题得到了很好的缓解。
- 分代和非分代并存:G1依然保留了分代的概念,但它的区域划分让它在处理大对象(Humongous Object)时更灵活,可以直接在老年代区域分配。
-
ZGC和Shenandoah收集器: 这是JDK 11之后出现的,代表了JVM GC的最新发展方向,它们的目标是将GC停顿时间控制在极低的水平(通常是10毫秒以内,甚至更低),即便是在TB级别的堆内存下也能保持。它们的核心技术突破在于使用了着色指针(Colored Pointers)和读屏障(Read Barriers)。 简单来说,它们通过在指针中编码GC状态信息,并在每次对象访问时插入读屏障来检测并处理并发GC操作,从而实现了几乎完全并发的垃圾回收。这意味着GC的绝大部分工作可以与应用线程并发执行,STW时间极短,几乎可以忽略不计。
选择合适的JVM垃圾回收器:针对不同应用场景的决策与调优考量
选择一个合适的垃圾回收器,就像是为你的汽车选择合适的轮胎,得看路况和驾驶习惯。没有万能的答案,只有最适合你应用场景的那个。
在做决策时,我通常会考虑以下几个核心因素:
-
应用类型和性能目标:
- 吞吐量优先型(Throughput-oriented):如果你跑的是批处理任务、大数据分析,或者不需要实时响应的后台服务,那么你可能更关心单位时间内能处理多少数据,而不是单次请求的响应时间。这种情况下,可以考虑使用ParallelGC(JDK 8默认)或G1。它们在保证较高吞吐量的同时,也能提供相对可接受的停顿。
- 延迟优先型(Latency-sensitive):如果你在做Web服务、API网关、实时交易系统、游戏服务器,或者任何对用户体验响应时间有严格要求的应用,那么GC停顿必须尽可能短。这时候,G1、CMS(虽然有些过时,但特定场景仍有人用)、以及最新的ZGC或Shenandoah就是你的首选。
-
堆内存大小:
- 小到中等堆(几百MB到几个GB):对于这类堆,ParallelGC通常表现不错,因为它实现简单,吞吐量高。G1也可以胜任,并且能提供更好的停顿控制。
- 大到超大堆(几十GB到TB级别):当堆内存达到这个量级时,传统的Full GC停顿会变得无法接受。G1是很好的通用选择,因为它能有效地管理大堆。而如果对延迟有极致要求,ZGC和Shenandoah则是几乎唯一的选择,它们能够将GC停顿时间稳定在个位数毫秒,即便堆很大。
-
对象分配和晋升速率:
- 如果你的应用频繁创建大量临时对象(高分配速率),并且这些对象很快就死亡,那么新生代的GC会很频繁。这种情况下,确保新生代有足够的大小,并且复制算法能高效工作很重要。
- 如果大量对象存活时间长,或者晋升到老年代的速度很快,那么老年代的GC压力就会增大。这时就需要关注老年代收集器的效率和停顿。
-
硬件资源:
- CPU核心数:并发GC器(如CMS、G1、ZGC、Shenandoah)需要更多的CPU核心来支持GC线程与应用线程的并发执行。如果CPU资源有限,可能需要权衡。
调优策略的一些考量点:
-
JVM参数是关键:不要盲目调优,但了解常用的GC参数是必要的。
-
-Xms和-Xmx:设置堆的初始和最大大小。通常建议设为相同值,避免运行时堆的动态扩展和收缩带来的额外开销。 -
-XX:+UseG1GC:启用G1收集器。 -
-XX:MaxGCPauseMillis=200:为G1设置目标停顿时间。 -
-XX:NewRatio或-Xmn:调整新生代和老年代的比例或直接指定新生代大小。这对于控制新生代GC频率和对象晋升速度很有用。 -
-XX:+PrintGCDetails和-XX:+PrintGCDateStamps:开启详细GC日志,这是GC调优的基石。没有日志,一切都是盲猜。
-
- 监控和分析:使用JMX、VisualVM、Arthas、GCViewer等工具来监控GC行为、分析GC日志。通过观察GC频率、停顿时间、内存使用趋势,才能找到瓶颈所在。
- 从小步快跑:不要一次性调整太多参数。每次只改动一个或一组相关参数,然后观察效果。
- 压力测试:在生产环境部署前,务必进行充分的压力测试,模拟真实负载,观察GC行为是否符合预期。
总之,选择和调优JVM垃圾回收器是一个持续迭代的过程。它需要你理解应用本身的特性,结合GC算法的原理,通过参数调整和数据分析,最终找到一个最适合你系统的平衡点。这就像是打磨一件工具,越了解它,越能让它发挥出最大的效用。










