内存管理(Memory Management)

JavaScript 中的数据存储和垃圾回收机制

学习重点

  • 栈空间和堆空间的区别
  • 原始类型和引用类型的存储方式
  • 垃圾回收机制
  • 闭包的内存模型

1. JavaScript 的内存空间

1.1 内存空间的分类

JavaScript 在执行过程中,主要有三种类型的内存空间:

  1. 代码空间:存储可执行代码
  2. 栈空间:存储执行上下文和原始类型数据
  3. 堆空间:存储引用类型数据

内存模型

JavaScript 内存模型
├── 代码空间(Code Space)
│   └── 存储可执行代码
├── 栈空间(Stack Space)
│   └── 存储执行上下文
│   └── 存储原始类型数据
└── 堆空间(Heap Space)
    └── 存储引用类型数据

1.2 栈空间(Stack Space)

定义:栈空间就是我们之前反复提及的调用栈,用来存储执行上下文。

特点

  • 栈空间不会设置太大,主要用来存放一些原始类型的小数据
  • 栈空间用来维护程序执行期间上下文的状态
  • 如果栈空间太大,会影响上下文切换的效率

存储内容

  • 执行上下文(Execution Context)
  • 原始类型数据(Primitive Types)
  • 变量的引用(Reference)

示例

function foo() {
  var a = '极客时间'  // 原始类型,存储在栈中
  var b = a          // 原始类型,存储在栈中
}
foo()

内存状态

调用栈:
┌─────────────────────┐
│ foo 执行上下文      │
│ a: '极客时间'       │  ← 存储在栈中
│ b: '极客时间'       │  ← 存储在栈中
└─────────────────────┘

1.3 堆空间(Heap Space)

定义:堆空间用来存储引用类型的数据,堆空间很大,能存放很多大的数据。

特点

  • 堆空间很大,能存放很多大的数据
  • 分配内存和回收内存都会占用一定的时间
  • 引用类型数据占用的空间都比较大,所以会被存放到堆中

存储内容

  • 对象(Object)
  • 数组(Array)
  • 函数(Function)
  • 其他引用类型数据

示例

function foo() {
  var c = { name: '极客时间' }  // 引用类型,存储在堆中
  var d = c                      // 引用类型,存储在堆中
}
foo()

内存状态

调用栈:                    堆空间:
┌─────────────────────┐    ┌─────────────────────┐
│ foo 执行上下文      │    │ { name: '极客时间' } │
│ c: 0x001 (引用地址) │───→│ 0x001               │
│ d: 0x001 (引用地址) │───→│                     │
└─────────────────────┘    └─────────────────────┘

2. 原始类型和引用类型的存储

2.1 原始类型的存储

原始类型

  • Number
  • String
  • Boolean
  • Null
  • Undefined
  • Symbol
  • BigInt

存储方式

  • 原始类型的数据值直接保存在栈中
  • 赋值操作会完整复制变量值

示例

function foo() {
  var a = 1
  var b = a
  a = 2
  console.log(a)  // 2
  console.log(b)  // 1
}
foo()

内存状态变化

[执行前]
栈: [a: undefined, b: undefined]

[执行 a = 1]
栈: [a: 1, b: undefined]

[执行 b = a]
栈: [a: 1, b: 1]  ← 完整复制值

[执行 a = 2]
栈: [a: 2, b: 1]  ← a 改变,b 不变

结论:原始类型的赋值会完整复制变量值,所以 a 和 b 是相互独立的,互不影响。


2.2 引用类型的存储

引用类型

  • Object
  • Array
  • Function
  • Date
  • RegExp
  • 其他对象类型

存储方式

  • 引用类型的数据值存储在堆中
  • 栈中只保存对象的引用地址
  • 赋值操作是复制引用地址

示例

function foo() {
  var a = { name: '极客时间' }
  var b = a
  a.name = '极客邦'
  console.log(a)  // { name: '极客邦' }
  console.log(b)  // { name: '极客邦' }
}
foo()

内存状态变化

[执行前]
栈: [a: undefined, b: undefined]
堆: []

[执行 a = { name: '极客时间' }]
栈: [a: 0x001, b: undefined]
堆: [0x001: { name: '极客时间' }]

[执行 b = a]
栈: [a: 0x001, b: 0x001]  ← 复制引用地址
堆: [0x001: { name: '极客时间' }]

[执行 a.name = '极客邦']
栈: [a: 0x001, b: 0x001]
堆: [0x001: { name: '极客邦' }]  ← 修改堆中的对象

结论:引用类型的赋值是复制引用地址,所以 a 和 b 指向同一个对象,修改 a 会影响 b。


2.3 为什么需要区分栈和堆

原因

  • JavaScript 引擎需要用栈来维护程序执行期间上下文的状态
  • 如果栈空间太大,所有的数据都存放在栈空间里面,会影响到上下文切换的效率
  • 引用类型的数据占用的空间都比较大,所以会被存放到堆中

示例

function foo() {
  var a = '极客时间'  // 原始类型,小数据,存储在栈中
  var b = {          // 引用类型,大数据,存储在堆中
    name: '极客时间',
    age: 100,
    // ... 更多属性
  }
}

执行上下文切换

[foo 函数执行完毕]
调用栈指针下移到全局执行上下文
foo 函数执行上下文栈区间全部回收

如果所有数据都存放在栈中,上下文切换时就需要复制大量数据,效率低下。


3. 垃圾回收机制

3.1 调用栈中的垃圾回收

回收机制

  • 当一个函数执行结束之后,JavaScript 引擎会通过向下移动 ESP(Extended Stack Pointer)来销毁该函数保存在栈中的执行上下文
  • ESP 指针向下移动,上面执行上下文虽然保存在栈内存中,但是已经是无效内存了
  • 当再次调用函数时,这块内容会被直接覆盖掉,用来存放另外一个函数的执行上下文

示例

function foo() {
  var a = 1
  var b = { name: '极客邦' }
  function showName() {
    var c = '极客时间'
    var d = { name: '极客时间' }
  }
  showName()
}
foo()

执行流程

[执行到 showName 函数时]
调用栈: [全局执行上下文, foo 执行上下文, showName 执行上下文]
ESP 指向: showName 执行上下文

[showName 函数执行完毕]
ESP 下移到: foo 执行上下文
showName 执行上下文变为无效内存

[foo 函数执行完毕]
ESP 下移到: 全局执行上下文
foo 执行上下文变为无效内存

关键点

  • ESP 指针向下移动,上面的执行上下文变为无效内存
  • 下次调用函数时,会直接覆盖这块内存
  • 栈中的垃圾回收非常高效

3.2 堆中的垃圾回收

回收机制

  • 堆中的垃圾数据需要通过垃圾回收器来回收
  • V8 引擎使用分代垃圾回收策略
  • 分为新生代和老生代两个区域

代际假说(The Generational Hypothesis)

  1. 大部分对象在内存中存在的时间很短:很多对象一经分配内存,很快就变得不可访问
  2. 不死的对象,会活得更久:一旦对象存活时间较长,就会一直存在

分代策略

  • 新生代:存放生存时间短的对象(通常只支持 1~8M 的容量)
  • 老生代:存放生存时间久的对象(支持的容量很大)

垃圾回收器

  • 副垃圾回收器:主要负责新生代的垃圾回收
  • 主垃圾回收器:主要负责老生代的垃圾回收

3.3 副垃圾回收器(Scavenge 算法)

算法:Scavenge 算法

工作原理

  1. 划分区域:把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域
  2. 标记阶段:对对象区域中的垃圾做标记
  3. 清理阶段:将存活的对象复制到空闲区域中,同时有序排列
  4. 角色翻转:对象区域与空闲区域进行角色翻转

示例

[初始状态]
对象区域: [对象A, 对象B, 垃圾对象1, 对象C]
空闲区域: [空, 空, 空, 空]

[标记阶段]
对象区域: [对象A(存活), 对象B(存活), 垃圾对象1(垃圾), 对象C(存活)]

[清理阶段]
对象区域: [对象A, 对象B, 垃圾对象1, 对象C]
空闲区域: [对象A, 对象B, 对象C]  ← 复制存活对象,有序排列

[角色翻转]
对象区域: [对象A, 对象B, 对象C]
空闲区域: [空, 空, 空, 空]

对象晋升

  • 经过两次垃圾回收依然还存活的对象,会被移动到老生区中
  • 解决新生区空间容易被存活的对象装满的问题

3.4 主垃圾回收器(Mark-Sweep 和 Mark-Compact)

算法

  • 标记-清除(Mark-Sweep):标记垃圾,然后清除
  • 标记-整理(Mark-Compact):标记垃圾,然后整理(让存活对象向一端移动)

标记-清除算法

  1. 标记阶段:从一组根元素开始,递归遍历这组根元素,能到达的元素称为活动元素,没有到达的元素就可以判断为垃圾数据
  2. 清除阶段:清除掉标记为垃圾的数据

标记-整理算法

  1. 标记阶段:与标记-清除算法相同
  2. 整理阶段:让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存

示例

[标记阶段]
堆: [对象A(存活), 垃圾1, 对象B(存活), 垃圾2, 对象C(存活)]

[标记-清除]
堆: [对象A, 空, 对象B, 空, 对象C]  ← 产生内存碎片

[标记-整理]
堆: [对象A, 对象B, 对象C, 空, 空]  ← 无内存碎片

3.5 全停顿(Stop-The-World)

问题

  • 一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来
  • 待垃圾回收完毕后再恢复脚本执行
  • 如果占用主线程时间过久,会导致页面卡顿

解决方案:增量标记(Incremental Marking)

工作原理

  • 将标记过程分为一个个的子标记过程
  • 让垃圾回收标记和 JavaScript 应用逻辑交替进行
  • 直到标记阶段完成

示例

[传统方式]
JavaScript 执行: [=====]
垃圾回收:        [=====]  ← 暂停 JavaScript 执行
JavaScript 执行: [=====]

[增量标记]
JavaScript 执行: [==]
垃圾回收标记:     [=]
JavaScript 执行: [==]
垃圾回收标记:     [=]
JavaScript 执行: [==]

4. 闭包的内存模型

4.1 闭包的产生过程

代码示例

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. 编译阶段

    • JavaScript 引擎执行到 foo 函数时,首先会编译,并创建一个空执行上下文
    • 在编译过程中,遇到内部函数 setName,JavaScript 引擎会做一次快速的词法扫描
    • 发现该内部函数引用了 foo 函数中的 myName 变量
    • 由于是内部函数引用了外部函数的变量,所以 JavaScript 引擎判断这是一个闭包
    • 在堆空间创建了一个 closure(foo) 的对象,用来保存 myName 变量
  2. 继续扫描

    • 继续扫描到 getName 方法时,发现该函数内部还引用变量 test1
    • 于是 JavaScript 引擎又将 test1 添加到 closure(foo) 对象中
    • 这时候堆中的 closure(foo) 对象中就包含了 myName 和 test1 两个变量了
  3. test2 的处理

    • 由于 test2 并没有被内部函数引用,所以 test2 依然保存在调用栈中

内存状态

调用栈:                    堆空间:
┌─────────────────────┐    ┌─────────────────────┐
│ foo 执行上下文      │    │ closure(foo)        │
│ myName: '极客时间'  │    │ myName: '极客时间'  │
│ test1: 1           │    │ test1: 1            │
│ test2: 2           │    └─────────────────────┘
│ innerBar: 0x002    │    │ innerBar 对象       │
└─────────────────────┘    │ setName: <function> │
                           │ getName: <function> │
                           └─────────────────────┘

关键点

  • 产生闭包的核心有两步:
    1. 需要预扫描内部函数
    2. 把内部函数引用的外部变量保存到堆中

4.2 闭包的内存回收

回收条件

  • 如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭
  • 如果这个闭包以后不再使用的话,就会造成内存泄漏

回收机制

  • 如果引用闭包的函数是个局部变量,等函数销毁后,在下次 JavaScript 引擎执行垃圾回收时,判断闭包这块内容如果已经不再被使用了,那么 JavaScript 引擎的垃圾回收器就会回收这块内存

示例

// 全局变量引用闭包
var bar = foo()  // 闭包会一直存在
 
// 局部变量引用闭包
function test() {
  var bar = foo()
  // 使用 bar
  // ...
}
test()  // test 执行完毕后,如果没有其他地方引用闭包,闭包会被回收

最佳实践

  • 如果该闭包会一直使用,那么它可以作为全局变量而存在
  • 但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量

5. 内存泄漏的原因和预防

5.1 常见的内存泄漏

1. 全局变量

// 不好的做法
function foo() {
  bar = '极客时间'  // 未声明,成为全局变量
}
 
// 好的做法
function foo() {
  var bar = '极客时间'  // 局部变量
}

2. 闭包引用

// 不好的做法
var globalArray = []
function foo() {
  var largeArray = new Array(1000000).fill('data')
  globalArray.push(function() {
    console.log(largeArray)  // 闭包引用 largeArray
  })
}
 
// 好的做法
var globalArray = []
function foo() {
  var largeArray = new Array(1000000).fill('data')
  globalArray.push(function() {
    console.log(largeArray.length)  // 只引用需要的部分
  })
  largeArray = null  // 手动解除引用
}

3. 定时器未清理

// 不好的做法
function startTimer() {
  setInterval(function() {
    // 执行任务
  }, 1000)
}
 
// 好的做法
var timerId
function startTimer() {
  timerId = setInterval(function() {
    // 执行任务
  }, 1000)
}
function stopTimer() {
  clearInterval(timerId)
}

5.2 内存泄漏的检测

方法 1:使用 Chrome DevTools

  1. 打开 Chrome DevTools
  2. 选择 “Memory” 标签
  3. 点击 “Take snapshot” 获取堆内存快照
  4. 执行操作后,再次点击 “Take snapshot”
  5. 对比两次快照,查看内存增长

方法 2:使用 Performance 面板

  1. 打开 Chrome DevTools
  2. 选择 “Performance” 标签
  3. 勾选 “Memory”
  4. 点击录制按钮
  5. 执行操作
  6. 停止录制,查看内存使用情况

6. 总结

6.1 核心要点

  1. 栈空间:存储执行上下文和原始类型数据,上下文切换效率高
  2. 堆空间:存储引用类型数据,空间大但分配和回收需要时间
  3. 原始类型:值存储在栈中,赋值会完整复制值
  4. 引用类型:值存储在堆中,栈中只保存引用地址
  5. 垃圾回收:栈中通过 ESP 指针移动回收,堆中通过垃圾回收器回收
  6. 闭包:内部函数引用的外部变量会保存在堆中,形成闭包

6.2 学习建议

  1. 理解内存模型:理解栈空间和堆空间的区别和作用
  2. 理解存储方式:理解原始类型和引用类型的存储方式
  3. 理解垃圾回收:理解垃圾回收的机制和策略
  4. 避免内存泄漏:理解内存泄漏的原因,学会预防

7. 参考资源


javascript 内存管理 栈空间 堆空间 垃圾回收 闭包 内存泄漏