垃圾回收
垃圾回收机制
垃圾回收是一种自动的内存管理机制。当计算机上的动态内存不再需要时,就应该予以释放,以让出内存。直白点讲,就是程序是运行在内存里的,当声明一个变量、定义一个函数时都会占用内存。内存的容量是有限的,如果变量、函数等只有产生没有消亡的过程,那迟早内存有被完全占用的时候。这个时候,不仅自己的程序无法正常运行,连其他程序也会受到影响。好比生物只有出生没有死亡,地球总有被撑爆的一天。所以,在计算机中,我们需要垃圾回收。
V8 的垃圾回收策略基于分代回收机制,该机制又基于 世代假说。该假说有两个特点:
- 大部分新生对象倾向于早死;
- 不死的对象,会活得更久。
基于这个理论,现代垃圾回收算法根据对象的存活时间将内存进行了分代,并对不同分代的内存采用不同的高效算法进行垃圾回收。
V8 的内存分代
在 V8 中,将内存分为了新生代(new space)和老生代(old space)。它们特点如下:
- 新生代:对象的存活时间较短。新生对象或只经过一次垃圾回收的对象。
- 老生代:对象存活时间较长。经历过一次或多次垃圾回收的对象。
V8 堆的空间等于新生代空间加上老生代空间。我们可以通过 --max-old-space-size
命令设置老生代空间的最大值,--max-new-space-size
命令设置新生代空间的最大值。老生代与新生代的空间大小在程序初始化时设置,一旦生效则不能动态改变。
node --max-old-space-size=1700 test.js // 单位为 MB
node --max-new-space-size=1024 test.js // 单位为KB
默认设置下,64 位系统的老生代大小为 1400M,32 位系统为 700M。
对于新生代,它由两个 reserved_semispace_size
组成。每个reserved_semispace_size
的大小在不同位数的机器上大小不同。默认设置下,在 64 位与 32 位的系统下分别为 16MB 和 8MB。我们将新生代、老生代、reserved_semispace_size
空间大小总结如下表。
类型 \ 系统位数 | 64 位 | 32 位 |
---|---|---|
老生代 | 1400MB | 700MB |
reserved_semispace_size | 16MB | 8MB |
新生代 | 32MB | 16MB |
新生代回收算法
Stop The World (全停顿)
在介绍垃圾回收算法之前,我们先了解一下「全停顿」。垃圾回收算法在执行前,需要将应用逻辑暂停,执行完垃圾回收后再执行应用逻辑,这种行为称为 「全停顿」(Stop The World)。例如,如果一次 GC 需要 50ms,应用逻辑就会暂停 50ms。
全停顿的目的,是为了解决应用逻辑与垃圾回收器看到的情况不一致的问题。举个例子,在自助餐厅吃饭,高高兴兴地取完食物回来时,结果发现自己餐具被服务员收走了。这里,服务员好比垃圾回收器,餐具就像是分配的对象,我们就是应用逻辑。在我们看来,只是将餐具临时放在桌上,但是服务员看来觉得你已经不需要使用了,因此就收走了。你与服务员对于同一个事物看到的情况是不一致,导致服务员做了与我们不期望的事情。因此,为避免应用逻辑与垃圾回收器看到的情况不一致,垃圾回收算法在执行时,需要停止应用逻辑。
Scavenge 算法
新生代中的对象
主要通过 Scavenge 算法进行垃圾回收。Scavenge 的具体实现,主要采用了 Cheney 算法。

Cheney 算法采用复制的方式进行垃圾回收。它将堆内存一分为二,每一部分空间称为 semispace。这两个空间,只有一个空间处于使用中,另一个则处于闲置。使用中的 semispace 称为 「From 空间」,闲置的 semispace 称为 「To 空间」。
过程如下:
- 从 From 空间分配对象,若 semispace 被分配满,则执行 Scavenge 算法进行垃圾回收。
- 检查 From 空间的存活对象,若对象存活,则检查对象是否符合晋升条件,若符合条件则晋升到老生代,否则将对象从 From 空间复制到 To 空间。
- 若对象不存活,则释放不存活对象的空间。
- 完成复制后,将 From 空间与 To 空间进行角色翻转(flip)。
对象晋升
对象晋升的条件有两个:
1)对象是否经历过 Scavenge 回收。对象从 From 空间复制 To 空间时,会检查对象的内存地址来判断对象是否已经经过一次 Scavenge 回收。若经历过,则将对象从 From 空间复制到老生代中;若没有经历,则复制到 To 空间。
2)To 空间的内存使用占比是否超过限制。当对象从 From 空间复制到 To 空间时,若 To 空间使用超过 25%,则对象直接晋升到老生代中。设置为 25% 的比例的原因是,当完成 Scavenge 回收后,To 空间将翻转成 From 空间,继续进行对象内存的分配。若占比过大,将影响后续内存分配。
对象晋升到老生代后,将接受新的垃圾回收算法处理。下图为 Scavenge 算法中,对象晋升流程图。

小结
Scavenge 算法的缺点是,它的算法机制决定了只能利用一半的内存空间。但是新生代中的对象生存周期短、存活对象少,进行对象复制的成本不是很高,因而非常适合这种场景。
老生代回收算法
老生代中的对象有两个特点,第一是存活对象多,第二个存活时间长。若在老生代中使用 Scavenge 算法进行垃圾回收,将会导致复制存活对象的效率不高,且还会浪费一半的空间。因而,V8 在老生代采用 Mark-Sweep 和 Mark-Compact 算法进行垃圾回收。
Mark-Sweep(标记清除)
Mark-Sweep,是标记清除的意思。它主要分为标记和清除两个阶段。
- 标记阶段,它将遍历堆中所有对象,并对存活的对象进行标记;
- 清除阶段,对未标记对象的空间进行回收。
与 Scavenge 算法不同,Mark-Sweep 不会对内存一分为二,因此不会浪费空间。但是,经历过一次 Mark-Sweep 之后,内存的空间将会变得不连续,这样会对后续内存分配造成问题。比如,当需要分配一个比较大的对象时,没有任何一个碎片内支持分配,这将提前触发一次垃圾回收,尽管这次垃圾回收是没有必要的。

在 JavaScript 中有个全局对象,浏览器中是 window。定期的,垃圾回收期将从这个全局对象开始,找所有从这个全局对象开始引用的对象,再找这些对象引用的对象...对这些活着的对象进行标记,这是标记阶段。清除阶段就是清除那些没有被标记的对象。
Mark-Compact(标记整理)
为了解决内存碎片的问题,提高对内存的利用,引入了 Mark-Compact (标记整理)算法。Mark-Compact 是在 Mark-Sweep 算法上进行了改进,标记阶段与 Mark-Sweep 相同,但是对未标记的对象处理方式不同。与 Mark-Sweep 是对未标记的对象立即进行回收,Mark-Compact 则是将存活的对象移动到一边,然后再清理端边界外的内存。

小结
由于 Mark-Compact 需要移动对象,所以执行速度上,比 Mark-Sweep 要慢。所以,V8 主要使用 Mark-Sweep 算法,然后在当空间内存分配不足时,采用 Mark-Compact 算法。
Incremental Marking(增量标记)
在新生代中,由于存活对象少,垃圾回收效率高,全停顿时间短,造成的影响小。但是老生代中,存活对象多,垃圾回收时间长,全停顿造成的影响大。为了减少全停顿的时间,V8 对标记进行了优化,将一次停顿进行的标记过程,分成了很多小步。每执行完一小步就让应用逻辑执行一会儿,这样交替多次后完成标记。如下图所示:

长时间的 GC,会导致应用暂停和无响应,将会导致糟糕的用户体验。从 2011 年起,v8 就将「全暂停」标记换成了增量标记。改进后的标记方式,最大停顿时间减少到原来的 1/6。
lazy sweeping(延迟清理)
- 发生在增量标记之后
- 堆确切地知道有多少空间能被释放
- 延迟清理是被允许的,因此页面的清理可以根据需要进行清理
- 当延迟清理完成后,增量标记将重新开始。
内存泄漏
在谈什么是良好实践(这里指有益于内存管理)之前,我想先谈谈内存泄漏,也就是差的实践。内存泄漏是指计算机可用的内存越来越少,主要是因为程序不能释放那些不再使用的内存。
循环引用
function func() {
let obj1 = {};
let obj2 = {};
obj1.a = obj2; // obj1 引用 obj2
obj2.a = obj1; // obj2 引用 obj1
}
当函数 func 执行结束后,返回值为 undefined,所以整个函数以及内部的变量都应该被回收,但根据引用计数方法,obj1 和 obj2 的引用次数都不为 0,所以他们不会被回收。
一旦数据不再使用,最好通过将其值设为 null 来释放其引用,这个方法被称为 “解除引用”。
obj1 = null;
obj2 = null;
无意的全局变量
function foo(arg) {
const bar = "";
}
foo();
当 foo 函数执行后,变量 bar 就会被标记为可回收。因为当函数执行时,函数创造了一个作用域来让函数里的变量在里面声明。进入这个作用域后,浏览器就会为变量 bar 创建一个内存空间。当这个函数结束后,其所创建的作用域里的变量也会被标记为垃圾,在下一个垃圾回收周期到来时,这些变量将会被回收。
但事情并不会那么顺利。
function foo(arg) {
bar = "";
}
foo();
上面的代码就无意中声明了一个全局变量,会得到 window 的引用,bar 实际上是 window.bar,它的作用域在 window 上,所以 foo 函数执行结束后,bar 也不会被内存收回。
另外一种无意的全局变量的情况是:
function foo() {
this.bar = "";
}
在 foo 函数中,this 指的是 window,犯的错误跟上面类似。
被遗忘的计时器和回调函数
let someResource = getData();
setInterval(() => {
const node = document.getElementById('Node');
if(node) {
node.innerHTML = JSON.stringify(someResource));
}
}, 1000);
上面的例子中,我们每隔一秒就将得到的数据放入到文档节点中去。但在 setInterval 没有结束前,回调函数里的变量以及回调函数本身都无法被回收。那什么才叫结束呢?就是调用了 clearInterval。如果回调函数内没有做什么事情,并且也没有被 clear 掉的话,就会造成内存泄漏。不仅如此,如果回调函数没有被回收,那么回调函数内依赖的变量也没法被回收。上面的例子中,someResource 就没法被回收。同样的,setTimeout 也会有同样的问题。所以,当不需要 interval 或者 timeout 时,最好调用 clearInterval 或者 clearTimeout。
DOM
在 IE8 以下的版本里,DOM 对象经常会跟 JavaScript 之间产生循环引用。看一个例子:
function setHandler() {
const ele = document.getElementById("id");
ele.onclick = function () {};
}
在这个例子中,DOM 对象通过 onclick 引用了一个函数,然而这个函数通过外部的词法环境引用了这个 DOM 对象,形成了循环引用。不过现在不必担心,因为所有现代浏览器都采用了标记-整理方法,避免了循环引用的问题。
除了这种情况,我们现在还会在其他时候在使用 DOM 时出现内存泄漏的问题。当我们需要多次访问同一个 DOM 元素时,一个好的做法是将 DOM 元素用一个变量存储在内存中,因为访问 DOM 的效率一般比较低,应该避免频繁地反问 DOM 元素。所以我们会这样写:
const button = document.getElementById('button');
当删除这个按钮时:
document.body.removeChild(document.getElementById('button'));
虽然这样看起来删除了这个 DOM 元素,但这个 DOM 元素仍然被 button 这个变量引用,所以在内存上,这个 DOM 元素是没法被回收的。所以在使用结束后,还需要将 button 设成 null。
另外一个值得注意的是,代码中保存了一个列表 ul 的某一项 li 的引用,将来决定删除整个列表时,我们自觉上会认为内存仅仅会保留那个特定的 li,而将其他列表项都删除。但事实并非如此,因为 li 是 ul 的子元素,子元素与父元素是引用关系,所以如果代码保存 li 的引用,那么整个 ul 将会继续呆在内存里。
良好实践
解除引用
优化内存的一个最好的衡量方式就是只保留程序运行时需要的数据,对于已经使用的或者不需要的数据,应该将其值设为 null,这上面说过,叫 “解除引用”。需要注意的是,解除一个值的引用不代表垃圾回收器会立即将这段内存回收,这样做的目的是让垃圾回收器在下一个回收周期到来时知道这段内存需要回收。
在内存泄漏部分,我们讨论了无意的全局变量会带来无法回收的内存垃圾。但有些时候,我们会有意识地声明一些全局变量,这个时候需要注意,如果声明的变量占用大量的内存,那么在使用完后将变量声明为 null。
避免创建对象
减少内存垃圾的另一个方法就是避免创建对象。new Object() 是一个比较明显的创建对象的方式,另外 const arr = [];、const obj = {}; 也会创建新的对象。另外下面这种写法在每次调用函数时都会创建一个新的对象:
function func() { return function() {}; }
另外,当清空一个数组时,我们通常的做法是 array = [],但这种做法的背后是新建了一个新的数组然后将原来的数组当作内存垃圾。建议的做法是 array.length = 0,这样做不仅可以重用原来的变量,而且还避免创建了新的数组。
总结
垃圾回收的原理较为复杂,在理解上需要花费一些功夫。了解 GC 原理,有助于我们对 NodeJS 项目进行性能瓶颈定位与调优。文章所描述的算法为 V8 中使用的基础算法,现代 V8 引擎对垃圾回收进行了很多改进,比如,在 Chrome 64 和 Node.js v10 中 V8 启用了「并行标记」技术,将标记时间缩短了 60%~70%。还有「Parallel Scavenger」技术,它将新生代的垃圾回收时间缩短了 20%~50%。
垃圾回收是影响服务性能的因素之一,为了提高服务性能,应尽量减少垃圾回收的次数。