内存管理与垃圾回收
JavaScript 的内存管理是自动的,但理解其机制对于编写高性能、无泄漏的代码至关重要。
1. 内存生命周期
- 分配内存:声明变量时自动分配。
- 使用内存:读写变量。
- 释放内存:垃圾回收器 (GC) 回收不再使用的内存。
栈内存 (Stack) vs 堆内存 (Heap)
- 栈 (Stack): 存储基本数据类型 (Number, String, Boolean, Null, Undefined, Symbol, BigInt) 和 引用类型的地址指针。
- 特点:空间小,速度快,系统自动管理。
- 堆 (Heap): 存储引用类型 (Object, Array, Function...) 的实体。
- 特点:空间大,速度慢,通过指针访问。
2. 垃圾回收机制 (GC)
2.1 引用计数 (Reference Counting) - 已过时
- 原理:跟踪记录每个值被引用的次数。如果一个值的引用次数变为 0,就表示这个值不再用到了,可以回收。
- 致命缺陷:循环引用。如果两个对象互相引用,但不再被其他任何对象引用,它们的引用计数永远是 1,导致无法回收。
javascript
function cycle() {
let o1 = {};
let o2 = {};
o1.a = o2; // o1 引用 o2
o2.a = o1; // o2 引用 o1
// 函数结束后,o1 和 o2 本应被回收
// 但因为互相引用,引用计数不为 0,导致内存泄漏
}2.2 标记清除 (Mark-and-Sweep) - 现代标准
- 原理:
- 根 (Roots):通常是
window(全局对象) 以及当前执行栈中的变量。 - 标记:垃圾回收器从根开始遍历,所有能从根到达的对象,都标记为活动对象 (Reachable)。
- 清除:那些没有被标记的对象(即从根出发无法到达的对象),被视为垃圾,进行回收。
- 根 (Roots):通常是
- 优势:完美解决了循环引用问题。在上面的
cycle例子中,函数结束后,o1和o2从window出发无法到达,因此会被判定为垃圾并回收。
2.3 V8 优化:分代回收
- 新生代 (Young Generation):存活时间短的对象。使用 Scavenge 算法 (复制算法),将内存分为 From 和 To 两个空间,存活对象复制到 To,清空 From,速度极快。
- 老生代 (Old Generation):存活时间长或晋升的对象。使用 标记-清除 (Mark-Sweep) 和 标记-整理 (Mark-Compact) 算法。
3. 常见内存泄漏场景与排查
3.1 意外的全局变量
javascript
function foo() {
bar = "I am global"; // 没有声明变量 (未用 let/const/var),自动挂载到 window
this.baz = "I am also global"; // 非严格模式下 this 指向 window
}修复:使用严格模式 'use strict',或正确声明变量。
3.2 被遗忘的定时器
javascript
const element = document.getElementById("button");
const intervalId = setInterval(() => {
// 定时器回调函数持有 element 的引用 (闭包)
// 即使在 DOM 中删除了 button,element 也无法被回收
const node = element;
if (node) {
node.innerHTML = Date.now();
}
}, 1000);
// 修复:组件销毁或不再需要时,必须清除定时器
clearInterval(intervalId);3.3 脱离 DOM 的引用 (Detached DOM)
虽然在页面上删除了节点,但 JS 变量还指着它,导致无法回收。
javascript
let detachedNodes = [];
function create() {
const ul = document.createElement("ul");
for (let i = 0; i < 10; i++) {
const li = document.createElement("li");
ul.appendChild(li);
}
// 这里的 ul 虽然没有添加到 document.body 中 (或者被 remove 了)
// 但因为它被 detachedNodes 数组引用,所以整个 ul 树都无法回收
detachedNodes.push(ul);
}修复:使用完后将变量置为 null,如 detachedNodes = null。
3.4 闭包导致的内存泄漏
闭包允许函数访问其外部作用域的变量,这本身是 JS 强大的特性。但如果不当使用,会导致这些变量一直常驻内存,无法被 GC 回收。
(1) 典型案例:无意中持有大对象
javascript
function outer() {
// 一个占用内存很大的对象
const bigData = new Array(1000000).fill("🍔");
// 即使 unused 没被使用,但因为与 inner 共享了词法作用域
// 某些 JS 引擎可能会为了闭包上下文而保留整个作用域链
const unused = function () {
if (bigData) console.log("hi");
};
return function inner() {
console.log("I am inner");
};
}
const fn = outer();
// outer 执行完了,但返回的 inner 函数引用了 outer 的作用域
// 如果 inner 一直存在 (比如挂在全局或定时器里),bigData 可能就无法回收(2) 修复方案
- 手动置空:不再需要时,将持有闭包的变量置为
null。javascriptlet fn = outer(); // 使用完后... fn = null; // 切断引用,bigData 就可以被回收了 - 避免在闭包中引用不必要的变量:不要在闭包外层定义巨大的、只用一次的数据,或者用完后手动设为
null。
(3) 并不是所有闭包都是泄漏
只有当闭包长期存在(如挂在全局、事件监听、定时器)且持有了不再需要的资源时,才叫内存泄漏。正常的函数调用产生的闭包,执行完销毁,是不会泄漏的。
4. 深拷贝与浅拷贝详解
4.1 浅拷贝 (Shallow Copy)
只拷贝对象的第一层属性。
- 基本类型:拷贝值。
- 引用类型:拷贝内存地址。
这意味着,如果你修改了新对象中的嵌套属性(引用类型),原始对象也会跟着变,因为它们指向同一个内存地址。
代码示例
javascript
const obj1 = {
name: "小明",
age: 18,
info: {
city: "北京", // 嵌套对象
},
};
// 使用扩展运算符实现浅拷贝
const obj2 = { ...obj1 };
// 1. 修改第一层属性 (基本类型) -> 互不影响
obj2.name = "小红";
console.log(obj1.name); // '小明' (未变)
// 2. 修改嵌套属性 (引用类型) -> 互相影响!
obj2.info.city = "上海";
console.log(obj1.info.city); // '上海' (变了!)其他常见浅拷贝方法:
Object.assign({}, obj)- 数组方法:
arr.slice(),arr.concat(),[...arr]
4.2 深拷贝 (Deep Copy)
递归拷贝所有层级,新对象与原对象完全独立,互不影响。
方案一:JSON 序列化 (乞丐版)
javascript
const newObj = JSON.parse(JSON.stringify(oldObj));- 优点:简单代码少。
- 缺点:
- 忽略
undefined、symbol、function。 Date对象变为字符串。RegExp变为空对象{}。- 无法处理循环引用 (会报错)。
- 忽略
方案二:手写递归 (进阶版)
解决循环引用需要用到 WeakMap。
javascript
function deepClone(target, map = new WeakMap()) {
// 1. 基础类型直接返回
if (target === null || typeof target !== "object") {
return target;
}
// 2. 处理 Date 和 RegExp
if (target instanceof Date) return new Date(target);
if (target instanceof RegExp) return new RegExp(target);
// 3. 处理循环引用 (如果已经拷贝过,直接返回 map 中的值)
if (map.has(target)) return map.get(target);
// 4. 创建新容器 (数组或对象)
const cloneTarget = Array.isArray(target) ? [] : {};
map.set(target, cloneTarget); // 先存进去,防止递归调用死循环
// 5. 递归拷贝属性
for (const key in target) {
if (target.hasOwnProperty(key)) {
cloneTarget[key] = deepClone(target[key], map);
}
}
return cloneTarget;
}5. 在线演示:浅拷贝 vs 深拷贝
点击下方按钮,观察修改拷贝对象时,对原始对象的影响。
原始对象 (Original)
{
"name": "小明",
"age": 18,
"info": {
"city": "北京",
"school": "北大"
}
}演示说明
- 浅拷贝:使用
...扩展运算符。修改info.city(嵌套属性) 时,原始对象也会变,因为它们共享同一个内存地址。 - 深拷贝:使用
JSON序列化。修改任何属性,原始对象都不会变,因为它们完全独立。
