JS内存谜题破解:从栈堆到闭包的可视化底层逻辑

56 阅读6分钟

JS内存谜题破解:从栈堆到闭包的可视化底层逻辑

JavaScript的内存机制是理解执行流程、数据类型、闭包等核心概念的基石。本文将结合代码,从内存空间划分数据类型存储差异闭包的内存表现三个维度,拆解JS内存的底层运行逻辑。

一、JS内存空间:代码、栈、堆的分工

JS运行时的内存主要分为三类,各自承担不同职责:

1.png

1. 代码空间

代码从硬盘加载到内存后的存储区域,用于存放可执行的JS代码。这部分是程序运行的“剧本”,不直接参与数据存储。

2. 栈内存(调用栈)

  • 特点:空间小、连续存储、访问速度快,由JS引擎自动管理(随执行上下文的创建/销毁而分配/释放)。

  • 存储内容

    • 原始数据类型(Number、String、Boolean等);
    • 执行上下文(变量环境、词法环境、this等);
    • 函数调用的“调用栈”(函数执行时入栈,执行完出栈)。
  • 核心作用:维护程序执行的上下文状态,通过“栈顶指针偏移”实现执行上下文的快速切换。

3. 堆内存

  • 特点:空间大、非连续存储、分配/回收耗时较长,由垃圾回收器管理。
  • 存储内容:复杂数据类型(Object、Array、Function等引用类型)。
  • 核心作用:存放体积较大的对象,避免占用栈空间影响上下文切换效率。

为什么要分栈和堆?

JS引擎用栈维护执行上下文状态,栈的“小、连续”是上下文快速切换的关键。若把复杂对象放在栈中,会导致栈空间碎片化、体积膨胀,降低程序执行效率。因此:

  • 原始类型直接存在中;
  • 引用类型的“指针”存在中,实际数据存在中。

二、数据类型的存储:值拷贝 vs 引用拷贝

我们结合代码,看原始类型与引用类型的内存表现差异。

1. 原始类型:栈中直接存“值”

function foo() {
  var a = 1;   // 栈中存储值1
  var b = a;   // 栈中拷贝值1,b与a独立
  a = 2;       // 修改a的值,不影响b
  console.log(a); // 2
  console.log(b); // 1
}
foo()

内存表现

a和b是原始类型,直接在foo函数执行上下文的变量环境中存储值。修改a只是改变栈中a的存储内容,不会影响b。

2. 引用类型:栈存“指针”,堆存“数据”

function foo() {
  var a = {name: "极客时间"}; // 栈存指针,堆存对象
  var b = a;                  // 栈中拷贝指针,b与a指向同一堆对象
  a.name = '极客邦';          // 修改堆中对象的属性
  console.log(a); // {name: "极客邦"}
  console.log(b); // {name: "极客邦"}
}
foo()

内存表现

  • 栈的foo执行上下文中,a和b存储的是堆内存的地址(如1003)
  • 堆内存中存储实际对象{name: "极客时间"}
  • 修改a.name本质是修改堆中对象的属性,因此b也会同步变化。

三、JS是动态弱类型语言:类型的“灵活”与“坑”

JS的类型特性直接影响内存使用方式:

  • 动态类型:变量类型在运行时确定(可随时修改类型);
  • 弱类型:不同类型可自动转换(如1 + "2"会转为字符串拼接)。

看代码示例:

var bar; 
console.log(typeof bar); // undefined(栈中存undefined)
bar = 12;
console.log(typeof bar); // number(栈中存12)
bar = "极客时间";
console.log(typeof bar); // string(栈中存字符串)
bar = null;
console.log(typeof bar); // Object(JS设计bug,null是原始类型)
bar = {name: "极客时间"};
console.log(typeof bar); // Object(栈存指针,堆存对象)

注意typeof null === "Object"是JS早期缺陷,判断null需用bar === null

四、闭包的内存机制

闭包是JS的核心概念,其本质是内部函数引用外部函数的变量,导致外部函数执行完后,变量仍保存在内存中。我们结合代码和示意图拆解其内存流程。

闭包代码示例

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("极客邦")
bar.getName() // 输出1,返回"极客邦"

4.jpg

闭包的内存执行流程

  1. 编译foo函数

全局执行上下文创建后,编译foo时会扫描内部函数(setNamegetName),发现它们引用了外部变量(myNametest1)。JS引擎会在堆内存中创建closure(foo)对象,用于存储这些“被引用的外部变量”。

  1. 执行foo函数

    1. foo执行时,其执行上下文入栈;
    2. myNametest1test2存入foo的变量环境;
    3. 由于内部函数引用了myNametest1,这两个变量会被复制到堆中的 closure(foo)
    4. innerBar(栈中存指针)的方法会记录对closure(foo)的引用。
  2. foo执行完毕后

    1. foo的执行上下文出栈(栈内存释放);
    2. bar(全局变量)引用了innerBar,而innerBar的方法引用了堆中的closure(foo)
    3. 因此closure(foo)不会被垃圾回收,myNametest1依然保存在堆中。
  3. 调用bar的方法

    1. 执行bar.setName("极客邦")时,修改的是closure(foo)中的myName
    2. 执行bar.getName()时,从closure(foo)中读取test1myName

闭包的内存核心

  • 内部函数引用的外部变量,会被持久化到堆内存的closure对象中;
  • 即使外部函数执行完毕,只要内部函数还被引用,closure对象就不会被回收。

五、内存管理的关键结论

  1. 原始类型存栈,引用类型“栈存指针、堆存数据” :这是JS执行效率的基础;
  2. 栈内存自动管理,堆内存垃圾回收:栈随执行上下文入栈/出栈自动释放,堆需等待“无引用”后由垃圾回收器回收;
  3. 闭包是堆内存的“持久化存储” :内部函数引用的外部变量,会被保存在堆的closure对象中,突破执行上下文的生命周期。

总结

JS内存机制是理解语言特性的“底层逻辑”:

  • 空间划分看,代码、栈、堆各司其职,栈快堆大的特性决定了数据类型的存储方式;
  • 数据类型看,原始类型和引用类型的存储差异,解释了“值拷贝”和“引用拷贝”的区别;
  • 闭包看,其本质是堆内存中closure对象对外部变量的持久化,让变量突破执行上下文的生命周期。

深入 JS 内存机制,就像为代码装上“透视眼”。栈与堆的分工、值与引用的差异、闭包中 closure 对象的驻留,不再是抽象概念,而是可追踪的内存行为。理解这些底层逻辑,不仅能避开常见陷阱,更能写出高效、可控的代码。真正掌握 JavaScript,从看见它的内存开始。