常见GC算法:标记-清除、复制、标记-整理

说起GC算法,很多刚入行的同学容易把它们当成理论考试题。但说实话,做性能优化的这几年,我真切体会到:选对算法,启动时间能直接砍掉一小半。

GC算法就那几种,不过每种都是个「矛盾体」——要么快但碎片多,要么整齐但慢。我习惯把这些算法看作「扫地策略」:你是想扫得干净但累死自己,还是随便扫扫但留一堆垃圾?

下面我一个个说,顺便聊聊我在项目里踩过的坑。

标记-清除算法(Mark-Sweep)

这是最朴素的算法。说白了就是两步:先标记哪些对象还活着,然后把没标记的全扔掉。JVM里的CMS收集器就用了这个思路。

优点缺点
实现简单,不用移动对象产生大量内存碎片
暂停时间可控(CMS的初衷)碎片导致大对象分配失败,触发Full GC
没有复制开销标记阶段需要遍历所有存活对象
我曾经踩过的坑: 有个低延迟交易系统,用了CMS收集器。运行两个月后,突然频发Full GC,每次耗时超过2秒。一查原因,就是标记-清除留下的碎片导致新生代晋升大对象时,老年代找不到连续空间。后来只能换成标记-整理算法。

适用场景:对延迟敏感、不介意碎片、堆内存够大的应用。比如某些实时通信中间件,GC暂停时间宁可控制在10ms内,碎片回头再用参数处理。

复制算法(Copying)

复制的思路很简单:把内存分成两块(From和To),只用一块。GC时,把存活对象从From复制到To,然后直接清空From。新生代用的就是这招。

核心要点: 复制算法适合「朝生夕死」的对象。比如Web应用里的请求处理对象,大部分活不过一次请求。实测中,新生代存活率通常只有1%~10%,复制代价很低。
优点缺点
没有碎片,分配指针连续浪费一半内存(Survivor区设计已经缓解了)
分配速度快(bump the pointer)存活率较高时效率下降
适合少量长命 + 大量短命场景需要额外内存用于复制目标区

我印象很深:一次广告推荐系统优化,新生代设到2GB,结果每次Minor GC都从10ms飙升到200ms。我一看,对象存活率出奇高——因为用了大量本地缓存。复制算法的本质就是「赌大多数对象都死得快」,如果赌输了,就反过来坑你。

所以,如果你发现Minor GC越来越慢,先检查幸存对象比例是不是超过30%。如果是,考虑调大老年代,或者改用标记-清除的变种。

标记-整理算法(Mark-Compact)

这算法像「标记-清除」的升级版。先标记存活对象,然后「推推挤挤」把它们挪到内存一端,剩下的空间直接回收。老年代的Old GC(比如G1的全局压缩)就用了这个思路。

优点缺点
没有碎片,内存连续需要移动对象,暂停时间长
大对象分配简单整理过程需要计算新地址,消耗CPU
适合老年代常驻对象必须暂停应用线程(Stop The World)
注意: CMS为什么一直想「逃」?就是因为它不想做整理的暂停。但在JDK9之后,G1成了默认收集器,它把堆分成Region,用「局部整理」来避免全局暂停。这给启动优化带来了新思路。

我个人的习惯是:如果项目启动阶段需要分配大量长生命周期对象(比如Spring容器、元数据),我会用标记-整理算法的老年代配置,比如Parallel Scavenge + Parallel Old。虽然暂停长一点,但启动后几乎不再GC。

三种算法对比:一张表看清

算法碎片暂停内存利用率适用区域
标记-清除严重低高老年代(CMS)
复制无低中(有浪费)新生代
标记-整理无高高老年代(Serial Old, Parallel Old)

关于GC算法的选择——我的实战建议

你想想看,GC算法没有银弹。但有个规律很实用:

  • 如果应用里对象全是「瞬时的」(比如无状态微服务),复制算法最香。
  • 如果对象「又老又多」(比如缓存、连接池),标记-整理更稳。
  • 如果既要低延迟又要防碎片,就得上G1或ZGC这类分代+分区混合算法。

其实,JVM默认就已经帮新手选了最优解(G1)。但做启动优化时,我们往往要「逆行」——比如启动阶段用Serial GC单线程整理,完成后切回G1。这个思路我在第四章会展开。

知识结构:三种算法的核心逻辑

下面这张图帮你把这三个算法的关系理清楚。记住:选择的关键在于「存活率」和「碎片容忍度」。

GC算法核心决策树与对比 GC 算法分类 标记-清除(Mark-Sweep) 复制算法(Copying) 标记-整理(Mark-Compact) 特点 • 存活标记后直接释放 • 内存碎片严重 • 暂停短,但碎片触发Full GC • 适用:老年代低延迟 特点 • 新生代标配 • 无碎片,分配快 • 内存利用率约50%~90% • 适用:大量短期对象 特点 • 老年代整理 • 无碎片但暂停长 • 需计算对象移动地址 • 适用:对象寿命长、内存大 总结:没有完美算法,只有最适合场景 启动优化时,我习惯将「复制」用于快速清理临时对象,「标记-整理」用于物理整理老年代

一点避坑指南

我曾经接手过一个大数据计算框架的启动优化,堆内存设了80GB。用了默认的G1,结果启动阶段一直在做全局标记,耗时超过20秒。后来我分析发现:启动时绝大部分对象都是「一次分配、全程存活」的(例如配置、集群元数据)。这时候复制算法毫无优势,反而标记-清除+老年代整理的组合更合适。

最后说个技巧:很多人忽略「预触发的GC」。如果启动阶段你明确知道会有大量对象消亡,可以手动触发一次Minor GC(用System.gc()并配合-XX:+DisableExplicitGC关掉?不,我是说先让它触发,然后再禁用)。嗯,这属于高级玩法,我后面章节会讲实例。

个人笔记: 在启动参数里加 -XX:+PrintGCDetails -XX:+PrintGCApplicationStoppedTime,能帮你快速定位是哪个算法在导致长暂停。然后对症下药换算法,比调一堆玄学参数更有效。

三种算法就聊到这儿。记住它们各自的「脾气」:标记-清除怕碎片,复制怕存活率高,标记-整理怕暂停。搞懂了这些,再去选GC收集器就会豁然开朗。