在 7.2 节我们了解到 JavaScript 的内存分为栈和堆:栈内存由操作系统自动管理,函数执行完毕即释放;堆内存则用于存储对象、闭包等动态分配的数据,这块内存的释放需要依赖垃圾回收(Garbage Collection,GC) 机制。
垃圾回收的本质是自动找出不再使用的内存并释放。如果没有 GC,开发者就必须手动释放内存(像 C 语言那样 free),这不仅繁琐,还极易导致内存泄漏和悬空指针。JavaScript 的 GC 让开发者可以专注于业务逻辑,但理解它的运作原理,对写出高性能、低内存占用的应用至关重要。
7.3.1 代际假说与分代回收
大多数 JavaScript 对象都是“朝生夕死”:一个函数内部创建的对象,函数执行完就不再需要;而有些对象(如全局缓存)则会存活很久。V8 等现代引擎利用这一规律,将堆内存分为两个区域:
- 新生代(Young Generation):存放生命周期短的新对象,空间较小(通常 1~8 MB)。
- 老生代(Old Generation):存放生命周期长的对象,空间较大。
这种分代设计让 GC 可以对不同区域采用不同策略:新生代回收频繁但速度快,老生代回收慢但触发频率低。
7.3.2 新生代回收:Scavenge 算法
新生代采用 Scavenge 算法(一种典型的半空间复制算法)。它将新生代空间平分为两个等大的半区:From 空间(使用区)和 To 空间(空闲区)。
垃圾回收步骤如下:
- 标记活动对象:从根对象(全局对象、栈中的变量等)出发,标记所有可达的对象。
- 复制存活对象:将 From 空间中所有存活的对象复制到 To 空间,并紧凑排列,同时更新引用指向。
- 交换角色:清空 From 空间,然后 From 和 To 角色互换。原 To 空间成为新的使用区,原 From 空间变为空闲区。
这种算法的优势是速度快且自带内存整理(紧凑),不会产生内存碎片。代价是只能使用一半的空间,并且复制操作有开销。好在新生代对象大多死亡,复制的存活对象很少,效率很高。
对象晋升(Promotion)
如果一个对象在新生代中经历了两轮 Scavenge 回收依然存活,或者 To 空间的使用率超过 25%,就会被晋升到老生代。这种机制确保了长生命周期的对象不会在新生代中反复复制,浪费资源。
7.3.3 老生代回收:标记-清除与标记-整理
老生代空间大、对象存活多,不适合使用 Scavenge 这种“复制一半空间”的算法。V8 在老生代主要使用 标记-清除(Mark-Sweep) 和 标记-整理(Mark-Compact) 算法。
标记-清除
- 标记阶段:从根对象出发,递归遍历所有可达对象,打上“存活”标记。
- 清除阶段:线性扫描整个老生代堆内存,将没有标记的对象空间回收,加入空闲链表。
标记-清除解决了对象存活率高的场景,但它有一个明显缺陷:产生内存碎片。被清除的对象散布在堆中,释放后的空间大小不一,后续分配大对象时可能找不到足够大的连续空间,触发更频繁的 GC。
标记-整理
为了解决碎片问题,V8 在特定情况下会执行标记-整理:
- 标记阶段与标记-清除相同。
- 整理阶段将所有存活对象向一端移动,使其紧密排列,然后清理掉边界外的内存。
整理过程需要移动对象和更新引用,比清除慢,但能有效消除碎片。V8 会根据碎片程度动态决定是用清除还是整理,通常当老生代空间不足以分配新对象,或者碎片严重时,会触发一次完整的标记-整理。
7.3.4 增量标记与并发回收:为什么 GC 不会长时间卡顿
无论新生代 Scavenge 还是老生代标记-清除,传统做法都需要“暂停程序执行”(Stop-The-World)来完成 GC。如果堆很大,一次全量 GC 可能耗时数百毫秒,直接导致页面卡顿甚至丢失帧。
现代引擎引入了多种优化,使 GC 可以与 JavaScript 程序并行或交替执行,大幅减少停顿时间。
增量标记(Incremental Marking)
将一次完整的标记拆分成许多小步骤,每步之间让 JavaScript 程序继续执行,交替进行。就像切香肠:GC 标记一点,程序运行一点,再标记一点……这样单次停顿极短,人类不易感知。但增量标记需要额外记录程序在间隙中修改了哪些对象引用,引入了一些“写屏障”开销。
并发标记(Concurrent Marking)
利用多核 CPU,将标记工作完全放在后台线程中执行,主线程几乎不暂停。JavaScript 程序仍在运行时,标记线程同时遍历对象图。只有当写屏障检测到引用变化时,才需要少量同步。目前,V8 的标记阶段已基本实现并发。
并发清除/整理
清除和整理工作同样可以在后台线程中进行,进一步减少主线程阻塞。V8 已经有了并发清除,并发整理也在逐步实现。这系列优化让现代 JavaScript 引擎的 GC 停顿降到低个位数毫秒,几乎对用户体验无感。
7.3.5 开发者如何与 GC 友好相处
虽然 GC 自动化程度很高,但开发者仍然可以通过以下习惯避免内存泄漏、减少 GC 压力:
- 及时解除引用
对于大型数组、闭包、DOM 元素,如果确定不再需要,将变量设为 null,帮助 GC 更早识别可回收对象。
- 注意闭包陷阱
闭包会维持对其外部作用域的引用,如果闭包长期驻留(如挂载到全局事件上),外部的大对象将无法被回收。确保及时清理事件监听器、定时器。
- 避免意外全局变量
未声明就赋值的变量会自动成为全局变量,生命周期与应用程序相同,容易造成泄漏。始终使用 let/const 声明变量,并开启严格模式。
- 善用 WeakMap 和 WeakSet
对于需要缓存对象又不希望阻止回收的场景,使用弱引用集合。当对象只有弱引用时,GC 会正常回收它,而不用担心 Map 持续持有。
- 减少不必要的对象创建
频繁创建短生命周期对象(如在循环中 new 大对象)会增加新生代 GC 压力。尽量复用对象、使用对象池。
垃圾回收不是魔法,它是一套精密的算法系统。了解它的设计思路,能帮助你写出更高效、更稳定的 JavaScript 应用。下一节我们将探讨内存泄漏的常见场景与排查思路,将理论与实践结合,真正驾驭内存。