内存管理(Memory Management)
JavaScript 中的数据存储和垃圾回收机制
学习重点:
- 栈空间和堆空间的区别
- 原始类型和引用类型的存储方式
- 垃圾回收机制
- 闭包的内存模型
1. JavaScript 的内存空间
1.1 内存空间的分类
JavaScript 在执行过程中,主要有三种类型的内存空间:
- 代码空间:存储可执行代码
- 栈空间:存储执行上下文和原始类型数据
- 堆空间:存储引用类型数据
内存模型:
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 原始类型的存储
原始类型:
NumberStringBooleanNullUndefinedSymbolBigInt
存储方式:
- 原始类型的数据值直接保存在栈中
- 赋值操作会完整复制变量值
示例:
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 引用类型的存储
引用类型:
ObjectArrayFunctionDateRegExp- 其他对象类型
存储方式:
- 引用类型的数据值存储在堆中
- 栈中只保存对象的引用地址
- 赋值操作是复制引用地址
示例:
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~8M 的容量)
- 老生代:存放生存时间久的对象(支持的容量很大)
垃圾回收器:
- 副垃圾回收器:主要负责新生代的垃圾回收
- 主垃圾回收器:主要负责老生代的垃圾回收
3.3 副垃圾回收器(Scavenge 算法)
算法:Scavenge 算法
工作原理:
- 划分区域:把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域
- 标记阶段:对对象区域中的垃圾做标记
- 清理阶段:将存活的对象复制到空闲区域中,同时有序排列
- 角色翻转:对象区域与空闲区域进行角色翻转
示例:
[初始状态]
对象区域: [对象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):标记垃圾,然后整理(让存活对象向一端移动)
标记-清除算法:
- 标记阶段:从一组根元素开始,递归遍历这组根元素,能到达的元素称为活动元素,没有到达的元素就可以判断为垃圾数据
- 清除阶段:清除掉标记为垃圾的数据
标记-整理算法:
- 标记阶段:与标记-清除算法相同
- 整理阶段:让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存
示例:
[标记阶段]
堆: [对象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()执行流程:
-
编译阶段
- JavaScript 引擎执行到 foo 函数时,首先会编译,并创建一个空执行上下文
- 在编译过程中,遇到内部函数 setName,JavaScript 引擎会做一次快速的词法扫描
- 发现该内部函数引用了 foo 函数中的 myName 变量
- 由于是内部函数引用了外部函数的变量,所以 JavaScript 引擎判断这是一个闭包
- 在堆空间创建了一个
closure(foo)的对象,用来保存 myName 变量
-
继续扫描
- 继续扫描到 getName 方法时,发现该函数内部还引用变量 test1
- 于是 JavaScript 引擎又将 test1 添加到
closure(foo)对象中 - 这时候堆中的
closure(foo)对象中就包含了 myName 和 test1 两个变量了
-
test2 的处理
- 由于 test2 并没有被内部函数引用,所以 test2 依然保存在调用栈中
内存状态:
调用栈: 堆空间:
┌─────────────────────┐ ┌─────────────────────┐
│ foo 执行上下文 │ │ closure(foo) │
│ myName: '极客时间' │ │ myName: '极客时间' │
│ test1: 1 │ │ test1: 1 │
│ test2: 2 │ └─────────────────────┘
│ innerBar: 0x002 │ │ innerBar 对象 │
└─────────────────────┘ │ setName: <function> │
│ getName: <function> │
└─────────────────────┘
关键点:
- 产生闭包的核心有两步:
- 需要预扫描内部函数
- 把内部函数引用的外部变量保存到堆中
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
- 打开 Chrome DevTools
- 选择 “Memory” 标签
- 点击 “Take snapshot” 获取堆内存快照
- 执行操作后,再次点击 “Take snapshot”
- 对比两次快照,查看内存增长
方法 2:使用 Performance 面板
- 打开 Chrome DevTools
- 选择 “Performance” 标签
- 勾选 “Memory”
- 点击录制按钮
- 执行操作
- 停止录制,查看内存使用情况
6. 总结
6.1 核心要点
- 栈空间:存储执行上下文和原始类型数据,上下文切换效率高
- 堆空间:存储引用类型数据,空间大但分配和回收需要时间
- 原始类型:值存储在栈中,赋值会完整复制值
- 引用类型:值存储在堆中,栈中只保存引用地址
- 垃圾回收:栈中通过 ESP 指针移动回收,堆中通过垃圾回收器回收
- 闭包:内部函数引用的外部变量会保存在堆中,形成闭包
6.2 学习建议
- 理解内存模型:理解栈空间和堆空间的区别和作用
- 理解存储方式:理解原始类型和引用类型的存储方式
- 理解垃圾回收:理解垃圾回收的机制和策略
- 避免内存泄漏:理解内存泄漏的原因,学会预防
7. 参考资源
- 浏览器工作原理与实践 - 栈空间和堆空间 — 李兵老师的系列文章
- 浏览器工作原理与实践 - 垃圾回收 — 李兵老师的系列文章
- V8 引擎博客 - 垃圾回收 — V8 官方博客