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)

晋升条件:

  1. 对象历经多次新生代 GC 仍存活
  2. 大对象(超过新生代空间限制)
  3. 长时间存活的闭包对象

算法:标记-清除 + 标记-整理

标记-清除(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"]

调用时发生了什么:

  1. 调用 outer(),创建执行上下文
  2. 发现 inner 用到了 name 和 count
  3. 这两个变量不能只放在临时栈帧里,于是进入 Context
  4. outer() 返回 inner
  5. 返回后的 inner 仍然持有对 Context 的引用

总结

内存区域存储内容特点
栈内存原始类型值、引用地址空间小、读取快、LIFO
堆内存引用类型实际数据空间大、动态分配

V8 内存管理核心:

  • 新生代:Scavenge 算法,处理短生命周期对象
  • 老生代:标记-清除/标记-整理,处理长生命周期对象
  • 核心判断:可达性分析,不可达对象会被回收

避免内存泄漏的关键:

  1. 及时断开不需要的引用
  2. 避免意外创建全局变量
  3. 合理使用闭包
  4. 及时清理定时器和事件监听器