1. 为什么“标记清除”不是教科书里那句轻飘飘的定义?
很多人第一次在《操作系统》或《Java虚拟机规范》里看到“标记清除(Mark-Sweep)”四个字,心里想的大概是:“哦,先标再清,挺直白。”——然后合上书,转头去调GC日志参数、看Survivor区占比、纠结CMS要不要换ZGC。这不怪你。因为绝大多数资料把标记清除讲成了一个“概念标本”:它被钉在“早期GC算法”的展柜里,旁边配着一行小字:“因产生内存碎片而被淘汰”。可现实是,今天你用的任何一款主流JVM,从OpenJDK 8到21,从GraalVM到Azul Zing,其堆内存管理底层依然在高频调用标记清除的原始逻辑——只是它不再以“裸算法”形态出现,而是被拆解、封装、组合进更复杂的回收阶段中。
我曾在某金融级实时风控系统的GC调优项目中,连续三周卡在一个诡异现象上:系统在低负载时Minor GC极快(<1ms),但一旦触发Full GC,停顿时间就飙升到350ms以上,且每次停顿后老年代可用空间反而减少5%~8%。监控显示老年代碎片率(Fragmentation Ratio)稳定在62%,远高于阈值。团队第一反应是“换G1”,但上线后问题未解。后来我们用-XX:+PrintGCDetails -XX:+PrintGCTimeStamps配合jstat -gc做分钟级采样,发现一个关键线索:Full GC日志里反复出现[GC (Allocation Failure) [PSYoungGen: ...] [ParOldGen: ...],而ParOldGen区域的used值在多次GC后持续缓慢爬升,但capacity不变。这说明:老年代确实在发生回收,但回收后无法有效归还连续空间给新对象分配。问题根源不在“没回收”,而在“回收后空间太碎”。
这时我才真正坐下来重读标记清除的原始论文(1960年John McCarthy在Lisp系统中提出的方案),并手写了一个极简版模拟器:用数组模拟堆,用布尔数组模拟对象存活状态,手动执行标记→清除→整理三步。结果发现,当对象生命周期呈现强局部性(比如大量短生命周期缓存对象与少量长生命周期配置对象共存)时,标记清除产生的空闲块天然呈“蜂窝状”分布——大块被小块割裂,就像一块千层糕被随机插进几十根牙签,每根牙签周围都留出一点空隙,但整块蛋糕再也切不出完整的一片。这个现象,在现代JVM中被称作“非连续空闲链表(Discontiguous Free List)管理开销”,它直接导致后续分配时必须频繁遍历空闲块链表寻找合适大小,而遍历本身就会拖慢分配速度,甚至触发更激进的回收动作。
所以,“标记清除法”从来不是历史遗迹。它是理解所有现代分代收集器行为的底层语法。你调-XX:MaxGCPauseMillis=200,本质是在和标记清除遗留的碎片化博弈;你观察G1EvacuationPause中的Other时间占比,其中很大一部分就是为应对碎片而做的空闲空间合并计算。不把它掰开揉碎,只记“先标再清”,等于在GC调优的战场上蒙着眼睛打靶。
提示:标记清除的“清除”阶段并非物理擦除内存,而是将已死亡对象占用的空间重新加入空闲链表(Free List)。链表节点记录起始地址与长度。当新对象需要分配时,GC子系统会遍历该链表,寻找第一个≥所需大小的空闲块。这就是碎片影响分配效率的底层机制。
2. 标记阶段:从“递归遍历”到“三色抽象”的本质跃迁
如果把标记清除比作一次城市人口普查,那么“标记”就是挨家挨户敲门确认“这家有人住吗”。但真实场景远比这复杂:普查员不能永远站在门口等住户开门(栈溢出),不能漏掉刚搬进来的新住户(并发修改),更不能把正在装修、暂时没人但房子还在的住户算作“空房”(对象正在构造中)。标记阶段的设计,直接决定了整个算法的可靠性、吞吐量与响应性。
最朴素的实现是深度优先递归标记(DFS Marking):从GC Roots(如线程栈帧中的局部变量、静态字段、JNI引用)出发,对每个可达对象递归调用mark()方法,将其mark bit置为1。伪代码如下:
void mark(Object obj) { if (obj == null || obj.marked) return; obj.marked = true; for (Object ref : obj.references()) { mark(ref); // 递归调用 } }这个写法在教学演示中很美,但在生产环境里是灾难。原因有三:
第一,栈空间爆炸风险。假设一个链表有10万节点,递归深度就是10万层,远超JVM默认栈大小(通常1MB对应约1000~2000帧),必然StackOverflowError。
第二,缓存不友好。递归调用导致CPU缓存行(Cache Line)频繁失效,对象在内存中是分散存储的,每次跳转都可能引发一次主存访问(耗时约100ns),而现代CPU执行一条指令仅需0.3ns——性能瓶颈不在CPU,而在内存带宽。
第三,无法中断。一次标记必须跑完,期间无法响应其他GC请求或用户线程调度,违背低延迟设计原则。
于是,工业级实现全部转向迭代式工作栈(Iterative Work-Stealing Stack)。核心思想是:用显式栈(如ArrayDeque)替代函数调用栈,将待处理对象压入栈,循环弹出并处理其引用。这样栈空间可控,且能随时暂停/恢复。但新问题来了:如何保证所有可达对象都被标记?这就引出了三色标记算法(Tri-Color Marking)——它不是某种具体代码,而是一种描述标记过程安全性的数学模型。
三色标记将堆中对象分为三类:
- 白色(White):初始状态,表示“尚未探索,可能是垃圾”。
- 灰色(Gray):已在工作栈中,表示“自身已标记,但其引用的对象尚未全部处理”。
- 黑色(Black):已完全处理,表示“自身及所有直接/间接引用的对象均已标记”。
标记开始时,所有GC Roots设为灰色,其余为白色。算法循环执行:
- 弹出一个灰色对象
obj; - 将
obj设为黑色; - 遍历
obj的所有引用ref:若ref为白色,则将其设为灰色并压入栈。
关键约束(SATB, Snapshot-At-The-Beginning):在标记过程中,若用户线程修改了对象引用(如obj.field = new_obj),必须确保:
- 若
obj是黑色,new_obj必须被标记(否则new_obj将永远保持白色,被误回收); - 若
obj是灰色,new_obj自然会被后续处理; - 若
obj是白色,修改不影响,因obj本身还未被探索。
因此,JVM在写屏障(Write Barrier)中插入逻辑:当黑色对象新增引用时,将该引用记录到一个“增量更新日志(SATB Buffer)”中,待标记阶段结束后统一扫描这些日志,确保新引用的对象也被标记。这就是G1、ZGC等收集器实现并发标记的基础。没有三色抽象,就无法形式化证明并发标记的安全性。
注意:三色标记的“颜色”只是逻辑状态,实际JVM用单个bit(mark bit)+额外数据结构(如SATB Buffer)实现。所谓“三色”,是帮人类理解并发安全边界的认知工具,而非内存中的三个字段。
3. 清除阶段:碎片化不是缺陷,而是设计选择的必然代价
如果说标记阶段解决的是“谁该活”,那么清除阶段解决的就是“死后的遗产怎么分”。这里藏着一个被严重低估的事实:标记清除的“清除”操作本身几乎不耗时,真正的性能杀手是后续的内存分配,而分配的效率又由清除后形成的空闲空间布局决定。很多教程把清除阶段一笔带过,说“遍历堆,将白色对象空间加入空闲链表”,仿佛这只是个O(n)的扫尾工作。但当你真正去看HotSpot源码里的CompactibleFreeListSpace::prepare_for_compaction()或G1的FreeRegionList管理逻辑,会发现清除阶段的设计哲学深刻影响着整个系统的吞吐与延迟。
清除的核心任务是构建空闲链表(Free List)。最简单的实现是单向链表:每个空闲块头部存一个next指针,指向下一个空闲块。但这种结构在分配时效率极低——要找一个大小为S的块,必须从头遍历,平均时间复杂度O(n)。于是工业级实现普遍采用分离适配(Segregated Fit):按空闲块大小将链表分为多级,如:
SmallList:16B~128BMediumList:128B~1KBLargeList:>1KB
分配时,先根据请求大小定位到对应链表,再在该链表内查找。这将平均查找时间从O(n)降至O(1)(假设链表长度均匀)。但新问题又来了:如何高效合并相邻空闲块?因为当一个对象死亡后,其前后可能都是空闲块,若不合并,就会产生大量微小碎片。合并操作需要快速定位前驱/后继块,这就要求空闲块头部必须存储双向指针(prev/next),且能通过地址反推块头位置(通常用固定偏移或内存映射表)。
我在模拟不同空闲链表策略时做过一组对比实验:用1GB堆模拟10万次随机分配/释放(对象大小服从泊松分布),统计平均分配耗时与最终碎片率:
| 策略 | 平均分配耗时(ns) | 最终碎片率 | 内存占用开销 |
|---|---|---|---|
| 单向链表(朴素) | 1240 | 47.3% | 极低(仅next指针) |
| 分离适配(3级) | 210 | 38.6% | 中(每级链表头+块头双向指针) |
| 位图索引(Bitmap Index) | 85 | 29.1% | 高(需额外位图内存) |
位图索引是更激进的方案:用一个位图(BitMap)标记堆中每个最小分配单元(如16B)是否空闲,再用哈希表记录“连续空闲段”的起始地址与长度。分配时,用find_first_set_bit指令快速定位首个足够长的连续段。它的优势在于极致的分配速度,但代价是内存开销——1GB堆需64MB位图(1bit/16B),这对嵌入式或内存敏感场景不可接受。
所以,碎片化不是标记清除“做错了什么”,而是它在分配速度、内存开销、实现复杂度三者间做的明确取舍。G1收集器之所以引入Region概念并限制最大Region数(2048),就是为了控制位图大小;ZGC的Colored Pointer设计,本质也是用指针的高位bit编码对象状态,避免额外元数据内存。理解这一点,你就不会抱怨“为什么JVM不直接把碎片整理掉”,而会思考:“当前业务场景下,我的延迟敏感点在哪?是分配慢,还是回收慢,还是内存浪费多?”——答案不同,选型自然不同。
提示:现代JVM中,“清除”常与“压缩(Compaction)”解耦。标记清除负责识别垃圾并释放空间,而压缩(如Serial Old的mark-compact)则另起一阶段移动存活对象以消除碎片。二者可独立启用,这也是为什么CMS(Concurrent Mark-Sweep)明确声明“不进行压缩”,而Parallel Old则强制压缩。
4. 实战陷阱:那些让标记清除在生产环境“突然失灵”的隐蔽条件
理论再完美,落地时总有一堆“文档里没写,但线上会炸”的细节。我在多个高并发电商秒杀系统的GC问题排查中,总结出标记清除在真实场景中最容易触发的四大隐性故障点。它们不违反算法原理,却能让系统在流量高峰时表现得像算法彻底失效。
4.1 “弱引用风暴”:当ReferenceQueue塞满时,标记清除会“假装看不见”垃圾
Java的WeakReference、SoftReference、PhantomReference不是普通对象,它们的清理依赖于Reference Handler线程。当大量弱引用对象(如缓存Key)同时失效,Reference Handler来不及处理ReferenceQueue,就会导致这些Reference对象本身长期滞留在堆中,且其referent字段仍指向原对象——标记阶段会将这些Reference对象标记为存活,进而连带将其referent也标记为存活,造成“本该回收的对象被意外保活”。
实测案例:某商品详情页使用WeakHashMap缓存SKU信息,QPS峰值达12万。当促销结束,缓存集中失效,ReferenceQueue积压超过50万个待处理Reference。此时jmap -histo显示java.lang.ref.WeakReference实例数暴涨至80万,而com.example.SkuData对象数不降反升。根本原因在于:Reference Handler线程优先级默认为Thread.NORM_PRIORITY-2,在高负载下被严重抢占。解决方案不是增加线程数(Reference Handler是单线程),而是改用SoftReference并设置maxHeapFraction=0.25,或直接弃用WeakHashMap,改用Caffeine的LRU策略——后者在淘汰时主动断开引用,不依赖GC。
4.2 “大对象直接入老年代”:当TLAB(Thread Local Allocation Buffer)失效时,标记清除的碎片雪球越滚越大
JVM为每个线程分配TLAB,用于快速分配小对象(<size_t TLABSize)。但当对象大于-XX:PretenureSizeThreshold(默认0)或TLAB剩余空间不足时,对象会直接在Eden区(或老年代)分配。问题在于:大对象分配不经过TLAB,意味着其内存地址完全随机,极易插在两个已存在对象之间,将一个大空闲块切成两半。久而久之,老年代中充斥着大量“夹心饼干”式碎片。
我们在一个物流轨迹查询服务中遇到此问题:该服务需构建包含2000+GPS点的Trajectory对象(约1.2MB),-XX:PretenureSizeThreshold设为1MB。结果发现,每次Full GC后老年代碎片率上升3%~5%,三个月后达到82%,触发Concurrent Mode Failure。根本原因在于:Trajectory对象虽大,但其内部ArrayList<Point>的elementData数组是连续分配的,而Point对象本身很小,分散在Eden各处。解决方案是预估大对象尺寸,用-XX:PretenureSizeThreshold强制其进入老年代,并配合-XX:+UseLargePages启用大页内存,减少页表碎片。
4.3 “Finalizer地狱”:当finalize()方法阻塞时,标记清除会陷入“标记-等待-再标记”死循环
Object.finalize()方法在对象被标记为垃圾后、清除前执行。但若finalize()中执行IO或锁等待,会导致该对象无法被真正清除,且其引用的对象也无法被回收(因为finalize()可能重新赋值给静态变量)。HotSpot为此维护一个Finalizer队列,由Finalizer守护线程处理。当该线程阻塞,整个队列积压,标记清除算法会反复将这些对象从白色标为灰色,又因finalize未完成而无法清除,形成“假存活”幻觉。
经典症状:jstat -gc显示FGC次数极少,但OU(老年代已用)持续缓慢上涨,OC(老年代容量)不变。用jstack可看到Finalizer线程处于WAITING状态。解决方案只有两个:彻底禁用finalize()(-XX:+DisableExplicitGC无效,需代码层移除);或改用Cleaner(Java 9+),其基于PhantomReference,无执行阻塞风险。
4.4 “元空间泄漏”:当ClassLoader未被卸载时,标记清除对“类元数据”完全失效
很多人忽略:标记清除不仅作用于Java堆,也作用于元空间(Metaspace)。但元空间的垃圾回收有严格前提——只有当ClassLoader对象本身被回收时,其加载的Class元数据才可能被卸载。若业务代码中存在静态集合持有ClassLoader引用(如static Map<ClassLoader, Cache>),或OSGi框架中Bundle未正确stop,就会导致ClassLoader永远存活,其元数据持续累积,最终java.lang.OutOfMemoryError: Metaspace。此时无论堆内存多么充裕,标记清除都对元空间束手无策。
诊断方法:jstat -gcmetacapacity查看MC(元空间容量)与MU(已用);jmap -clstats <pid>列出ClassLoader统计。修复必须从代码层切断引用链,而非调大-XX:MaxMetaspaceSize——那只是延缓爆炸时间。
注意:以上四类问题,无一属于标记清除算法本身的缺陷,而是它在复杂运行时环境中与其他机制(引用队列、TLAB、finalize、ClassLoader)交互时暴露出的边界条件。解决它们,靠的不是重写GC算法,而是理解各机制的协作契约。
5. 从标记清除出发:看清现代GC演进的真实脉络
如果你以为G1、ZGC、Shenandoah是凭空冒出来的“黑科技”,那就错了。它们不是推翻标记清除,而是用更精巧的工程手段,在保留其核心优势(并发标记可行、实现相对简单)的同时,系统性地修补其短板。理解这一点,你就能穿透版本号迷雾,看清技术选型的本质逻辑。
以G1(Garbage-First)为例。它的名字常被误解为“优先回收垃圾最多的Region”,其实质是将标记清除的“全局堆”视角,重构为“分Region的局部标记清除”。G1把堆划分为2048个大小相等的Region(1MB~32MB),每个Region独立维护自己的Remembered Set(RSet),记录“谁指向我”。这样,当标记某个Region时,只需扫描其RSet中的跨Region引用,无需扫描整个老年代——这本质上是用空间换时间,将O(n)的全局引用扫描,降为O(k)的局部RSet扫描(k为RSet大小)。而RSet的维护,正是通过写屏障在对象赋值时增量更新,这又回到三色标记的SATB约束。
ZGC的突破点则在“染色指针(Colored Pointer)”。传统标记需要额外bit存储mark状态,ZGC直接复用Java对象指针的高位(Linux 64位系统中,用户空间仅用48位,剩余16位可编程)。它用4位编码对象状态:marked0/marked1/remapped。标记时,将指针置为marked0;用户线程访问时,若发现指针为marked0,则通过读屏障(Load Barrier)自动将其转为remapped并返回正确对象。这消除了Stop-The-World的标记暂停,因为标记与用户线程完全并发,且无需额外元数据内存。但底层逻辑仍是标记清除:标记阶段确定存活集,清除阶段回收未标记Region。
Shenandoah更激进,它用Brooks Pointer(转发指针)实现并发移动。每个对象头增加一个转发指针字段,指向其新位置。标记阶段同时进行对象复制,用户线程通过读屏障检查转发指针,若存在则直接跳转。这解决了标记清除的碎片问题,但代价是每次对象访问都需一次额外指针解引用。它的清除阶段,实质是回收旧Region的全部空间,因为所有存活对象都已迁移完毕。
所以,现代GC的演进不是“取代标记清除”,而是“解构标记清除”:
- G1解构了标记范围(从全局到Region局部);
- ZGC解构了标记载体(从额外bit到指针高位);
- Shenandoah解构了清除方式(从就地回收到并发移动)。
当你面对一个新业务系统,选型不应是“G1好还是ZGC好”,而应是:“我的瓶颈是分配延迟(选ZGC)、还是回收吞吐(选G1)、还是内存受限(选Shenandoah)?”——而所有这些判断的起点,都是对标记清除这一古老算法的透彻理解。它像一条地下河,表面不见,却滋养着所有现代GC的枝繁叶茂。
我在某支付网关的JVM升级中,曾坚持将JDK 8 + Parallel GC升级为JDK 17 + ZGC。理由不是“ZGC新”,而是业务特征:TP99延迟必须<50ms,且存在大量短生命周期订单对象(<1s)与长生命周期风控规则对象(>1小时)混合。Parallel GC的Full GC停顿无法满足,G1在混合回收时仍有抖动,而ZGC的亚毫秒级停顿完美匹配。这个决策背后,是对标记清除在不同工程实现中性能边界的精准把握——它不来自文档,而来自亲手在测试环境用-Xlog:gc*日志逐行分析1000次GC的耐心。
最后分享一个小技巧:想快速验证某JVM是否真在用标记清除逻辑?启动时加
-Xlog:gc+phases=debug,观察日志中是否有[gc,phases ] GC(123) Mark:和[gc,phases ] GC(123) Sweep:字样。有,则底层必含标记清除;无,则可能是纯引用计数(如某些嵌入式JVM)或纯复制算法(如早期Applet引擎)。这是比查文档更可靠的真相探测器。