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) │
│ 访问快 │ 访问相对慢 │
└───────────────────┴─────────────────────────┘
理解栈和堆的内层机制,对于:
-
避免内存泄漏
-
理解闭包原理
-
优化性能
-
调试栈溢出
都有决定性作用。