垃圾收集(GC)核心算法与现代收集器调优指南

发布时间:2026/10/20 19:40:11
垃圾收集(GC)核心算法与现代收集器调优指南 1. 垃圾收集到底在解决什么问题1.1 从手动释放内存说起对于混过 C/C 时代的开发者来说内存管理就是一把双刃剑。malloc和free是一对精准的搭档精准到稍不留神就会出事。释放过早对象还在被引用程序下一秒就可能踩到悬空指针释放过晚或者干脆忘了释放内存就会悄悄漏掉服务跑几天之后开始异常卡顿最后 OOM 崩溃。这类 bug 还不像逻辑错误那么好复现通常需要负载上来、时间足够长才暴露排查起来特别头疼。我记得早年维护过一个常驻服务功能本身非常简单接收请求处理返回结果。一开始内存曲线非常平稳可运行大约两天后内存占用就开始单调上升一周后直接触顶。查了很久才发现某个缓存分支里往全局列表塞了数据却没有任何清理机制。内存于是只进不出服务越跑越慢。这种问题在那个年代几乎人人都会遇到。正是因为手动内存管理太容易出错高级语言才纷纷引入自动内存管理。垃圾收集Garbage CollectionGC要干的事情就是让运行时自己判断哪些对象以后不可能再被访问然后替开发人员把这些对象占用的空间收回来。开发人员不再需要关心“什么时候释放”只需要关心“什么时候不再持有引用”。听起来很美好但实现起来远没有这么简单。1.2 判定垃圾的唯一标准可达性很多初学者会把“垃圾”理解成“不再使用的对象”。这个说法其实不准确。GC 不会去分析代码逻辑来判断某个对象“以后还会不会用到”因为它做不到这类问题在理论上不可判定。GC 采用的是一条更务实的路线从一组已知的根对象出发沿着对象引用链遍历凡是能到达的对象一律视为存活对象凡是遍历不到的对象一律视为垃圾。根对象包括哪些常见的有当前线程栈帧里的局部变量和参数静态变量引用的对象活跃线程对象本身本地方法栈里的 JNI 引用运行时内部数据结构比如类加载器、常量池等这个模型非常关键因为内存泄漏的根源往往就藏在“看起来该回收实际上从根出发还能到达”的场景里。举个例子某个全局缓存对象的 value 引用了业务 key业务层以为自己把 key 删掉了但缓存内部结构仍然持有这个 key 的强引用。这样一来key 对象从根对象出发依然可达GC 永远不会回收它。类似的坑几乎每天都在线上出现。1.3 自动回收的代价停顿与开销引用手工释放free一条指令就能完成。自动回收则复杂得多识别垃圾需要遍历对象图、标记状态、维护引用关系、最后才能真正回收内存。遍历越深花费的时间和 CPU 就越多。更麻烦的是移动对象时所有引用这些对象的代码都可能正在执行如果线程还在运行很容易在引用地址和实际地址之间出现不一致。于是几乎所有现代收集器都会引入“暂停用户线程”的机制也就是 Stop The WorldSTW。STW 期间应用线程全部停下来等垃圾收集干完活再恢复。这个停顿时间直接决定了用户体验也是 GC 调优的核心指标。自动化的代价一旦被理解就知道没有“免费午餐”这回事。GC 设计本质上是一场在吞吐量、延迟、内存占用、实现复杂度之间的多目标平衡。理解了这一点再去看各种收集器参数思路就会清晰很多。2. 四个基础算法理解它们就理解了 GC 的一半2.1 引用计数直觉最简单但环是死敌引用计数是最直觉的算法每个对象内部保存一个计数器记录当前有多少个引用指向它。引用被赋值给新变量时计数器加一引用离开作用域或被重定向时计数器减一。计数器归零的那一刻对象立即被回收。这个方案的优势很明显回收即时、逻辑简单、不涉及全局扫描也不需要暂停应用线程。因此在一些脚本语言里引用计数至今仍占有一席之地。但它有一个致命缺陷循环引用。两个对象互相持有对方的引用从外部看已经没有任何引用指向它们可是它们各自的计数器都大于零永远等不到归零那一刻。经典场景是父节点持有子节点子节点持有父节点外部把父节点引用释放之后整棵对象树依然存活造成内存泄漏。引用计数的另一个问题是每次引用赋值都要更新计数器在并发环境下还需要原子操作和内存屏障开销并不低。经验上看绝大多数引用写入是热路径上的高频操作这个成本不可小觑。语言实现如果要用它一般会额外配一个兜底的追踪式收集器专门回收那些引用计数处理不了的循环垃圾。2.2 标记-清除通用的第一步标记-清除算法分两个阶段。第一阶段从根对象出发遍历所有可达对象在对象头上打上“存活”标记。第二阶段线性扫描整个堆把那些没有标记的对象当成垃圾回收。相比引用计数标记-清除能正确处理循环引用也不需要每次赋值都做额外操作通用性大幅提升。它的核心问题也暴露得很快回收完的内存是零散的。存活对象之间夹杂着大大小小的空闲块下次要分配一个大对象时可能找不到连续空间哪怕总空闲内存非常充裕。这就是内存碎片问题。碎片化在长时间运行的服务里尤其致命。堆越大回收次数越多碎片化越严重。很多收集器后来又做了一步改进比如把多个相邻空闲块合并成更大的块或者干脆引入“位图标记 空闲链表”来管理但这只是缓解治标不治本。2.3 标记-复制用空间换时间也换来了年轻代为了彻底解决碎片问题标记-复制算法登场了。它把可用内存划分为两个等大的区域每次只使用其中一个区域。收集发生时把当前区域里的存活对象全部复制到另一个区域然后一次性清空原来的区域。因为存活对象被紧密排列在新区里分配空间时只需要移动一个指针效率极高也不存在碎片。复制算法有个鲜明特点回收成本与存活对象数量成正比而不是与堆大小成正比。如果存活对象很少比如大部分对象都是朝生夕死的临时对象复制耗费极低回收速度飞快。如果存活对象一大堆复制开销就会迅速膨胀反而得不偿失。这个特性直接催生了分代收集。统计规律告诉我们绝大多数对象的生命周期极短只有少数对象会存活很久。于是堆被划分为年轻代和老年代年轻代专门用复制算法处理那些短命对象只有熬过多次回收的对象才晋升到老年代。后续几乎所有主流收集器都沿用了这个框架。2.4 标记-整理移动对象压实内存老年代里存活的都是长命对象如果还用复制算法每次回收都要搬运大量对象成本过高。于是标记-整理算法应运而生。它的标记阶段和标记-清除一模一样但在回收时不是简单地释放未标记对象而是把所有存活对象向内存的一端移动互相紧挨着排列然后一次性清掉边界以外的空间。这样既消除了碎片又解决了复制算法在老年代场景下成本过高的问题。代价是移动对象本身是一笔不小的开销而且对象地址变了所有指向这些对象的引用都必须同步更新。如果更新不及时前面刚改完引用后面又读到旧地址程序就会直接崩溃。现代实现一般通过“转发指针”或者句柄表来辅助处理。对象被移动后原位置留下一句话我搬去新地址了。后续访问到这个旧位置时通过这些信息找到新地址再修正引用。多了一层间接跳转性能自然会受些影响。2.5 算法对比没有绝对的优劣算法是否移动对象是否能处理循环引用回收后碎片主要适用场景引用计数否不能需要辅助无碎片短生命周期对象或作为兜底标记-清除否能有碎片通用型堆回收实现简单标记-复制是能无碎片存活率低的新生代标记-整理是能无碎片存活率高的老年代把这些算法放在一起看无外乎两个关键决策点一是怎么判定存活二是要不要移动对象。任何现代收集器本质都是在这两个问题之间找平衡再叠加并发、增量、分代等手段来压榨性能空间。3. 现代收集器的工程折中3.1 分代假设八二定律的启发前面提到分代模型这里展开说。分代假设源于一个统计观察绝大多数对象在创建后不久就会变成垃圾。可以说是“对象的八二定律”——80% 的对象活不过几次收集。先建对象、用一下、扔掉的模式在业务逻辑里非常普遍请求来了创建一个临时对象处理完就再也没有引用。分代收集把这个规律用到了极致。年轻代采用复制算法因为活下来的对象少复制成本低。每次年轻代收集后存活对象被搬到 Survivor 区每经历一次回收对象年龄就加一。年龄超过阈值后对象晋升到老年代。老年代采用标记-整理或标记-清除因为这里存活率高移动成本大适合低频次、大面积地收拾。不过分代不是银弹。有些应用的对象生命周期特征很反常比如缓存系统对象全是长命对象又比如批量数据处理短时间内创建海量临时对象。分代模型遇到这类负载表现可能就不理想。这也是为什么很多收集器设计了自适应策略根据运行时数据动态调整代的大小和晋升阈值。3.2 三色标记与写屏障并发收集器如今是主流但并发有一个绕不开的难题标记过程还没结束应用线程就把引用关系改掉了。如果收集器刚判定某个对象是垃圾应用线程立刻又让一个存活对象引用它那这个“垃圾”其实已经重新变成了存活对象。收集器如果看不见这个变化回收时就会把存活对象误杀。三色标记抽象把这个问题描述得很清楚。白色表示未被访问黑色表示自身及其子对象都已被访问灰色表示自身被访问但子对象还没扫完。并发标记的安全要求是不存在“黑色对象引用白色对象”的情况。因为黑色对象已经扫完不再重新扫描如果它新增了一个白色引用而没人记录白色对象就会被漏标回收时当成垃圾清掉。解决方案是写屏障。在每次引用写入前后插入一小段额外逻辑把新写入的引用记录下来然后标记线程在后续扫描中去处理这些记录。写屏障的代价是每次引用赋值都要多跑几条指令。但为了并发安全这个代价必须付。理解写屏障是理解现代收集器性能差异的关键因为内存写操作往往处于程序热路径上屏障设计的好坏直接决定了收集器的吞吐表现。3.3 主流收集器的演进路径Java 平台的收集器演进最能说明问题。早期的串行收集器简单但停顿极长一次 Full GC 能停顿几秒钟。后来并行收集器把标记和清除阶段并行化吞吐量上来了但 STW 时间依然不理想。CMS 被设计成并发标记、并发清除希望用户线程在大部分时间内都能继续执行可它解决不了碎片问题而且并发失败时反而更糟。G1 的思路是分区化。它不再严格维护物理连续的新生代和老年代而是把堆切成若干个大小相等的 Region。新生代和老年代是逻辑概念由多个 Region 组成。收集时先挑垃圾最多的 Region 来回收而不是全堆扫描。这个“回收集”机制把停顿时间控制得更加平滑。再后来的低延迟收集器则直接把目标定在“无论堆多大停顿都在几十毫秒以内”技术上主要依赖染色指针和读屏障几乎彻底弱化了堆大小对停顿的影响。收集器演进的核心线索非常清晰从单线程到多线程从串行到并发从停顿可控到接近并发。每一次演进都是对吞吐量和延迟权衡的重新调整。3.4 不同语言运行时怎么选Go 语言的收集器则是另一条路线。Go 选择了无分代、并发标记-清除配合混合写屏障。原因是 Go 的堆分配很多来自 goroutine 栈对象和短期临时结构分代边界不容易界定。无分代让实现更简单、停顿更可控很多在 Java 里需要调参才能获得的效果Go 默认配置就能达到。Python 的实现是引用计数加分代回收。引用计数保证了大多数对象的回收即时发生分代程序专门处理循环引用。JavaScript 的主流引擎普遍采用增量式标记和并行收集响应速度优先。C#/.NET 则走传统分代加后台收集同时单独管理大对象堆。没有哪种方案可以通吃所有场景。选型的判断依据不是“哪个更先进”而是“负载特征和延迟目标更适合哪种策略”。比如追求极端低延迟的服务可能更倾向那些偏向并发、停顿可预测的收集器而离线批处理任务则完全可以把吞吐量放在第一位容忍相对较长的停顿时间。4. 调优与排查不是玄学的实操经验4.1 先看三个指标很多开发者一遇到 GC 问题就开始慌默认调参数这是不推荐的。动手之前先明确三个指标。吞吐量应用执行有效工作的时间占总运行时间的比例。GC 时间占比越高吞吐越低。延迟单次停顿对请求响应时间的影响业务上通常看 GC 停顿导致的超时率。足迹堆占用的物理内存总量。堆设得越小收集越频繁堆设得越大收集周期越长单次停顿也可能越大。三个指标互相制约不可能同时拉满。比如把堆调大Full GC 频率下降但每次 Full GC 的停顿时间可能变长换用并发收集器平均停顿变短但总体 CPU 开销变高吞吐量下降。调优的第一步就是确定业务到底最在乎哪一个指标。4.2 读懂 GC 日志GC 日志是排查问题的第一现场。日志打印的内容看似抽象其实包含了几项关键信息收集类型、回收前后各区占用量、实际停顿时间和堆总大小。以一次常见的年轻代收集为例[GC (Allocation Failure) [PSYoungGen: 46852K-1024K(48128K)] 46852K-1024K(124416K), 0.0018418 secs]这行日志说明年轻代从 46852K 回收到 1024KEden 几乎清空耗时 1.8 毫秒。如果老年代频繁出现在 Full GC 日志里而且回收前后占用几乎没变化通常意味着大量对象已经无法回收大概率是泄漏或对象长期不被释放。再看一组更危险的日志[Full GC (Ergonomics) ... 77316K-77012K(124416K), 0.4822830 secs]回收前 77MB回收后 77MB白忙活一场还停顿了 482 毫秒。这种典型信号一出现就别再纠结参数了赶紧拉一份堆转储回来分析对象分布。除了日志还可以配合运行时监控工具实时观察堆使用趋势、GC 频率、各代容量变化。先看趋势再定位对象比盲目调参数靠谱得多。4.3 分区字段含义速查日志字段含义关键度GC / Full GC收集类型年轻代还是全局收集高PSYoungGen年轻代回收前后占用及总容量高ParOldGen老年代回收前后占用及总容量高secs实际停顿耗时最高Allocation Failure触发原因通常为空间分配失败中Metaspace元数据区占用情况低4.4 常见问题与排查步骤问题一Full GC 频繁CPU 飙升业务超时。这种场景见过很多次。第一步不是看参数而是确认老年代的对象到底是什么。用堆转储分析工具拉一把快照按对象实例数量排序。随随便便就能看到某个全局缓存的实例数量和占用空间异常。查引用链看它为什么从根对象可达基本就能定位到具体代码。如果分析下来确认是缓存没有过期机制那 GC 参数再怎么调都没用得先改业务代码。毕竟收集器只能回收“不可达”对象而缓存对象一直可达不从源头切断引用内存只会有去无回。问题二GC 时间不长但总吞吐量上不去。这种情况往往是分配速率太高。比如热路径上频繁创建临时对象或者拼接长字符串时产生大量中间对象。GC 本身单次工作很干净但因为垃圾生成速度太快收集器几乎一直在工作。调参只能缓解真正有效的手段是优化代码减少不必要的对象分配。问题三延迟目标极低。实时性要求高的业务对单次停顿非常敏感。这时可以考虑低延迟收集器同时配合小堆控制收集周期。另外一个方向是减少 GC 压力把大对象移到堆外比如用直接内存或者内存映射文件管理缓存数据。这样虽然多了序列化/反序列化成本但能把 GC 频率和停顿降到很低的水平。问题四OOM 与内存泄漏。OOM 是最严重的表现。可如果已经 OOM现场往往已经被破坏最好的做法是在 JVM 参数里开启-XX:HeapDumpOnOutOfMemoryError让进程在崩溃前自动导出堆快照。之后拿着快照重点看两个地方占用最大的对象以及这些对象到根节点的引用路径。只要沿着引用链向上追溯就能找到是谁“扣住”了这些内存不放。4.5 一些被误解的“玄学”社区里流传着不少调优口诀比如“新生代越大越好”“GC 越少越好”。实际经验并不是这样。新生代调大确实能降低年轻代收集频率但每次收集要扫描的空间也变大单次停顿可能上升。GC 次数少也不代表质量高一次长时间停顿带来的伤害可能远大于十次短停顿。我习惯把 GC 调优的优先级排成这样先修代码层面的问题再改运行参数最后才考虑换收集器。如果代码里存在大量本该被释放的对象任何参数都只能帮你拖延时间不能根治问题。还有一点经常被忽略升级运行时版本。新版运行时往往内置了改进后的收集器实现和默认参数停顿控制和内存管理能力比旧版强非常多。很多时候升级一个版本比手工调参数收益更大也更省心。5. 一点个人体会做开发这些年我有过几次印象深刻的 GC 排障经历。一次是某内部服务每到凌晨就频繁出现长停顿所有人都在调堆大小、调并发线程数折腾了几天没有结论。后来仔细看对象分布才发现问题出在一个批量任务里它一次性把好几个 GB 的临时数据加载到内存又不及时释放引用导致老年代直接被打满。改成流式处理后Full GC 几乎消失了。这件事让我一直记得先看数据和代码再动参数。GC 多数时候只是表象根因永远在对象分配和引用持有方式上。另一次经验是调优时不要追求极端。有人喜欢把收集器调到几乎不发生 Full GC结果发现每次大回收反而更痛。真正健康的服务GC 日志应该是规律、短促、可预测的。某次年轻代收集频率略高但每次只有几毫秒整体响应一直很稳定这就比偶尔来一次几百毫秒的停顿更符合实际需求。用“规律性”而不是“零 GC”来指导调优思路会清晰很多。最后分享一个我常用的实战习惯每次上线前把 GC 日志定时抓下来连续观察一周。不为别的就为了给服务建立一套“健康状态基线”。一旦出现与基线明显偏离的曲线就能尽早介入而不用等到用户反馈超时再来查问题。GC 原理虽然理论味很重但落到日常维护里就是这些细碎却有效的习惯。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询