内存快满的时候,程序会突然卡一下。你盯着转圈的鼠标,不知道后台正在发生一场悄无声息的拆迁——那些不再被引用的对象,正被一个叫“标记清除”的算法挨个点名,然后扫地出门。这个听起来像垃圾分类的机制,其实是编程语言里最基础的生存智慧:先标记谁该死,再统一处决。没有它,你的手机App用不了半小时就会因为内存爆炸而闪退。

标记清除的思路朴素得让人意外。第一步,从根节点出发,顺着引用关系走一遍,能到达的对象全部做个记号,就像老师拿着花名册点名,到的举手。第二步,遍历整个内存堆,凡是没举手的对象,一律视为垃圾,直接回收。整个过程分两个阶段,像先列清单再拆违建。可这看似简单的逻辑,背后藏着不少讲究——标记阶段要避免重复遍历,清除阶段要处理好内存碎片,一个环节偷懒,整个系统就得跟着遭殃。
早年间的JVM用的就是原始版标记清除,但很快暴露出一个尴尬问题:内存碎片化。想象你有一整排书架,把几本不用的书抽走,剩下的书还是原位站着,空出来的位置零散分布,新的大部头书籍根本塞不进去。这导致明明内存总量够用,却因为碎片太多而频繁触发垃圾回收,性能反而更糟。所以后来的改进版本加了个“整理”步骤,把存活对象往一头搬,腾出连续空间,这就是标记-整理算法。但整理也有代价,移动对象要更新所有引用,卡顿感更强。
真正让标记清除大放异彩的,是它和“分代收集”理念的结合。大部分对象朝生夕灭,比如循环里的临时变量,活不过一次迭代。而少数对象像老寿星,从启动活到退出。于是JVM把堆分成新生代和老年代,新生代用复制算法(标记后直接复制存活对象到另一块区域,顺带整理),老年代才用标记清除或标记整理。这种分而治之的策略,让标记清除从“全场清理”变成了“局部精扫”,效率提升不止一个数量级。
可标记清除有个天生的痛点——它得暂停业务线程。因为标记过程中引用关系不能变,否则就会误判,所以JVM里叫“Stop The World”,一切工作停摆,等算法干完活再恢复。你可以想象一下,你正在高速路上开车,突然所有车都停下,让清洁工扫一遍路面,再继续行驶。这种停顿在几十毫秒到几百毫秒之间,对普通应用无所谓,但对金融交易系统、游戏服务器来说,简直是灾难。所以后来的G1、ZGC等垃圾回收器都在努力缩短停顿时间,甚至做到几乎不停顿。
有意思的是,标记清除的变体还渗透到了其他领域。浏览器里的JavaScript引擎,V8,用的就是类似思路——它把堆分成新生代和老生代,新生代用半空间复制,老生代用标记清除加整理。你在网页上滚动、点击、输入,背后都在不停地进行标记清除。还有Redis的过期键清理,用的是惰性删除加定期删除,本质上也是标记思想的变种——不是主动扫全表,而是等访问时顺手检查,用概率代替遍历,换取性能。
当然,标记清除再优雅也有它的边界。如果内存中大部分对象都是存活的,标记阶段要遍历所有存活对象,清除阶段却几乎无事可做,整体效率反而低。更极端的场景是,当内存接近满负荷时,标记清除会频繁触发,形成“抖动”——就像堵车时你不停踩刹车,油耗反而更高。所以现代垃圾回收器都加了自适应阈值,根据回收效果动态调整堆大小和触发时机,把“优雅”发挥到极致。
回到开头那个卡顿的瞬间。你感受到的那一下停滞,其实是算法在做一次深呼吸——它标记了所有还活着的对象,清掉了那些死去的,然后深吸一口气继续运行。标记清除从来不是最聪明的算法,复制算法更高效,引用计数更直观,但它的优雅在于:简单到不会出错,稳定到可以依赖,而且通过分代、并发、增量等手法不断进化。就像老城区改建,不推倒重来,只精准拆除危楼,保留主干道,让城市在更新中保持活力。下次你的程序卡一下,别急着骂,那是算法在替你收拾残局。
