JS 内存机制:栈管流程,堆管数据

53 阅读7分钟

“调用栈和堆的协作关系?”

“闭包的数据如何存储?”

这些问题背后,都是同一件事:你在和 JS 的内存机制打交道。这篇文章不讲玄学,只帮你搭一套清晰、够用的心智模型,让你明白其中的道理

1. JS 到底是什么语言?先把“动态弱类型”说明白

你的示例里有一段代码,大概是这样玩的:

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"  经典 bug

bar = { name: '极客时间' };
console.log(typeof bar); // "object"

从这段就能看出几个关键点:

  • 动态语言:变量声明的时候不用写类型,运行时才决定类型
  • 弱类型:同一个变量,可以随便换类型,数字、字符串、对象都行
  • typeof null === 'object' 是 JS 设计遗留的问题,教材里常见的那个“坑”

也正因为是动态弱类型,JS 引擎在执行时,需要:

  • 跟踪每个变量当前是什么类型
  • 决定这个值应该放在什么地方(栈里还是堆里)

这就把我们自然带到了“内存空间”话题。

2. 代码是怎么进内存的?三块空间先搞清楚

可以先粗略把 JS 运行时的内存分成三块:

  • 代码空间:程序的代码从硬盘读出来,放到内存里

  • 栈内存:调用栈、执行上下文都在这里

    • 体积小
    • 连续空间,切换快、好管理
  • 堆内存:对象、数组等大块数据在这里

    • 空间大
    • 分配和回收都要多花点时间

V8 这样的引擎,会用栈来维护程序执行期间的执行上下文状态
执行上下文的切换,本质上就是栈顶指针的移动,所以栈空间要小而精简,才能频繁、快速地切换。

于是就有了常见那句总结:

  • 简单数据类型 → 放栈里(直接存)
  • 复杂数据类型 → 放堆里(栈里放的是“地址”)

下面用几段你已经写好的小代码,把这个讲清楚。

3. 简单数据类型:在栈里是“值拷贝”

看这段关于基本类型的小例子:

function foo() {
    var a = 1;  // 赋值
    var b = a;  // 拷贝
    a = 2;
    console.log(a); // 2
    console.log(b); // 1
}
foo();

在内存里的感觉可以理解为:

  • 栈里创建一块空间存 a = 1
  • b = a 这一步,是把值 1 再拷贝一份到 b 自己那块栈空间
  • 后面 a = 2 只是把 a 那一格改成 2
  • b 那一格还是 1,互不影响

所以:

  • 简单类型(numberstringbooleanundefinedsymbolbigint 等)
  • 在栈里就是值本身,赋值就是再拷贝一份

你在另一个例子里也写了:

var a = "极客时间";
var b = a;
var d = a;

这里 abd 在栈里各自占一格,里面都直接存的是那个字符串的引用值,行为也是“值拷贝”的语义。

4. 复杂数据类型:在栈里是“地址”,在堆里才是对象

再看你写的这段关于对象的代码:

function foo() {
    var a = { name: '极客时间' }; // 引用式赋值
    var b = a;                   // 引用式拷贝
    a.name = '极客邦';
    console.log(a);
    console.log(b);
}
foo();

结合注释里那句:

栈内存中是地址,堆内存中是对象(引用)

可以这样理解:

  • 在堆里:

    • 有一个对象 { name: '极客时间' }
  • 在栈里:

    • a 这块空间里 存的是“这个对象在堆里的地址”
    • b = a → 把这个地址又拷贝了一份给 b

所以当你:

a.name = '极客邦';

是对堆里那一个对象动手:

  • a 和 b 都指向它
  • 打印出来时,两边看到的都是修改后的结果

这一点和基本类型的“值拷贝”是完全不一样的语义。

5. 执行上下文、调用栈和内存之间的关系

再往下,你的笔记里提到了执行上下文的几个关键部分:

  • 调用栈

  • 执行上下文对象

    • 变量环境
    • 词法环境
    • outer 词法作用域链(闭包相关)
    • this

可以把整个流程连起来看:

  1. 代码加载进来

    • 放到“代码空间”里
  2. 开始执行时

    • 创建全局执行上下文,压入调用栈
    • 栈里此时有一帧:全局上下文
  3. 每次调用一个函数

    • 为这个函数创建一个新的执行上下文

    • 里面会有它自己的:

      1.变量环境(var 声明的变量、函数声明等)

      2.词法环境(letconst 声明的变量)

      3.指向外部作用域的引用(outer 链)

      4.this

    • 然后把这个执行上下文压入调用栈

  4. 函数执行完

    • 对应的那一层栈帧弹出
    • 这一层里的局部变量(简单类型)就可以随着这次弹出释放
    • 复杂类型如果已经没有任何变量再引用它,对应的堆内存对象就会在后面逐步被引擎回收

这就是那句总结:

回收的时候,栈回收(指针偏移),堆内存中的对象没有变量引用就可以慢慢回收。

6. 用一个闭包例子,把“词法环境”和“堆里的 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)先编译,再执行

  • 先编译 foo 函数,创建全局执行上下文

  • 在编译阶段,对 foo 里的代码做了一次词法扫描

    • 发现有内部函数(setName、getName)

    • 发现这些内部函数用到了外部变量:

      • myNametest1test2

2)发现有闭包,要在堆里准备“额外的东西”

  • JS 引擎判断:这里有闭包

  • 会在堆内存里为 foo 相关的东西准备一个结构,可以理解成一个
    closure(foo)

    • 把内部函数本身放到堆里
    • 把内部函数依赖的外部变量(myNametest1test2)也一起保存下来

你的笔记里总结得很直接:

  • 第一步:需要扫描内部函数,放到堆内存中
  • 第二步:把内部函数引用的外部变量保存到堆中

3)为什么 foo 已经执行完,myName 还活着?

流程往下走:

  • 调用 foo() 时:

    • 为 foo 创建执行上下文,压栈
    • 在这个上下文里创建 myNametest1test2innerBar
  • foo 返回 innerBar 这个对象给外面的变量 bar

  • 从此以后,bar.setName、bar.getName 这两个函数:

    • 虽然在“语法上”看起来是在全局被调用
    • 但它们背后都带着对 closure(foo) 的引用
    • closure(foo) 里保存着 myNametest1 等变量的那块环境

所以:

  • 虽然 foo 的那一层栈帧已经弹出了
  • 但因为外面还有 bar 在引用那几个内部函数
  • 这些内部函数又需要访问 myNametest1
  • 于是与之相关的那部分数据就不能被回收,会被保留在堆里

这就是“闭包”的内存意义:让某些本来会随着函数结束而销毁的变量,延长了生命周期

7. 小结:这几件事想清楚,就算是入门 JS 内存了

简单收个尾,把你代码里涉及的点再串一下:

  • JS 是动态弱类型语言

    • 变量类型可以在运行时不断变化
    • typeof null === 'object' 是历史“坑”
  • 内存分区和职责

    • 代码空间:放代码
    • 栈内存:调用栈、执行上下文,小而快
    • 堆内存:对象、数组等大块数据,分配/回收都要时间
  • 简单类型 vs 复杂类型

    • 简单类型:栈里直接存值,赋值就是“值拷贝”
    • 复杂类型:堆里存对象,栈里存地址,赋值就是“引用拷贝”
  • 执行上下文 & 调用栈

    • 全局 + 每次函数调用都会有一个执行上下文
    • 切换本质是栈顶指针移动
  • 闭包与内存

    • 编译阶段发现内部函数和它依赖的外部变量
    • 在堆里为它们准备 closure 结构
    • 只要外面还有地方引用这些内部函数,对应的外部变量就会继续“活着”