从调用栈到闭包:带你参观 JavaScript 的“内存大楼”内部结构

131 阅读5分钟

从调用栈到闭包,从原始类型到堆内存,用最顺的比喻、最狠的例子,让你一次性看懂 JS 的底层逻辑。
看完:你写代码会更稳,你 debug 会更快,你面试官会更爱你。


🌈 1. JS 的世界观:每段代码都是一场“搬运工协作大会”

当你在写 JS 时,其实你在指挥三位主角:

  • 执行上下文(Execution Context) :负责“场景搭建”
  • 调用栈(Call Stack) :负责“流程调度”
  • 内存系统(Memory System) :负责“存储与回收”

如果把 JavaScript 比作一栋大楼:

  • 栈内存(Stack) 是电梯井:
    👉 空间固定、连续、速度超快。
  • 堆内存(Heap) 是自由办公室:
    👉 空间巨大、布局混乱、对象随意入住。
  • 代码区(Code Area) 则是大楼自带的书架:
    👉 存放已加载的 JS 字节码。

理解这三个区,你就会明白:“原始类型为什么放栈里,对象为什么放堆里,闭包为什么能记住外部变量。”


🎯 2. 核心问题:JS 为什么要分成“栈”和“堆”?

🧱 理由很简单:为了快。超级快。快得飞起。

🔹 栈内存特点

  • 连续空间(像一摞叠好的书)
  • 分配回收都依赖 指针移动
  • 创建变量:压栈
  • 执行完毕:指针往回移,整层一起“消失”

适合存:

  • number、string、boolean、undefined、null、symbol、bigint
    👉 体积小、不变、生命周期短

🔹 堆内存特点

  • 空间大且不连续(像办公室租赁格子)
  • 分配慢、回收更慢
  • 适合存:

👉 对象、数组、函数等复杂结构

为什么对象要放堆?
因为对象大小不确定(属性随时加),无法放在栈这种连续空间中。

🌟 一句话总结:

简单类型 → 一次性存,生命周期短
复杂类型 → 可能越长越大,必须堆里养着


🧪 3. 栈 vs 堆:50 行代码看穿所有细节

🍬 简单类型:复制的是“值”

function foo(){
    var a = 1      // 栈里开辟一格:{a:1}
    var b = a      // 复制值,b 的格子里也是 1
    a = 2

    console.log(a) // 2
    console.log(b) // 1
}

📌 这里没有任何对象,所有数据都“放在栈里”,修改 a 不会影响 b。


🍱 复杂类型:复制的是“地址”

function foo(){
    var a = {name:"极客时间"} // a 存的是堆对象的地址
    var b = a                 // b 存的是同一个地址

    a.name = "极客邦"

    console.log(a) // {name: "极客邦"}
    console.log(b) // {name: "极客邦"}
}

📌 两个变量依然指向同一个堆对象,这是 JS 引用类型最关键的机制。


🌀 4. 执行上下文:JS 如何知道变量在哪?

每次函数执行时,都会生成一个执行上下文(Execution Context):

包含三个部分:

  1. 变量环境(Variable Environment) ——处理 var
  2. 词法环境(Lexical Environment) ——处理 letconst
  3. outer 指针(Scope Chain) ——作用域链

还有一个特别重要的:

  1. this 绑定

JS 用这一整套东西去构成执行栈:

image.png

函数每调用一次,就往栈顶 push 一层,执行完就 pop。


🔥 5. 闭包:你以为是语法糖,其实是堆内存黑魔法

闭包为什么能保持外部变量?
一句话:

JS 在编译阶段扫描到内部函数引用外部变量时,会把这些变量“搬”进堆里,形成一个 closure 对象

看例子:

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

    var innerBar = {
        setName:function(newName){
            myName = newName
        },
        getName:function(){
            console.log(test1)
            return myName
        }
    }

    return innerBar
}

var bar = foo()
bar.setName("极客邦")
console.log(bar.getName())

流程解析:

🥇 步骤 1:编译阶段扫描内部函数引用了哪些外部变量

扫描到:

  • myName
  • test1
  • test2

JS 判断需要闭包,于是把这 3 个变量放入堆内存:

Heap:
closure(foo) = {
  myName: "极客时间",
  test1: 1,
  test2: 2
}

🥈 步骤 2:返回的函数绑定这个闭包地址

bar.getName()bar.setName() 都指向这同一个 closure。

所以函数执行完 foo 也不会被回收

🥉 步骤 3:外层函数栈被销毁,但堆里的 closure 继续存在

这就是闭包不释放的本质。


⚠️ 6. JS 是动态弱类型:变量不是固定类型的

var bar
console.log(typeof bar) // undefined

bar = 12
console.log(typeof bar) // number

bar = "极客时间"
console.log(typeof bar) // string

bar = null
console.log(typeof bar) // object(历史遗留 Bug)

这就是 JS 的核心:

  • 变量没有类型
  • 值才有类型
  • 变量随时可以指向不同类型的值

也因此:

JS 完全不需要像 C/C++ 一样手动 malloc/free。


♻️ 7. JS 的垃圾回收:栈快、堆慢

✔ 栈内存回收

执行上下文一结束:

👉 “整层数据”指针一移,瞬间消失。
回收速度极快。

✔ 堆内存回收

由 V8 的垃圾回收器:

  • 新生代(Scavenge)
  • 老生代(Mark-Sweep、Mark-Compact)

对象没有引用时:

👉 标记为可回收
👉 空闲时才真正释放

闭包容易造成内存泄漏,就是因为对象一直被引用着。


🧭 8. 所有知识串成一句话(面试必杀)

JS 把轻巧的原始值放栈里,把会长大的对象放堆里;闭包把外部变量从栈里搬到堆里;垃圾回收器按引用关系清理堆;执行栈用来管理所有函数的上下文切换。

学完这个,你对 JS 的理解已经超过 90% 的前端开发者。


🎁 9. 完整总结

🌟 JS 内存体系的三大区

  • 代码区:存放字节码
  • 栈内存:原始类型 + 执行上下文
  • 堆内存:对象、函数、闭包

🌟 栈快、堆大,各司其职

  • 栈:连续、高速、自动回收
  • 堆:灵活、可变、慢一点但能放大物体

🌟 闭包是堆内存黑魔法

  • 内部函数引用外部变量
  • 扫描绑定 → 放入 closure
  • 函数返回后上下文消失,closure 继续存活

🌟 动态弱类型

  • JS 变量不固定类型
  • 变量只是“指向值的引用”

🚀 10. 彩蛋:一张图总结全篇

image.png


🏁 结语:理解内存,你才算真正懂 JS

当你不再把 JS 看成“跑逻辑的工具”,
而是看成“管理内存的大型系统”,
你会发现:

  • 变量为何这样变?
  • 闭包为何能记住外部变量?
  • 为什么对象传来传去都会被改?
  • 为什么浏览器页面越用越卡?

全都通了。