JavaScript 中的“栈”和“堆”

一、本质区别:谁管理,怎么存

栈(Stack)—— 线性结构,自动管理

  • 存储内容:基本类型的值 + 引用类型的内存地址

  • 管理方式:操作系统自动分配/释放,函数执行时压栈,执行完出栈

  • 特点:后进先出(LIFO),连续的内存空间,访问极快

  • 大小限制:固定(通常几MB),超出会栈溢出

function foo() {
  let a = 10; // 栈:a = 10
  let b = { x: 20 }; // 栈:b = 地址0x001,堆:0x001存 {x:20}
  let c = 'hello'; // 栈:c = "hello"(字符串基本类型存栈)
}
foo(); // 执行完,a、b、c全部弹出栈,内存自动释放

堆(Heap)—— 树形结构,手动分配(GC回收)

  • 存储内容:引用类型的实际数据(对象、数组、函数等)

  • 管理方式:开发者创建,JS引擎的垃圾回收器(GC) 自动回收

  • 特点:无序存储,内存空间不连续,访问相对慢

  • 大小限制:理论内存上限(通常可达几百MB甚至GB)

let obj = { name: '张三' };
// 堆中分配一块内存存 {name:"张三"}
// 栈中变量 obj 存储这块内存的地址(如 0x001)

二、内层机制详解

1. 栈的“内层” —— 执行上下文栈(Call Stack)

JS 运行时维护一个调用栈,记录函数调用顺序:

function a() {
  let x = 1; // 栈帧1:a的局部变量
  b(); // 调用b,压入新栈帧
  let y = 2; // 等待b执行完才能执行
}

function b() {
  let m = 10; // 栈帧2:b的局部变量
  c(); // 调用c,再压栈
  let n = 20;
}

function c() {
  let p = 100; // 栈帧3:c的局部变量
  console.log(p);
}

a();

调用栈变化:

1. [a] 入栈
2. [a, b] 入栈(a调用b)
3. [a, b, c] 入栈(b调用c)
4. c执行完 → [a, b](c出栈)
5. b执行完 → [a](b出栈)
6. a执行完 → [](a出栈)

栈溢出示例:

function recursion() {
  recursion(); // 无限递归
}
recursion(); // Uncaught RangeError: Maximum call stack size exceeded

2. 堆的“内层” —— 垃圾回收机制

堆内存由垃圾回收器(GC) 管理,主要有两种算法:

① 标记清除(Mark-Sweep)

let obj = { name: '张三' }; // 堆中分配内存
obj = null; // 断开引用,GC标记为"不可达",下次回收

② 分代回收(Generational Collection)

V8引擎将堆分为新生代和老生代:

堆内存
├── 新生代(Young Generation)
│   ├── From-Space(活跃对象)
│   └── To-Space(空闲空间)
│   └── 特点:存活时间短,频繁回收(Scavenge算法)
│
└── 老生代(Old Generation)
    ├── 指针区(存放对象指针)
    └── 数据区(存放原始数据)
    └── 特点:存活时间长,较少回收(Mark-Sweep + Mark-Compact)

三、深入对比:变量传递时发生了什么

基本类型(栈拷贝)

let a = 10; // 栈:a → 10
let b = a; // 栈:b → 10(新开辟空间,复制值)
b = 20; // 栈:b → 20,a仍为10
console.log(a); // 10(独立)

引用类型(栈存地址,堆存数据)

let obj1 = { name: '张三' };
// 栈:obj1 → 地址0x001
// 堆:0x001 → { name: "张三" }

let obj2 = obj1;
// 栈:obj2 → 地址0x001(复制地址,指向同一堆数据)

obj2.name = '李四';
// 通过地址修改堆中数据

console.log(obj1.name); // "李四"(相互影响)

函数参数传递(值传递)

function change(a, b) {
  a = 20; // 修改栈中的副本,不影响外部
  b.name = '李四'; // 通过地址修改堆中数据,影响外部
  b = { name: '王五' }; // 重新赋值b的地址,不影响外部
}

let x = 10; // 栈
let y = { name: '张三' }; // 栈存地址,堆存对象

change(x, y);

console.log(x); // 10(没变)
console.log(y.name); // "李四"(被修改了)

四、特殊情况:闭包中的栈变量“逃逸”到堆

function outer() {
  let count = 0; // 正常情况下应该存在栈中,函数结束就释放

  return function inner() {
    count++; // 内部函数引用外部变量
    console.log(count);
  };
}

const counter = outer();
// 关键:count 没有被释放,从栈"逃逸"到堆中
// 因为 inner 函数还引用着它

counter(); // 1
counter(); // 2
// count 持续存在堆中,直到 counter 不再被引用

这种现象叫闭包内存逃逸,是理解 JS 内存管理的关键点。

五、可视化总结

┌─────────────────────────────────────────────┐
│              内存结构                        │
├───────────────────┬─────────────────────────┤
│    栈(Stack)     │      堆(Heap)         │
├───────────────────┼─────────────────────────┤
│ • 基本类型值       │ • 对象实际数据           │
│ • 引用类型地址     │ • 数组实际数据           │
│ • 函数调用帧       │ • 函数体代码             │
│ • 执行上下文       │ • 闭包引用的变量         │
├───────────────────┼─────────────────────────┤
│ 自动管理,LIFO     │ GC管理,无序             │
│ 空间小(~MB)      │ 空间大(~GB)            │
│ 访问快            │ 访问相对慢               │
└───────────────────┴─────────────────────────┘

理解栈和堆的内层机制,对于:

  • 避免内存泄漏

  • 理解闭包原理

  • 优化性能

  • 调试栈溢出

    都有决定性作用。