我们来系统且深入地探讨一下 JavaScript 的垃圾回收(Garbage Collection, GC)机制,并结合实际案例,说明如何利用这些知识来优化代码性能。
核心思想:为何需要垃圾回收?
从第一性原理出发,程序运行的本质是处理数据,而数据需要存储在内存中。内存是有限的资源。如果程序只申请内存而不释放,内存最终会被耗尽,导致程序崩溃。
在像 C/C++ 这样的语言中,开发者需要手动管理内存,通过 malloc() 申请,通过 free() 释放。这种方式给予了开发者极大的控制权,但同时也带来了巨大的心智负担和风险——忘记释放会导致内存泄漏(Memory Leak),而释放了仍在使用的内存则会导致**悬挂指针(Dangling Pointer)**和程序崩溃。
JavaScript 的设计哲学之一是让开发者更专注于业务逻辑,而非底层细节。因此,它采用了自动垃圾回收机制。这意味着,开发者只管申请和使用内存(例如,创建一个对象 let a = {}),而“哪些内存不再需要”以及“如何释放它们”则由 JavaScript 引擎自动完成。
垃圾回收的核心任务:识别并回收不再被“需要”的内存。
JS 垃圾回收的基石:可达性(Reachability)
现代垃圾回收算法的根基是可达性这个概念。简单来说,一个对象是否“存活”,取决于它是否可以从一个“根(Root)”对象沿着引用链被访问到。
-
根(Roots):这是一组固定的、总是可访问的引用,它们是可达性分析的起点。主要包括:
-
全局对象:比如浏览器中的
window对象,Node.js 中的global对象。 -
当前调用栈:函数调用时,其中的局部变量、参数等。
-
CPU 寄存器中的值。
-
回收过程的本质:从所有的“根”出发,遍历所有可访问的对象。任何遍历结束后未能访问到的对象,就被认为是“垃圾”,可以被回收。
在上图中,从 Root 出发,对象 C 和 E 是不可达的,因此它们是垃圾,将被回收。
主要的垃圾回收算法
1. 引用计数(Reference Counting)- 已被淘汰
这是早期的一种简单算法,但如今主流的 JS 引擎已不再使用它作为核心算法。
-
原理:为每个对象维护一个“引用计数器”。当有一个引用指向该对象时,计数器加 1;当引用被移除时,计数器减 1。当计数器变为 0 时,说明没有任何地方引用该对象,可以立即回收。
-
致命缺陷:循环引用(Circular References)。
看一个例子:
JavaScript
function createCircularReference() {
let objectA = {};
let objectB = {};
objectA.b = objectB; // objectB 的引用计数为 1
objectB.a = objectA; // objectA 的引用计数为 1
// 函数执行完毕后,objectA 和 objectB 的引用都消失了
// 但是,它们内部互相引用,导致各自的引用计数永远不会是 0
}
createCircularReference();
// objectA 和 objectB 无法被回收,造成内存泄漏。
由于这个致命缺陷,现代 JS 引擎转向了下面更复杂的算法。
2. 标记-清除(Mark-and-Sweep) - 现代GC的基础
这是现代垃圾回收算法的核心思想,它完美地解决了循环引用的问题。
-
原理:分为两个阶段。
-
标记阶段(Mark):GC 从“根”开始,递归地遍历所有可达的对象,并给它们打上“存活”的标记。
-
清除阶段(Sweep):GC 遍历整个内存堆(Heap),回收所有没有被标记的对象。
-
这个算法可以正确处理循环引用,因为即使 objectA 和 objectB 互相引用,但如果它们从“根”出发是不可达的,它们就不会在标记阶段被标记,最终会在清除阶段被回收。
- 缺点:内存碎片化(Memory Fragmentation)。清除后,内存中会留下许多不连续的空闲空间。当需要分配一个较大的对象时,可能因为找不到足够大的连续空间而失败,尽管总的空闲空间是足够的。
3. 标记-整理(Mark-and-Compact)
为了解决内存碎片化问题,标记-整理算法在标记-清除的基础上增加了一个步骤。
-
原理:
-
标记阶段(Mark):同上,标记所有存活对象。
-
整理阶段(Compact):将所有存活的对象向内存的一端移动,使它们紧凑地排列在一起。
-
清除阶段(Sweep):直接清理掉整理边界之后的所有内存。
-
这样,不仅回收了垃圾,还消除了碎片,让后续的内存分配变得简单高效。
V8 引擎的优化:分代回收(Generational Collection)
现代 JS 引擎(如 Chrome 的 V8)为了极致的性能,并不会简单地对所有对象都使用同一种算法。它们基于一个重要的观察——“分代假说”(The Generational Hypothesis):
大多数对象在内存中存在的时间很短,很快就会变成垃圾。只有少数对象会存活很长时间。
基于此,V8 将内存堆分为了两个主要区域:
-
新生代(New Space / Young Generation):
-
特点:容量小(通常几MB),存放新创建的、生命周期短的对象。
-
垃圾回收方式(Minor GC / Scavenge):非常频繁且快速。新生代内部又分为两个等大的空间:From-Space 和 To-Space。
-
新对象被分配在 From-Space。
-
当 From-Space 满了,触发 Minor GC。
-
GC 检查 From-Space 中的存活对象,并将它们复制到 To-Space。
-
复制完成后,From-Space 和 To-Space 的角色互换。
-
这个过程天然地完成了内存整理,不会产生碎片。
-
-
晋升(Promotion):如果一个对象在多次 Minor GC 后依然存活,它就会被“晋升”到老生代。
-
-
老生代(Old Space / Old Generation):
-
特点:容量大,存放生命周期长的对象(例如,全局变量、存活很久的闭包)以及从新生代晋升上来的对象。
-
垃圾回收方式(Major GC):不那么频繁,但更耗时。主要采用**标记-清除(Mark-Sweep)和标记-整理(Mark-Compact)**相结合的方式。
-
优化:为了避免长时间的 Major GC 导致页面卡顿(“Jank”),V8 引入了**增量标记(Incremental Marking)和并发标记(Concurrent Marking)**等技术,将标记工作拆分成许多小任务,穿插在 JavaScript 执行的间隙中完成,从而减少主线程的阻塞时间。
-
利用垃圾回收机制优化性能的常见案例
理解了 GC 原理,我们就能写出对 GC 更友好的代码,从而提升应用性能。核心思路是:减少 GC 的触发频率和单次 GC 的开销。
案例一:警惕全局变量,使用作用域管理内存
-
原理:全局变量是“根”的一部分,除非手动设置为
null,否则它们引用的对象永远不会被回收,会一直存在于老生代中。 -
不良实践:
JavaScript
// 假设这是某个数据获取模块 let largeDataCache = []; // 全局缓存 function fetchData() { const data = getSomeVeryLargeData(); // 获取了 10MB 的数据 largeDataCache.push(...data); // largeDataCache 会持续增长,永远不会被回收 } -
优化方案:使用局部作用域。让变量在不需要时能够自然地脱离作用域,从而被 GC 回收。
JavaScript
function processData() { const data = getSomeVeryLargeData(); // data 是局部变量 // ...对 data 进行处理... } // 函数执行完毕后,data 引用的对象就变得不可达,可以被回收了 // 如果确实需要缓存,应提供明确的清理机制 class DataCache { constructor() { this.cache = []; } fetch() { /* ... */ } clear() { this.cache = []; } // 手动释放 }最佳选择:优先使用函数/块级作用域内的
const和let,避免使用var和不必要的全局变量。这能确保变量的生命周期最短,使其更有可能在新生代就被快速回收。
案例二:小心闭包(Closure)造成的意外内存泄漏
-
原理:闭包会使其内部函数持有对外部作用域变量的引用。如果这个内部函数变成了长生命周期的对象(如事件监听器、定时器),那么它引用的外部变量也无法被回收。
-
不良实践:
JavaScript
function attachListener() { let largeObject = new Array(1e6).fill('*'); // 一个很大的对象 let element = document.getElementById('myButton'); // click 回调函数是一个闭包,它引用了 largeObject element.addEventListener('click', function onClick() { // ... 也许这里并没有用到 largeObject console.log('Button clicked!'); }); // largeObject = null; // 如果不手动解除引用... } attachListener(); // 只要 #myButton 元素存在,它的 click 监听器就存在 // 监听器存在,就一直引用着 largeObject // 导致 largeObject 这个大对象被意外地留在了老生代中,无法回收 -
优化方案:在不再需要时,主动解除引用或移除事件监听器。
-
主动解除引用:如果闭包内部不再需要某个外部变量,在合适的时机(例如,组件卸载时)将其设置为
null。 -
移除监听器:在 DOM 元素被销毁或不再需要监听时,使用
removeEventListener移除监听器。这会自动断开闭包的引用链。
JavaScript
function setupComponent() { let largeObject = new Array(1e6).fill('*'); let element = document.getElementById('myButton'); function onClick() { console.log('Button clicked!'); } element.addEventListener('click', onClick); // 返回一个清理函数,这是 React/Vue 等框架的常见模式 return function cleanup() { element.removeEventListener('click', onClick); // 监听器被移除,闭包被销毁,largeObject 变得不可达 largeObject = null; // 显式设置更好 }; } const cleanup = setupComponent(); // ...在某个时刻,比如页面切换时 cleanup(); -
案例三:对象/数组池化(Object/Array Pooling)
-
原理:对于需要频繁创建和销毁的同类对象(例如,游戏中的子弹、粒子效果,或数据可视化中的几何体对象),反复创建会导致新生代频繁的 Minor GC,增加 CPU 开销。
-
不良实践:
JavaScript
// 动画循环中 function gameLoop() { for (let i = 0; i < 100; i++) { let particle = { x: 0, y: 0, life: 10 }; // 每帧创建 100 个新对象 // ...更新粒子状态... } // 每一帧都会产生大量立即被丢弃的对象,加重 GC 负担 requestAnimationFrame(gameLoop); } -
优化方案:使用对象池。预先创建一定数量的对象放入一个“池子”(通常是数组)。需要时从池中取,用完后归还池中,而不是让 GC 回收。
JavaScript
class ObjectPool { constructor(createFn, size) { this.pool = []; this.createFn = createFn; for (let i = 0; i < size; i++) { this.pool.push(this.createFn()); } } get() { return this.pool.length > 0 ? this.pool.pop() : this.createFn(); } release(obj) { // ...可以重置对象的状态... this.pool.push(obj); } } const particlePool = new ObjectPool(() => ({ x: 0, y: 0, life: 0 }), 500); function gameLoop() { for (let i = 0; i < 100; i++) { let particle = particlePool.get(); // 从池中获取 // ...设置和更新粒子状态... if (particle.life <= 0) { particlePool.release(particle); // 归还到池中 } } requestAnimationFrame(gameLoop); }最佳选择:这种模式在游戏开发、高性能计算等对性能极致敏感的场景中非常有效。它通过复用内存,将 GC 的影响降到最低。
总结
JavaScript 的垃圾回收机制是其易用性的重要保障,但它并非“免费的午餐”。其运行本身会消耗计算资源,并在某些情况下阻塞主线程。
作为开发者,虽然我们不能直接控制 GC,但通过深入理解其工作原理——尤其是可达性和分代回收——我们可以编写出更加高效、对 GC 更友好的代码:
-
最小化内存占用和生命周期:优先使用局部变量,让不再需要的对象尽快变得不可达。
-
警惕长生命周期的引用:注意全局变量、闭包和事件监听器等可能意外延长对象生命周期的模式。
-
复用代替创建:在性能敏感的循环中,考虑使用对象池等技术来减少垃圾的产生。
最终,优化性能的本质是与 JS 引擎的运行机制和谐共处,而不是对抗它。理解 GC,就是迈向高性能 JavaScript 开发的关键一步。