前言
在 C 和 C++ 里,跟踪内存是开发者的职责,这给开发者带来很大负担,也是很多问题的根源。JS 为开发者卸下了这一负担,它通过自动内存管理,实现了内存的分配和闲置资源的回收。
在这里,闲置资源的回收,我们称之为垃圾回收(GC,Garbage Collect)。本文将从两个方面,来探讨 JS 这门语言的 GC 机制。
- 常见的 GC 方案
介绍几种比较常见的 GC 方案,同时也尝试对它们进行一些优劣性的对比和分析。 - V8 引擎的 GC 方案
介绍 Google Chrome 浏览器内部的 V8 引擎目前正在使用的 GC 方案,同时也尝试了解一下,它所进行的一系列针对性优化。
好了,废话不多说,我们正式开始吧。
常见 GC 方案
深入 GC 方案之前,我们需要先了解一下垃圾回收的过程。简单来说,一次由程序发起的自动 GC ,会包含如下两个过程:
- 识别垃圾
- 清理垃圾
那么,什么样的数据会被定义为垃圾数据呢?
什么样的数据会被定义为垃圾数据?
我们知道,JS 运行时存在一个储存作用域链
的栈,这个栈最底层的元素是全局作用域,往上叠加的,是一个个按顺序被创建的函数 / 块作用域。
这些作用域被创建时,一些变量也会跟着被声明和赋值,原始类型的数据被保存在栈内,引用类型的数据则会被保存在栈外的堆内存之中。
函数/块执行结束,作用域随即被推出,被推出的作用域变得不再可以访问,那么自然而然得,其内部所保存的一些变量,也跟着变得不再可以访问。
这些再也无法被访问的数据,我们称之为垃圾。
很形象,是不是?(∩_∩)~
有了这些认知之后,我们再来看看,如何评判一个 GC 方案的优劣。
如何评判一个 GC 方案的优劣?
评价 GC 算法的优劣时,我们经常用到这四个标准。
- 吞吐量
- 最大暂停时间
- 堆使用效率
- 访问的局部性
下面我们来逐个介绍。
- 吞吐量
吞吐量(throughput)反映了程序在单位时间内处理数据的能力。如上图所示。
我们知道 JS 是单线程的,一段程序的执行,势必影响另一段。一般来说, GC 会在线程闲置时执行,但页面是展示在用户面前的,点击、输入、阅览等操作,也完全由用户来操控。我们没有隔着屏幕操控用户意念的能力,线程的闲置时间理所当然变得不可估算。
那么,为了最大程度保证用户体验,我们势必需要减少 GC 的执行次数,和每一次的 GC 执行耗时。
以上图为例,整个程序执行过程中, GC 一共启动了3次,我们把花费的时间分别设为 A、B、C,堆大小设为 HEAP_SIZE,那么此次 GC 的吞吐量便为 HEAP_SIZE/(A+B+C)。
HEAP_SIZE 恒定的情况下,执行时间越低,算法的吞吐量越高。
-
最大暂停时间
我们知道 GC 算法,会导致应用逻辑的暂停。最大暂停时间指的是,因执行 GC 而暂停执行应用逻辑的最长时间。 -
堆使用效率
一般来说,堆中存放的信息越多, GC 算法的效率越高,吞吐量也越大。除此之外,堆用法的不同,也会很大程度上影响堆的使用效率。 -
访问的局部性
访问的局部性指的是,具有引用关系的对象之间很可能存在连续访问的情况。
基于这一现象,当CPU尝试从内存中读取数据时,我们不仅让它读取使用的数据,还让它读取该数据附近的所有数据,继而压缩数据读取的时间。
为了让这一策略能够生效,我们在存储数据时,就需要把具有引用关系的对象,安排在堆中较近的位置。
有了衡量标准,我们就可以详细来看看每一种 GC 算法了,本文将涉及六种 GC 算法,它们分别是:
- 引用计数算法
- 标记 - 清除算法
- 复制算法
- 标记 - 整理算法
- 分代式垃圾回收
- 增量式垃圾回收
下面我们逐个来分析。
1. 引用计数算法
引用计数的思路是,记录每个值的被引用的次数,当它被初始化,并被赋值给一个变量,引用为 1;如果另外一个变量引用了它,引用加 1;如果一个变量不再引用它,引用减 1。当一个值的引用数变为 0,那就说明它再没法被访问了,因此它成为垃圾,可以放心回收。
优点
这个算法的优点是吞吐量较大,一个对象的被引用次数变为0,它就可以立刻被释放,所占用的内存也即刻被回收。这样一来,内存管理的开销就被分摊到了程序的运行过程中,最大暂停时间也随即被大大缩短。
缺点
引用计数最早由Netscape Navigator 3.0采用,但很快就遇到了严重的问题:无法回收循环引用的对象。
function problem() {
let objectA = new Object();
let objectB = new Object();
objectA.someOtherObject = objectB;
objectB.anotherObject = objectA;
}
在上面这个例子中,objectA 和 objectB 通过各自的属性相互引用,于是它们的引用数都是 2。函数运行结束,objectA 和 objectB 的引用次数各自减 1,但因为它们还引用着彼此,所以它们的引用次数永远不会置为 0,也就无法被回收。
2. 标记 - 清除算法
标记 - 清除算法(Mark-Sweep)由标记和清除两个阶段构成:
- 标记阶段:给所有的活动对象打上标记。
- 清除阶段:回收没有标记的对象。
优点
堆的使用效率较高。因为可以在整个堆中存放对象,所以相对来说堆的使用效率较高。
缺点
碎片化。经过多轮空间分配和垃圾回收后,堆从一整片完整的空间被划分为了很多不连续的碎片,这就导致另外一个问题:分配速度受限。因为每次重新分配空间时,都要遍历所有空闲链表,去寻找足够大的分块,最坏的情况下,每次进行分配都要遍历整个空闲链表。
3. 复制算法
简单来说,就是把整个存储空间一分为二,一个空间(From 区)用来存放数据,另一个空间(To 区)暂时闲置。
算法执行步骤如下:
- 将 From 区的活动对象,复制到 To 区,——注意是活动对象,非活动对象不复制。
- 清空 From 区。
- 将 From 区和 To 区对调。
优点
- 优秀的吞吐量。因为复制算法只搜索并复制活动对象,所以跟标记 - 清除算法相比,它能在较短时间内完成。
- 不会发生碎片化。复制算法每次运行 GC 时都会执行整理,因此不会发生碎片化。也因此,当出现新的数据需要分配内存时,它的工作效率也更高。
缺点
堆使用效率低下。只有一半堆能够被使用,相较其他算法,它在堆的利用方面,效率可以说相当低下。
4. 标记 - 整理算法
标记 - 整理算法(Mark Compact)是标记 - 清除算法和复制算法相结合的产物。该算法由标记阶段和整理两个阶段构成:
- 标记阶段:这个阶段跟标记 - 清除算法的标记阶段一致。
- 整理阶段:移动活动对象的位置,使活动对象聚集到堆的一端。
优点
- 堆空间利用率较高。可以在整个堆中存放对象,堆使用效率较高。
- 没有碎片化的问题。经过整理后的堆内存空间是连续的,接下来的内存分配也不会有问题。
缺点
吞吐量较差。因为整理过程需要多次整堆遍历,速度较慢,所以吞吐量较差。
5. 分代垃圾回收
这个算法基于一个普遍的共识:大部分对象生成后都会马上变成垃圾,只有少部分对象能够存活很久。
基于这个共识,分代垃圾回收算法(Generational Collection)在对象中引入了年龄的概念。通过优先回收容易成为垃圾的对象,提高垃圾回收的效率。
该算法把对象分类成几代,针对不同的代使用不同的 GC 算法。首先来了解一些概念。
- 新生代对象(new generation),指刚生成的对象。
- 老年代对象(old generation),指到达一定年龄的对象。
- 新生代 GC(minor GC),指对新生代对象所执行的 GC。
- 老年代 GC(major GC),指对老生代对象所执行的 GC。该算法的执行频率往往更低。
- 晋升(promotion),指将存活了一定次数的新生代对象转化为老年代对象。
优点
吞吐量得到改善,“很多对象年纪轻轻就会死”虽然是经验之谈,却能够适用于大多数情况。新生代 GC 只把刚生成的对象当成对象,这就有效降低了时间上的消耗。老生代 GC 的执行时间或许会更长,但它的执行频率降低不少。
缺点
当“很多对象年纪轻轻就会死”这一法则不成立时,分代垃圾回收的表现就会大打折扣,这种情况下,基本算法的表现可能更加优秀。
6. 增量式垃圾回收
增量式垃圾回收(Incremental)是一种通过逐渐推进垃圾回收的策略,来控制应用逻辑最大暂停时间的算法。
当 GC 的任务变得繁重时,它所消耗的时间也会跟着提升。JS 单线程运行,GC 占用的时间越多,应用逻辑所受到的影响也越大。最严重的情况下,它甚至可能导致应用逻辑的暂停。这种暂停应用逻辑运行的 GC,被我们称为停止型 GC 。
停止型 GC为避免出现停止型 GC,增量式垃圾回收应运而生。正如增量(Incremental)这个词所描述的,——递增的,逐渐增加的,增量式垃圾回收所做的事情便是,让 GC 和应用逻辑一点点交替运行。
增量式垃圾回收我们可以使用Edsger W. Dijkstra 等人提出的三色标记算法(Tri-color marking),来完成增量式垃圾回收。这个算法将内存中的对象分成三种:
- 白色:尚未进入检索过的对象。
- 灰色:正在被检索的对象。
- 黑色:已经完成检索的对象。
增量式的 GC 标记 - 清除算法可分为以下三个阶段:
- 根查找阶段;
- 标记阶段;
- 清除阶段;
下面是详细描述:
- 将内存中的所有对象置成白色。
- 从根开始,依次检索每个对象,并在检索到它们时,将它们置为灰色。
- 当一个对象的子对象全被检索完,它会置为黑色。
4.检索结束,清除白色对象。
优点
缩短最大暂停时间。采用三色标记算法的增量式垃圾回收,跟应用逻辑交替运行,因此它能够最大限度地保证应用逻辑的正常运行。
缺点
降低了吞吐量。交替进行的标记算法虽能够有效降低算法的最大暂停时间,频繁的访问和检索却会影响到算法总体的吞吐量。
V8的垃圾回收策略
V8采用分代式垃圾回收算法。通过回忆该算法的原理,我们可以知道,针对不同年龄的对象,它会采用不同的垃圾回收策略。
- 针对新生代对象:复制算法 + 标记 - 整理算法。
- 针对老生代对象:标记 - 清除算法 + 标记 - 整理算法。
默认情况下,它将内存空间一分为二:
- 针对 32 位系统,新生代内存大小为 16MB,老生代内存大小为 700MB 。
- 针对 64 位系统:新生代内存大小为 32MB,老生代内存大小为 1.4GB 。
回收新生代对象
针对新生代对象,V8采用复制算法 + 标记 - 整理算法的模式,具体执行步骤如下:
- 将新生代存储区,分为大小相等的两个空间,使用空间为 From,空闲空间为 To 。
- 算法执行时,将 From 空间的活动对象拷贝至 To 空间。
- 清空 From 空间的所有数据。
- 对象 From 空间和 To 空间。
将活动对象从 From 空间拷贝至 To 空间时,如果满足以下条件之一,需要将活动对象的存储位置晋升到老生代存储区:
- 该对象已经经过一轮回收。
- To 空间使用率超过 25% 。之所以设置阈值,是因为该轮回收结束后,现在的 To 空间会变为 From 空间,接下来的内存分配也将在这个空间中进行,空间使用率若过高,后续的内存分配有一定概率会受到影响。
回收老生代对象
老生代对象中,存活对象占较大比重,此时,若继续采用复制算法和标记 - 整理算法,就会出现两个问题。
- 效率低下。因为存活对象较多,此时再进行逐个复制,效率大打折扣。
- 堆利用率低下。老生代内存使用远大于新生代,复制算法却将那么大的存储区一分为二,浪费很严重。
综合以上两点,V8 采用标记 - 清除、标记 - 整理和增量标记算法,对老生代对象进行回收。具体过程如下:
- 首先,使用标记 - 清除算法完成垃圾空间的回收。此时可能出现内存空间碎片化的问题。
- 当对象从新生代存储区晋升到老生代时,使用标记 - 整理算法对内存空间进行优化。
- 考虑到 GC 的工作很可能阻塞应用逻辑的运行,所以使用增量标记算法,将 GC 拆成多段,与应用逻辑交替执行。
编写有利于垃圾回收的代码
了解 JS 语言的垃圾回收机制,看起来对我们的日常工作没有太大影响,毕竟,谁也不是一天到晚忙优化,是不是?甚至我们经手的大多数页面都相当简单,有的甚至只能存活十天半个月,此时,比起锲而不舍地对它进行最大程度的优化,保证它能正常运行,才是我们日常最重要的工作。
但俗话也说,千里之堤溃于蚁穴,不积跬步无以至千里,不积小流难以成大江,长年累月形成的好习惯,可以帮助我们走得更远,而旷日持久的坏习惯,即便它微乎其微,对一个人造成的影响也可能难以估量。
了解 JS 语言的垃圾回收机制,看起来对我们的日常工作没有太大影响,但通过它形成的编码好习惯,可以让我们受益良多。
那么,该如何编写一份有利于垃圾回收的优质代码呢?
可以从如下几点入手。
-
使用 const 和 let 替代 var
es6 新增的两个关键字,不仅有助于改善代码风格,更有助于改进垃圾回收。相较函数和全局作用域的 var,块作用域的 const 和 let,可以更早地被 GC 回收。 -
解除引用
降低内存占用量可以一定程度上提升页面性能,基于这个共识,我们需要想办法在执行的代码中,只保存必要的数据。
如果一个对象不再需要,那么请将它设置为null。
如果一个对象很大,你只需要其中的一小部分,那么请用一个单独的变量保存它,而不是每次都去访问大对象的一个小属性。
// 示例 1,程序执行结束后,解除对 inner 的引用。
function outer() {
let name = 'Jake';
return function () {
return name;
};
};
let inner = outer();
inner();
inner = null;
/*
* 示例 2,使用 WeakMap 和 WeakSet。
* WeakMap 和 WeakSet 不会阻止垃圾程序的运行。
* 当 Countdown 的实例被回收,_counter 和 _action 也会随即被置空,不会造成内存泄漏。
*/
const _counter = new WeakMap();
const _action = new WeakMap();
class Countdown {
constructor(counter, action) {
_counter.set(this, counter);
_action.set(this, action);
}
dec() {
let counter = _counter.get(this);
if (counter < 1) return;
counter--;
_counter.set(this, counter);
if (counter === 0) {
_action.get(this)();
}
}
}
const c = new Countdown(2, () => console.log('DONE'));
c.dec()
c.dec()
好啦!
以上就是本文的全部内容。
感谢整理相关内容并分享出来的大神们,也感谢看到这里的大小可爱们。
比一个心:(′▽`ʃ♡ƪ)
咱们有缘江湖再见!
网友评论