垃圾回收(GC)入门:从手动管理到自动回收的底层逻辑与演进

一、从一道"送命题"说起

先看一段 C 语言代码:

arduino 复制代码
int* create_array(int size) {
    int* arr = (int*)malloc(size * sizeof(int));
    return arr;  // 返回数组,但谁来释放它?
}

int main() {
    int* data = create_array(1000);
    // ... 用完之后,我忘记 free(data) 了
    return 0;
}

这段代码的问题在于:data 所指向的内存块在堆上分配,但 main 函数返回后,指针变量 data 本身销毁了,而它指向的那块 4KB(假设 int 占 4 字节)内存却依然被操作系统标记为"已分配"状态。这块内存既不再被任何指针引用,也无法被程序再次使用------这就是内存泄漏

在 C/C++ 的世界里,开发者必须自己管理内存的分配和释放。mallocfree 本质上是向操作系统申请和归还连续虚拟地址空间的接口。手动管理的优势在于零运行时开销(无 GC 暂停)、精确控制内存布局(适合缓存友好型数据结构)、以及可预测的性能表现。但代价也极其沉重:悬垂指针 (释放后继续使用)、重复释放 (double free)、内存泄漏是 C/C++ 程序中超过 60% 的安全漏洞根源(据 Microsoft Security Response Center 统计)。

于是,计算机科学家们开始思考:能否让程序自动判断哪些内存不再可达,并安全地回收它们?这便是垃圾回收(Garbage Collection, GC) 的原始动机。从 1960 年 John McCarthy 在 LISP 中首次实现 GC 至今,这个领域已经发展了六十余年,诞生了数十种算法变体。

二、核心概念:内存模型与对象存活判定

在深入 GC 算法之前,必须建立清晰的内存模型认知。

2.1 栈与堆的本质差异

栈(Stack) 是线程私有的连续内存区域,由编译器自动管理。每个函数调用对应一个栈帧(Stack Frame) ,包含参数、返回地址、局部变量(包括原始类型和对象引用)。栈帧的分配与释放仅仅移动栈指针(rsp/rsp 寄存器),开销为 O(1)。函数返回时,栈帧整体弹出,其中的局部变量直接失效------无需 GC 参与

堆(Heap) 是所有线程共享的离散内存池,由运行时动态管理。堆内存的分配需要寻找合适大小的空闲块(涉及空闲链表或空闲位图扫描),释放则涉及归还和合并相邻空闲块。堆上对象没有自动的生命周期边界------一个对象可能在函数返回后依然被其他对象引用,因此需要 GC 来判定其存活性。

简单来说:

栈(Stack) 就像一个小本子,记录函数调用和局部变量。函数一结束,对应的栈帧就被丢弃,里面的数据自动失效,完全不需要 GC 操心。

堆(Heap) 则像一个大型仓库,存放那些生命周期不确定的数据。比如:

javascript 复制代码
function createUser(name) {
    let user = { name: name, createdAt: Date.now() };
    return user;  // 对象在堆上,引用返回给调用方
}
let me = createUser("张三");
// 即使 createUser 的栈帧已销毁,堆上的 user 对象依然活着

user 对象活在堆里,me 引用着它。只要 me 还在,这个对象就是"活着"的。

2.2 可达性------GC 的判定公理

GC 的核心问题是:如何判定一个对象是死是活?

几乎所有现代 GC 都采用可达性(Reachability) 作为存活判据。从一组固定的根对象(Roots) 出发------具体包括:

  • 全局对象 :浏览器中的 window、Node.js 中的 global
  • 当前执行上下文:栈上的局部变量、参数
  • 寄存器中的对象引用
  • JNI/FFI 引用(跨语言边界持有的对象)
  • 活跃的定时器、DOM 节点等 Web API 持有的引用

从这些根出发,沿着对象间的引用关系图进行遍历(DFS 或 BFS),所有能被访问到的对象标记为"存活",其余均为"垃圾"。

可以把这想象成树的根系:从根出发能顺着分支找到的所有叶子都是活的,那些断开的枯枝败叶就是待回收的垃圾。

三、三大经典算法:原理、优劣与演进脉络

3.1 引用计数(Reference Counting)------最直观但有硬伤

这个算法思路极其朴素:每个对象记录自己被引用的次数。引用数归零,立即回收。

ini 复制代码
let a = { data: 1 };  // a 的 refCount = 1(根引用)
let b = a;            // a 的 refCount = 2
b = null;             // a 的 refCount = 1
a = null;             // a 的 refCount = 0 → 立即回收

优点

  • 即时性:垃圾一旦变得不可达,立刻回收,内存压力小
  • 可预测性:回收开销分摊到赋值操作中,无 STW 长暂停
  • 适合实时系统:如早期的 Adobe Flash Player 就采用引用计数

致命缺陷------循环引用

ini 复制代码
function cycle() {
    let obj1 = {};
    let obj2 = {};
    obj1.ref = obj2;   // obj1.refCount = 1(obj2 引用它? 反了)
    obj2.ref = obj1;   // 实际: obj1 引用 obj2, obj2 引用 obj1
    return;
    // 函数返回后,obj1 和 obj2 从栈上消失,但它们的 refCount 都是 1
    // 互相持有,形成"孤岛",永远无法回收
}

更隐蔽的问题是维护开销 :每次指针赋值(a = b)都需要原子操作增减计数器,在多核多线程环境下需要内存屏障(Memory Barrier),这会显著拖累程序吞吐量------据 IBM 的测量,引用计数的开销通常占程序总运行时间的 5%~10%。

如今的引用计数主要用于资源管理(如 C++ 的 shared_ptr)或作为辅助手段(如 Python 的 GC 就结合了引用计数 + 分代回收)。

3.2 标记-清除(Mark-Sweep)------现代 GC 的基石

标记-清除算法分为两个阶段:

  1. 标记(Mark) :从根集合出发,遍历对象图,将所有可达对象在头信息中打上"存活"标记位。这一步本质上是图遍历,时间复杂度 O(V + E),其中 V 是堆中对象数,E 是引用边数。
  2. 清除(Sweep) :线性扫描整个堆,检查每个对象的标记位。未标记的对象将其内存块加入空闲链表(Free List),已标记的对象则清除标记位为下一轮 GC 做准备。

循环引用不再是问题------obj1obj2 从根出发不可达,都不会被标记,一同被清除。

但标记-清除存在三个严重问题

① 内存碎片(Fragmentation) :反复分配和释放后,堆内存被切割成大小不一的空闲块。一个大对象(如 1MB 的数组)到来时,即使总空闲内存超过 1MB,但若没有连续空闲块,分配仍然失败。

② 暂停时间不可控(STW) :标记阶段必须冻结所有应用线程(Stop The World),因为应用若在标记过程中修改对象引用,可能导致标记结果不一致。对于 GB 级堆,标记耗时可达数百毫秒甚至秒级。

③ 分配效率低:由于碎片的存在,分配内存时需要遍历空闲链表查找合适大小的块,最坏情况下 O(n) 复杂度。

3.3 标记-整理(Mark-Compact)------用移动换取连续空间

标记-整理在标记阶段与标记-清除完全相同,但在清除阶段之前增加了一步:整理(Compact) ------将所有存活对象按内存地址顺序重新排列,挤压到堆的一端,剩余空间合并为一大块连续空闲区。

css 复制代码
[ 存活A ] [ 碎片 ] [ 存活B ] [ 碎片 ] [ 存活C ]
↓ 整理后 ↓
[ 存活A ][ 存活B ][ 存活C ] [      超大空闲区       ]

优点

  • 消除碎片:分配可退化为指针碰撞(Bump Pointer),O(1) 分配效率
  • 缓存友好:存活对象聚集在一起,空间局部性改善

代价

  • 移动开销:需要更新所有指向被移动对象的引用(修改指针值),这意味着额外的遍历和写入
  • 复制成本:对象越大,移动的内存拷贝量越大
  • 暂停时间更长:标记 + 整理两个阶段都需 STW

因此,标记-整理通常用于老生代 (对象存活率高,移动频率低),而新生代则采用复制算法(见后文)。

四、从分代到并发:三次关键性能进化

随着 Web 应用和服务端程序规模膨胀,堆内存从几十 MB 增长到几 GB,单次标记-清除的暂停时间从几毫秒飙升到几百甚至上千毫秒。在这期间,程序必须暂停(Stop The World,简称 STW) ------用户会感到卡顿,这在网页交互或高并发服务中是不可接受的。于是 GC 领域发生了三次关键进化。

4.1 分代收集(Generational GC)------基于弱分代假说

弱分代假说(Weak Generational Hypothesis) 是 1984 年由 Lieberman 和 Hewitt 提出的经验观察,包含两条规律:

  • 绝大多数对象朝生暮死:约 80%~98% 的对象在分配后极短时间内变得不可达,用完就死了
  • 越老的对象越不容易死:存活过多次 GC 的对象往往有更长的生命周期

基于此,将堆划分为两代或多代:

特点 回收算法 回收频率
新生代(Young) 容量小(通常 1~16MB),对象存活率低 复制算法(Copying) 高频(每秒数次)
老生代(Old) 容量大(可达数 GB),对象存活率高 标记-整理(Mark-Compact) 低频(数十秒一次)

对象晋升(Promotion) :新生代中熬过一定次数(如 V8 的 2 次)Minor GC 仍存活的对象,或新生代空间不足时的大对象,被"晋升"到老生代。

分代 GC 的威力在于"将 80% 的 GC 精力花在只占 20% 内存的新生代上",大幅提升了整体效率。Java 的 HotSpot JVM 甚至将堆分为 Eden、Survivor 0、Survivor 1、Old 四个区域,精细化管理。

4.2 增量收集(Incremental GC)------化整为零

传统标记-清除是一次性完成整个标记过程,期间 STW。增量收集将标记阶段拆分为多个小步(Slice) ,穿插在应用线程执行的间隙完成,积少成多,用户几乎感觉不到卡顿。

ini 复制代码
传统 STW:  [===== 标记 (200ms) =====] [应用运行] [===== 标记 =====]
增量式:    [标记 5ms][应用][标记 5ms][应用][标记 5ms][应用][标记 5ms]

关键技术------三色标记(Tri-color Marking)

  • 白色:尚未访问的对象(潜在垃圾)
  • 灰色:已访问但其引用对象尚未全部扫描的对象
  • 黑色:已访问且其引用对象全部扫描完成的对象

增量标记过程中,应用线程可能修改对象引用(如将白色对象挂到黑色对象下),导致"漏标"------本该存活的对象被错误回收。解决办法是写屏障(Write Barrier) :每次赋值操作时,若黑色对象引用被改为指向白色对象,则将该白色对象染成灰色,确保后续标记不会遗漏。

增量 GC 将最大暂停时间从 200ms 降低到 5~10ms,代价是总 GC 耗时增加约 10%~20%(因为写屏障的开销和多次中断的上下文切换)。

4.3 并发收集(Concurrent GC)------后台隐形杀手

并发收集更进一步:GC 线程与应用线程同时运行 ,互不阻塞。这需要复杂的读写屏障(Barrier) 机制来保证程序修改对象时,GC 不会读到脏数据。V8 的并发标记就是这样工作的------主线程跑 JavaScript,后台线程默默做标记。

在 V8 的并发标记实现中:

  • 主线程:执行 JavaScript 代码,同时负责新生代 GC(快速)
  • 后台线程:执行老生代的并发标记,无需 STW
  • 三色标记 + 读写屏障:保证并发修改的安全性

并发 GC 将 STW 时间压缩到极致(通常 < 1ms),但实现极其复杂,需要处理:

  • 读屏障(Read Barrier) :从黑色对象读引用时,确保读到的是最新值
  • 写屏障(Write Barrier) :引用修改时维护三色不变式
  • 并发根扫描:栈和寄存器的快照需要在安全点(Safepoint)获取

V8 从 2018 年的 6.9 版本开始全面启用并发标记,将大型 Web 应用(如 Gmail)的 GC 暂停从 50ms 降到了 2ms 以内。

五、V8 引擎 GC 深度解析

V8 作为 Chrome 和 Node.js 的 JavaScript 引擎,其 GC 设计代表了工业界顶尖水准,它主要采用了分代式 GC

新生代 使用 Scavenge 算法(也叫 Cheney 算法),将新生代空间一分为二:一半是"使用区",另一半是"空闲区"。每次 GC 时,把使用区里存活的对象复制到空闲区,然后清空使用区,角色互换。因为新生代里存活对象极少,复制成本很低,非常高效。

老生代 则使用标记-整理 算法,配合增量标记并发标记 来减少停顿。V8 还会在空闲时间(比如浏览器渲染完一帧后)做空闲时间 GC(Idle GC) ,把回收工作"偷"着做完。

5.1 新生代:Scavenge 算法(Cheney 算法)

新生代使用半区复制(Semi-space Copying) 策略:

  • 将新生代空间划分为两个等大的半区:From (使用区)和 To(空闲区)
  • 每次 Minor GC 时,遍历 From 区中的存活对象,复制到 To 区
  • 复制完成后,一次性清空 From 区,然后交换角色(From ↔ To)
ini 复制代码
GC 前: [From: 存活A 存活B 垃圾C] [To: (空)]
GC 后: [From: (空)] [To: 存活A 存活B]

为何高效? 新生代存活对象极少(通常 < 1%),复制成本几乎等于扫描全部对象的成本,且无需处理碎片------To 区总是连续的。时间复杂度 O(存活对象数),分配退化为指针碰撞。

晋升条件

  • 对象已经历过一次 Scavenge还没死(年龄 ≥ 1)
  • To 区使用率达到 25%(防止 To 区溢出)

V8 8.0+ 引入了少停顿标记(Minor MS) ,在特定场景(如大量小对象)下,通过提前标记引用来避免全部扫描,将 Minor GC 时间从几毫秒降到微秒级。

5.2 老生代:标记-整理 + 增量/并发

老生代采用标记-整理(Mark-Compact) 为主,配合增量标记并发标记

具体流程

  1. 并发标记:后台线程扫描对象图,标记存活对象(主线程可同时执行 JS)
  2. 增量标记:主线程在空闲时执行剩余标记任务(通过 Idle Task)
  3. 并发整理 :V8 采用碎片整理(Defragmentation) 而非完全整理------仅移动部分碎片区域的对象,平衡整理收益与移动开销
  4. 空闲时间 GC(Idle GC) :浏览器渲染完一帧后(约 16.6ms 内),若剩余时间 > 5ms,则执行小规模 GC 任务

5.3 V8 内存管理与调优参数

Node.js 中可通过以下参数控制内存:

ini 复制代码
node --max-old-space-size=4096  # 老生代上限 4GB
node --max-new-space-size=128   # 新生代上限 128MB
node --trace-gc                 # 打印 GC 日志
node --trace-gc-verbose         # 详细 GC 日志

内存压力处理 :当老生代使用率超过 90% 时,V8 会触发主线程并发标记(Major GC) ,若仍无法释放足够内存,则抛出 FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed

六、跨语言 GC 策略对比与设计哲学

不同的编程语言对 GC 有不同的取舍:

语言/引擎 策略 吞吐量 延迟 适用场景
V8 (JS) 分代式 + 并发标记 + 空闲 GC 极低(< 5ms) 浏览器交互、Node.js I/O 密集
HotSpot JVM (Java) G1/ZGC/Shenandoah,分区式 (> 95%) 低(可调至 < 1ms) 服务端高吞吐、大数据处理
Go 并发三色标记 + 混合写屏障 低(< 1ms) 云原生微服务、网络服务
Python (CPython) 引用计数 + 分代回收 不可预测 脚本、数据分析(依赖 C 扩展)
Rust 无 GC(所有权 + 借用检查) 极高 零暂停 系统编程、嵌入式、WebAssembly

设计取舍本质

  • JavaScript/前端 :牺牲吞吐量换取低延迟,保证 UI 流畅度
  • Java/后端 :追求高吞吐,GC 可以占用更多 CPU 资源(如 G1 的并发线程数可达 CPU 核心数的 25%)
  • Go :平衡吞吐与延迟,注重简洁性启动速度(GC 不需要额外 JIT 预热)
  • Rust :选择零成本抽象,完全在编译期解决内存安全,彻底避免 GC

七、生产环境中的内存泄漏排查指南

即使有了强大的 GC,开发者仍会在以下场景中遭遇内存泄漏:

7.1 四种常见泄漏模式及底层原理

① 意外的全局变量

csharp 复制代码
function foo() {
    bar = "hello";  // 严格模式下报错,非严格模式挂到 globalThis
}

globalThis.bar 成为根引用,永远可达。Node.js 中使用 global 对象同样危险。

② 定时器与事件监听器的"僵尸引用"

javascript 复制代码
let data = new BigArray(1e6);
const timer = setInterval(() => {
    console.log(data.length);  // data 被闭包捕获
}, 1000);
// clearInterval(timer) 被遗忘,data 永远存活

定时器回调函数(闭包)持有 data 的引用,直到定时器被清除。Node.js 中 setInterval 未清除还会导致事件循环无法退出。

③ 闭包捕获整个作用域

javascript 复制代码
function outer() {
    let huge = new Array(1e6);
    let small = 42;
    return function inner() {
        return small;  // 只用到了 small
    };
}
let fn = outer();  // huge 被保留!因为闭包捕获的是整个 VariableEnvironment

这是 V8 的闭包实现策略------除非编译器能证明 huge 未被使用(逃逸分析),否则整个作用域对象被保留。

④ DOM 节点分离(Detached DOM)

ini 复制代码
let cache = [];
document.querySelectorAll('.item').forEach(el => cache.push(el));
// 即使这些 .item 从 DOM 树移除,cache 数组仍持有引用
// 在 DevTools Memory 中显示为 "Detached HTMLElement"

7.2 排查工具链

Chrome DevTools

  1. Memory 面板 → Heap Snapshot :拍摄快照,使用"Comparison"模式对比两次快照,查找 Delta 持续增加的对象
  2. Allocation Timeline:记录内存分配栈,定位哪些函数不断分配大对象
  3. Performance 面板:录制时勾选"Memory",观察堆大小曲线和 GC 事件

Node.js

arduino 复制代码
node --inspect --max-old-space-size=4096 app.js
# 打开 chrome://inspect,使用 Memory 面板连接 Node 进程

或使用 v8-profiler 库在代码中编程式获取堆快照。

关键排查信号

  • 堆大小曲线呈阶梯状上升(GC 后下降,但比前一轮高)→ 存在泄漏
  • 老生代大小稳步增长而不回落 → 大对象泄漏
  • Detached DOM 元素计数持续增加 → DOM 引用未释放

八、总结

  1. GC 的本质:从根集合出发计算对象可达性,不可达即回收
  2. 算法演进:引用计数(循环引用缺陷)→ 标记-清除(STW + 碎片)→ 标记-整理(移动代价)→ 分代(弱分代假说)→ 增量/并发(三色标记 + 屏障)→ 分区(如 G1 的 Region)
  3. V8 实践:新生代 Scavenge(复制) + 老生代 Mark-Compact(并发/增量标记)
  4. 泄漏排查:根引用、闭包、定时器、DOM 分离是四大元凶
相关推荐
qq_454245032 小时前
EventSource Framework — 事件源框架设计文档
架构
洛卡卡了2 小时前
Claude Code 项目级 Skill:团队协作场景下的使用场景
人工智能·后端·claude
用户762352425912 小时前
SpringAi 1.1更新说明
后端
fsssb2 小时前
Chromium 源码学习笔记(一):一个 URL 输入之后,浏览器里发生了什么?
架构
程序员黎剑2 小时前
我把算卦变成了一段if-else
java·vue.js·spring boot·后端·ai编程
头茬韭菜2 小时前
4.1-4.2 工具注册 — Fail-Closed 类型安全设计与四层工具架构
java·安全·架构
SimonKing2 小时前
Base62编码生成6位不重复短码的背后设计
java·后端·程序员
IT_陈寒2 小时前
JavaScript的隐式类型转换又双叒坑了我一次!
前端·人工智能·后端
她说彩礼65万3 小时前
Asp.net core 返回类型
数据库·后端·asp.net