JS 语言不像 C/C++, 让程序员自己去开辟或者释放内存,而是类似Java,采用自己的一套垃圾回收算法进行自动的内存管理。今天就从内存结构说起,一步步聊聊 V8 的垃圾回收机制。
先搞懂JS 的内存都存在哪里?
JS 的内存存储分两块:栈(Stack) 和堆(Heap) ,就像家里的 "鞋柜" 和 "储物间"------ 常用的小东西放鞋柜,大件杂物放储物间。
栈
栈是一块连续的内存空间,就像排队的抽屉,每个抽屉大小固定。它主要存两种东西:
- 基本类型值(Number、String、Boolean 等),比如
let age = 25
,age
和 25 都存在栈里; - 引用类型的地址(指针),比如
let user = {name: '张三'}
,user
这个变量名存在栈里,而{name: '张三'}
这个对象存在堆里,栈里的user
只存了指向堆中对象的地址。 - 闭包是存储在堆内存中的
栈的回收特别简单:函数执行时会创建 "执行上下文
",压入栈顶;函数执行完,执行上下文弹出,栈顶的内存会被自动回收。这个过程由 JS 引擎自动完成,靠的是ESP 指针(栈指针)的移动:函数执行时 ESP 上移,执行完 ESP 下移,原来的栈空间就成了 "可回收区域"。
堆
堆是一块不连续的内存空间,大小不固定,就像开放式储物间,专门存引用类型(对象、数组、函数等)。比如创建一个{name: '张三', age: 25}
对象,它的键值对全存在堆里;数组[1, 2, 3]
的元素也存在堆里。
堆的麻烦在于:对象不会像栈里的变量那样 "用完就走"。比如全局对象window.appData = { ... }
,只要页面不刷新,它就一直占着堆内存;再比如闭包中被引用的对象,即使函数执行完,只要还被引用,就不会被回收。这些不再被引用的对象,就是堆里的 "垃圾"------ 如果不及时清理,堆内存会被越占越多,最终导致页面卡顿甚至崩溃。
V8 的内存结构
V8 引擎把堆内存又细分了两块:
- 新生代内存:临时货架,存存活时间短的对象(比如函数里的局部对象、循环中创建的临时变量)。64 位系统下约 32MB,32 位系统约 16MB,空间不大但回收频繁。
- 老生代内存:长期储物柜,存存活时间长的对象(比如全局变量、被多次回收仍存在的对象)。64 位系统下最大约 1.4GB,32 位系统约 0.7GB,空间大但回收频率低。
这就是 V8 的 "分代回收" 思路:不同生命周期的对象,用不同的方式回收,效率更高。
栈内存的回收
栈的回收几乎不用我们操心,全靠 JS 引擎的 "执行上下文管理"。举个例子:
js
function add(a, b) {
let sum = a + b; // sum存在栈里
return sum;
}
let result = add(1, 2); // add执行时,上下文入栈;执行完,上下文出栈
- 调用
add(1, 2)
时,JS 引擎会创建一个执行上下文,压入栈顶,里面包含a=1
、b=2
、sum=3
这些变量。 - 函数返回后,执行上下文从栈顶弹出,ESP 指针下移到上一个上下文(全局上下文)。此时
a
、b
、sum
占用的栈空间就成了 "无效区域",下次有新函数调用时,直接覆盖这些空间就行 ------ 不用专门 "清理",相当于自动回收。
这种回收方式效率极高,几乎不消耗额外性能,所以栈内存很少出问题。
新生代内存的回收
新生代存的都是 "短命对象",比如循环里创建的临时对象:
js
for (let i = 0; i < 1000; i++) {
const temp = { id: i }; // 每次循环创建的temp对象,用完就成垃圾
console.log(temp.id);
}
这些对象的回收,V8 用的是Scavenge 算法,核心是 "复制存活对象,清空剩余空间"。具体步骤可以脑补成这样:
- 新生代内存被分成两块等大的空间,一块叫 "From 空间"(正在用),一块叫 "To 空间"(闲置),就像两个并排的抽屉,每次只用一个。

- 当 From 空间快满时,触发回收:遍历 From 空间,把所有还在被引用的 "存活对象",按顺序复制到 To 空间。
- 复制完后,直接清空 From 空间(把旧抽屉里的垃圾全扔了),然后交换 From 和 To 的角色 ------ 下次用新的 From(原来的 To),闲置新的 To(原来的 From)。

为什么要这么折腾?主要是为了避免 "内存碎片
"。如果直接删除垃圾,存活对象可能零散分布在内存中,就像抽屉里的杂物东一个西一个,下次想放个大点的对象(比如一个长数组),可能找不到连续的空间。

而复制到 To 空间时按顺序排列,存活对象会挤在一起,剩下的空间是一整块连续区域,下次分配新对象就很方便。

当然,这种方式也有代价:新生代内存实际只能用一半(总有一个空间闲置)。但好在新生代对象存活时间短,复制成本低,总体算下来比标记清除快得多。
老生代内存的回收
当新生代的对象 "活过" 多次回收(比如被全局变量引用,或者在闭包里被长期持有),就会被 "晋升
" 到老生代。 晋升的条件有两个
- 已经经历过一次 Scavenge 回收。
- To(闲置)空间的内存占用超过25%。
老生代的对象要么体积大,要么存活久,用 Scavenge 算法复制太费时间,所以 V8 换了套思路:标记 - 清除 +标记 - 整理。
第一步:标记 - 清除
- 标记阶段 :从全局对象(比如
window
)开始,遍历所有能访问到的对象,给它们贴个 "有用" 的标签(可达性分析)。 - 清除阶段:遍历完后,没贴标签的对象就是 "垃圾",直接释放它们的内存。
这种方式解决了引用计数法的 "循环引用" 问题。比如两个对象互相引用,但都不再被全局访问,标记阶段它们不会被标记,清除阶段会被回收 ------ 而引用计数法会因为它们互相引用,计数不为 0,永远不回收,导致内存泄漏。
第二步:标记 - 整理
标记 - 清除后,堆内存会像被挖过的地一样坑坑洼洼:存活对象零散分布,中间夹杂着被回收的空白区域(内存碎片)。下次想分配一个大对象,可能找不到连续的空间,明明总内存够,却分配失败。
所以 V8 会紧接着做 "标记 - 整理":把所有存活对象往内存的一端 "挤",让空白区域集中到另一端,形成一整块连续的空闲内存。就像把衣柜里的衣服都推到左边,右边留出一大块空地放新衣服。
老生代的"增量标记"
JS 是单线程的,一旦开始垃圾回收,JS 代码就会暂停(称为 "Stop-The-World")。如果老生代内存很大,一次完整的标记 - 清除可能要卡 1 秒以上 ------ 用户点按钮没反应,页面像死机了一样。
为了解决这个问题,V8 用了增量标记:把原本一口气完成的标记阶段,拆成一小块一小块,穿插在 JS 代码执行间隙。比如标记 10ms,就让 JS 执行 20ms,再标记 10ms... ,如果循环,直到标记阶段完成才进入内存碎片的整理上面来,不耽误JS代码的正常执行。
这样一来,单次垃圾回收的阻塞时间从几百毫秒降到几十毫秒,用户几乎感觉不到卡顿。数据显示,增量标记能把垃圾回收的阻塞时间减少到原来的 1/6,对大型应用来说太重要了。
最后
搞懂 V8 的回收机制后,我总结了几个对开发有用的点:
- 少创建临时对象 :循环、频繁调用的函数里,尽量复用对象(比如把
const obj = { ... }
提到循环外),减少新生代回收压力。 - 及时解除引用 :不用的全局变量、定时器、事件监听,记得设为
null
,让对象失去引用,被及时回收。 - 小心闭包陷阱 :闭包会让内部对象被长期引用,比如
function create() { const data = bigObject; return () => data; }
,data
会一直存在老生代,不用时要手动解除引用。
希望本文对你了解V8的垃圾回收机制有帮助~