《吃透JS内存机制:从栈堆分离到闭包底层,一篇打通所有难点》

0 阅读8分钟

吃透JS内存机制:从栈堆分离到闭包底层,看这一篇就够了

作为前端开发者,我们每天都在写JS代码,但你是否真的理解代码背后的内存逻辑?

为什么简单数据类型赋值后修改互不影响,而引用类型修改后会“牵一发而动全身”?为什么闭包能保留外部变量,它的内存是如何存储的?JS引擎又是如何管理内存,避免内存泄漏的?

今天就结合具体代码案例,一步步拆解JS内存机制的核心知识点,从栈内存、堆内存的区别,到执行上下文、闭包的底层原理,帮你彻底吃透,再也不用死记硬背。

一、先明确:JS内存不只有“栈”和“堆”

很多人入门时都会听说“JS简单数据类型存在栈里,复杂数据类型存在堆里”,这句话没错,但不够完整。JS的内存空间其实分为三大块,各司其职:

1. 代码空间:存放代码的“仓库”

我们写的JS代码,在运行前会从硬盘读取到内存中,这个专门存放代码的空间就是代码空间。它是程序执行的基础,JS引擎会从这里逐行读取代码并执行,我们平时很少直接关注它,但它是不可或缺的一环。

2. 栈内存:JS执行的“主角”,轻量且高效

栈内存(调用栈)是JS执行程序的核心,它的特点是 速度快、空间小、存储连续,适合存储体积小、生命周期短的数据。

JS引擎会通过栈内存维护程序执行期间的上下文状态,执行上下文的切换,本质上就是栈顶指针的偏移(非常高效)。

栈内存主要存储:简单数据类型(Number、String、Boolean、Undefined、Null、Symbol、BigInt)、执行上下文、函数调用栈

看案例理解栈内存的赋值逻辑
function foo() {
  var a = 1;  // 简单数据类型,直接存在栈内存
  var b = a;  // 拷贝a的值,在栈内存中新增一个空间存b=1
  a = 2;      // 修改a的值,只影响栈中a的空间,与b无关
  console.log(a); // 2
  console.log(b); // 1
}
foo();

这个案例的核心的是:简单数据类型的赋值是“值拷贝” ,a和b在栈内存中是两个独立的空间,修改其中一个,不会影响另一个。

3. 堆内存:“打辅助”的大空间,存储复杂数据

堆内存的特点是 空间大、存储不连续、分配和回收耗时稍长,适合存储体积大、生命周期长、结构复杂的数据。

为什么复杂数据类型不放在栈内存?因为如果把大的对象、数组放在栈中,会破坏栈“连续、轻量”的特点,影响执行上下文切换的效率,进而拖慢整个程序的运行速度。

堆内存主要存储:复杂数据类型(Object、Array、Function、RegExp等)

这里有个关键细节:JS中我们操作的复杂数据类型,本质上是操作它在栈内存中的“地址指针” ,而真实的对象数据,是存在堆内存中的。

看案例理解堆内存的引用逻辑
function foo() {
  // a是引用类型,栈内存中存的是堆内存中对象的地址
  var a = {name: "极客时间"};
  // b拷贝的是a的地址(栈中数据),而非堆中的对象本身
  var b = a; 
  // 修改a.name,本质是通过地址找到堆中的对象,修改对象的属性
  a.name = '极客邦';
  // ab指向同一个堆对象,所以输出结果一致
  console.log(a); // {name: "极客邦"}
  console.log(b); // {name: "极客邦"}
}
foo();

这个案例的核心是:复杂数据类型的赋值是“引用拷贝” ,a和b在栈中存储的是同一个堆对象的地址,修改其中一个变量指向的对象属性,另一个变量也会受到影响。

二、补充知识点:JS是动态弱类型语言,和内存有什么关系?

我们常说JS是动态弱类型语言,这其实和它的内存管理密切相关,先明确两个核心概念,避免混淆:

1. 动态语言 vs 静态语言

  • 静态语言(如Java、C++):使用变量前,必须先声明变量的数据类型,且类型不可随意修改(编译期检查类型)。
  • 动态语言(如JS):运行过程中才会检查变量的数据类型,变量声明时无需指定类型,可随时赋值不同类型的数据。

2. 强类型 vs 弱类型

  • 强类型语言(如Python、Java):不同类型的数据不能随意转换,必须手动进行类型转换(如Java中int不能直接赋值给String)。
  • 弱类型语言(如JS):不同类型的数据可以自动转换,无需手动操作(如数字和字符串可以直接拼接)。
看案例直观感受JS的动态弱类型
// JS 是动态弱类型语言,变量声明时无需指定类型
var bar; 
console.log(typeof bar); // undefined (执行上下文初始化时,栈中默认存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,null本质是简单类型,却返回Object)
bar = {name: "极客时间"};
console.log(typeof bar); // Object (复杂类型,返回Object)

这里补充一个小知识点:JS不需要我们直接操作内存(和C、C++不同)。C、C++中需要用malloc分配内存、free释放内存,而JS引擎(如V8)会自动管理内存的分配和回收,我们只需要关注代码逻辑即可。

三、重点突破:内存机制如何理解闭包?

闭包是JS中的难点,也是面试高频考点,很多人能写出闭包,但说不清楚它的底层原理。其实,闭包的本质,就是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
console.log(bar.getName()) // 输出1,返回"极客邦"

这个案例中,foo函数执行完毕后,本应被销毁的myName、test1,却因为innerBar的两个方法引用,依然可以被访问到,这就是闭包。

闭包的内存执行流程(关键!)

结合栈内存、堆内存,一步步拆解闭包的执行过程,看懂这3步,就吃透了闭包的底层:

第一步:编译阶段,扫描词法,判断闭包

JS引擎执行代码前,会先对foo函数进行编译,创建全局执行上下文(存放在栈内存中)。编译过程中,会快速扫描foo函数内部的代码,发现innerBar对象中的setName、getName两个内部函数,引用了foo函数中的myName、test1两个变量(这些变量对内部函数来说,就是“自由变量”)。

此时,JS引擎判断出存在闭包,会在堆内存中创建一个closure(foo)对象,专门用来存储这些被内部函数引用的自由变量。

第二步:执行阶段,保存自由变量到堆内存

当执行foo函数时,会创建foo函数的执行上下文(压入栈顶),并将myName、test1、test2的值初始化。同时,会把被内部函数引用的myName、test1,保存到堆内存的closure(foo)对象中(test2没有被内部函数引用,不会存入)。

这里要注意:innerBar对象(复杂数据类型)存放在堆内存中,它的setName、getName方法,会持有一个指向closure(foo)对象的引用,这样即使foo函数执行完毕,内部函数也能访问到这些自由变量。

第三步:foo执行完毕,闭包保留变量

foo函数执行完毕后,它的执行上下文会被弹出栈内存(栈内存回收,通过栈顶指针偏移实现),但堆内存中的closure(foo)对象并不会被回收——因为bar变量(存放在全局执行上下文,栈内存中)持有innerBar对象的引用,而innerBar的方法又持有closure(foo)的引用。

所以,当我们调用bar.setName、bar.getName时,依然可以通过closure(foo)访问到myName、test1,这就是闭包能“保留外部变量”的核心原因。

四、总结:JS内存机制核心要点

梳理一下全文核心,帮你快速回顾,加深记忆:

  1. JS内存分为三大空间:代码空间(存代码)、栈内存(执行核心)、堆内存(存复杂数据)。
  2. 栈内存:轻量、连续、快速,存简单数据类型、执行上下文、函数调用栈;赋值是“值拷贝”。
  3. 堆内存:庞大、不连续、稍慢,存复杂数据类型;赋值是“引用拷贝”(拷贝栈中的地址)。
  4. JS是动态弱类型语言:运行时检查类型,变量类型可动态修改,无需手动操作内存。
  5. 闭包本质:堆内存中的closure对象保存内部函数引用的自由变量,即使外部函数执行完毕,变量依然可访问。

最后说一句

理解JS内存机制,不仅能帮你搞懂闭包、原型链等难点,还能让你在写代码时,更清晰地知道变量的存储和销毁逻辑,从而避免不必要的内存泄漏(比如闭包滥用导致的内存占用)。

如果觉得这篇文章对你有帮助,欢迎点赞、收藏、转发,也可以在评论区留言讨论,一起精进前端技术~