首页 / 资讯中心 / 文章详情

垃圾回收算法全解析:从标记-清除到三色标记,JVM与Go实战

垃圾回收算法全解析:从标记-清除到三色标记,JVM与Go实战 ★ FEATURED ARTICLE
垃圾回收算法是编程语言运行时里最常被提起、却最容易被一笔带过的知识。Java开发者跟它朝夕相处Go开发者靠它兜底Python和JavaScript开发者甚至很少主动去碰它——反正内存会自动管理。但真到了线上服务频繁卡顿、内存膨胀到撑爆容器的那个晚上很多人才发现自己对垃圾回收的理解只停留在帮我们把没用的对象清掉这个层面。这篇文章不打算给你做学院派的理论复习而是从一个一线开发者的视角把垃圾回收算法从原始动机到现代工程实现完整串一遍聊清楚每种方案到底怎么工作、代价在哪里再附上我自己在调优和排查过程中踩过的几个真实的坑。不管你是刚入门、想搞懂GC本质的新手还是写过几年代码却一直对标记-清除分代收集三色标记只有模糊概念的开发者这篇都值得你读完。1. 垃圾回收到底在解决什么问题1.1 从手动内存管理的痛苦说起想理解垃圾回收的价值最好先回到什么都要自己动手的年代。C语言里的malloc和free、C里的new和delete给了开发者绝对的内存控制权也给了你无数的翻车机会。忘了释放内存越用越少跑几天就OOM释放早了另一个持有着指针的模块还在读写这块内存直接use-after-free重复释放double free直接让程序崩溃。这些问题的本质在于内存的分配和释放不在同一个时机也不总在同一个模块手里你很难保证我觉得这块内存没用了这个判断在所有并发路径上都成立。这就好比你租了一辆共享单车骑完之后忘了在App上还车平台就永远认为这辆车还在你手上别人没法用费用还在继续算。手动内存管理就是强行要求每个用户都记得还车这对人性和代码的复杂程度都是巨大的考验。所以垃圾回收Garbage CollectionGC的核心动机就一句话把判断对象是否存活和回收不再存活对象的内存这两件事从开发者手里接管过来交给运行时自动完成。它解决的问题不是性能而是复杂性和确定性——把一类极容易出错的工作变成运行时层面的基础设施。需要强调的是垃圾回收管理的对象是对象也就是堆上分配的那块内存区域。一个对象的存活标准不是我觉不觉得它有用而是客观的、图论意义上的可达性。这个转变非常关键——它让什么是垃圾变成一个可以被严格定义、被算法判定的概念而不是靠程序员感觉判断。1.2 两个核心概念存活对象与可达性当程序运行的时候从任意一个活跃的入口出发顺着引用关系一路找下去能到达的那个对象集合就是目前绝对不能回收的对象。这个入口在垃圾回收术语里叫根Root也叫GC Roots。根包括什么呢最常见的是栈上的局部变量和参数、静态变量、当前正在执行的方法栈帧里引用的对象、JNI引用等。你可以把根想象成一张巨大的蜘蛛网的悬挂点所有对象都是网上挂着的水珠引用关系就是蜘蛛丝。只要某条丝线还能从悬挂点连到某个水珠这颗水珠就是活着的。反过来任何从根出发都走不到的对象无论它作为共事者在互相引用上显得多么紧密在GC的眼里都已经死了。因为程序将来再也无法触及它们它们所占的内存就应该被系统回收。这个以根为起点做图遍历把能到的标记下来剩下的全回收的思路就是现代垃圾回收算法的骨架。所有算法的差异本质上都是围绕怎么遍历、怎么标记、怎么回收、怎么尽量减少对正在运行的程序的影响这四个问题展开的。理解了这个基础再看后面的标记-清除、复制算法、分代收集你会发现它们各自的取舍其实非常清晰。2. 经典垃圾回收算法逐个拆解2.1 引用计数见招拆招的朴素方案引用计数Reference Counting可能是最直觉化的算法每个对象内存里额外记录一个计数器表示当前有多少个引用指向我。引用每被赋值一次就加一引用每被丢弃一次就减一当计数器变成零说明已经没人再持有这个对象了当场就可以把内存收回来。它的好处非常明显——回收是即时的、分布式的不需要暂停整个程序去做一次全局扫描每一次引用变化只需要做很小的工作量非常适合对延迟敏感的场景。学过Python的朋友应该知道Python的对象内存管理就是基于引用计数的你在CPython里写的每一个变量赋值背后都在默默增加或者减少引用计数。为什么Python选择引用计数因为这门语言在设计上强调简单和即时反馈引用计数不需要在某个时刻停下来打扫全屋而是每次洒出一滴水就立刻擦掉行为非常可控。但引用计数有一个从原理上就绕不过去的死穴循环引用。想象两个对象互相持有对方的引用但程序外部已经没有任何东西指向它们。按引用计数的逻辑它们各自的计数永远至少是1这个内存团块就永远无法被回收。用Python写一个双向链表删掉所有外部引用后链表内部互相指向的节点并不会被释放这就是著名的循环引用问题。Python的解决方式是额外引入一个gc模块定期用标记-清除的思路去扫一遍堆专门识别循环引用形成的孤岛。但这也从一个侧面说明纯粹的引用计数并不是一个完整的垃圾回收方案它通常需要配合其他算法才能兜底。C的智能指针shared_ptr同理weak_ptr的引入本质上也是在处理循环引用的破局问题。引用计数的另一个工程痛点是计数器本身的并发更新问题。多线程环境下两个线程同时操作同一个对象的引用计数必须通过原子操作保证计数正确性这会带来不小的CPU开销。这也是为什么很多高性能运行时没有把引用计数作为主力算法而只是把它用在边缘场景。2.2 标记-清除从根出发的清扫方案标记-清除Mark and Sweep是理解所有现代算法的基础也是最朴素的从根出发的实现。它把回收过程拆成两个阶段标记阶段从GC Roots出发沿着引用链遍历所有可达对象并在对象头上打上一个存活标记。清除阶段遍历整个堆把所有没有被标记的对象所占用的内存全部回收。这个方案解决了引用计数的循环引用问题因为标记的依据是从根是否可达而不是谁引用了我。但它有两个很明显的代价。第一标记和清除都需要遍历整个堆堆越大这两个阶段的耗时就越长。对于延迟敏感的应用来说这两个阶段都会带来不同程度的停顿——业界管这个叫Stop The WorldSTW也就是程序里的所有业务线程都得停下来等垃圾回收干完活才能继续跑。第二清除之后内存空间变得支离破碎就好比把一个连续的大停车场清空了一些车位剩下的空位不连续导致后续想分配一个很大的对象时找不到连续的地址空间不得不触发新一轮GC来整理。这里有一个大多数人不注意的细节标记-清除里的清除并不会移动存活对象所以它回收的只是一些散落的空洞碎片化是必然结果。在我接触过的很多小项目里开发者用Python或者Ruby可能一辈子都没法直观感受碎片化带来的痛苦。但在JVM这样讲究大堆管理的场景里碎片化会直接导致明明还有大量空闲内存却分配不出一个大对象的恐怖故事。2.3 标记-整理解决碎片化的改良版既然碎片化是个问题最直接的解决思路就是在清除之前把存活对象往一端搬移互相紧挨着排列然后一次性清理掉边界之外的所有空间。这就是标记-整理Mark and Compact。它多了一个整理阶段效果是让存活对象紧密排列在堆的一侧剩下的是一整块连续的空闲区。这样做带来的最大好处是分配变简单了。有了连续空间新对象可以直接在空闲区域的边界上顶上bump pointer分配速度接近指针位移比在碎片化空间里搜索合适大小的空洞不知道快多少倍。同时也不会有碎片化导致的有空间但分配不了大对象问题。但它的代价也相当直接搬移存活对象需要更新所有指向这些对象的引用。想象一下内存里一个对象的地址从0x1000搬到了0x2000原来所有引用着它的变量、字段、数组元素都必须跟着变。要完成这一步垃圾回收器又需要遍历整个对象图去修正引用这对CPU的开销不小而且整个搬移的过程往往需要STW否则业务线程正在读写对象你突然把对象搬走了程序立刻出错。标记-整理在实际工程中很少单独作为一个现代垃圾回收器的全部它的思想更多是嵌在一个更大的框架里。比如JVM的Parallel Scavenge收集器在老年代部分就使用了带有整理性质的算法通过压缩存活对象来消除碎片。在我的理解里标记-整理其实是在吞吐量和空间连续性之间做了一个比较平衡的选择——它可以接受较长的停顿但换来的是后续分配和回收的稳定。2.4 复制算法以空间换时间的精妙设计复制算法Copying的出发点非常聪明既然标记-清除会碎片化标记-整理要搬移对象还要更新引用那不如换个思路——直接把堆切两半每次只使用一半空间。分配对象时在正在使用的半边里往后顶回收时把这一半内存里的存活对象全部复制到另外半边去紧接着把旧半边的所有内容直接清空。因为复制的过程天然就是连续排列的所以既没有碎片也不用对老空间的空洞做复杂的链表管理。这是典型的以空间换时间一半内存空间浪费着不用但换来的是回收过程极其简单、快速存活对象越少越赚。复制算法的一个重要特性是它非常契合一个著名的观察大部分对象活不过第一次GC。在新生代里朝生夕灭的对象特别多一次GC下来存活比例通常只有个位数百分比这时候把少数存活对象复制到另一个区把老的区域整块抹掉效率极高比标记-清除那种遍历全堆找空洞甚至还要快。JVM的新生代就是复制算法最著名的应用场景标准设计是Eden区和两个Survivor区对象优先分配在EdenMinor GC时把存活对象复制到Survivor反复多次后还活着的对象再晋升到老年代。但是复制算法的毛病也绕不开内存利用率低因为总有一部分空间是备用的当存活对象比例很高时复制成本也会迅速上升因为每个存活对象都要被搬走。所以它不适合老年代这种存活率极高的区域。可以看到每种经典算法都不是银弹各自的优缺点恰好互补这也为后面分代收集的设计埋下了伏笔。为了让你对上面四种基础算法有一个更直观的对比我整理了一张表算法核心策略最大优点最大缺点典型应用引用计数实时更新计数器回收即时、无需全局扫描循环引用无法识别、原子操作开销大Python、Objective-C ARC标记-清除标记后扫描回收未标记对象实现简单、可处理循环引用停顿时间长、产生内存碎片CMS算法的基础、早期Lisp运行时标记-整理标记后搬移存活对象无碎片、分配可走指针碰撞搬移和修正引用成本高JVM老年代的Parallel Scavenge复制算法一分为二、复制存活对象无碎片、回收高效空间浪费、存活率极高时开销大JVM新生代的Eden/Survivor3. 现代垃圾回收器从理论到工程的演进之路3.1 分代收集为何是工程界的通解经典算法各有致命短板那怎么办一个伟大的洞见是把内存按对象存活时间分成几块对不同块使用不同的回收策略。这就是分代收集Generational Collection。它依赖两个统计规律弱分代假说——绝大多数对象朝生夕灭强分代假说——经历过多次GC的对象未来存活的可能性越来越大。基于这两个假说JVM把堆分成新生代和老年代。新生代里堆满了触发Minor GC由于存活对象很少用复制算法非常划算存活多轮的对象晋升到老年代老年代堆满后触发Major GC或Full GC用标记-整理或并发标记类算法来解决空间碎片和长生命周期对象的回收。这种各取所长的组合堪称工程美学。Go的GC在演进早期也借鉴了类似思路虽然最终没有采用传统意义上的分代结构但分代的统计洞察对整个领域影响极其深远。不过分代引入了一个新问题跨代引用。一个老年代对象可能引用了一个新生代对象做新生代GC时如果不去扫描老年代就可能把一个明明是可达的年轻对象误判为垃圾。最粗暴的做法是每次Minor GC都把整个老年代扫一遍那就等于没分代了。于是有了记忆集Remembered Set和卡表Card Table这样的工程机制——把老年代按固定大小分成许多小卡页维护一个标记卡页的字节数组只要某个卡页里有老年代对象引用了新生代对象就标记这个卡页脏。Minor GC时只需扫描这些脏卡页而不用把整个老年代翻一遍。这个设计精妙又务实也是为什么现代JVM能在几十GB甚至上百GB的堆下依然控制GC停顿的原因之一。我再往深挖一层分代收集并不是没有缺点。跨代引用本身的管理有额外开销而且新生代和老年代的边界迁移会导致对象晋升路径变长。但这些都是可接受的工程代价因为分代带来的整体吞吐量提升是压倒性的。3.2 三色标记法并发标记的理论根基分代解决的是回收哪些区域但真正的现代GC难题是如何在业务线程不停机的情况下安全地完成标记——即并发标记。这里就不得不提三色标记法Tri-color Marking它是理解CMS、G1、ZGC、Go GC等各种现代垃圾回收器的理论基础。三色标记将对象抽象的分为三种颜色白色还没被访问到的对象初始状态下所有对象都是白色。灰色对象被访问到了但是它引用的对象还没有全部被访问完。黑色对象和它引用的所有对象都已经被访问完是绝对安全存活的对象。标记过程从一个灰色根集合开始不断把灰色对象引用的白色对象染成灰色然后把当前灰色对象染成黑色直到没有灰色对象为止。最终剩下的白色对象就是不可达垃圾。这套追踪机制本身不复杂但真正的难点在于并发如果标记线程和业务线程同时运行业务线程可能一边标记一边修改引用关系导致某个已经被标成黑色的对象其内部新增了一个指向白色对象的引用——而这个白色对象永远没有机会被标成灰色最终被当成垃圾回收掉这就是经典的漏标问题。漏标的后果远比误伤大你把一个活着正用的对象回收了程序随后一访问就崩。业界解决漏标有两个经典思路。一个是增量更新Incremental Update在黑色对象被写入一个新的白色对象引用时把这个黑色对象重新变成灰色相当于记下这笔账让它在下一轮继续被扫描。CMS采用的就是这个方案。另一个是SATBSnapshot At The Beginning在并发标记开始时把整个堆的对象引用关系以逻辑快照方式记下来在标记过程中如果某个引用的目标发生变化就把变化前的旧引用记录到一个待扫描队列里确保旧引用对象不被漏掉。G1和Go的GC都沿着这个思路做了工程实现。为了支持这两种机制运行时需要引入写屏障Write Barrier也就是每次对象引用字段被修改时额外执行一小段逻辑。看到这里你应该能明白现代垃圾回收器之所以复杂到让很多人望而生畏不是因为回收本身复杂而是因为一边让马路正常通行、一边施工这件事本质复杂。每一次引用写入都需要付出额外成本这是并发标记的固定税。3.3 从STW到近零停顿几大现代垃圾回收器怎么打配合理论归理论落到实际工程里每个运行时都有自己的设计哲学和取舍。JVM是GC算法的集大成者。CMSConcurrent Mark Sweep是最早尝试让标记阶段与业务线程并发的收集器它的标记过程分为初始标记、并发标记、重新标记、并发清除其中大部分时间业务线程都在跑。但CMS有两个老毛病并发清除阶段会产生浮动垃圾——标记之后新产生的垃圾要留到下一次GC并且CMS不整理空间碎片化问题严重最终在JDK 9之后被标记为废弃。G1Garbage First则换了一套思路不再严格按新生代老年代物理分块而是把堆划分成一个个Region让每个Region动态扮演Eden、Survivor或Old区的角色。它用追踪每个Region的回收价值的方式优先回收垃圾最多的Region并在标记过程中使用SATB和写屏障来保证并发安全。G1还引入了可预测的停顿时间模型你可以通过参数设定最大GC暂停毫秒数让回收器自己调控节奏这在很多互联网业务里是刚需。ZGC则是沿着另一条路走到极致的产物它默认支持TB级别的堆靠染色指针Colored Pointer在对象引用的高位嵌入标记信息配合读屏障来感知引用是否被移动让并发整理成为可能实现了毫秒级甚至是亚毫秒级的停顿基本达到了GC停顿随堆大小保持恒定的境界。不过ZGC的读屏障开销并不低不是所有场景都需要这么极端的低延迟。Go的运行时走了一条非常有辨识度的路线无分代、并发双色标记后来演进为带混合屏障的并发GC。Go的GC从1.5开始引入并发标记之后每个版本都在降低STW时间。它没有采用分代因为Go语言的对象分配小、存活对象比例低工程师认为分代带来的额外复杂性不值得。Go通过写屏障配合混合屏障同时吸收增量更新和SATB的思路来处理并发标记下的漏标问题并结合GOMEMLIMIT、GOGC等环境变量来管理GC触发频率。在云原生领域Go GC的最大优势是延迟模型极其稳定配合高并发协程模型可以让业务在百万级goroutine下依然保持低GC压力。为了帮大家快速定位不同垃圾回收器的风格我做了一份简表回收器并发策略目标指标关键机制主要代价CMS并发标记低停顿增量更新写屏障浮动垃圾、碎片化G1并发标记可预测停顿平衡吞吐与延迟Region化、SATB卡表维护和RSet开销ZGC并发几乎全阶段近零停顿染色指针读屏障读屏障CPU开销、内存占用大Go GC并发标记并发清除低延迟、高吞吐混合屏障、无分代大堆时标记阶段耗时长4. 实战经验调优与排查记录我踩过的坑4.1 我常用的GC调优思路和关键参数聊算法原理最终还是要回到发现问题、调整参数、验证效果这个闭环。很多开发者一上来就抄各种GC参数组合这是最忌讳的。我自己的习惯是三步走第一步先量化现状。部署GC日志用jstat看每次GC的间隔、每次停顿时间、各代内存使用趋势至少观察一天或者一个完整业务周期。没有数据所有调优都是猜。第二步定目标。业务是高频接口、低延迟敏感还是批处理任务、多吞吐量前者我优先用G1并严格设停顿目标后者可以考虑Parallel Scavenge配大吞吐量。第三步固定堆大小再调比例。在JVM里我通常先通过-Xms和-Xmx把堆大小设定为同一个值避免堆在运行期动态扩缩容。动态扩缩容会带来分配不稳定也会让GC行为不可预测。个人实践经验是对于大多数Web服务-Xmx4g起步观察GC频率不要一上来就给32G大堆大堆的GC停顿可能反而更难受。比较常用的参数包括-Xms4g -Xmx4g -XX:NewRatio2 -XX:SurvivorRatio8 -XX:UseG1GC -XX:MaxGCPauseMillis200NewRatio2表示新生代和老年代的比例是1:2SurvivorRatio8表示Eden区与单个Survivor区的比例是8:1。注意这些数值不是随便写的必须结合对象分配速率来调。如果服务的临时对象特别多、但对象都活不长可以适当调大新生代比如-Xmn2g如果大对象很多比如大量缓存数据要防止它们反复进入新生代复制可以考虑增大老年代或者调大晋升阈值。真要落到参数上没有万能配方只有反复验证。Go这边的调优就更意识流一些。Go默认的GOGC100表示当堆大小增长到活动对象的100%时触发GC。如果你想降低内存占用可以把GOGC调成50或者更低代价是GC更频繁如果你希望尽可能少做GC、换取吞吐量在容器场景的物理内存上限内可以把GOGC调成200或者配合GOMEMLIMIT使用。GOMEMLIMIT是Go 1.19之后引入的软内存上限它的价值在于当GOGC设置的触发线超过容器内存限制时可以让GC更早介入避免被操作系统OOM Kill。4.2 真实排查案例复盘纸上谈兵容易真正线上问题出来的时候每一个细节都能要命。我分享四个自己实际跟过的典型案例具体参数有脱敏但思路和排查路径完全真实。案例一周期性接口超时真凶是Full GC。现象某个服务每天晚上八点高峰期都会出现一次大规模接口超时持续时间几十秒期间部分节点甚至无法响应健康检查。最初大家怀疑是网络问题但我看了监控发现超时时间点恰好与Full GC重合。进一步查jstat看到老年代在每晚八点从约1.5G冲到接近3G触发了Full GC每次停顿约4秒以上。排查堆转储后发现罪魁祸首是一个全局静态Map缓存里面Cache了用户的权限配置但只写不删数据随时间累积最终撑爆老年代。修复方案很简单为缓存加过期策略和淘汰机制同时把Map改成弱引用结构彻底避免类加载器卸载问题。这个案例的教训是GC调优解决不了内存泄漏你必须先找出谁在往堆里塞东西。案例二Young GC频率过高但堆远没到上限。现象服务各项内存指标都正常堆占用一直不高但Young GC每秒好几次CPU白白耗在GC线程上。查了之后发现是某个热点日志组件的String拼接。字符串在Java里是不可变对象一个简单的字符串拼接就会产生多个中间对象这些对象被马上丢弃但数量巨大导致新生代被疯狂填充。优化动作很简单改用StringBuilder或者把日志级别调高减少无效日志生成。实际修复后Young GC次数降了90%。这个案例的教训是减少对象分配速率是调优里性价比最高的一步GC参数只是辅助。案例三元空间持续增长的ClassLoader泄漏。现象服务不重启JVM的Metaspace使用率持续一个月缓慢增长最终触发Full GC并OOM。排查时用jmap -clstats导出了类加载器统计发现大量重复的阴影类加载器每次动态加载某个插件都会新创建一个ClassLoader而且旧的ClassLoader因为持有类引用无法回收最后把元空间消耗殆尽。修复是复用ClassLoader并显式清理临时类加载器。这个案例很多做插件化开发、SPI机制的朋友都容易踩到——普通的堆内存泄漏你会警惕但元空间泄漏往往要等到变成Full GC才引起注意。案例四Go服务内存上涨不下跌。现象一个Go服务内存稳定上涨直到容器接近OOM重启后降下来一段时间后又爬上去。这种情况很典型地指向两个原因要么是某些goroutine持续持有数据没释放要么是GC触发频率过低。我用pprof抓到堆内存增长的采样发现是一个消息中间件消费者在处理高吞吐消息时把未确认消息的引用堆积在一个内部切片里同时这些对象存活时间超过GC周期导致主动GC一直不触发。解决方案是调整消费者的批量确认机制让消息更快释放且将GOGC调到80让GC提前介入。经验之谈Go的GC如果一直不触发并不代表内存很健康可能只是垃圾生成速度刚好压着触发线。4.3 常见问题速查表最后整理一张我在日常排查里反复用到的速查表多数情况按这张表顺藤摸瓜很快能定位方向现象可能原因建议排查路径Full GC频繁、老年代快速上涨内存泄漏、缓存无界、大对象过多检查堆转储、分析存活对象占比、检查缓存和全局集合Young GC频率过高、CPU开销大对象分配速率过高、新生代太小看分配速率、优化热点逻辑减少对象创建、调大新生代GC停顿时间超预期堆太大、并发标记压力大、卡表/记忆集维护开销高设置停顿目标参数、评估是否使用ZGC、检查GC日志中的各阶段耗时元空间持续增长动态生成类、ClassLoader泄漏用clstats看类加载器数量、检查反射/代理/动态字节码的使用Go内存持续上涨未释放的goroutine持有数据、GOGC过大pprof抓堆内存采样、查看goroutine数量、调GOMEMLIMITGC后内存无法还给操作系统空闲内存仍被运行时持有等待复用这是正常现象的过度版本JVM可用-XX:MaxHeapFreeRatio控制Go可考虑去掉不必要缓冲这张表并不能包治百病但排查的意义在于把问题层层剥离先确认是GC本身的毛病还是业务逻辑造成的分配和引用问题然后再谈参数调整。顺序错了一切都白搭先修逻辑、再调参数、最后才考虑换回收器。这也是我反复跟团队强调的原则。最后再多说一句我在实操中的一个很深切的体会垃圾回收算法不是冷冰冰的理论它是每个开发者在内存爆炸那一晚能不能睡个好觉的底气。想真正吃透它光看这篇文章远远不够你最好亲手部署一次带GC日志的服务观察一次Young GC和Full GC的全过程然后在一个测试环境故意制造一次内存泄漏用它来验证自己对标记、分代、并发这些概念的理解。把这篇当作一张地图就好真正的路还得靠你自己踩出来。
阅读完成 · 觉得有帮助?
咨询建站