你写过 C/C++ 吗?那种"malloc 了就要记得 free、new 了就要记得 delete"的日子,本质上是把一个决定权——这个内存什么时候可以还回去——交给了每一个程序员。听起来合理,实践起来是灾难。忘记释放叫内存泄漏(memory leak),程序越跑越胖;释放两次叫双重释放(double-free),内存管理器直接崩溃;释放之后还在用叫释放后使用(use-after-free),这是安全漏洞的温床——曾经震动业界的 Heartbleed,根子就在这里。
那有没有一种办法,让运行时自己决定"哪些内存该回收"?有,这就是垃圾回收(Garbage Collection, GC)。SysSketch 这支《What Happens During Garbage Collection?》用动画把这件事拆得清清楚楚:堆是怎么布局的、回收器怎么判断一个对象是"死"是"活"、分代回收为什么快、现代 JVM 又怎么做到几乎不卡顿。它解决的核心问题只有一个——让开发者彻底忘掉"释放"这件事,同时程序还不会把内存吃光。这篇文章我带你把它的内核挖到底。
| 项目 | 内容 |
|---|---|
| 视频标题 | What Happens During Garbage Collection? (Explained Visually) |
| 频道 | SysSketch |
| 主题 | 垃圾回收机制可视化拆解:堆布局、可达性、分代、并发回收 |
| 核心问题 | 运行时如何自动判断并回收"死"对象,又不卡住应用 |
| 适合人群 | 后端工程师、JVM/.NET 用户、面试备考者、想搞懂卡顿根因的人 |
视频最关键的一步,是重新定义什么叫"活着"。GC 不去追踪"谁创建了这块内存",那是引用计数(reference counting)的思路,它有致命缺陷——循环引用(A 引用 B,B 引用 A,谁都不会归零)。现代 GC 用的是一个更聪明的判据:可达性(reachability)。
它先定义一组GC Roots(根集合),这是回收器眼中的"绝对起点":
然后规则只有一条:一个对象"活着",当且仅当从某个 root 出发,沿着引用链一路能走到它。走不到的,就是垃圾。这一步的伟大之处在于,它把"该不该释放内存"这个看似需要全局判断的难题,转化成了"从一个起点集合出发遍历一张图"的标准计算机科学问题。这正是 SysSketch 视频里那张从 root 向外辐射的引用图想表达的核心——死亡不是被宣布的,而是被遗忘的。(这是视频原话的逻辑内核,下面是我的延伸。)
判据有了,怎么执行?最朴素的算法叫 Mark-Sweep(标记-清除),分两步:
听起来很美,但有两个硬伤。第一是内存碎片化(fragmentation):死对象东一块西一块地被清掉,留下的空隙大小不一。等你需要一个连续的大对象时,明明总空间够,却塞不进去。第二是遍历要避免递归导致的栈溢出——这正是 C.J. Cheney 在 1970 年那篇 ACM 论文要解决的问题(后面讲)。
Mark-Sweep 至今仍在用(比如 Go 的早期设计、很多老式运行时),但它的碎片问题催生了下一个更优雅的方案。
为了干掉碎片,人们发明了复制式回收(Copying Collection)。思路简单粗暴:把堆劈成两半,叫 from-space 和 to-space。平时只在 from-space 里分配;回收时,把所有存活的对象从 from-space 复制到 to-space,复制的过程中让它们一个挨一个紧凑排列,然后两个空间角色对调。
这一招有四个副产品,每一个都影响深远:
所以复制式回收特别适合存活率低的区域——这就直接引出了整个现代 GC 体系的地基。
这是 SysSketch 视频里最有"aha"时刻的点,也是我建议你反复琢磨的地方。它叫分代假说(Generational Hypothesis),分弱、强两条:
这不是理论推导,是几十年实测的经验观察:大量真实程序的统计显示,80%–98% 的对象在第一次回收时就已经死了。临时变量、循环里的中间结果、一次请求里组装的字符串……它们用完即弃。
这条经验直接决定了 GC 的架构。既然"新对象大概率是垃圾",那把堆按年龄分代就划算:
这是用"局部性"换"全局效率"的经典操作——回收器把火力集中在最容易出活、成本最低的地方。Oracle 官方调优文档里那句话几乎是定律:"绝大多数对象在新生代里就死掉了。"(视频原话强调了这个分代直觉,下面的数学结论是我的延伸。)
分代看起来完美,但有个洞:老年代的对象也可能引用新生代的对象。如果新生代回收时只从新生代自己的 roots 出发遍历,那些被老年代"指着"的新生代对象就会被误判成垃圾、被错误回收——这是致命的。
怎么补?靠写屏障(Write Barrier)+ 记忆集(Remembered Set)。它的逻辑是:每次有代码往老年代写一个指向新生代的引用时,触发一段"屏障"代码,把这个老年代区域记进一个叫卡表(Card Table)的结构里。于是新生代回收时的 roots = 新生代真正的 roots + 记忆集里指向新生代的那些跨代引用。
这样,Minor GC(新生代回收)就只需要扫新生代那一小块,通常几毫秒就结束,完全不用动老年代。这就是 JVM 里"Minor GC 很频繁但很快"的全部秘密。代价是每次写引用都多了一点点开销(写屏障),但跟省下的全堆扫描比,血赚。
到这里你可能会问:回收的时候,应用线程还在跑吗?经典答案是——必须停下来。因为回收期间如果应用线程还在改引用关系(比如刚标记完的对象突然没人引用了,或者又冒出新的引用),回收器的遍历结果就会错乱。这个"暂停所有应用线程"的动作叫 Stop-The-World(STW)。
STW 就是你感受到的那些莫名其妙卡顿的根源:界面卡一下、接口超时、交易延迟飙升。Minor GC 的 STW 通常很短(毫秒级),但 Full GC(整堆回收)的 STW 可能长达几百毫秒甚至几秒,对延迟敏感的系统(交易、游戏、实时风控)是灾难。
所以过去十几年,JVM 和 .NET 工程师们干的所有事情,几乎都指向同一个目标:把回收工作从 STW 里搬出来,挪到和应用线程并发执行。
视频结尾触及的现代前沿,是并发收集器(Concurrent Collector)。代表是 Java 的 G1、ZGC 和 Shenandoah。ZGC 尤其激进,它的设计目标直接写成一行字:亚毫秒级停顿(sub-millisecond pause),且停顿时间不随堆大小增长——10GB 的堆和 10TB 的堆,停顿差不多。
ZGC 的杀手锏是两个,我的延伸解读如下:
Shenandoah 的思路类似,但用 Brooks 指针(每个对象多一个指向自己"当前版本"的转发指针)。殊途同归:让回收和计算真正并行,把 STW 压到几乎不可见。
flowchart TD
A[内存管理难题] --> B{手动管理<br/>malloc/free}
A --> C{自动管理<br/>GC}
B --> B1[双重释放 / 泄漏 / UAF]
C --> D[可达性判据<br/>GC Roots]
D --> E[从根遍历引用图<br/>可达=活 不可达=垃圾]
E --> F[Mark-Sweep<br/>标记后清扫]
E --> G[Copying<br/>半空间复制+整理]
G --> G1[副产品: bump pointer<br/>O(1) 分配]
F --> H[碎片化问题]
G --> I[适合存活率低的区域]
I --> J[分代假说<br/>弱:对象朝生夕死<br/>强:越老越长寿]
J --> K[堆分代<br/>新生代 / 老年代]
K --> L[Minor GC 极快]
K --> M[跨代引用问题]
M --> N[写屏障 + 记忆集<br/>卡表]
L --> O[Stop-The-World 停顿]
O --> P[低延迟并发回收<br/>G1 / ZGC / Shenandoah]
P --> Q[着色指针 + 读屏障<br/>转移与应用并发]
Q --> R[亚毫秒级停顿]
看完这些机制,对你写代码有什么实际帮助?我给你几条立刻能用上的判断。
第一,警惕"还可达但已无用"的内存泄漏。 GC 时代的泄漏不是"忘了 free",而是对象已经没用了,却还被某个长寿的引用链攥着——比如塞进一个 static 的 Map 当缓存、注册了监听器(listener)却忘了反注册、把对象挂到单例上。回收器判断不了"有没有用",只认"可不可达"。只要可达,就永远不会被回收,这才是 Java/.NET 应用内存泄漏的真正长相。
第二,少在热路径里造短命大对象。 弱分代假说说对象朝生夕死,听起来"反正马上回收"无所谓——但回收是有成本的。一个高频循环里疯狂 new 大对象,会撑爆新生代、触发频繁 Minor GC,甚至让大对象直接进老年代(很多 JVM 对超过一定阈值的大对象会绕过新生代直接进 Old,因为复制大对象太贵),最终引发 Full GC。短循环里复用对象、用基本类型替代包装类、注意 Stream/lambda 捕获带来的隐式分配,都是这个道理。
第三,按场景选对 GC。 吞吐优先(离线计算、批处理)选 G1 或 Parallel;延迟敏感(交易、实时接口、游戏服务端)选 ZGC 或 Shenandoah。JDK 21 起 ZGC 已经是生产可用的低延迟主力,停顿基本压到 1ms 以内。别再用默认配置硬扛延迟问题,换一个 GC 往往比优化代码立竿见影。
第四,理解 STW,就能解释"偶发抖动"。 当线上出现规律性的延迟毛刺、又查不出业务原因时,先看 GC 日志——很可能就是某次 Full GC 的 STW。会读 GC 日志、能区分 Minor / Mixed / Full,是后端工程师的基本功。
垃圾回收的内核,是把"该不该释放"转化为"从根能不能到达",再借助"对象朝生夕死"这条经验,用分代 + 写屏障把回收成本压到局部,最终靠并发把停顿压到亚毫秒——你写代码时少造短命大对象、选对 GC,就能让它在你察觉不到的地方安静运转。
本文由「51学通信」技术团队出品。51学通信由通信行业18年老兵爱卫生(微信ID: gprshome201101)维护,聚焦5G/6G核心网、3GPP标准解读、通信前沿技术深度分析。 出版作品有《5G核心网原理与实践》《IMS原理与实践》等4本通信专业书籍。知识星球「51学通信」提供深度技术答疑和行业洞察。
[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。
