JavaScript 内存管理深度解析
为什么要关注内存
当程序出现内存溢出时,占用的内存空间会越来越多,最终导致客户端卡顿、无响应,甚至自动退出。理解内存管理是写出高质量 JavaScript 代码的基础。
JS 数据类型与内存机制
JavaScript 的数据类型分为两大类:
- 原始数据类型:String、Number、Boolean、Null、Undefined、Symbol
- 引用数据类型:Object、Array、Function
这两种类型在内存中的存储方式完全不同,理解这一点是掌握内存管理的关键。
栈内存 vs 堆内存
栈内存
栈是一种先进后出(LIFO)的数据结构,主要特点:
- 空间较小,读取速度快
- 存储原始数据类型的值
- 存储引用数据类型的引用地址
// 原始类型直接存在栈中
let name = "Tom"; // 栈中存储 "Tom"
let age = 25; // 栈中存储 25
let isActive = true; // 栈中存储 true
堆内存
堆用于存储大小不固定的数据:
- 引用类型(Object、Array 等)存储在堆中
- JS 不允许直接操作堆内存
- 操作对象实际上是操作对象的引用地址
// 引用类型:栈中存地址,堆中存实际数据
let person = {
name: "Tom",
age: 25
};
// person 变量在栈中,存储的是指向堆中对象的地址
// 堆中存储实际的 { name: "Tom", age: 25 }
内存示意图:
栈内存 堆内存
┌─────────────┐ ┌─────────────────┐
│ person: 0x1 │ ───▶ │ { name: "Tom", │
└─────────────┘ │ age: 25 } │
└─────────────────┘
垃圾回收机制
垃圾回收器会周期性检测不再使用的变量,释放其占用的内存。这是 JS 自动管理内存的机制。
优势:
- 不需要手动处理内存管理代码
- 减少因长时间运行导致的内存泄漏
劣势:
- 没有暴露内存管理 API
- 无法强制垃圾回收
- 无法干预内存管理
回收策略 1:引用计数
跟踪记录每个值被引用的次数:
- 被引用时 +1
- 被释放时 -1
- 当计数为 0 时,被垃圾回收
let obj = { name: "Tom" }; // 引用计数 = 1
let ref = obj; // 引用计数 = 2
ref = null; // 引用计数 = 1
obj = null; // 引用计数 = 0 → 被回收
问题:循环引用
function problem() {
let objA = {};
let objB = {};
objA.ref = objB; // objA 引用 objB
objB.ref = objA; // objB 引用 objA
// 即使函数结束,引用计数永远不为 0
// 解决方案:手动赋值为 null
objA = null;
objB = null;
}
回收策略 2:标记清除(现代主流)
基于执行环境的概念:
- 变量进入环境 → 标记为”进入环境”
- 变量离开环境 → 标记为”离开环境”
- 垃圾回收器销毁带”离开环境”标记的变量
function markingExample() {
let localVar = "我是局部变量"; // 进入环境:标记
// 执行代码...
return localVar;
} // 离开环境:标记 → 函数执行完毕后被回收
// 全局变量不会被标记清除(除非手动赋值为 null)
window.globalVar = "我不会被自动回收";
造成内存泄漏的常见情况
1. 循环引用
// ❌ 错误示例
function createCircularRef() {
let obj = {};
obj.self = obj; // 循环引用
return obj;
}
// ✅ 正确做法
function fixCircularRef() {
let obj = {};
obj.self = obj;
// 使用完毕后
obj = null; // 手动断开引用
}
2. 全局变量
// ❌ 意外创建全局变量
function意外创建() {
leak = "我变成了全局变量"; // 没有声明,自动成为全局
}
// ✅ 使用严格模式
"use strict";
function 严格模式() {
leak = "报错了"; // ReferenceError
}
3. 闭包
// 闭包会保留对外部变量的引用
function outer() {
const largeData = new Array(10000).fill('x');
return function inner() {
// largeData 被闭包引用,不会被回收
return largeData.length;
};
}
// ✅ 使用完毕后手动释放
function outerFixed() {
let largeData = new Array(10000).fill('x');
const inner = () => largeData.length;
largeData = null; // 手动释放
return inner;
}
4. 未清理的定时器
// ❌ 定时器引用外部变量
function startPolling() {
const largeData = fetchData();
setInterval(() => {
console.log(largeData); // largeData 永远不会被回收
}, 1000);
}
// ✅ 手动清除定时器
function startPollingFixed() {
const largeData = fetchData();
const timer = setInterval(() => {
console.log(largeData);
}, 1000);
// 使用完毕后
clearInterval(timer);
}
V8 引擎的内存管理
V8 采用分代回收策略,将堆内存分为新生代和老生代。
新生代(Young Generation)
特点: 空间小、回收频繁、操作快
存储对象: 新创建的对象
算法:Scavenge 算法( Cheney 算法)
新生代内存分为两块:From 和 To
┌─────────┬─────────┐
│ From │ To │
│ (使用中) │ (空闲) │
└─────────┴─────────┘
回收时:
1. 把 From 中存活的对象复制到 To
2. 清空 From
3. 交换 From 和 To 的角色
优点:
- 分配速度快
- 回收速度快
- 适合存活对象少的场景
缺点:
- 不适合特别大的对象
- 空间利用率只有 50%
老生代(Old Generation)
晋升条件:
- 对象历经多次新生代 GC 仍存活
- 大对象(超过新生代空间限制)
- 长时间存活的闭包对象
算法:标记-清除 + 标记-整理
标记-清除(Mark-Sweep)
1. 从根对象开始遍历
2. 给可达对象打上"存活"标签
3. 清理未被标记的对象
问题:会产生内存碎片
标记-整理(Mark-Compact)
1. 标记存活对象
2. 将存活对象移动到一端
3. 让空闲内存变成连续区域
优点:解决碎片问题
缺点:需要移动对象,成本更高
V8 的优化策略
V8 通过多种方式减少 GC 停顿时间:
- 增量标记:将标记工作分成小块,穿插在代码执行中
- 并行 GC:主线程和辅助线程同时工作
- 并发 GC:在后台线程中执行 GC
闭包与内存的关系
什么是闭包
闭包 = 函数 + 它创建时能访问的词法作用域
即使外层函数执行完毕,内层函数仍然可以访问外层变量。
function outer() {
const name = "Tom";
let count = 0;
function inner() {
count++;
return `${name}:${count}`;
}
return inner;
}
const fn = outer();
fn(); // Tom:1
fn(); // Tom:2
闭包的内存机制
当 outer() 执行结束后,name 和 count 本应该随栈帧一起消失。但 inner 还要继续使用它们,所以 V8 会将这些”被捕获的变量”放到堆上的 Context 中。
flowchart LR
A["调用 outer()"] --> B["创建 outer 执行上下文"]
B --> C["创建 Context 存放被捕获变量"]
C --> D["name = Tom"]
C --> E["count = 0"]
B --> F["创建 inner 函数对象"]
F --> C
F --> G["return inner"]
调用时发生了什么:
- 调用
outer(),创建执行上下文 - 发现
inner用到了name和count - 这两个变量不能只放在临时栈帧里,于是进入 Context
outer()返回inner- 返回后的
inner仍然持有对 Context 的引用
总结
| 内存区域 | 存储内容 | 特点 |
|---|---|---|
| 栈内存 | 原始类型值、引用地址 | 空间小、读取快、LIFO |
| 堆内存 | 引用类型实际数据 | 空间大、动态分配 |
V8 内存管理核心:
- 新生代:Scavenge 算法,处理短生命周期对象
- 老生代:标记-清除/标记-整理,处理长生命周期对象
- 核心判断:可达性分析,不可达对象会被回收
避免内存泄漏的关键:
- 及时断开不需要的引用
- 避免意外创建全局变量
- 合理使用闭包
- 及时清理定时器和事件监听器