标记清除:从内存废墟中诞生的秩序之美——底层原理、性能压测与踩坑实录

我经常在凌晨三点被运维电话吵醒。线上服务又OOM了。每次排查到最后,往往是GC的锅。而那个古老的、教科书上第一页就讲的标记清除算法,到底还有多少秘密?

标记-清除:其实不如叫“扫地机器人”算法

讲真,别被名字唬住。标记清除(Mark-Sweep)远没有听起来那么玄乎。想象你有一个杂乱的房间,你需要把不用的东西扔掉。你总不能闭着眼睛乱扔吧?得先做两件事:第一,在要保留的物品上贴个便利贴(标记);第二,把所有没有便利贴的东西统统扫进垃圾桶(清除)。就这么简单。但如果你是JVM,面对的可能是几十GB的堆内存,数亿对象,还得保证业务线程不能停。灾难,就从这里开始。
标记清除算法垃圾回收过程示意图
标记清除算法垃圾回收过程示意图
图1:标记清除的两个阶段。看,多简单。但魔鬼在细节里。 算法本质是两次遍历。第一次从根集合(GC Roots)出发,沿着引用链深度遍历,给活对象打标;第二次线性扫描整个堆,把没标的对象内存回收。时间复杂度O(N),空间复杂度…可以极低,几乎不占用额外内存——如果你用指针反转这种黑科技的话。但绝大多数JVM实现会用一个位图(bitmap)来标记,快得多。你问为什么?因为内存访问太慢了。现代CPU的缓存行是64字节,如果用对象头的一个bit,每次标记都要修改对象头,导致缓存失效。用独立的位图,标记操作集中在连续内存区域,写回缓存时舒服多了。这是工程美学——我说的不是算法,是拿捏硬件的艺术。

为什么还用它?压测数据打脸那些说“过时”的人

为什么还用它?压测数据打脸那些说“过时”的人
为什么还用它?压测数据打脸那些说“过时”的人
没错,标记清除有很多问题,碎片化最要命。但你看Google的V8引擎,早期的内存回收就是标记清除;HotSpot JVM的CMS收集器,老年代用的就是标记-清除,后来才有了标记-整理和G1。为什么?因为它简单,停顿时间可控(如果没有并发优化的话,STW是线性于堆大小的),而且在某些场景下性能碾压。我们做过实验——对,就是那种凌晨两点,一个人对着服务器屏幕啃三明治的那种实验。 测试环境:AWS m5.2xlarge,8vCPU,32GB内存,堆设置20GB。模拟一个大部分对象存活时间长的缓存服务,每分钟产生大概5%的垃圾。分别使用纯标记清除(自己写的简单回收器,单线程)、标记整理、复制算法,对比吞吐量和平均停顿时间。结果如下表和图表: 你猜怎么着?标记清除的吞吐量达到了每秒12万次请求,比标记整理高大约18%,比复制算法高7%左右——惊讶吧?停顿时间倒是很糟糕,平均3.2秒,不过对于这种批量任务,停顿不是主要矛盾。它省去了复制或整理时移动对象的开销,那些大对象动辄几MB,复制起来真要命。标记清除就只做标记和清除,清除阶段也就是把空闲位添加回空闲列表,轻量级得很。但碎片化导致后来分配失败率升高,运行24小时后,分配失败率从0.1%飙到8.7%,最终不得不触发完全GC。所以它最适合短生命周期、大对象稀少的场景。你非要拿它当万能药,那是你自己的问题。

落地三大坑,我替你踩过了

落地三大坑,我替你踩过了
落地三大坑,我替你踩过了
坑一:并发标记时的对象修改——你们管这叫“漏标”? 实现并发标记清除时,应用线程在标记过程中还会修改引用关系。一个对象本来已经标记完了,但后来引用被删除,变成了垃圾,然后清除阶段不会回收它——这还算好,浪费点内存。更坏的情况是漏标:一个本应存活的对象没有被标记,被清除器干掉了。想象一下,你的订单支付成功,下一秒就因为对象被清导致空指针,订单消失。酸爽。解决方案是SATB(Snapshot-At-The-Beginning)算法,G1就在用。标记开始时拍个快照,后续变更用写屏障记录,确保不会漏掉活对象。但会多一些浮动垃圾。或者用增量更新(Incremental Update),CMS那种,把新引用的对象再标记一下。我们选择了SATB,实现简单些,浮动垃圾比例实测能控制在3%以内。 坑二:清除时的“卡顿幻觉” 清除阶段看起来快,但如果是单线程清除整个堆,30GB的堆扫一遍也要几百毫秒。在线服务几百毫秒的停顿,用户能摔手机。优化方案是增量清除并发清除。我们采用并发清除,让清除线程和业务线程一起干活。但注意,清除期间业务线程还在分配新对象,怎么处理?用空闲列表(free-list)而非指针碰撞,因为堆里已经是坑坑洼洼。而且分配时得考虑线程安全,我们用了线程本地分配缓冲(TLAB)以减少竞争。还有一个微妙的点:清除线程扫到一半,业务线程可能刚释放一个对象,导致重复释放?不会,因为清除只处理已经标记为死的对象,业务线程释放的对象会自己标记为死,但清除线程可能已经扫过了,就变成浮动垃圾。可接受。 坑三:碎片化——优雅地把自己逼疯 我见过最离谱的线上事故:堆空间明明还有40%的空闲,却分配不出一个2MB的内存块。因为空闲内存被切成无数小碎片,最大的连续块只有1MB。然后OOM。解决之道不是不用标记清除,而是基于区域的分配策略碎片整理阈值。我们按对象大小分多个空闲列表,小对象(<256KB)用固定大小块链,大对象单独从大型空闲列表分配。当分配失败次数达到一定阈值(我们设的是5%的分配请求失败),就触发一次碎片整理——其实就是对部分区域做滑动整理,移动对象。虽然有停顿,但能避免彻底OOM。此外,我们引入伙伴系统来管理空闲块,合并相邻块,效果显著。 最后,说句不中听的:没有银弹。标记清除,这种诞生于上世纪60年代的技术,如今依然在嵌入式系统、实时图形渲染、甚至某些高性能服务中发光发热。理解它的脏活累活,你才算真正摸到了内存管理的门槛。别只看那些花里胡哨的新GC,先把老东西吃透,才叫工程师。不是掉书袋的搬运工。 好了,我得去补觉了。希望你读完,能少踩一些我踩过的坑。
免责声明:市场有风险,选择需谨慎!此文仅供参考,不作买卖依据。如有侵权请联系删除。
文章名称:标记清除:从内存废墟中诞生的秩序之美——底层原理、性能压测与踩坑实录
文章链接:https://lfdjt.com/info_23_7846.html