一、从一道"送命题"说起
先看一段 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++ 的世界里,开发者必须自己管理内存的分配和释放。malloc 和 free 本质上是向操作系统申请和归还连续虚拟地址空间的接口。手动管理的优势在于零运行时开销(无 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 的基石
标记-清除算法分为两个阶段:
- 标记(Mark) :从根集合出发,遍历对象图,将所有可达对象在头信息中打上"存活"标记位。这一步本质上是图遍历,时间复杂度 O(V + E),其中 V 是堆中对象数,E 是引用边数。
- 清除(Sweep) :线性扫描整个堆,检查每个对象的标记位。未标记的对象将其内存块加入空闲链表(Free List),已标记的对象则清除标记位为下一轮 GC 做准备。
循环引用不再是问题------obj1 和 obj2 从根出发不可达,都不会被标记,一同被清除。
但标记-清除存在三个严重问题:
① 内存碎片(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) 为主,配合增量标记 和并发标记:
具体流程:
- 并发标记:后台线程扫描对象图,标记存活对象(主线程可同时执行 JS)
- 增量标记:主线程在空闲时执行剩余标记任务(通过 Idle Task)
- 并发整理 :V8 采用碎片整理(Defragmentation) 而非完全整理------仅移动部分碎片区域的对象,平衡整理收益与移动开销
- 空闲时间 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:
- Memory 面板 → Heap Snapshot :拍摄快照,使用"Comparison"模式对比两次快照,查找
Delta持续增加的对象 - Allocation Timeline:记录内存分配栈,定位哪些函数不断分配大对象
- 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 引用未释放
八、总结
- GC 的本质:从根集合出发计算对象可达性,不可达即回收
- 算法演进:引用计数(循环引用缺陷)→ 标记-清除(STW + 碎片)→ 标记-整理(移动代价)→ 分代(弱分代假说)→ 增量/并发(三色标记 + 屏障)→ 分区(如 G1 的 Region)
- V8 实践:新生代 Scavenge(复制) + 老生代 Mark-Compact(并发/增量标记)
- 泄漏排查:根引用、闭包、定时器、DOM 分离是四大元凶