普通视图

发现新文章,点击刷新页面。
昨天 — 2026年4月12日首页

深入理解 JS 中的栈与堆:从内存模型到数据结构,再谈内存泄漏

作者 往日种种
2026年4月12日 11:09

深入理解 JS 中的栈与堆:从内存模型到数据结构,再谈内存泄漏

在 JavaScript 编程中,“栈”(Stack)和 “堆”(Heap)是两个高频出现的核心概念 —— 它们既贯穿于 JS 的内存管理逻辑,也是计算机科学中经典的数据结构。理清栈与堆的本质区别,不仅能帮助我们理解 JS 代码的执行机制,更能从根源上规避内存泄漏这类影响前端性能的关键问题。

一、JS 内存模型中的栈与堆:内存管理的核心

JS 引擎在运行代码时,会将内存划分为栈和堆两个区域,二者各司其职,共同支撑代码的执行,核心差异体现在存储内容管理方式上。

1. 栈:轻量高效的 “自动管理区”

栈是 JS 引擎为快速执行设计的内存区域,具备 “连续内存、后进先出(LIFO)” 的特性,由系统自动分配和释放,无需垃圾回收(GC)介入,访问速度极快。

  • 存储内容:栈主要存储执行上下文(比如函数调用时的执行环境)、基本数据类型(Number、String、Boolean、Undefined、Null、Symbol、BigInt),以及引用类型的 “引用地址”。
  • 管理逻辑:函数调用时,JS 引擎会为函数创建执行上下文并压入栈中;函数执行结束后,该执行上下文会被立即弹出栈,对应的内存自动释放。这种 “压栈 - 弹栈” 的操作仅需移动内存指针,效率极高。

2. 堆:灵活的 “动态数据区”

堆是用于存储复杂数据的内存区域,内存分配不连续,可能产生内存碎片,访问速度慢于栈,其生命周期由垃圾回收机制(GC)管理(而非系统自动释放)。

  • 存储内容:堆中存放所有引用类型的真实数据,包括对象、数组、函数等。
  • 管理逻辑:当我们创建一个对象(如let obj = {a: 1})时,变量obj会被存入栈中,而对象{a: 1}的真实数据会被存入堆中,栈里的obj实际保存的是指向堆中该对象的引用地址。GC 会通过 “标记清除”(标记可达对象,回收未标记对象)或 “引用计数”(为对象计数,引用数为 0 则回收)的方式,清理堆中无引用的无用数据。

3. 栈与堆的关联:闭包的特殊场景

闭包是栈与堆交互的典型案例 —— 本该在函数执行结束后随栈释放的局部数据(比如函数内的变量),因被内部函数引用,会被转移到堆中持续存在,直到引用关系消失。例如:

function outer() {
    let obj = { a: 1 }; // 本该随outer执行结束释放
    return function inner() {
        console.log(obj); // 闭包引用导致obj被存入堆
    }
}
let fn = outer(); // inner保留对obj的引用,obj无法被回收

二、数据结构视角下的栈与堆:结构与用途的差异

除了内存管理,栈和堆也是两种核心数据结构,其设计逻辑和用途与内存模型中的栈堆截然不同。

1. 栈:线性有序的 “容器”

作为数据结构的栈是线性结构,严格遵循 “后进先出(LIFO)” 原则 —— 只能在栈顶进行元素的插入(压栈)和删除(弹栈)操作,无法随机访问中间元素。这种特性使其适用于函数调用栈、表达式求值、括号匹配等场景。

2. 堆:树状无序的 “优先级结构”

作为数据结构的堆是非线性的树状结构(通常为完全二叉树),无 “后进先出” 的规则,核心特点是 “堆顶元素为最值”:

  • 大顶堆:堆顶元素是整个堆的最大值;
  • 小顶堆:堆顶元素是整个堆的最小值。堆的核心用途是排序(如堆排序)、优先队列实现等,与内存模型中 “存储引用类型” 的堆无直接关联,仅为同名不同概念。

三、内存泄漏:本该释放的内存 “被滞留”

理解了栈与堆的内存管理逻辑,就能更清晰地认识内存泄漏问题 —— 这是前端性能优化的核心痛点,其本质和成因都与 “引用关系” 密切相关。

1. 内存泄漏的本质

内存泄漏的核心本质是:本该被垃圾回收机制回收的内存,因被意外保留了可达引用,导致无法释放,内存占用持续增长。最终会引发页面卡顿、响应缓慢,甚至浏览器崩溃。

判断一个对象是否可被回收,核心标准是 “可达性”:只要对象能被 JS 的根对象(如 window)通过引用链访问到,就会被判定为 “有用”,不会被 GC 回收;反之则会被清理。

2. 内存泄漏的常见成因

在 JS 中,内存泄漏的诱因多与 “不当保留引用” 有关,常见场景包括:

  • 定时器 / 延时器未清理:组件卸载后,定时器未取消,其内部函数会持续引用外部变量,导致变量无法被回收。例如 React 组件中未清除的setInterval,会让组件内的状态始终处于可达状态。
  • 事件监听未移除:给 DOM 元素绑定事件后,未在组件卸载或元素销毁时移除监听,监听函数会持续引用相关变量,造成内存泄漏。
  • DOM 引用未释放:即使移除了 DOM 节点(如document.body.removeChild(el)),若仍保留对该节点的变量引用(el未置为null),该节点仍可达,无法被 GC 回收。
  • 全局变量滥用:意外声明的全局变量(如未加let/const的变量)会挂载到window上,成为根对象的可达引用,永远无法被回收;刻意创建的全局变量若未手动置空,也会长期占用内存。
  • 闭包导致的泄漏:不合理的闭包会让栈中本该释放的局部变量被持久化到堆中,若闭包长期存在(如赋值给全局变量),则变量会一直无法回收。
  • Map/Set 使用不当:普通Map/Set对键 / 值的强引用关系,会导致即使对象本身被置为nullobj = null),只要Map/Set仍引用该对象,就无法被回收;而WeakMap/WeakSet的弱引用特性可避免此问题(对象被回收时,对应的键值对会自动清除)。
  • 订阅 / 发布模式未取消订阅:若未取消对事件总线、状态管理库的订阅,订阅函数会持续引用相关数据,造成泄漏。
  • Promise/Promise 链未结束:Promise 处于pending/rejected未处理状态,或请求发起后组件已卸载但响应未中止(如未使用AbortController终止 API 请求),会导致相关引用无法释放。

总结

栈与堆在 JS 中承担着不同的角色:内存模型中,栈是高效的自动管理区,堆是动态的引用类型存储区;数据结构中,栈是线性 LIFO 结构,堆是树状最值结构。而内存泄漏的本质,是打破了堆内存的 “可达性” 规则 —— 本该被 GC 清理的对象,因意外的引用关系始终处于可达状态。

理清栈、堆的内存逻辑,规避不当的引用行为(如及时清理定时器、移除监听、使用弱引用容器等),是写出高性能、低内存泄漏风险 JS 代码的核心前提。

❌
❌