引言
JavaScript 无需手动管理内存,也未提供显式 GC API,内存回收完全由引擎自动处理。但这不意味着可以忽视内存问题——闭包、事件监听、缓存或组件状态等常见场景仍可能导致内存泄漏。作为前端开发者,理解引擎的内存模型与 GC 机制(如引用计数、标记清除、分代回收),有助于预判风险、优化性能,并写出更健壮、可持续运行的应用代码。
JS 对象在内存中的存储基础
原始类型和引用类型
JS 数据类型可以划分为两种:原始类型和引用类型。其中原始类型目前包含 number、string、boolean、null、undefined、bigInt 和 symbol 七种,而其他类型都归属于引用类型(Object)。
我们常见的 Date、RegExp、Function 等对象它们都属于引用类型,并且所有引用类型的祖先类都是 Object。
我们常说的“对象”有广义和狭义之分,在狭义上,对象特指{}或者 new Object声明的对象类型,但是在广义上,对象通常包括 Date、RegExp 等所有直接继承或间接继承 Object 的数据类型。
在很多文章中通过 object 和 Object 进行区分狭义对象和广义对象,前者指狭义的对象,后者泛指继承于 Object 构造函数的所有类型,由于广义对象几乎等同于引用值,因此 Object有时也特指引用值,很多国内文章直译成对象,需要注意其在不同语境的指向。
在 Object.create 出现之前,所有对象都通过原型链直接或者间接继承 Object 类(严格来说应该是构造函数),但是在 Object.create 出现后,前面这一说法将不再准确,因为Object.create 创建的对象没有原型链,自然也不会继承 Object。
除非特别说明,否则本文中的对象都特指广义上的对象,即引用值。
let o = {} // 狭义上的对象,引用类型
let d = new Date() // 广义上的对象,引用类型JS 变量声明时,JS 引擎会为其分配内存,其中根据值的不同(原始类型和引用类型),内存存储位置也不同,简单来说:
- 原始类型:直接保存在栈内存中
- 引用类型:真实数据保存在堆内存中,执行栈中保存指向堆中对象的内存地址(可以理解为指针)
JS 内存中的栈和堆
那么,什么是栈空间和堆空间?
要明白这个问题,首先需要知道什么是调用栈(又称执行栈,全称执行上下文栈,Execution Context Stack)和执行上下文(Execution Context),可以参考深入JavaScript I:从规范中看JavaScript运行时机制。
执行上下文其实类似于进程,进程可以看做是静态代码的动态时概念,它包含:
- 代码逻辑(程序代码)
- 运行时数据(变量)
- 系统资源上下文(网络、文件等)
而执行上下文也非常类似,它自身也可以看做是由三部分组成:
- 静态代码(特定三种类型: 函数、eval 和全局)
- 运行时数据(this、参数等)
- 资源上下文(由 js 引擎提供)
执行栈则是专门管理调度执行上下文的结构,它具备栈“先进后出”的特性,JS 引擎同一时刻只会处理一个执行上下文,也就是栈顶,作为栈顶的执行上下文就是正在执行的执行上下文。
现在回答前面的问题:栈空间其实就是执行上下文栈中专门分配的一块连续的、大小固定的、用于管理执行上下文运行时数据的内存区域,执行栈中所有执行上下文的变量都保存在这块栈内存空间中,以函数执行上下文为例,其栈内存空间通常保存 this、argument 以及函数内声明的变量。
额外提一嘴,由于栈内存空间是固定的,因此无线递归调用函数时,就会产生无限函数执行上下文,从而占满整个栈空间,产生栈溢出。
但是需要注意,栈空间只存储原始值和引用值的指针,引用值真正被存储在堆空间中,这样设计出于多方面考虑,比如执行上下文生命周期比较短暂,但是引用值(对象)又通常比较长,如果将对象直接保存在栈内存空间中,当该执行上下文执行完毕被释放后,对象将不可被访问。
function f1(){
// 假设如果对象被保存在栈空间中
let ctx = {a: 1}
return ctx
}
const ctx = f1()
console.log(ctx) // 将无法访问,因为f1执行上下文被释放另一种方法是分配一个非常大的内存空间用于保存所有的对象,对象创建时将对象保存在这个内存空间中,然后再复制到栈空间中。但是这种方法需要频繁复制,性能极差,且同一个对象被内存隔离,无法共享内部属性。
因此,JS 引擎采用了一个更优的方法,分配一个非常大的内存空间用于保存所有的对象,这个内存空间被称为堆空间,对象创建时将对象保存在这个内存空间中,但是后面不同的是,JS 引擎不会复制对象到栈空间中,而是在栈空间中保存这个对象在堆内存空间中的地址(类似 c 语言中的指针的概念),js 可以通过栈空间的这个指针访问到堆空间中的真实对象。
标识符与对象的联系
前面的栈空间和堆空间解决了动态数据分配和存储问题,我们还需要解决静态标识符和真实内存之间的问题,也就是 JS 怎么通过标识符访问到真实的内存地址?
事实上,在 ECMA 规范中是通过一个叫环境记录(Environment Record)的概念来保存标识符和内存地址的关系,也就是我们常说的作用域。
我们可以用一个 Map 来简单的模拟这个关系。
let a = {};
// 底层可以看做是
identifierMap = new Map(); // 标识符与变量内存地址映射表
const memory_id = Page.alloc() // 分配内存
identifierMap.set('a', memory_id) // 记录关系页
页是堆内存的基本管理单位,类似于操作系统中的内存页。在 V8 中,页是固定大小的内存块(通常为 1MB),用于高效管理内存分配和回收。
页的作用:
- 内存分配单元:V8 以页为单位向操作系统申请内存
- 内存隔离:不同代的垃圾回收策略可以在不同页上实施
- 内存整理:以页为单位进行内存碎片整理
- 快速分配:通过空闲列表(free-list)在页内快速分配对象
页的结构:
- 内存空间:连续的内存块,存储对象数据
- 分配指针:指向当前空闲内存位置
- 代际信息:标记页属于新生代还是老生代
- 元数据:存储页的使用情况和回收状态
用 JS 实现一个页
let pageId = 1;
class Page {
id = pageId++;
MAX_PAGE_SIZE = 1024;
HALF_PAGE_SIZE = Math.floor(this.MAX_PAGE_SIZE / 2);
space = new Array(this.MAX_PAGE_SIZE);
allocPtr = 0;
fromStart = 0;
fromEnd = this.HALF_PAGE_SIZE - 1;
toStart = this.HALF_PAGE_SIZE;
toEnd = this.MAX_PAGE_SIZE - 1;
identifierMap = new Map(); // 标识符与变量内存地址映射表
hasFreeSpaceSize(size) {
return size > 0 && this.fromEnd - this.allocPtr + 1 >= size;
}
alloc(identifier, size, data, scoped) {
if (!this.hasFreeSpaceSize(size)) {
return null;
}
const start = this.allocPtr;
for (let i = start; i < start + size; i++) {
this.space[i] = {
identifier,
data,
scoped,
gcCount: 0,
};
this.allocPtr++;
}
this.identifierMap.set(identifier, start); // 记录标识符与变量内存地址的映射关系
return this.space[start];
}
dealloc(start, size) {
if (!this.space[start]) {
return;
}
const { data, identifier } = this.space[start];
for (let i = start; i < start + size; i++) {
this.space[i] = undefined;
}
this.identifierMap.delete(identifier);
}
}V8 垃圾回收
大部分文章谈及到垃圾回收都只谈到标记清除法和引用计数法,但是实际上,现代浏览器引擎为 GC 做了大量优化工作。
关于 V8 引擎 GC 整个流程,可以参考深入浏览器引擎 IV:V8 垃圾回收机制
代际假说(Generational Hypothesis)
代际假说是现代垃圾回收器的理论基础,它基于一个假设:大部分对象都是“朝生暮死”的,也就是说大部分对象的存活时间很短。
基于这个假说,V8 将堆内存分为两代:
- 新生代(New Space):存放新创建的对象,采用高效的复制算法
- 老生代(Old Space):存放存活时间较长的对象,采用标记清除/标记整理算法
而 V8 引擎也根据这个假说设置了主副两个垃圾回收器:
- 副 GC:专门负责回收新生代。
- 主 GC:回收所有死对象。
基于我们前面的假说,大部分对象创建后很快就会死掉,需要被回收,也就是说新生代回收较为频繁,因此 V8 专门提供一个副 GC 去回收新生代。主 GC 回收所有死对象,只有达到一定限制条件才会执行。
半空间(Semi-Space)
新生代采用半空间复制算法(Cheney’s algorithm),也就是将新生代空间分为两个相等的半空间from-space 和 to-space,新对象总是分配在 from-space,另一半留空,当垃圾回收时:
- 标记 from-space 中的存活对象
- 将存活对象复制到 to-space
- 清空 from-space
- 交换 from-space 和 to-space 的角色
- 更新标识符指针
这种方法由于只处理存活对象,因此回收速度快,且自动处理内存碎片问题,但是缺点是需要复制对象,且会浪费一半的内存空间
标记清除法(Mark-Sweep)
老生代采用标记清除算法:
- 标记阶段:从根对象(全局对象、执行上下文等)开始,递归标记所有可达对象
- 清除阶段:遍历整个堆,回收未被标记的对象
- 整理阶段(可选):移动对象以消除内存碎片
为了解决标记清除的碎片问题,V8 还使用标记整理算法:
- 在标记完成后,将存活对象向一端移动
- 更新所有指向移动对象的引用
- 一次性回收所有空闲内存
增量标记和并发标记
为避免长时间停顿,V8 采用许多优化策略,例如:
- 增量标记:将标记过程分成多个小步骤,与 JS 执行交替进行
- 并发标记:在后台线程进行标记,不阻塞主线程
- 空闲时间 GC:在主线程空闲时执行 GC
- 三色标记法:
- 白色:未访问
- 灰色:已访问,但子对象未访问
- 黑色:已访问,且子对象已访问
这些优化使得 V8 可以在几百毫秒内完成数 GB 堆的垃圾回收,用户几乎无感知。
最终代码
let pageId = 1;
class Page {
id = pageId++;
MAX_PAGE_SIZE = 1024;
HALF_PAGE_SIZE = Math.floor(this.MAX_PAGE_SIZE / 2);
space = new Array(this.MAX_PAGE_SIZE);
allocPtr = 0;
fromStart = 0;
fromEnd = this.HALF_PAGE_SIZE - 1;
toStart = this.HALF_PAGE_SIZE;
toEnd = this.MAX_PAGE_SIZE - 1;
identifierMap = new Map(); // 标识符与变量内存地址映射表
hasFreeSpaceSize(size) {
return size > 0 && this.fromEnd - this.allocPtr + 1 >= size;
}
alloc(identifier, size, data, scoped) {
if (!this.hasFreeSpaceSize(size)) {
return null;
}
const start = this.allocPtr;
for (let i = start; i < start + size; i++) {
this.space[i] = {
identifier,
data,
scoped,
gcCount: 0,
};
this.allocPtr++;
}
this.identifierMap.set(identifier, start); // 记录标识符与变量内存地址的映射关系
return this.space[start];
}
dealloc(start, size) {
if (!this.space[start]) {
return;
}
const { data, identifier } = this.space[start];
for (let i = start; i < start + size; i++) {
this.space[i] = undefined;
}
this.identifierMap.delete(identifier);
}
}
class Heap {
static UpGradeCount = 2; // 升级阈值
// 新生代和老生代物理隔离
pages = [new Page(), new Page()]; // 新生代
oldPages = [new Page(), new Page()]; // 老生代
rootSet = new Set([globalThis]); // 一组根,包含执行上下文、全局对象、dom根节点等,这个例子为了简化只包含全局对象
linkMap = new Map(); // 模拟对象引用关系
remarkSet = new Set(); // 模拟标记可达对象
objMemoryMap = new Map(); // 记录对象内存地址(page, memoryData, size, identifier)
createObject(identifier, size, data, scoped) {
for (const page of this.pages) {
if (!page.hasFreeSpaceSize(size)) {
continue;
}
const memoryData = page.alloc(identifier, size, data, scoped);
if (memoryData !== null) {
this.objMemoryMap.set(data, { page, memoryData, size, identifier });
return memoryData;
}
}
return null;
}
// 分配对象内存
alloc(identifier, size, obj, scoped) {
const memoryData = this.createObject(identifier, size, obj, scoped); // 模拟创建对象并分配内存
if (memoryData === null) {
return null;
}
// 记录对象引用关系
const set = this.linkMap.get(scoped) || new Set();
set.add(obj);
this.linkMap.set(scoped, set);
console.log(
`创建对象${identifier},内存地址${memoryData.start},大小${size}`
);
return obj;
}
// 新生代晋升,只改变物理内存地址,不改变对象引用关系
upgradeToOldPage({ data: obj, scoped, identifier }) {
const { page, memoryData, size } = this.objMemoryMap.get(obj);
// 从新生代移除
page.dealloc(memoryData.start, size);
this.objMemoryMap.delete(obj);
// 加入老生代
for (const page of this.oldPages) {
if (!page.hasFreeSpaceSize(size)) {
continue;
}
const memoryData = page.alloc(identifier, size, obj, scoped);
if (memoryData !== null) {
console.log(`对象${identifier}晋升到老生代`);
this.objMemoryMap.set(obj, { page, memoryData, size, identifier });
return memoryData;
}
}
return null;
}
// 回收对象
dealloc({ data: obj, scoped, identifier }) {
// 删除引用关系
const set = this.linkMap.get(scoped);
if (!set) {
return;
}
set.delete(obj);
// 回收内存
const { page, memoryData, size } = this.objMemoryMap.get(obj);
page.dealloc(memoryData.start, size);
this.objMemoryMap.delete(obj);
console.log(
`回收对象${identifier},内存地址${memoryData.start},大小${size}`
);
}
markLiveObjects() {
const mark = (obj) => {
this.remarkSet.add(obj);
// 递归引用的对象
for (const ref of this.linkMap.get(obj) || []) {
if (!this.remarkSet.has(ref)) {
mark(ref);
}
}
};
// 从一组根开始
for (const obj of this.rootSet) {
mark(obj);
}
console.log(
`标记可达对象,可达对象有:` +
Array.from(this.remarkSet)
.map((obj) => {
if (obj === globalThis) {
return "global";
}
return JSON.stringify(obj);
})
.join(", ")
);
}
clearMark() {
this.remarkSet.clear();
}
}
function minorGC(heap) {
console.log("Minor GC");
// 1. 标记可达对象
heap.markLiveObjects();
// 2. 遍历新生代,回收不可达对象
let pageLen = heap.pages.length;
for (let pageIndex = 0; pageIndex < pageLen; pageIndex++) {
const page = heap.pages[pageIndex];
let fromStart = page.fromStart;
let fromEnd = page.fromEnd;
let toStart = page.toStart;
let toEnd = page.toEnd;
// 2.1 遍历fromSpace,将活对象移动到toSpace,多次存活的对象晋升为老生代
for (let i = fromStart, j = toStart; i <= fromEnd; i++) {
if (page.space[i] === undefined) {
// 内存碎片
continue;
}
if (!heap.remarkSet.has(page.space[i].data)) {
// 死对象(不可达)
continue;
}
// 检查对象是否需要晋升到老生代
if (page.space[i].gcCount >= Heap.UpGradeCount) {
heap.upgradeToOldPage(page.space[i]);
continue;
}
page.space[i].gcCount++;
// 移动对象到toSpace
page.space[j] = page.space[i];
// 更新指针
page.space[j].start = j;
page.allocPtr = ++j;
}
// 2.2 清空fromSpace
for (let i = fromStart; i <= fromEnd; i++) {
page.space[i] = undefined;
}
// 2.3更新标识符映射表
for (let i = toStart; i <= toEnd; i++) {
const obj = page.space[i]?.data;
if (!obj) {
continue;
}
page.identifierMap.set(page.space[i].identifier, i);
}
// 3. 交换fromSpace和toSpace
const oldFromStart = page.fromStart;
const oldFromEnd = page.fromEnd;
page.fromStart = page.toStart;
page.fromEnd = page.toEnd;
page.toStart = oldFromStart;
page.toEnd = oldFromEnd;
}
// 4. 清空标记
heap.clearMark();
}
function majorGC(heap) {
console.log("Major GC");
// 1. 标记所有可达对象
heap.markLiveObjects();
// 2. 遍历所有对象,删除未被标记的对象
// 这里忽略了oldPage和page的差异,但是实际上,老生代的page整页有效,而新生代只使用整页的一半
for (const page of [...heap.pages, ...heap.oldPages]) {
for (let i = page.fromStart; i <= page.fromEnd; i++) {
// 内存碎片或已被回收的对象
if (page.space[i] === undefined) {
continue;
}
if (!heap.remarkSet.has(page.space[i].data)) {
// 死对象(不可达)
heap.dealloc(page.space[i]);
}
}
}
// 3. 内存碎片整理(可选,这里省略)
// ...
// 4. 清空标记
heap.clearMark();
}
const demo = {
initVal() {
const heap = new Heap();
var a = { _id: 1 }; // 这里用var是为了对象自动绑定为window属性
let b = { _id: 2 };
a.child = b;
// 创建对象并分配内存
heap.alloc("a", 1, a, globalThis);
heap.alloc("b", 1, b, a);
return {
vars: [a, b],
heap,
};
},
testUpgrade() {
const { vars, heap } = this.initVal();
const [a, b] = vars;
// 多次回收新生代,以触发晋升
minorGC(heap);
minorGC(heap);
minorGC(heap);
// 对象a晋升到老生代
// 对象b晋升到老生代
},
testMinorGC() {
const { vars, heap } = this.initVal();
const [a, b] = vars;
minorGC(heap);
// 标记可达对象,可达对象有:global, {"_id":1,"child":{"_id":2}}, {"_id":2}
{
const { page, memoryData } = heap.objMemoryMap.get(b);
console.log(
`内存地址:${memoryData.start}, 内容:${JSON.stringify(
page.space[memoryData.start].data
)}`
);
// 内存地址:513, 内容:{"_id":2}
// 说明地址已经从from空间移动到了to空间
}
delete a.child;
// 模拟引擎内部解除引用
heap.linkMap.get(a).delete(b);
minorGC(heap);
// 标记可达对象,可达对象有:global, {"_id":1}
{
const { page, start } = heap.objMemoryMap.get(b);
console.log(page.space[start]);
// undefined
// 说明对象b已经被回收
}
},
testMajorGC() {
const { vars, heap } = this.initVal();
const [a, b] = vars;
majorGC(heap);
// 标记可达对象,可达对象有:global, {"_id":1}
{
const { page, start } = heap.objMemoryMap.get(b);
console.log(page.space[start]);
// undefined
// 说明对象b已经被回收
}
},
};
// demo.testUpgrade();
demo.testMinorGC();