V8 引擎是如何给 JS"打扫房间"的 ?

JS 语言不像 C/C++, 让程序员自己去开辟或者释放内存,而是类似Java,采用自己的一套垃圾回收算法进行自动的内存管理。今天就从内存结构说起,一步步聊聊 V8 的垃圾回收机制。

先搞懂JS 的内存都存在哪里?

JS 的内存存储分两块:栈(Stack)堆(Heap) ,就像家里的 "鞋柜" 和 "储物间"------ 常用的小东西放鞋柜,大件杂物放储物间。

栈是一块连续的内存空间,就像排队的抽屉,每个抽屉大小固定。它主要存两种东西:

  • 基本类型值(Number、String、Boolean 等),比如let age = 25age和 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=1b=2sum=3这些变量。
  • 函数返回后,执行上下文从栈顶弹出,ESP 指针下移到上一个上下文(全局上下文)。此时absum占用的栈空间就成了 "无效区域",下次有新函数调用时,直接覆盖这些空间就行 ------ 不用专门 "清理",相当于自动回收。

这种回收方式效率极高,几乎不消耗额外性能,所以栈内存很少出问题。

新生代内存的回收

新生代存的都是 "短命对象",比如循环里创建的临时对象:

js 复制代码
for (let i = 0; i < 1000; i++) {
  const temp = { id: i }; // 每次循环创建的temp对象,用完就成垃圾
  console.log(temp.id);
}

这些对象的回收,V8 用的是Scavenge 算法,核心是 "复制存活对象,清空剩余空间"。具体步骤可以脑补成这样:

  1. 新生代内存被分成两块等大的空间,一块叫 "From 空间"(正在用),一块叫 "To 空间"(闲置),就像两个并排的抽屉,每次只用一个。
  1. 当 From 空间快满时,触发回收:遍历 From 空间,把所有还在被引用的 "存活对象",按顺序复制到 To 空间。
  2. 复制完后,直接清空 From 空间(把旧抽屉里的垃圾全扔了),然后交换 From 和 To 的角色 ------ 下次用新的 From(原来的 To),闲置新的 To(原来的 From)。

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

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

当然,这种方式也有代价:新生代内存实际只能用一半(总有一个空间闲置)。但好在新生代对象存活时间短,复制成本低,总体算下来比标记清除快得多。

老生代内存的回收

当新生代的对象 "活过" 多次回收(比如被全局变量引用,或者在闭包里被长期持有),就会被 "晋升" 到老生代。 晋升的条件有两个

  • 已经经历过一次 Scavenge 回收。
  • To(闲置)空间的内存占用超过25%。

老生代的对象要么体积大,要么存活久,用 Scavenge 算法复制太费时间,所以 V8 换了套思路:标记 - 清除 +标记 - 整理

第一步:标记 - 清除

  1. 标记阶段 :从全局对象(比如window)开始,遍历所有能访问到的对象,给它们贴个 "有用" 的标签(可达性分析)。
  2. 清除阶段:遍历完后,没贴标签的对象就是 "垃圾",直接释放它们的内存。

这种方式解决了引用计数法的 "循环引用" 问题。比如两个对象互相引用,但都不再被全局访问,标记阶段它们不会被标记,清除阶段会被回收 ------ 而引用计数法会因为它们互相引用,计数不为 0,永远不回收,导致内存泄漏。

第二步:标记 - 整理

标记 - 清除后,堆内存会像被挖过的地一样坑坑洼洼:存活对象零散分布,中间夹杂着被回收的空白区域(内存碎片)。下次想分配一个大对象,可能找不到连续的空间,明明总内存够,却分配失败。

所以 V8 会紧接着做 "标记 - 整理":把所有存活对象往内存的一端 "挤",让空白区域集中到另一端,形成一整块连续的空闲内存。就像把衣柜里的衣服都推到左边,右边留出一大块空地放新衣服。

老生代的"增量标记"

JS 是单线程的,一旦开始垃圾回收,JS 代码就会暂停(称为 "Stop-The-World")。如果老生代内存很大,一次完整的标记 - 清除可能要卡 1 秒以上 ------ 用户点按钮没反应,页面像死机了一样。

为了解决这个问题,V8 用了增量标记:把原本一口气完成的标记阶段,拆成一小块一小块,穿插在 JS 代码执行间隙。比如标记 10ms,就让 JS 执行 20ms,再标记 10ms... ,如果循环,直到标记阶段完成才进入内存碎片的整理上面来,不耽误JS代码的正常执行。

这样一来,单次垃圾回收的阻塞时间从几百毫秒降到几十毫秒,用户几乎感觉不到卡顿。数据显示,增量标记能把垃圾回收的阻塞时间减少到原来的 1/6,对大型应用来说太重要了。

最后

搞懂 V8 的回收机制后,我总结了几个对开发有用的点:

  1. 少创建临时对象 :循环、频繁调用的函数里,尽量复用对象(比如把const obj = { ... }提到循环外),减少新生代回收压力。
  2. 及时解除引用 :不用的全局变量、定时器、事件监听,记得设为null,让对象失去引用,被及时回收。
  3. 小心闭包陷阱 :闭包会让内部对象被长期引用,比如function create() { const data = bigObject; return () => data; }data会一直存在老生代,不用时要手动解除引用。

希望本文对你了解V8的垃圾回收机制有帮助~

相关推荐
WeiXiao_Hyy3 小时前
成为 Top 1% 的工程师
java·开发语言·javascript·经验分享·后端
吃杠碰小鸡3 小时前
高中数学-数列-导数证明
前端·数学·算法
kingwebo'sZone3 小时前
C#使用Aspose.Words把 word转成图片
前端·c#·word
xjt_09013 小时前
基于 Vue 3 构建企业级 Web Components 组件库
前端·javascript·vue.js
我是伪码农3 小时前
Vue 2.3
前端·javascript·vue.js
夜郎king4 小时前
HTML5 SVG 实现日出日落动画与实时天气可视化
前端·html5·svg 日出日落
辰风沐阳4 小时前
JavaScript 的宏任务和微任务
javascript
夏幻灵5 小时前
HTML5里最常用的十大标签
前端·html·html5
冰暮流星5 小时前
javascript之二重循环练习
开发语言·javascript·数据库
Mr Xu_5 小时前
Vue 3 中 watch 的使用详解:监听响应式数据变化的利器
前端·javascript·vue.js