3. JavaScript 内存管理基础:栈与堆归纳总结
说实话,平时写 JavaScript 大家可能很少直接跟内存打交道。但一旦遇到内存泄漏导致的卡顿,你就得直面一个核心问题:内存到底是怎么分配的?
JS 引擎(比如 V8)管理内存主要有两块区域:栈(Stack) 和 堆(Heap)。这两块东西搞不清楚,排查内存泄漏基本就是在瞎蒙。
栈(Stack)—— 轻量级的临时储物间
栈是一种后进先出(LIFO)的数据结构。它存储的是函数调用上下文、局部变量、基本类型值。
我习惯把它想象成一摞盘子:每次调用函数就往顶层压一个盘子,函数返回就弹出盘子。盘子叠得太高,就会栈溢出(Stack Overflow)。
栈中存放的内容:
原始类型(number、string、boolean、null、undefined、symbol、bigint)的值直接放在栈里
对象类型(object、array、function)的引用地址(指针)也放在栈里,但实际数据在堆里
原始类型(number、string、boolean、null、undefined、symbol、bigint)的值直接放在栈里
对象类型(object、array、function)的引用地址(指针)也放在栈里,但实际数据在堆里
// 基本类型 — 值直接存栈
let a = 42; // 栈:存 42
let b = a; // 栈:复制 42 给 b
b = 100; // b 改为 100,a 仍然是 42
console.log(a); // 42 — 互不影响
// 引用类型 — 栈存指针,堆存数据
let obj1 = { value: 1 }; // 栈:存指向堆的指针;堆:存 {value:1}
let obj2 = obj1; // 栈:复制指针,两个变量指向同一堆对象
obj2.value = 2; // 改的是堆里的数据
console.log(obj1.value); // 2 — 共享同一对象
你想想看,如果函数内部创建了大量临时对象,却忘记释放堆中的引用,堆就慢慢被塞满,GC 压力变大,最后渲染线程被卡住。嗯,这就是内存泄漏的典型路径之一。
堆(Heap)—— 大仓库,但需要打扫
堆是无结构的、用指针串联的内存空间。所有对象、闭包、数组、DOM 节点都在堆里分配。
堆的分配比栈慢得多,而且需要垃圾回收器(GC)来清理不再使用的对象。
GC 算法概览(不深入,但要知道):
- 标记-清除:从根(全局变量、当前执行上下文等)出发,标记可达对象,清除未标记的。缺点:产生内存碎片。
- 标记-整理:标记后把存活对象向一端移动,避免碎片。代价是耗时更长。
- 分代收集:V8 把堆分为新生代(年轻对象,频繁 GC)和老生代(长寿对象,较少 GC)。
我曾经踩过的坑:写了一个定时器每 100ms 往数组里 push 一个大对象,以为局部变量函数结束后会被回收。结果定时器一直持有对数组的引用,堆从 2MB 涨到 200MB,页面卡死。这就是忘记清除引用导致 GC 无法回收。
栈与堆对比表
| 维度 | 栈 | 堆 |
|---|---|---|
| 存储内容 | 基本类型值 + 引用地址 | 对象、数组、函数、闭包 |
| 分配速度 | 极快(直接入栈出栈) | 较慢(需要查找空闲内存) |
| 管理方式 | 自动(函数作用域结束即释放) | 自动 + 手动(依赖 GC) |
| 空间大小 | 小(KB 级别,固定上限) | 大(GB 级别,动态增长) |
| 溢出风险 | 递归过深 → 栈溢出 | 引用未释放 → 堆膨胀 → 内存泄漏 |
| 变量赋值 | 值拷贝,独立 | 引用拷贝,共享 |
我的个人习惯:写代码时先判断这个数据是“值”还是“引用”。如果是对象,时刻问自己“谁还持有它的引用?”——这能避免 90% 的堆泄漏。
内存生命周期三阶段
- 分配内存 — 变量声明、对象创建时自动分配。
- 使用内存 — 读写变量、调用方法。
- 释放内存 — 栈:出栈自动释放;堆:依赖 GC 回收不再可达的对象。
问题就出在第三步。栈释放得很干脆,但堆里的对象只要还有一条引用链从根可达,GC 就认为它“活着”。
判断对象是否可被回收的核心:
从 GC Roots(全局变量、当前调用栈中的局部变量、处理器寄存器等)出发,如果无法到达该对象,它就是垃圾。
从 GC Roots(全局变量、当前调用栈中的局部变量、处理器寄存器等)出发,如果无法到达该对象,它就是垃圾。
常见的堆泄漏场景
- 意外的全局变量 — 未声明变量直接赋值,变成 window 的属性,永远可达。
- 被遗忘的定时器/回调 — setInterval 中引用了 DOM 或大对象,未 clearInterval。
- 脱离 DOM 的引用 — 用变量保存了 DOM 节点,即使删除了节点,变量仍持有引用。
- 闭包滥用 — 闭包中引用了外层大对象的变量,导致整个大对象无法释放。
- 大量缓存不设上限 — 往对象或 Map 里无限添加数据,没有清理策略。
避坑指南:我曾经在 React 的 useEffect 中没写清除函数,setInterval 定时往一个数组里推数据。用户切换页面组件卸载了,但定时器还在跑,数组越来越大。解决办法:useEffect 返回清理函数,clearInterval 并置空数组引用。
好了,栈与堆的核心区别其实就一句话:栈管着执行流程和临时变量,堆管着对象存储。 内存泄漏几乎都发生在堆里,因为你忘了断开引用链。把这张图记住,后面分析具体的泄漏案例时,你就能迅速定位到问题区域。