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的垃圾回收机制有帮助~

相关推荐
Jerry Lau3 分钟前
go go go 出发咯 - go web开发入门系列(二) Gin 框架实战指南
前端·golang·gin
我命由我1234532 分钟前
前端开发问题:SyntaxError: “undefined“ is not valid JSON
开发语言·前端·javascript·vue.js·json·ecmascript·js
0wioiw032 分钟前
Flutter基础(前端教程③-跳转)
前端·flutter
Jokerator34 分钟前
深入解析JavaScript获取元素宽度的多种方式
javascript·css
落笔画忧愁e35 分钟前
扣子Coze纯前端部署多Agents
前端
海天胜景37 分钟前
vue3 当前页面方法暴露
前端·javascript·vue.js
GISer_Jing1 小时前
前端面试常考题目详解
前端·javascript
Boilermaker19922 小时前
【Java EE】SpringIoC
前端·数据库·spring
中微子2 小时前
JavaScript 防抖与节流:从原理到实践的完整指南
前端·javascript
天天向上10242 小时前
Vue 配置打包后可编辑的变量
前端·javascript·vue.js