3. JavaScript 内存管理基础:栈与堆归纳总结

说实话,平时写 JavaScript 大家可能很少直接跟内存打交道。但一旦遇到内存泄漏导致的卡顿,你就得直面一个核心问题:内存到底是怎么分配的?

JS 引擎(比如 V8)管理内存主要有两块区域:栈(Stack) 和 堆(Heap)。这两块东西搞不清楚,排查内存泄漏基本就是在瞎蒙。

栈(Stack)—— 轻量级的临时储物间

栈是一种后进先出(LIFO)的数据结构。它存储的是函数调用上下文、局部变量、基本类型值。

我习惯把它想象成一摞盘子:每次调用函数就往顶层压一个盘子,函数返回就弹出盘子。盘子叠得太高,就会栈溢出(Stack Overflow)。

栈中存放的内容:
原始类型(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% 的堆泄漏。
JavaScript 内存管理:栈与堆结构图 栈(Stack) 全局作用域: window/global 函数A调用: 局部变量 函数B调用: 更多局部变量 a: 42 objRef: 指针→堆 b: 100 obj2Ref: 同指针→堆 栈溢出!递归过深 堆(Heap) 对象 { value: 2 } 被 obj1, obj2 引用 数组 [1,2,3,...] 定时器持有引用 → 泄漏 未释放的闭包变量 GC 无法回收 DOM 节点引用 已从DOM移除但JS仍持有 引用指针 注意:泄漏的对象无法被 GC 标记清除

内存生命周期三阶段

  1. 分配内存 — 变量声明、对象创建时自动分配。
  2. 使用内存 — 读写变量、调用方法。
  3. 释放内存 — 栈:出栈自动释放;堆:依赖 GC 回收不再可达的对象。

问题就出在第三步。栈释放得很干脆,但堆里的对象只要还有一条引用链从根可达,GC 就认为它“活着”。

判断对象是否可被回收的核心:
从 GC Roots(全局变量、当前调用栈中的局部变量、处理器寄存器等)出发,如果无法到达该对象,它就是垃圾。

常见的堆泄漏场景

  • 意外的全局变量 — 未声明变量直接赋值,变成 window 的属性,永远可达。
  • 被遗忘的定时器/回调 — setInterval 中引用了 DOM 或大对象,未 clearInterval。
  • 脱离 DOM 的引用 — 用变量保存了 DOM 节点,即使删除了节点,变量仍持有引用。
  • 闭包滥用 — 闭包中引用了外层大对象的变量,导致整个大对象无法释放。
  • 大量缓存不设上限 — 往对象或 Map 里无限添加数据,没有清理策略。
避坑指南:我曾经在 React 的 useEffect 中没写清除函数,setInterval 定时往一个数组里推数据。用户切换页面组件卸载了,但定时器还在跑,数组越来越大。解决办法:useEffect 返回清理函数,clearInterval 并置空数组引用。

好了,栈与堆的核心区别其实就一句话:栈管着执行流程和临时变量,堆管着对象存储。 内存泄漏几乎都发生在堆里,因为你忘了断开引用链。把这张图记住,后面分析具体的泄漏案例时,你就能迅速定位到问题区域。