垃圾回收机制
# 前言
底层语言中往往需要开发者手动管理内存空间,比如C语言就需要开发者在程序中显示分配程序占用的内存。而在高级语言中,则是自动分配内存的,这一机制称为垃圾回收机制
。
首先得理解三个基础的概念:可达性,标记清除法(mark-and-sweep)、引用计数
# 可达性原则
“可达”值是那些以某种方式可访问或可用的值。它们一定是存储在内存中的
那么如何判断某个变量是可达的,或者说是一个"可达值"呢?
首先得有个"根"的概念,一般下面这几种情况的变量可以当作"根"
- 函数的局部变量,包括定义时的函数的局部变量和参数、函数调用时的当前调用链上的所有变量和参数
- 全局变量
let obj = {name: '小新'};
let anotherObj = obj
2
此时是这样一个引用关系:
<global variable> ----> obj <object> ----> anotherObj<object>
这里全局变量作为根,所有引用都是可达的,所以垃圾回收器不会回收这里的变量。
# mark-and-sweep算法
基本步骤是这样的:
- 垃圾收集器找到所有的根,并“标记”(记住)它们。
- 然后它遍历并“标记”来自它们的所有引用。
- 然后它遍历标记的对象并标记 他们的 引用。所有被遍历到的对象都会被记住,以免将来再次遍历到同一个对象。
- ……如此操作,直到所有可达的(从根部)引用都被访问到。
- 没有被标记的对象都会被删除。
# 引用计数
引用计数就是跟踪记录每个值被引用的次数,如定义了一个变量,并且赋值给了它一个引用对象,此时这个变量的引用计数加1,如果此时将这个变量置于null,这个变量的引用计数就会减1,当回收器发现某个变量的引用计数是0,这个变量就会被回收器回收。
以上是基本的解释,各大浏览器的垃圾回收机制并不是统一的实现方式,我们来浅析一下V8引擎的垃圾回收机制。
# V8引擎的垃圾回收机制
# 分代垃圾回收机制算法
JS对象都是存放在内存中一个叫做堆的结构中,根据对象的存活时间将内存的垃圾回收进行不同的分代,然后分别对不同的分代的内存释以更高效的算法。
V8中主要将内存分为新生代和老生代,新生代存储存活时间较短的对象,老生代存储存活时间较长或者是常驻内存的对象。V8堆的整体大小就是新生代所用内存空间加上老生代的内存空间。可以看到,新生代的内存空间是小于老生代的,因为新生代存储的都是存活时间比较短的对象,垃圾回收的操作更频繁,若是新生代内存很大会严重影响V8的性能。
# Scavenge算法
在分代基础上,新生代中的对象主要通过Scavenge算法进行垃圾回收。在Scavenge的具体实现中,主要采用了Cheney算法
Cheney算法
是一种采用复制的方式实现的垃圾回收算法。它将堆内存一分为二,每一部分空间称为semispace
。在这两个semispace空间
中,只有一个处于使用中,另一个处于闲置状态。处于使用状态的semispace空间
称为From空间
,处于闲置状态的空间称为To空间
。当我们分配对象时,先是在From空间
中进行分配。当开始进行垃圾回收时,会检查From空间
中的存活对象,这些存活对象将被复制到To空间
中,而非存活对象占用的空间将会被释放。完成复制后,From空间
和To空间
的角色发生兑换。简而言之,在垃圾回收过程中,就是通过将存活对象在两个semispace空间
之间进行复制。
实际上,生命周期短的场景其实是少部分,并且Scavenge算法
只复制存活的对象,所以Scavenge算法
在新生代中时间效率很高。可以看出,Scavenge算法
是典型的空间换时间的做法,因此也不适合大规模的用作垃圾回收场景中。
V8堆内存中的组成,由两个semispace
和老生代内存空间组成,但是由于分代处理机制,Scavenge算法
也做了优化,将多次复制,仍然存活的对象视为生命周期较长的对象,这种对象将被移入老生代,采用新的算法管理,这种行为称作"晋升"。
晋升的条件主要是两个:
- 一个是对象是否经历过Scavenge回收
- 一个是To空间的内存占用比超过限制。
一般情况下,v8的对象分配主要集中在From空间
,当从From空间
向To空间
复制对象时,会根据对象的内存地址判断对象是否经过一次Scavenge回收
,如果已经经历过了,则将该对象移动到老生代中。另外一种情况,会先判断To空间
的内存使用已经超过了25%,那么同样的,该对象也会被移入老生代中。
进入老生代的对象,由于老生代占据了更大的内存空间,并且存储的都是生命周期比较长的对象,Scavenge已经不适合用在老生代中。
# Mark-Sweep & Mark-Compact算法
与
Scavenge
相比,Mark-Sweep
并不将内存空间划分为两半,所以不存在浪费一半空间的行为。与Scavenge
复制活着的对象不同,Mark-Sweep
在标记阶段遍历堆中所有对象,并标记活着的对象,在随后的清除阶段中,只清除没有被标记的对象。可以看出,Scavenge
中只复制活着的对象,而Mark-Sweep
只清理死亡对象。活对象在新生代中只占较小部分,死对象在老生代中只占较小部分,这是两种回收方式能高效处理的原因。
Mark-Sweep算法
的一个最大的问题是内存碎片化,在经历了一次标记清除后,内存空间会出现不连续的状态。这种状态会造成后面的内存分配问题,加入此时需要存储一个大对象,此时内存时无法存储的,只能提前出发一次垃圾回收,但是这是没有必要的。
为了解决Mark-Sweep
的内存碎片问题,提出了Mark-Compact
:
Mark-Compact
是标记整理的意思,是在Mark-Sweep
的基础上演变而来的。它们的差别在于对象在标记为死亡后,在整理的过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存
可以对比以下三个垃圾回收算法的简单对比:
回收算法 | Mark-Sweep | Mark-Compact | Scavenge |
---|---|---|---|
速度 | 中等 | 最慢 | 最快 |
空间开销 | 少(有碎片) | 少(无碎片) | 双倍空间(无碎片) |
是否移动对象 | 否 | 是 | 是 |
从表格上看,Mark-Sweep
和Mark-Compact
之间,由于Mark-Compact
需要移动对象,所以它的执行速度不可能很快,所以在取舍上,V8主要使用Mark-Sweep
,在空间不足以对从新生代中晋升过来的对象进行分配时才使用Mark-Compact
。
# Incremental Marking
- 为了避免出现js应用逻辑与垃圾回收器看到的不一致的情况,垃圾回收的3种基本算法都需要将应用逻辑暂停下来,待执行完垃圾回收后再恢复执行应用逻辑,这种行为被称为“全停顿”(stop-the-world)。在V8的分代式垃圾回收中,一次小垃圾回收只收集新生代,由于新生代默认配置得较小,且其中存活对象通常较少,所以即便它是全停顿的影响也不大。但V8的老生代通常配置得较大,且存活对象较多,全堆垃圾回收(full垃圾回收)的标记、清理、整理等动作造成的停顿就会比较可怕,需要设法改善。
- 为了降低全堆垃圾回收带来的停顿时间,V8先从标记阶段入手,将原本要一口气停顿完成的动作改为增量标记(incremental marking),也就是拆分为许多小“步进”,每做完一“步进”就让js应用逻辑执行一小会,垃圾回收与应用逻辑交替执行直到标记阶
- V8在经过增量标记的改进后,垃圾回收的最大停顿时间可以减少到原本的1/6左右。
- V8后续还引入了延迟清理(lazy sweeping)与增量式整理(incremental compaction),让清理与整理动作也变成增量式的。同时还计划引入并行标记与并行清理,进一步利用多核性能降低每次停顿的时间。段完成。
# 垃圾回收机制小结
从V8的自动垃圾回收机制的设计角度可以看到,V8对内存使用进行限制的缘由。新生代设计为一个较小的内存空间是合理的,而老生代空间过大对于垃圾回收并无特别意义。V8对内存限制的设置对于Chrome浏览器这种每个选项卡页面使用一个V8实例而言,内存的使用是绰绰有余,对于Node编写的服务器端来说,内存限制也并不影响正常场景下的使用。但是对于V8的垃圾回收特点和js在单线程上的执行情况,垃圾回收是影响性能的因素之一。想要高性能执行效率,需要注意让垃圾回收尽量少地进行,尤其是全堆垃圾回收。
# 内存泄漏
所谓的内存泄漏是指程序中已分配的堆内存由于某种原因未释放或无法得到释放,导致程序运行速率变慢和程序崩溃的情况。
常见的内存泄漏有
# 1.缓存
实际开发中我们经常使用对象中的键值对来缓存一些东西,但是实际上这是不明智的,因为随着对象中键值对的增加,缓存的对象常驻老生代,在垃圾回收的扫描和处理中,这些对象得不到回收。其次,使用对象作为缓存东西,这和严格意义上的缓存是有区别的,因为传统的缓存的策略有完善的过期机制,而普通对象是没有的。
# 2.作用域未释放(闭包)
let unreleasedArray = [];
exports.leak = function () {
unreleaseArray.push("我是未被释放的内容")
}
2
3
4
当我们每次调用leak函数的时候,由于模块的缓存策略,每次都会往unreleasedArray里面添加内容,导致unreleasedArray一直得不到垃圾回收。闭包可以维持函数内部变量驻留内存,使其得不到释放
# 3.没有必要的全局变量
这一点,不言而喻,因为申明过多的全局变量,会导致变量常驻老生代,被分配的内存得不到释放
# 4.无效的dom引用
function click () {
// 这里定义的button变量实则是全局变量
var button = document.getElementById('button');
button.click()
// 这里对好定义一个remove函数
remove ();
}
function remove () {
document.removeChild(document.getElementById("button"))
}
2
3
4
5
6
7
8
9
10
11
12
# 5.定时器未清除
// vue 的 mounted 或 react 的 componentDidMount
componentDidMount() {
setInterval(function () {
// ...do something
}, 1000)
}
2
3
4
5
6
vue
或 react
的页面生命周期初始化时,定义了定时器,但是在离开页面后,未清除定时器,就会导致内存泄漏。
# 6.事件监听未清除
componentDidMount() {
window.addEventListener("scroll", function () {
// do something...
});
}
2
3
4
5
在页面生命周期初始化时,绑定了事件监听器,但在离开页面后,未清除事件监听器,同样也会导致内存泄漏。
防止内存泄漏可以关注以下几个方面:
- 在业务不需要的用到的内部函数,可以重构到函数外,实现解除闭包。
- 避免创建过多的生命周期较长的对象,或者将对象分解成多个子对象。
- 避免过多使用闭包。注意清除定时器和事件监听器。
- nodejs中使用stream或buffer来操作大文件,不会受nodejs内存限制。
- 使用redis等外部工具来缓存数据。
想更多了解V8中的垃圾回收机制,可以看这两篇文章V8的垃圾回收机制 (opens new window)和V8的垃圾回收机制与内存限制 (opens new window)。