☰
垃圾收集(GC)原理与调优实战:从算法到线上内存排查
2026/10/22 18:08:42 网站建设 项目流程

最近有个朋友跑来找我,说线上服务的堆内存曲线像爬山,早上还是平的,到下午就开始一路往上顶,重启能好半天,过阵子又旧病复发。我让他先别急着加机器扩内存,把 GC 日志开了看一眼,结果果然又是老问题。很多人听到“垃圾收集”(Garbage Collection,GC)就觉得这是虚拟机内部的事,跟写业务代码的人没关系,其实恰恰相反。只要你还得让程序跑得稳、跑得快,你就躲不开要理解它。

这篇文章我会从原理一直聊到实际排查经验,把三类基础算法、根可达性分析、分代回收的逻辑都拆开讲,最后再分享几个我实际调内存问题时的操作步骤和踩坑心得。无论你是刚接触 GC 的学生、写过一段时间业务代码但没系统梳理过对象回收的开发者,还是正在准备技术面试的人,这篇应该都能让你少走点弯路。

1. 从手动释放到自动回收:垃圾收集到底解决了什么

1.1 手动内存管理的三个典型困境

在没有垃圾收集的语言里,内存是要自己借、自己还的。每次malloc或者new出来的对象,本质上就是从系统手里借了一块地,用完得亲手还回去。这件事听起来简单,做起来却非常反人性,因为你要在代码的每一个退出路径上都记得归还,而且还得保证归还的时机不多不少。

最常见的三个坑我估计写过底层代码的人都遇到过:第一种是忘了归还,叫内存泄漏,对象永远占着位置,程序越跑越胖;第二种是还早了,别人还在用这块内存,结果内容已经被改写,这就是悬垂指针;第三种更狠,同一块内存还了两次,直接让进程崩溃。你可以把内存想象成一间临时借来的房子,忘了退房,房东就没法把这个房间租给别人;退早了,你室友晚上回来发现钥匙已经换了,进不去;同一间房退两次,物业系统直接报错。

在没有垃圾收集的年代,这些问题只能靠开发者的自律和大量的代码审查去规避。但人总会犯错,尤其是代码规模一大,谁还分得清某个对象到底还有没有人在引用。

1.2 自动回收的价值:把判断交给运行时

自动内存管理的思想其实很早就有,最早可以追溯到 1960 年代的一批动态语言实现,它们尝试让运行时自己去判断“这个对象还活不活”,活着的留下,死掉的回收。这就是垃圾收集的本质:运行时不依赖开发者手动释放,而是自动找出那些“再也没有人使用”的对象,并回收它们占用的空间。

这个转变最大的价值不是省了开发者几行代码,而是把“对象是否还活着”的判断从人脑转移到了程序机制里。人判断靠记性,机制判断靠规则,规则虽然会消耗一些额外资源,但稳定性和可靠性远远超过人肉维护。

当然,GC 不是银弹,它也有成本。垃圾收集需要占用 CPU 来扫描对象、标记状态、搬运内存,还可能在回收过程中让应用程序暂停一下。理解这些成本,以及成本花在了哪里,正是我们后面要聊的重点。

2. 引用计数、标记-清除、标记-压缩:三种经典思路的取舍

2.1 引用计数:实时性强,但怕“循环引用”

引用计数(Reference Counting)是所有垃圾收集算法里最直观的一种思路。它的核心逻辑很简单:每个对象在创建时挂一个计数器,其他对象每引用它一次,计数器加一;引用断开时,计数器减一;当计数器归零,说明没有任何人再引用它,立刻回收。

这个方案的优点是实时性强,内存一但没人引用,马上就能释放,不需要等到某个特定时机才动手。实现上也不需要全堆扫描,每个对象的生命周期都比较清晰。不少语言和框架在局部场景下还在使用或借鉴引用计数。

但它有两个明显的坑。第一个是循环引用。假如 A 对象持有了 B,B 又持有了 A,而且这两个对象已经从根上断开了,但它们的引用计数互相撑着永远不为零,就谁也回收不了,形成死锁一样的泄漏。第二个是维护计数器的开销,在多线程环境下,计数器加减往往需要原子操作,竞争激烈时会影响性能。所以现实中的引用计数方案,通常还要配一个额外的机制去处理循环引用,比如定时扫描那些“有可能互指”的对象。

2.2 标记-清除:从根源出发,先标记后回收

标记-清除(Mark-Sweep)算法是另一个经典思路,它分两个阶段工作。

第一个阶段是标记。从一组确定的“根”出发,沿着对象的引用链一路走,把所有还能碰到的对象都做上记号。这就好比你要统计一栋楼里还有哪些房间住着人,你会从大门口开始,顺着楼层通道逐个房间查看,凡是能走到的地方都画个勾。第二个阶段是清除,遍历整个内存区域,把没有做过记号的对象回收掉。

这种方案天然解决了循环引用问题,因为判断依据不是“谁引用了我”,而是“能不能从根到达这里”。不过它也有自己的短板:标记和清除都要遍历大量对象,停顿时间往往比引用计数长;更麻烦的是,清除之后内存空间会变得七零八落,碎片化严重,就好比一整层楼的房间被随机退租了大半,后面想分配一间大办公室时可能反而找不出连续空间。

2.3 标记-压缩与复制算法:解决碎片,但各有代价

为了解决碎片问题,出现了两种改良思路。

标记-压缩(Mark-Compact)在做完标记之后,不直接清扫描区域,而是把存活对象往一端搬运,然后把另一端一次性清空。这样内存始终是连续的,分配新对象时找指针往后挪就行,但搬运存活对象的成本不低,而且整个过程中应用程序通常要暂停。

另一种是复制算法(Copying)。它将内存分成两个区域,平时只用一块,回收时把存活对象整个复制到另一块,再清空当前块。实现简单、无碎片,而且只要遍历存活对象即可,但代价是需要浪费一半内存空间来当“备用区”。你可以把它想象成搬家时先租个临时仓库,把要留下的东西搬过去,再回来清空老房子,东西搬得轻松,可仓库租金是实打实的成本。

三种算法其实没有一个全能的,所以现在主流托管运行时里的垃圾收集器,基本都是它们的组合变体。理解每种方案的痛点,才能理解为什么后面会有分代回收这种设计。

算法回收依据主要优点主要缺点典型适用场景
引用计数计数器归零实时回收、实现直观循环引用、线程竞争开销小型对象、无环场景
标记-清除可达性分析解决循环引用、实现简单内存碎片、全堆扫描老年代、整堆回收
标记-压缩可达性分析无碎片、空间利用率高搬运成本高、停顿明显老年代整理
复制算法可达性分析无碎片、回收高效浪费一半空间对象存活率低的年轻代

3. 谁是垃圾由谁来定:GC Roots 与可达性分析

3.1 根集合:从哪些地方出发找活对象

搞清楚了基础算法,接下来一个关键问题就是:标记是从哪些点开始的?这个起始点集合在垃圾收集里叫 GC Roots,也就是根集合。

根集合包含的东西其实是可穷举的,比如当前正在执行的方法里被局部变量引用的对象,静态变量引用的对象,还有一些底层运行时结构持有的引用,像是线程栈上的对象引用等。这些东西有一个共同点:它们是程序当前正在使用或可以立即使用的入口。

从这些根出发,沿着引用关系一路走下去,所有能走到的对象都是“活”的,走不到的就是“死”的。这个判定方法在业界叫可达性分析,它在逻辑上要比引用计数严谨得多,因为判断的是“这张引用关系网里,你是否还有路径连接到程序入口”。你可以把根集合想象成房间的电源总闸,从总闸出发能点亮的那串灯泡就是活对象,没有连上总闸的线路,即便看起来还通着电,也只是虚电,随时可以拆掉。

3.2 三色标记法:让并发标记不混乱

可达性分析听起来不算复杂,但真实场景里一个难点在于:标记过程中,程序还在运行,对象的引用关系时刻在变。为了在并发环境下把标记做对做细,现代垃圾收集器普遍采用三色标记法。

它把对象分成三种颜色:白色代表还没扫描到,灰色代表已经发现但它引用的对象还没处理完,黑色代表这个对象和它引用的对象都处理完了。标记开始时,所有对象都是白色,根对象变成灰色。然后循环做两件事:取出一个灰色对象,把它变成黑色,同时把白色引用对象变成灰色。等到没有灰色对象时,剩下的白色就是真正没人可达的了。

这个过程看起来简单,但并发环境下会有“漏标”风险。怎么避免?通常要配合写屏障,也就是在引用关系发生改变时,额外做一些记录或补救动作,保证新建立的引用也能被后续标记感知到。三色标记是理解现代垃圾收集器并发机制的钥匙,你后面如果去看各种回收器的停顿描述,会发现很多术语都绕不开它。

3.3 引用类型:给回收留出“协商空间”

除了“强引用”这种默认的生死与共的模式,主流托管运行时还提供了软引用、弱引用、虚引用这几类特殊引用,用来表达“可以协商”的对象生命周期。

软引用的对象,在内存充足时不会急着回收,内存紧张时就会被清理掉,适合做缓存类数据;弱引用的对象,只要被垃圾收集一扫描,发现它只有弱引用可达,就直接回收,适合做“有最好、没有也没关系”的旁路数据;虚引用最特殊,它本身几乎不决定对象生死,更多是配合底层内存管理做回收通知用。

我自己的理解是:强引用是“这是我的东西,谁也别动”,软引用是“平时留着,真没钱了再卖”,弱引用是“路边小广告,没人在意就撕掉”。理解这一层,对后面排查内存问题非常有帮助,因为很多“GC 收了但没效果”的案例,本质上是对象其实是通过强引用被根拽住了。

4. 现实世界的 GC:分代回收与停顿博弈

4.1 弱分代假说:为什么大多数对象活不过第一轮

前面提到复制算法有个空间浪费问题,为什么现实中的垃圾收集器还是大量使用复制思想?关键就在于一个统计规律:绝大多数对象的存活时间极短。比如你在循环里临时创建的对象、方法调用产生的中转数据,往往在创建之后几个操作内就再也没人用了。这个规律叫弱分代假说。

既然大多数对象很快死掉,那么“把内存分成两块、用复制算法回收年轻对象”就很划算。存活对象比例不高,需要复制的就少,即使浪费一半空间,换来的是可以在密集的年轻对象回收中获得很高的效率。反过来,那些经过多轮回收还活着的对象,大概率会活很久,就叫老年代对象,处理它们的算法就要换思路了。

4.2 年轻代里的 Eden 与 Survivor:复制算法的工程化改造

直接分两块区域做复制会浪费一半内存,所以工程实践上做了优化。年轻代被进一步分成 Eden 区和两块 Survivor 区,新对象统一在 Eden 区分配。进行一次年轻代回收时,把 Eden 里存活的对象复制到一块 Survivor,而之前已经在另一块 Survivor 里活过一轮的对象,则提升年龄,超过阈值的直接升入老年代。

这样做的好处是,不需要把整块年轻代空间都当成“备用区”,浪费的空间被压缩到两块 Survivor 之一,同时还能借助年龄统计,把真正长命的对象逐步送到老年代。你去看主流托管运行时,核心思路大致都是这个框架:局部用复制算法应对“朝生夕死”的对象,整体用标记-压缩或标记-清除去管理老年代。

这样设计最直接的效果是,回收动作大部分时候只发生在年轻代这个小范围里,停顿时间相对短。老年代则因为对象少、存活率高,回收频率相对低,每次停顿虽然长一些,但不会频繁打扰程序。

4.3 吞吐优先还是延迟优先:回收器的两条技术路线

理解了分代结构,再去看不同垃圾收集器的差异就清楚了。它们本质上是同一套原理下,对“吞吐量”和“停顿时间”做了不同的取舍。

偏向吞吐量的方案,会尽量把回收工作集中起来批量做,GC 期间暂停应用,但总消耗时间相对小,适合后台任务、离线计算的场景。偏向延迟的方案,则会把标记和清除的大部分工作拆散,与应用线程交替执行,尽力缩短单次停顿,适合在线服务这种对响应时间敏感的场景。还有一类分区式思路,不再严格按连续的年轻代和老年代整块处理,而是把堆切成更小的区域,每次只回收部分区域,用于精细控制停顿。

回收器风格核心追求停顿表现适合场景
串行回收最简单、单线程长时间停顿小型应用、开发环境
并行回收高吞吐量每次停顿较长但总时间少后台任务、离线计算
并发标记清除低延迟较短停顿,多数与应用并发在线业务、交互服务
分区式回收停顿可预期细粒度停顿,兼顾吞吐大堆、大规模服务

5. 从日志到行动:GC 调优的完整排查思路

5.1 第一步:打开 GC 日志,别再用“重启大法”糊弄问题

前面提到朋友那个线上服务的问题,我最开始的建议就是先开 GC 日志。很多团队直到内存告警了才想起来看日志,结果日志保留时间不够,或者干脆没开,最后只能靠重启缓解。这不是解决问题的办法,只是把问题往后推。

主流托管运行时基本都支持通过启动参数打开 GC 日志。以常见的 Java 虚拟机为例,新版本可以直接用-Xlog:gc*这种模式把日志输出到文件,设置日志文件的滚动和保留份数。我习惯把 GC 日志放到独立目录,单独设置保留策略,避免它在运行一段时间后把磁盘写爆。日志信息包括每次回收发生的时间点、回收前用了多少内存、回收后还剩多少、总共耗时多长,这些是判断内存健康程度的第一手材料。

打开 GC 日志本身对性能的影响微乎其微,却能在出问题时给你留下最可靠的证据链。对于任何要长期运行的服务,我都建议把 GC 日志当做和业务日志同样重要的基础设施来对待。

5.2 第二步:读懂日志里的关键信号

拿到 GC 日志之后,不要抓瞎。我一般先看几类指标:单次 GC 的停顿时间是否稳定,年轻代回收频率是否过高,老年代回收即 Full GC 出现了多少次,以及在老年代回收前后,堆占用是否一直维持在高位不回落。

有个概念要搞清楚,就是分配速率。分配速率是单位时间内新对象占用的内存量,它比“堆用了多少”更能反映代码层面的健康度。如果 GC 频率突然上升,往往不是 GC 本身出了问题,而是分配速率被某段代码拉高了。比如一个循环里无意识地创建了大量临时对象,堆再大也扛不住。

除了看日志,还可以用运行时自带的监控命令观察内存池的使用趋势,比如查看各个内存区域的使用率、老年代对象年龄分布等。把这些数据和 GC 日志对照起来看,才能判断当前的问题是“堆整体偏小”还是“某个区域明显异常”。

5.3 第三步:分清“堆太小”与“真有泄漏”

很多新手一看到 Full GC 频繁,第一反应就是调大堆内存。但实践里这个方向经常是错的。GC 频繁无非两种原因:一是对象总量确实超过了当前堆的容量,二是存在泄漏,程序已经不再使用的对象却始终被引用链拽着,导致堆里“活对象”越来越多。

怎么区分?我通常看这样一个趋势:把 GC 日志里每次回收之后的堆占用拉成一条曲线。如果曲线整体平缓,只是偶尔冲到高峰,那多半是峰值压力大,堆容量可以适当调大。如果曲线持续爬升、每次回收后占用都比上一次高,那就不是堆大小的问题了,而是有对象一直在累积,再怎么调堆也只是推迟崩溃。

确认是泄漏以后,需要用堆转储工具拿到内存快照,分析那些占据大量空间的到底是什么类型的对象,再顺着引用链找到它是被谁持有。这一步才是根因所在。我见过不少案例,表面上是 GC 线程忙个不停,实际元凶就是一个静态容器不断塞对象,或者某个缓存用了强引用没有清理策略。

5.4 我踩过的三个调优误区

第一个误区是堆越大越好。大堆确实能延缓触发 GC 的时间,但单次回收的扫描范围也会变大,停顿时间往往会更长。在线服务里,宁可让 GC 稍微频繁一点,也不能让单次停顿冲上秒级。

第二个误区是一上来就调参数。正确的顺序应该是先确认问题到底是分配过快、对象不释放、还是堆容量不够。没有日志和快照做依据的调参,基本靠猜,运气好解决了,运气不好下一个爆点藏在别处。

第三个误区是忽略分配速率的恶化。分配速率突然上升常常是代码改动引入的,比如一个更频繁的循环、一次更大的集合遍历。遇到这种问题,即使你把所有 GC 参数都调一遍,也不如从代码层面把临时对象的产生量降下来有效。

6. 一个典型的“GC 背锅”案例与我的排查习惯

6.1 缓存用了强引用,GC 只能干瞪眼

前阵子有个项目组说他们的服务频繁 Full GC,老年代一直在涨,怀疑是垃圾收集器不行,想换个延迟更低的回收器。我去看 GC 日志,发现老年代每次回收之后占用都会比以前高一截,这个趋势基本可以排除回收器本身的问题。

后来做了堆转储,发现某个全局缓存结构里存了大量业务对象,而这些对象被取出来后业务代码仍然长期持有引用,等于把缓存里的对象又“借”了一份出去,一直不还。根因清楚了以后,方案其实很简单:把缓存里的引用改成弱引用语义,让对象在外部不再使用时能够被正常回收;同时给缓存加上容量上限和过期机制。

最终效果立竿见影,老年代占用曲线直接回落,Full GC 次数大幅下降,压根不需要换回收器。这种案例我看过不止一次,所以现在最怕听到的不是“GC 调不动”,而是“你知道给我换个回收器就行”这种话。抛开证据链谈调优,多半是无用功。

6.2 我的排查顺序已经固定成四条线

现在我自己处理内存问题,基本固定一套流程。第一步看 GC 日志,确认回收暂停、回收前后占用和频率。第二步看分配速率,判断是不是代码层面产生了大量临时对象。第三步看堆转储,确认大对象分布和引用链。最后一步才是调参数,而且每改一个参数都要观察一段时间再做结论。

这套流程看起来朴素,但比到处搜“XX 回收器参数怎么配”靠谱得多。如果是团队协作,我还会建议把 GC 日志采集、关键指标监控提前接好,而不是等出事故了再去回溯。内存问题和性能问题一样,越早暴露越好处理,回头去找证据是最费劲的。

6.3 最后分享一个我判断 GC 是否异常的土办法

如果你刚开始接触 GC,连日志都不想看,可以先加一个粗略的监控:把“每秒新分配了多少字节”和“老年代回收后占用变化”记录到定时任务里,持续观察几天。正常服务的年轻代分配速率会有波动,但老年代回收后的占用水平应该是相对稳定的。

一旦发现老年代回收后的占用水平持续走高,哪怕走得很慢,也值得警惕,这往往比单次 Full GC 持续时间长更能说明问题。花五分钟把这个监控挂上,可能比你在配置文件里反复折腾半天参数要有价值得多。我自己这些年调 GC 最大的体会就是:垃圾收集本身不可怕,无视证据、凭感觉调参才可怕。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询