JavaScript 内存管理和垃圾回收
简介
像 C 语言这样的底层语言一般都有底层的内存管理接口,比如 malloc()和 free()。相反,JavaScript 是在创建变量(对象,字符串等)时自动进行了分配内存,并且在不使用它们时“自动”释放。释放的过程称为垃圾回收。这个“自动”是混乱的根源,并让 JavaScript(和其他高级语言)开发者错误的感觉他们可以不关心内存管理。
内存生命周期
不管什么程序语言,内存生命周期基本是一致的:
- 分配你所需要的内存
- 使用分配到的内存(读、写)
- 不需要时将其释放\归还
所有语言第二部分都是明确的。第一和第三部分在底层语言中是明确的,但在像 JavaScript 这些高级语言中,大部分都是隐含的。
JavaScript 的内存分配
值的初始化
为了不让程序员费心分配内存,JavaScript 在定义变量时就完成了内存分配。
var n = 123 // 给数值变量分配内存
var s = 'azerty' // 给字符串分配内存
var o = {
a: 1,
b: null
} // 给对象及其包含的值分配内存
// 给数组及其包含的值分配内存(就像对象一样)
var a = [1, null, 'abra']
function f(a) {
return a + 2
} // 给函数(可调用的对象)分配内存
// 函数表达式也能分配一个对象
someElement.addEventListener(
'click',
function () {
someElement.style.backgroundColor = 'blue'
},
false
)
通过函数调用分配内存
有些函数调用结果是分配对象内存:
var d = new Date() // 分配一个 Date 对象
var e = document.createElement('div') // 分配一个 DOM 元素
有些方法分配新变量或者新对象:
var s = 'azerty'
var s2 = s.substr(0, 3) // s2 是一个新的字符串
// 因为字符串是不可改变的量,
// JavaScript 可能决定不分配内存,
// 只是存储了 [0-3] 的范围。
var a = ['ouais ouais', 'nan nan']
var a2 = ['generation', 'nan nan']
var a3 = a.concat(a2)
// 新数组有四个元素,是 a 连接 a2 的结果
使用值
使用值的过程实际上是对分配内存进行读取与写入的操作。读取与写入可能是写入一个变量或者一个对象的属性值,甚至传递函数的参数。
当内存不再需要使用时释放
大多数内存管理的问题都在这个阶段。在这里最艰难的任务是找到“哪些被分配的内存确实已经不再需要了”。它往往要求开发人员来确定在程序中哪一块内存不再需要并且释放它。
高级语言解释器嵌入了“垃圾回收器”,它的主要工作是跟踪内存的分配和使用,以便当分配的内存不再使用时,自动释放它。这只能是一个近似的过程,因为要知道是否仍然需要某块内存是无法判定的(无法通过某种算法解决)。
垃圾回收
如上文所述自动寻找是否一些内存“不再需要”的问题是无法判定的。因此,垃圾回收实现只能有限制的解决一般问题。这里解释必要的概念,了解主要的垃圾回收算法和它们的局限性。
引用
垃圾回收算法主要依赖于引用的概念。在内存管理的环境中,一个对象如果有访问另一个对象的权限(隐式或者显式),叫做一个对象引用另一个对象。例如,一个 Javascript 对象具有对它原型的引用(隐式引用)和对它属性的引用(显式引用)。
在这里,“对象”的概念不仅特指 JavaScript 对象,还包括函数作用域(或者全局词法作用域)。
引用计数垃圾收集
这是最初级的垃圾收集算法。此算法把“对象是否不再需要”简化定义为“对象有没有其他对象引用到它”。如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收。
var o = {
a: {
b: 2
}
}
// 两个对象被创建,一个作为另一个的属性被引用,另一个被分配给变量 o
// 很显然,没有一个可以被垃圾收集
var o2 = o // o2 变量是第二个对“这个对象”的引用
o = 1 // 现在,“这个对象”只有一个 o2 变量的引用了,“这个对象”的原始引用 o 已经没有
var oa = o2.a // 引用“这个对象”的 a 属性
// 现在,“这个对象”有两个引用了,一个是 o2,一个是 oa
o2 = 'yo' // 虽然最初的对象现在已经是零引用了,可以被垃圾回收了
// 但是它的属性 a 的对象还在被 oa 引用,所以还不能回收
oa = null // a 属性的那个对象现在也是零引用了
// 它可以被垃圾回收了
限制:循环引用
该算法有个限制:无法处理循环引用的事例。在下面的例子中,两个对象被创建,并互相引用,形成了一个循环。它们被调用之后会离开函数作用域,所以它们已经没有用了,可以被回收了。然而,引用计数算法考虑到它们互相都有至少一次引用,所以它们不会被回收。
function f() {
var o = {}
var o2 = {}
o.a = o2 // o 引用 o2
o2.a = o // o2 引用 o
return 'azerty'
}
f()
实际例子
IE 6, 7 使用引用计数方式对 DOM 对象进行垃圾回收。该方式常常造成对象被循环引用时内存发生泄漏:
var div
window.onload = function () {
div = document.getElementById('myDivElement')
div.circularReference = div
div.lotsOfData = new Array(10000).join('*')
}
在上面的例子里,myDivElement 这个 DOM 元素里的 circularReference 属性引用了 myDivElement,造成了循环引用。如果该属性没有显示移除或者设为 null,引用计数式垃圾收集器将总是且至少有一个引用,并将一直保持在内存里的 DOM 元素,即使其从 DOM 树中删去了。如果这个 DOM 元素拥有大量的数据 (如上的 lotsOfData 属性),而这个数据占用的内存将永远不会被释放。
标记 - 清除算法
这个算法把“对象是否不再需要”简化定义为“对象是否可以获得”。
这个算法假定设置一个叫做根(root)的对象(在 Javascript 里,根是全局对象)。垃圾回收器将定期从根开始,找所有从根开始引用的对象,然后找这些对象引用的对象……从根开始,垃圾回收器将找到所有可以获得的对象和收集所有不能获得的对象。
这个算法比前一个要好,因为“有零引用的对象”总是不可获得的,但是相反却不一定(不可获得的对象,没一定是零引用),参考“循环引用”。
从 2012 年起,所有现代浏览器都使用了标记 - 清除垃圾回收算法。所有对 JavaScript 垃圾回收算法的改进都是基于标记 - 清除算法的改进,并没有改进标记 - 清除算法本身和它对“对象是否不再需要”的简化定义。
循环引用不再是问题了
在上面的示例中,函数调用返回之后,两个对象从全局对象出发无法获取。因此,他们将会被垃圾回收器回收。第二个示例同样,一旦 div 和其事件处理无法从根获取到,他们将会被垃圾回收器回收。
限制
- 那些无法从根对象查询到的对象都将被清除 尽管这是一个限制,但实践中我们很少会碰到类似的情况,所以开发者不太会去关心垃圾回收机制。
- 在清除垃圾之后,剩余对象的内存位置是不变的,就会导致空闲内存空间不连续。这样就出现了内存碎片,并且由于剩余空间不是整块,就需要内存分配的问题
V8 的内存管理
V8 是有内存限制的,因为它最开始是为浏览器设计的,不太可能遇到大量内存的使用场景。关键原因还是垃圾回收所导致的线程暂停执行的时间过长。根据官方说法,以 1.5G 内存为例,V8 一次小的垃圾回收需要 50ms,而一次非增量的,即全量的垃圾回收更需要一秒。这显然是不可接受的。因此 V8 限制了内存使用的大小,但是 Node.js 是可以通过配置修改的,更好的做法是使用 Buffer 对象,因为 Buffer 的内存是底层 C++分配的,不占用 JS 内存,所以他也就不受 V8 限制。
V8 采用了分代回收的策略,将内存分为两个生代:新生代和老生代
- Old Space(老生代):分配的内存较大,存储生命周期较长的对象,比如页面或者浏览器的长时间使用对象;
- New Space(新生代):分配的内存较小,存储生命周期较短的对象,比如临时变量、函数局部变量等;
- Large Object Space(大对象):分配的内存较大,存储生命周期较长的大型对象,比如大数组、大字符串等;
- Code Space(代码空间):存储编译后的函数代码和 JIT 代码;
- Map Space(映射空间):存储对象的属性信息,比如对象的属性名称、类型等信息;
- Cell Space(单元格空间):存储对象的一些元信息,比如字符串长度、布尔类型等信息。
标记整理
- 和“标记-清除”相似;
- 不同的是,回收期间同时会将保留的存储对象搬运汇集到连续的内存空间,从而整合空闲空间,避免内存碎片化;
分代收集 —— 对象被分成两组:“新的”和“旧的”。
-
许多对象出现,完成它们的工作并很快死去,它们可以很快被清理;
-
那些长期存活的对象会变得“老旧”,而且被检查的频次也会减少;
增量收集
- 如果有许多对象,并且我们试图一次遍历并标记整个对象集,则可能需要一些时间,并在执行过程中带来明显的延迟。
- 所以引擎试图将垃圾收集工作分成几部分来做,然后将这几部分会逐一进行处理,这样会有许多微小的延迟而不是一个大的延迟;
闲时收集
- 垃圾收集器只会在 CPU 空闲时尝试运行,以减少可能对代码执行的影响。
- 这种算法通常用于移动设备或其他资源受限的环境,以确保垃圾收集对用户体验的影响最小。
如何避免内存泄漏
- 手动设置变量为 null 来帮助垃圾收集器更快回收内存
- 避免循环引用,这会导致内存无法被垃圾收集器回收
- 当处理大型数据结构时,如果你确定不会再使用它们,可以手动释放它们
- 避免在循环中创建大量的临时对象,这可能会导致内存泄漏
- 使用 WeakMap 和 WeakSet,它们不会阻止它们包含的对象被垃圾收集器回收
- 减少不必要的全局变量,尽量使用 let const
- 合理利用 console,线上项目尽量少的使用 console。console 保存大量数据在内存中:过多的 console,比如定时器的 console 会导致浏览器卡死。
- 少用闭包;在函数执行完之后,回收闭包,及时释放内存;
- 在定时器完成工作的时候,手动清除定时器。
- 意外的全局变量:因为只有当应用程序退出,全局执行上下文才会出栈,全局变量才会被销毁,所以要减少不必要的全局变量。 解决方法:或者手动释放全局变量的内存;在函数内部避免创建不必要的全局变量。
- 在移除 DOM 元素前,先移除相关的事件监听器。