浅析JavaScript的内存机制

前言

"世界需要一种什么样的语言?"基于应用场景、编程理念和设计目标等的不同,在计算机系统中我们需要以不同方式实现某种语言。JavaScript诞生之初作为一款用于Navigator浏览器上的脚本语言,其设计目标是让网页具备动态交互能力。JS的诞生为Web开发敲开新的大门,而为满足Web开发过程中逐渐复杂的交互、灵活的设计需求,也使得JS弱类型动态性的语言特性愈发鲜明。而这又和JavaScript的内存机制有何关联?

引子

在此之前我们先看两段JS代码来引出我们对这个问题的思考

js 复制代码
let a = 1
let b = a
a = 2
console.log(b);      //输出为:1
js 复制代码
 let a = { name: '徐可欣', age: 18 }
 let b = a
 a.age = 19
 console.log(b.age);     //输出为:19

或许我们会有这样的疑问

  • 为什么在let a = 1; let b = a; a = 2;的操作后,b的值仍然是1而没有随着a的改变而改变呢?毕竟一开始看起来b好像是 "绑定" 在了a上。这种赋值操作在内存层面具体是怎样进行的呢?

  • 为什么在let a = { name: '徐可欣', age: 18 }; let b = a; a.age = 19;之后,b.age的值会变成19呢?明明只是修改了a的属性值呀。ab与它们所指向的对象在内存中的关系如何体现呢?

其实,在JS的类型中分为两个大类,原始类型引用类型 。当我们把变量赋值给一个变量时,解析器首先要确认的就是这个值是原始类型值还是引用类型值。原始类型存储在栈中 ,在栈中存放的是对应的值;引用类型的对象存储于堆中 ,在栈中存放的是指向堆内存的地址。在JS中我们可以直接操作保存在栈内存空间的值,而不允许直接访问堆内存中的位置,我们只能操作对象的引用,即在栈内存中指向堆内存的一个地址。因此,原始类型按值访问,引用类型的值按引用访问。故而,原始类型的赋值是值的复制,生成两个相同的值;引用类型赋值是将保存对象的内存地址赋值给另一个变量,也就是两个变量指向堆内存中同一个对象。

如果我们理解这些便能解答以上两个疑问,当执行上层代码let b = a;时,实际上是在栈内存中把a的值1复制了一份给b,所以后续a的值改变并不会影响到b,它们在栈内存中是相互独立存储的值。当执行下层代码let b = a;时,因为ab指向同一个对象,所以修改a.ageb.age也会同时修改。

解决了这些我们是否又会有疑问:既如此,那JS的内存空间究竟是什么样的呢,何时分配空间,又如何分配空间呢?

如若盲点太多不能理解,没关系,我们往下看。

语言的类型

我们先看这段C语言代码

c 复制代码
int main(){
    int a = 1
    char* b = 'hello'
    bool c = false
    c=a      //不会报错    隐式类型转换
}
  • 为什么在C语言中我们使用了不同的关键字如int,char等去定义了不同类型的变量,而在JS中我们用的是let呢?这不会使得JS中的变量类型混乱而难以区分吗?而在C语言c=a这行代码中将字符类型的a赋值给布尔类型的b为什么没有报错呢?

为什么呢?简单来说JavaScript是动态语言,在运行时检查数据类型 。它可以用let(es6前只有var,但与之后出现的let,const同样不能标明变量的类型 )声明任何变量,即在编写代码时不用确定变量的数据类型,运行阶段JS引擎会根据变量第一次被赋予的值记录下变量的数据类型。而C语言是静态语言,编译时变量的数据类型就可确定 ,多数静态类型语言要求在使用变量之前必须声明数据类型。同时C语言是门弱类型语言,支持隐式类型转换隐式类型转换是指在运算符操作时,编译器自动将不同类型的数据转换为相同类型的数据 ),即在c=a这行代码中可以将字符类型的a赋值给布尔类型的c。

由此我们将语言的类型划分出这四种:

  1. 在使用前就需要确定其变量的数据类型 -- 静态语言
  2. 在运行的过程中检查数据的类型 -- 动态语言
  3. 支持隐式类型转换的语言 --- 弱类型语言
  4. 不支持隐式类型转换的语言 --- 强类型语言

JS的数据类型

我们知道JavaScript是动态弱类型语言,在运行的过程中检查数据的类型,并且支持隐式类型转换。既然语言类型的划分和数据类型有关,那么我们就来探讨一下JS的数据类型吧。 JavaScript中的数据类型可分为两大类:

js 复制代码
// 原始类型(Primitive Types)
let num = 1            // Number
let str = 'text'       // String 
let bool = true        // Boolean
let nul = null         // Null
let undef = undefined  // Undefined
let sym = Symbol()     // Symbol
let bigInt = 10n       // BigInt

// 引用类型(Reference Types)
let obj = {}           // Object
let arr = []           // Array
let func = function(){}// Function

以上便是JS的数据类型,分为原始类型引用类型

JS的内存空间

了解完JS有这些数据类型,那么我们应该将它们放哪里呢。就像我是谁,要到哪里去。

在 JavaScript 中,程序运行时内存被划分为三个核心区域:代码空间栈空间堆空间。它们分别承担不同的职责,共同支撑代码的执行。以下是三者的详细解析:

一、代码空间(Code Space)

存储内容

  • 静态代码:用户编写的 JavaScript 代码(函数、变量声明、逻辑语句等)。
  • 可执行指令:JavaScript 引擎将代码编译后的机器码或字节码。

特点

  • 只读性:通常情况下,代码空间是只读的,防止运行时修改代码逻辑。
  • 预加载:代码在解析阶段被加载到代码空间,执行时直接调用。
javascript 复制代码
// 代码空间存储以下内容:
function add(a, b) {
  return a + b;
}
const result = add(1, 2);

二、栈空间(Stack)

存储内容

  • 函数调用上下文:包括参数、局部变量、返回地址等。
  • 原始类型值 :如numberbooleanstring(小字符串可能被优化存储)。

机制

  • 后进先出(LIFO) :函数调用时压栈(Push),执行完毕后出栈(Pop)。
  • 调用栈:维护函数调用关系,记录当前执行路径。
javascript 复制代码
function outer() {
  const a = 10;
  inner();
}

function inner() {
  const b = 20;
  console.log(a + b); // 访问outer的变量a(闭包)
}

outer(); // 调用栈顺序:outer → inner

栈溢出(Stack Overflow)

原因:递归过深或函数调用层级过多,超出栈空间容量。

javascript 复制代码
 function infiniteLoop() {
   infiniteLoop(); // 导致栈溢出
 }
 infiniteLoop();

三、堆空间(Heap)

存储内容

  • 引用类型 :对象(Object)、数组(Array)、函数(Function)等。
  • 大字符串:部分引擎将长字符串存入堆中以优化内存。

特点

  • 动态分配:按需分配内存,支持复杂数据结构。
  • 共享性:多个变量可引用同一堆地址,修改会相互影响。
javascript 复制代码
const obj = { key: 1 }; // obj在栈中存储堆地址,对象内容存放在堆中
const arr = [1, 2, 3]; // 数组内容存放在堆中

四、三者的协作关系

内存区域 存储内容 生命周期 访问速度
代码空间 静态代码、可执行指令 程序运行期间固定
栈空间 函数上下文、原始类型 函数调用时动态创建 极快
堆空间 引用类型、大字符串 对象不再被引用时回收 较慢

执行流程示例

  1. 代码加载 :函数add被存入代码空间。
  2. 调用栈压栈 :执行add(1, 2)时,参数12(原始类型)存入栈,函数体从代码空间读取。
  3. 堆分配 :若函数内部创建对象(如new Date()),对象存入堆,栈中保存其地址。
  4. 返回结果:函数执行完毕,栈帧弹出,堆中的对象由垃圾回收管理。

五、小试牛刀

我们借助这段代码再现场景加以理解

js 复制代码
function foo() {
  var a = 1
  var b = a
  var c = {name: 'xxx'}
  var d = c
}
foo()

我们定义了一个foo然后调用它。

编译器扫描全局代码,发现 function foo(){} 声明,将 foo 函数对象存入堆内存 当执行foo()时,创建函数执行上下文(EC),声明变量 a/b/c/d 并初始化为 undefined压入调用栈顶部:

go 复制代码
```
|----------------|
| foo() 执行上下文 |
|----------------|
| 全局执行上下文   |
|----------------| <-- 栈底
```
  • var a = 1:在栈中分配内存,直接存储原始值1
  • var b = a:复制a的值到新栈位置,b独立存储1
css 复制代码
 栈空间(Stack)
 +-------+-------+
 |  a=1  |  b=1  |
 +-------+-------+
  • var c = {name: 'xxx'}

    • 堆中创建对象{name: 'xxx'},假设地址为#0x100
    • 栈中变量c存储堆地址#0x100
    • var d = c:复制地址#0x100d,形成共享引用
lua 复制代码
 栈空间             堆空间(Heap)
 +---------------+  +-------------------+
 | c → #0x100    |  | #0x100: {name:'xxx'} |
 | d → #0x100    |  +-------------------+
 +---------------+

函数执行完毕

markdown 复制代码
-   函数上下文弹出栈,栈变量`a/b/c/d`被销毁
-   堆中的对象`#0x100`因无变量引用,等待垃圾回收

总结

  1. 栈: 原始类型(原始类型的值一般都很小)
  2. 堆: 引用类型 (要占据的内存很大)
  • 栈的设计本身就很小:因为如果栈设计的很大的话,那么栈中函数的上下文切换效率就会大大降低
  • 原始类型的复制是值的复制,引用类型的赋值,是引用地址的复制

与闭包的结合

一、闭包的本质

让我们通过一个经典案例,揭示闭包如何突破函数作用域的限制,实现跨上下文的状态保持:

javascript 复制代码
function createCounter() {
  let count = 0;          // 原始类型变量
  const config = {        // 引用类型变量
    max: 100
  };
  
  return {
    increment: () => {
      if (count < config.max) count++;
    },
    getCount: () => count
  };
}

const counter = createCounter();
counter.increment();
console.log(counter.getCount()); // 1

内存演变过程

  1. 函数执行阶段 先调用counter = createCounter();createCounter 执行上下文入栈

    arduino 复制代码
    ┌──────────────────┐       ┌──────────────────┐
    │ createCounter EC │       │       Heap       │
    ├──────────────────┤       ├──────────────────┤
    │ count → 0 (栈)   │       │ 闭包对象#closure1 │
    │ config → #obj1   │───────▶ { max:100 }      │
    └──────────────────┘       └──────────────────┘
  2. 函数返回后 createCounter EC销毁,闭包还在堆内

    less 复制代码
    ┌──────────────────┐       ┌──────────────────┐
    │   Global EC      │       │       Heap       │
    ├──────────────────┤       ├──────────────────┤
    │ counter → #func1 │───────▶ 闭包对象#closure1 │
    └──────────────────┘       │ count → 0 (堆)   │
                               │ config → #obj1   │
                               └──────────────────┘
  3. 执行 increment() 由于需要用到那两个变量就从堆中取闭包

    lua 复制代码
    ┌──────────────────┐       ┌──────────────────┐
    │ increment() EC   │       │       Heap       │
    ├──────────────────┤       ├──────────────────┤
    │                  │       │ 闭包对象#closure1 │
    │ [[Scope]] →      │───────▶ count → 1 (堆)   │
    │   #closure1      │       │ config → #obj1   │
    └──────────────────┘       └──────────────────┘

1. 闭包形成的必要条件

  • 内部函数引用外部变量
    当检测到函数内部存在对外部变量的引用时,JS引擎会将相关变量提升到堆内存。
  • 函数被外部引用
    返回的函数或对象必须被外部作用域持有引用,否则闭包对象会被回收。

2. 闭包的内存结构

lua 复制代码
▲ 函数作用域链            ▼ 闭包对象(堆内存)
┌──────────────────┐      ┌──────────────────┐
│                  │      │                  │
│ 当前执行上下文      │      │ 闭包变量副本       │
│ [[Scopes]]       │─────▶│ myname → 'bbbb'  │
│                  │      │ test1 → 1        │
└──────────────────┘      └──────────────────┘

3. 示例代码解析

js 复制代码
function foo() {
  var myname = 'aaaaa'
  let test1 = 1
  const test2 = 2
  var innerBar = {
    setName: function (name) {
      myname = name // 捕获myname
    },
    getName: function () {
      console.log(test1); // 捕获test1
      return myname
    }
  }
  return innerBar
}

// 执行阶段内存变化:
1. 调用foo()时创建执行上下文:
   - 栈中存储:myname='aaaaa', test1=1, test2=2
   - innerBar对象存入堆(地址#obj1)

2. 返回innerBar后,foo()上下文出栈:
   - 由于setName/getName引用了myname和test1:
     → 创建闭包对象#closure1:
       { myname: 'aaaaa', test1: 1 }
     → test2未被引用,随栈销毁

3. 后续操作:
   bar.setName('bbbb') → 修改闭包对象中的myname
   bar.getName() → 读取闭包对象中的test1和myname

三、闭包的内存特征

1. 堆内存的持久化

变量类型 存储位置 生命周期
被闭包引用的变量 堆内存(闭包对象) 直到闭包不再被引用
未被引用的变量 栈内存 函数执行完毕立即销毁

常见面试问题:为什么栈不能分配得太大?

栈的大小直接影响函数上下文切换效率,需在空间与时间之间权衡。

  1. 栈的核心职责:存储函数调用上下文(参数、局部变量、返回地址等),维持调用栈的层级关系。

  2. 上下文切换的代价

    • 当 CPU 切换线程或函数时,需保存当前栈状态(如栈指针、寄存器值)并加载新状态。
    • 栈越大,需保存 / 恢复的数据越多,切换耗时越长,导致性能下降。
  3. 理解

    • 栈类似裤兜,设计过长(过大)会使取物(上下文切换)效率降低,即使只携带少量物品(小函数),也需冗余移动。

总结回答

"栈的设计需平衡效率与资源。较小的栈能快速完成上下文切换,而大栈会增加切换延迟并浪费内存。就像裤兜,设计过长(过大)会使取物(上下文切换)效率降低,即使只携带少量物品(小函数),也需冗余移动。这一机制是操作系统为优化性能做出的选择。"

扩展思考

  • 避免栈溢出:过大的栈易导致递归或深层调用时溢出。
  • 优化策略:将大对象存入堆,减少递归深度,或改用迭代实现。
相关推荐
花花鱼1 小时前
node-modules-inspector 可视化node_modules
前端·javascript·vue.js
TDengine (老段)3 小时前
TDengine 中的关联查询
大数据·javascript·网络·物联网·时序数据库·tdengine·iotdb
再学一点就睡4 小时前
大文件上传之切片上传以及开发全流程之前端篇
前端·javascript
Alo3656 小时前
面试考点复盘(二)
面试
難釋懷6 小时前
JavaScript基础-移动端常见特效
开发语言·前端·javascript
还是鼠鼠7 小时前
Node.js全局生效的中间件
javascript·vscode·中间件·node.js·json·express
自动花钱机7 小时前
WebUI问题总结
前端·javascript·bootstrap·css3·html5
bst@微胖子7 小时前
Flutter项目之登录注册功能实现
开发语言·javascript·flutter
拉不动的猪7 小时前
简单回顾下pc端与mobile端的适配问题
前端·javascript·面试
拉不动的猪7 小时前
刷刷题49(react中几个常见的性能优化问题)
前端·react.js·面试