JavaScript 是怎么运行的?一文看懂栈、堆、闭包与执行上下文

48 阅读7分钟

JavaScript 内存机制与执行原理:小白也能看懂的深入解析

在学习 JavaScript 的过程中,很多初学者会遇到一些“玄学”现象:为什么变量类型可以随时改变?为什么函数内部能访问外部的变量?为什么对象赋值后修改一个会影响另一个?这些看似神奇的行为背后,其实都源于 JavaScript 引擎对内存管理执行上下文的精巧设计。

本文将用通俗易懂的语言、生活化的比喻,带你揭开 JavaScript 执行机制的神秘面纱,理解栈内存 vs 堆内存执行上下文闭包等核心概念,一步步还原 JS 引擎是如何运行我们的代码的。


一、JavaScript 是什么语言?——动态弱类型的“自由派”

要真正理解 JavaScript 的行为,我们需要把它放在编程语言的“类型光谱”中来看。

编程语言可以从两个维度分类:

  • 静态 vs 动态:类型是在写代码时就固定(静态),还是程序跑起来才确定(动态)?
  • 强类型 vs 弱类型:是否允许不同类型之间“悄悄转换”?

组合起来就有四种典型:

  • 静态强类型:Java、C#、Rust —— 安全严谨,适合大型项目。
  • 静态弱类型:C、C++ —— 高效灵活,但需手动管理风险。
  • 动态强类型:Python、Ruby —— 写起来自由,但类型规则严格。
  • 动态弱类型JavaScript、PHP —— 最自由,也最容易“翻车”。

JavaScript 属于最后一种:动态弱类型语言

看这段代码:

var bar;
console.log(typeof bar); // "undefined"
bar = 12;
console.log(typeof bar); // "number"
bar = "极客时间";
console.log(typeof bar); // "string"
bar = true;
console.log(typeof bar); // "boolean"
bar = null;
console.log(typeof bar); // "object"(这是 JS 的一个历史 bug)
bar = { name: "极客时间" };
console.log(typeof bar); // "object"

变量 barundefined 到数字、字符串、布尔值、对象……一路变身,毫无障碍。这在 Python 中会因类型不匹配而报错。

而这与 C/C++ 等静态强类型语言形成鲜明对比。例如在 C 语言中:

int main() {
    int a = 1;
    char* b = "极客时间";
    bool c = true;
    c = a; // 虽然能编译,但类型不匹配,存在风险
    return 0;
}

C 语言要求你在使用变量前就明确其类型,且不能随意混用。而 JavaScript 则像一位“自由派艺术家”——不拘小节,灵活多变,但也因此需要更聪明的内存管理机制来支撑这种灵活性。


二、内存的两种空间:栈 vs 堆 —— 小物件抽屉 vs 大仓库

想象一下你的家:

  • 栈内存(Stack) 就像你书桌上的小抽屉:空间小、整齐、取放快。你把常用的小物件(比如笔、橡皮、钥匙)放在这里,随手就能拿到。
  • 堆内存(Heap) 则像你家的大仓库:空间巨大,但找东西慢。你把沙发、冰箱、行李箱这些大件物品存进去,需要时再搬出来。

在 JavaScript 中:

  • 简单数据类型(如 number, string, boolean, undefined, null, symbol, bigint)直接存在栈内存中。
  • 复杂数据类型(即 Object 及其子类,如数组、函数、日期等)则存储在堆内存中,栈中只保存一个指向堆中对象的地址(引用)

来看一个例子:

function foo() {
    var a = 1;     // 栈中存 1
    var b = a;     // 栈中再存一份 1(拷贝)
    a = 2;
    console.log(a); // 2
    console.log(b); // 1
}
foo();

这里 ab 都是数字,属于简单类型,直接在栈中各自保存一份值。修改 a 不会影响 b,就像你把抽屉里的红色笔换成蓝色笔,不会影响另一支红色笔。

再看复杂类型:

function foo() {
    var a = { name: "极客时间" }; // 对象存堆中,a 存的是地址
    var b = a;                    // b 也指向同一个堆地址(引用赋值)
    a.name = '极客邦';
    console.log(a); // { name: "极客邦" }
    console.log(b); // { name: "极客邦" } —— 被同步修改了!
}
foo();

这里 ab 都是指向同一个堆中对象的指针。修改 a.name 实际上是修改了堆中的那个对象,b 自然也会看到变化——就像两个人拿着同一把仓库钥匙,一个人往仓库里放东西,另一个人开门也能看到。

关键点:简单类型是“值拷贝”,复杂类型是“引用共享”。


三、调用栈与执行上下文 —— 厨房里的菜谱执行队列

JavaScript 是单线程语言,它靠调用栈(Call Stack) 来管理函数的执行顺序。

想象你在厨房做菜:

  • 每道菜对应一个函数
  • 当你要做“红烧肉”时,就把“红烧肉菜谱”放到操作台上(入栈)。
  • 如果红烧肉需要先炒糖色,你就临时拿出“炒糖色菜谱”放在最上面(新函数入栈)。
  • 炒完糖色,把菜谱收走(出栈),继续做红烧肉。
  • 全部做完,操作台清空(栈空),程序结束。

这个“操作台”就是调用栈,每张“菜谱”就是一个执行上下文(Execution Context)

每个执行上下文包含:

  • 变量环境(Variable Environment) :存放 var、函数声明等。
  • 词法环境(Lexical Environment) :处理块级作用域(如 letconst)。
  • this 绑定

当函数执行完毕,它的上下文就被弹出栈,其中的栈内存变量随之释放(指针偏移即可)。而堆中的对象,只有当没有任何变量引用它时,才会被垃圾回收器慢慢清理。


四、闭包:跨越时空的“记忆胶囊”

闭包是 JS 中最神奇又最常用的概念之一。它的本质是:内部函数记住并能访问外部函数的变量,即使外部函数已经执行完毕

为什么会这样?因为 JS 引擎发现内部函数引用了外部变量,就会把这些变量从栈中“抢救”出来,放到堆内存中,并打上一个标签,比如 closure(foo)

看代码:

function foo() {
    var myname = "极客时间";
    function getName() {
        console.log(myname); // 引用了外部变量 myname
    }
    function setName(newName) {
        myname = newName;
    }
    return { getName, setName };
}

var obj = foo(); // foo 执行完毕,按理 myname 应该消失
obj.setName("极客邦");
obj.getName(); // 却能输出 "极客邦"!

为什么 myname 没有被销毁?

因为在 foo 编译阶段,JS 引擎扫描到 getNamesetName 都引用了 myname,于是判断:“这里有闭包!”
随即在堆内存中创建一个 closure(foo) 对象,把 myname 存进去。
即使 foo 的执行上下文从调用栈中弹出,closure(foo) 依然存在,供返回的两个函数使用。

🔍 闭包的核心两步

  1. 编译时扫描内部函数,识别“自由变量”(即外部变量)。
  2. 将这些变量从栈移到堆,形成闭包对象。

五、为什么 JS 不让你直接操作内存?

你可能会问:既然有堆和栈,为什么不像 C 语言那样用 malloc/free 手动管理内存?

答案是:为了安全与效率的平衡

  • C/C++ 让程序员直接操作内存,灵活但危险(容易内存泄漏、野指针)。
  • JavaScript 把内存管理完全交给引擎(如 V8) ,开发者只需关注逻辑。
  • 引擎通过栈快速切换上下文(因栈小且连续),通过垃圾回收机制自动清理堆中无用对象

这种设计让 JS 能高效运行在浏览器这种资源受限的环境中,同时避免大多数内存错误。


六、总结:一张图看懂 JS 执行全景

概念作用存储位置特点
简单类型数字、字符串等栈内存值拷贝,独立
复杂类型对象、数组、函数堆内存引用共享
调用栈管理函数执行顺序栈内存后进先出(LIFO)
执行上下文函数运行时的环境栈内存(部分信息)包含变量、this、作用域链
闭包保留外部变量供内部函数使用堆内存(closure 对象)延长变量生命周期

JavaScript 的优雅之处,在于它用栈的高效处理日常运算,用堆的弹性承载复杂数据,再通过闭包实现强大的状态保持能力——这一切对开发者透明,却支撑起现代 Web 应用的复杂交互。


理解这些底层机制,你离写出更健壮的 JS 代码又近了一步!