深入理解 JavaScript 内存机制:从执行上下文到闭包

30 阅读6分钟

一、JavaScript 是什么语言?

JavaScript 是一门 动态弱类型语言

  • 动态语言:变量的数据类型在运行时才确定。
  • 弱类型语言:允许隐式类型转换(如 "1" + 2 === "12")。

💡 对比:

  • 静态强类型语言(如 C++、Java):编译期需明确类型,不能随意转换。
  • 动态强类型语言(如 Python):运行时确定类型,但不允许随意转换(如 "1" + 2 会报错)。

JS 的设计哲学是“灵活优先”,但也带来了类型陷阱(比如 typeof null === 'object' 这个历史 bug)。


二、JS 不需要手动管理内存

与 C/C++ 不同,JavaScript 由引擎自动管理内存

c
编辑
// C 语言:手动申请/释放内存
int *p = malloc(sizeof(int));
*p = 42;
free(p); // 忘记这行 → 内存泄漏!

而在 JS 中:

js
编辑
let obj = { name: "极客时间" };
obj = null; // 引用断开,GC 自动回收

开发者无需调用 malloc/free,内存分配与回收由 垃圾回收器(Garbage Collector, GC) 自动处理。


三、内存空间划分:栈 vs 堆

JS 引擎(如 V8)将内存分为两类:

类型存储内容特点
栈内存(Stack)原始类型(Number、String、Boolean、undefined、null、Symbol、BigInt) 函数调用记录(执行上下文)空间小、连续、分配/释放快 生命周期短,随函数调用自动管理
堆内存(Heap)引用类型(Object、Array、Function 等)空间大、不连续、分配/回收慢 通过引用(指针)访问

示例对比

js
编辑
function foo() {
    var a = '极客时间';      // 栈:直接存储字符串值(或指向常量池)
    var b = a;               // 栈:拷贝值 → b 是独立副本

    var c = { name: '极客时间' }; // 栈:存的是堆地址;堆:存实际对象
    var d = c;               // 栈:拷贝地址 → c 和 d 指向同一堆对象
}

关键理解

  • 原始类型:值传递(拷贝值)
  • 引用类型:引用传递(拷贝地址)

image.png


四、执行机制:调用栈与栈顶指针

JS 是单线程语言,靠 调用栈(Call Stack) 管理函数执行顺序。而调用栈的高效运作,离不开一个关键角色:栈顶指针(Stack Pointer)

什么是栈顶指针?

  • 栈顶指针是一个 CPU 寄存器(在 V8 中由引擎模拟),始终指向当前栈帧的顶部

  • 每当函数被调用:

    • 引擎在栈上分配一个新的执行上下文(即栈帧);
    • 栈顶指针向上移动(向高地址方向偏移),指向新帧的起始位置。
  • 函数执行完毕:

    • 引擎弹出当前栈帧
    • 栈顶指针向下回退(恢复到上一帧的位置)。

image.png

🔧 技术细节
在底层,栈顶指针的偏移操作是极其快速的整数加减,不需要遍历或查找,因此函数调用/返回成本极低。

为什么栈空间必须小且连续?

  • 栈顶指针依赖连续内存布局进行快速偏移;

  • 如果把大对象(如数组、复杂对象)也放在栈中:

    • 栈帧大小变得不可预测;
    • 栈顶指针频繁大幅跳动 → 破坏局部性,降低 CPU 缓存命中率
    • 极易导致 栈溢出(Stack Overflow)

因此,V8 将大对象统一放入堆中,栈只保留固定大小的原始值和指针,确保栈顶指针切换高效稳定。


五、执行上下文(Execution Context)包含:

  1. 变量环境(Variable Environment)var 声明的变量
  2. 词法环境(Lexical Environment)let/const 声明的变量(ES6+)
  3. this 绑定
  4. Outer 引用:指向外层作用域(构成作用域链)

📌 作用域链 = 词法作用域 + Outer 链
由函数声明位置决定,而非调用位置。


六、闭包与内存:为什么闭包能“记住”外部变量?

闭包的本质

闭包 = 内部函数 + 被捕获的外部自由变量

当内部函数引用了外部函数的变量,JS 引擎会:

  1. 执行 foo 之前, 编译过程快速的词法扫描 JS 判断有闭包了,在 堆内存 中创建一个 closure(foo) 对象;
  2. 将被引用的变量(如 myNametest1提升到堆中保存
  3. 即使 foo() 执行完毕、调用栈弹出(栈顶指针回退),这些变量仍因被引用而不会被 GC 回收

代码示例

js
编辑
function foo() {
    var myName = "极客时间";
    let test1 = 1;
    const test2 = 2;

    return {
        setName(newName) { myName = newName; },
        getName() {
            console.log(test1); // 引用了 test1 → 触发闭包
            return myName;
        }
    };
}

const bar = foo(); // foo 执行完毕,栈帧被弹出,栈顶指针回退
bar.setName("极客邦");
console.log(bar.getName()); // 输出 1 和 "极客邦"

🔍 内存视角

  • foo() 返回后,其栈帧被销毁(栈顶指针已回退);
  • 但 myName 和 test1 因被闭包引用,存活于堆中的 closure 对象
  • bar 持有对 closure 的引用 → 变量持续存在。

image.png


七、为什么原始类型放栈,对象放堆?

这是性能与安全的权衡:

考虑因素栈内存堆内存
大小固定、小(通常几 MB)动态、大(可达 GB)
分配速度极快(栈顶指针偏移)较慢(需查找空闲块)
连续性连续(利于指针操作)不连续
GC 成本函数结束自动释放(指针回退即可)需标记-清除等算法

⚠️ 如果把大对象也放栈:

  • 栈帧变大 → 栈顶指针跳动剧烈;
  • 上下文切换变慢(影响函数调用性能);
  • 容易栈溢出。

因此,小而固定的原始值 → 栈;大而动态的对象 → 堆,是最优解。


八、总结要点(速记版)

JS 内存模型核心

概念说明
栈内存存原始类型 + 函数调用帧;快、小、自动管理
堆内存存对象;慢、大、靠 GC 回收
栈顶指针指向当前栈帧顶部,函数调用时上移,返回时下移;是调用栈高效的关键
闭包本质是堆中保存的外部变量快照,不受栈帧销毁影响
引用 vs 值原始类型拷贝值,对象拷贝地址
GC 触发条件对象无任何引用(不可达)

闭包三要素

  1. 外部函数;
  2. 内部函数;
  3. 内部函数引用外部变量。

九、拓展思考

1. 栈顶指针与性能

现代 CPU 高度优化栈操作。V8 利用这一点,将执行上下文设计为固定大小结构体,使得栈顶指针偏移成为纳秒级操作。这也是 JS 函数调用如此高效的原因之一。

2. 闭包会导致内存泄漏吗?

可能,但不是必然
只要闭包引用的变量最终能被释放(如解除引用),就不会泄漏。

js
编辑
// 潜在泄漏
const cache = {};
function createUser(id) {
    if (!cache[id]) {
        cache[id] = { /* 大对象 */ };
    }
    return () => cache[id]; // 闭包长期持有 cache 引用
}
// 若 cache[id] 永不删除 → 内存泄漏

建议:及时清理无用引用,尤其在长生命周期对象中