垃圾回收机制
要了解垃圾回收机制,首先需要弄清 JS 中的内存管理
JS 内存管理
JS 数据类型
众所周知,JS 数据类型共有 8 种,如下表
| 类型 | 描述 |
|---|---|
| Number | 数字; |
| String | 字符串; |
| Boolean | 布尔值;只有两个值 true 和 false |
| Undefined | 未定义;变量未赋值时的默认值,或访问对象中不存在的属性时的值,只有一个值 undefined |
| Null | 空值;一般用于描述一个空对象,只有一个值 null |
| BigInt | 任意精度的整数; |
| Symbol | 实例唯一且不可改变的数据类型 |
| Object | 对象;包括 array、object、function |
习惯上我们把前七种叫做基本数据类型,而对象则单独拎出来,称之为引用数据类型。之所以要分门别类,原因在于它们在内存中存储的位置不同。
从上面的 JavaScript 内存模型图可以看出,在 JavaScript 代码的执行过程中,主要有三种类型内存空间,分别是代码空间、栈空间和堆空间。
顾名思义,代码空间是用来存放可执行代码的。上篇文章在讲 JS 执行上下文 时也有介绍过执行栈等概念,由此我们知道了执行上下文就是在执行栈里面运行的,而在执行上下文中又存储着代码运行时的词法环境和变量环境。变量又是保存在这些环境上,那么是不是可以推断出,JS 中的数据都存放在 栈空间 中呢?
数据存储方式
事实上并非完全如此,在 JS 中,对基本类型的数据赋值会完整复制变量值,而引用类型的赋值是复制引用地址。当创建一个 object 类型的数据时,变量中保存是这部分数据的引用地址。而这个地址实际上是 堆空间 中的内存地址,也就是说引用类型的数据是保存在 堆空间 中的。在赋值时, 堆空间 中分配一块内存来写入数据,然后再将这块空间的地址写入到 栈空间 变量的值上。
这样我们就洞悉了 JS 数据的内存分配机制,知道了原来基本类型的数据存放在 “栈” 中,引用类型的数据存放在 ”堆“ 中。而只有明白了数据是怎么存储的,才能更好地理解后续的步骤。
这里还有一个问题:为什么有了 栈空间 还要分一块 堆空间 出来?为什么不把所有数据都放到一起?
这是因为 JavaScript 引擎需要用栈来维护程序执行期间上下文的状态,如果栈空间大了话,所有的数据都存放在栈空间里面,那么会影响到上下文切换的效率,进而又影响到整个程序的执行效率。
所以通常情况下,栈空间都不会设置太大,主要用来存放一些原始类型的小数据。而引用类型的数据占用的空间都比较大,所以这一类数据会被存放到堆中,堆空间很大,能存放很多大的数据,缺点是分配内存和回收内存都会占用一定的时间。
JS 语言类型
JavaScript 的变量是没有数据类型的,值才有数据类型,变量可以随时持有任何类型的数据。这与 C语言 不同, C 语言 在定义变量之前,就需要确定变量的类型
int main()
{
int a = 1;
char* b = "hello world";
bool c = true;
return 0;
}
这段声明变量的代码与 JS 声明变量的代码有所不同,它在声明变量之前定义了变量的类型。这种在使用之前就需要确认其变量数据类型的称为静态语言。有静就有动,我们把在运行过程中需要检查数据类型的语言称为动态语言。
所以 JS 是一门动态类型的语言,因为在声明变量之前并不需要确认其数据类型。我们可以把所有类型的数据都赋值给同一个变量。
let bar;
bar = 10; // 接收 Number 类型
bar = "hello world"; // 接收 String 类型
bar = true; // 接收 Boolean 类型
bar = null; // 接收 Null 类型
bar = {
name: "xiaolonga"
}; // 接收 Object 类型
我们知道 JS 中的 + 运算符不仅可以用来数学上相加操作,还可以对两个字符串进行拼接操作。但如果两个操作数是一个数字一个字符串,那将会发生什么呢?
let a = 10;
let b = "20";
let c = a + b;
console.log("c 的值为 ", c, "c 的类型为 ", typeof c);
// c 的值为 1020 c 的类型为 string
最终打印的结果是 1020 字符串,也就是在执行 a + b 的过程中,a 原本是 Number 类型,被转化成了 String 类型,这种转化并不是代码中写明的,而是在 JS 运行期间自动做的转换,通常把这种自动转换的操作称为隐式类型转换。支持隐式类型转换的语言称为弱类型语言,不支持隐式类型转换的语言称为强类型语言。所以,JS 也是一种弱类型语言。
现在我们知道了,JavaScript 是一种弱类型的、动态的语言。动态意味着我们不用关注变量类型,弱类型意味着代码在运行过程中会发生隐式转化,这点要特别注意,因为有可能无意间的某些操作会导致一些无法预料的结果。
垃圾回收机制 | JavaScript 的数据是如何回收的
随着代码的运行,产生的数据将会越来越多,造成内存紧张。长此以往势必会导致性能的下降。而内存是有限的,因此需要将之前的已分配内存中的数据做一遍梳理,保留有用的,清理无用的,以节省内存空间。
通常情况下,垃圾数据的回收分为手动回收以及自动回收这两种策略。何时分配内存,何时销毁内存由代码进行控制的这种方式称为手动回收;与之对应的自动回收则是由垃圾回收器自动处理垃圾数据,JS 正是采用的这种策略。
上面在讲内存模型时我们已经知道了 JS 数据是存放在栈空间和堆空间中的,下面就来对这两个空间分别来进行分析一下。
栈空间
在介绍 执行上下文 这篇文档中我们分析过,在栈空间中,每当创建一个新的执行上下文,就会出现在栈空间的顶部。与此同时,还有一个记录当前执行状态的指针(ESP)始终指向栈顶。当前执行上下文中的代码执行完毕后,就会被弹出栈空间,同时指针下移,仍然指向栈顶。
这个下移操作就是销毁执行上下文的过程。而所谓的销毁实际上是打个标记,告诉 JS 引擎这块内存是无效的,然后下次再有新的执行上下文被创建时,这块无效内存中的内容就会直接被新执行上下文的内容覆盖。
堆空间
在上面的 JS 数据类型介绍中,我们知道 object 引用类型的数据是存放在“堆”中的,那也就是说”栈“中销毁执行上下文时,实际上并没有销毁变量值为引用类型的数据,那“堆”中的数据是如何被回收的呢?这就要用到 JS 中的垃圾回收器了。
垃圾回收器的实现建立在一个名为代际假说的术语之上。代际假说:内存被分为不同的代 ( generations ),通常分为年轻代 ( young generation ) 和老年代 ( old generation ) 两个部分。这种分代的策略基于以下观察:年轻代 ( Young Generation ) :在程序运行的初期,大部分对象都是临时的,它们被创建后不久就会变得不再可访问(被垃圾回收)。因此,年轻代包含了大量的临时对象,这些对象经历了多次的垃圾回收。年轻代通常被划分为三个部分:Eden 区、From 区和 To 区。新创建的对象在 Eden 区分配,经过一次垃圾回收后,仍然存活的对象会被移动到 From 区,然后在下一次垃圾回收时,存活的对象从 From 区复制到 To 区。这个过程不断重复,直到对象变得足够老,就会被晋升到老年代。老年代 ( Old Generation ) :对象在经历多次年轻代的垃圾回收后,如果仍然存活,它们就会被晋升到老年代。老年代通常包含生存周期更长的对象,因此垃圾回收发生得较少,且代价更高。代际假说的基本思想是将垃圾回收集中在年轻代,因为大部分对象很快就会被回收,从而减少了垃圾回收的成本。只有那些经历了多次回收的对象才会被提升到老年代。
总结一下,它有以下两个特点:
- 第一个是大部分对象都是“朝生夕死”的,也就是说大部分对象在内存中存活的时间很短,比如函数内部声明的变量,或者块级作用域中的变量,当函数或者代码块执行结束时,作用域中定义的变量就会被销毁。因此这一类对象一经分配内存,很快就变得不可访问;
- 第二个是不死的对象,会活得更久,比如全局的 window、DOM、Web API 等对象。
V8 中的垃圾回收器
在 V8 中,把堆分为新生代和老生代两个区域。新生代中存放的是生存时间短的对象,在老生代中存放生存时间久的对象。新生区通常只支持 1~8M 的容量,而老生区支持的容量就大很多了。如果我们只使用一个垃圾回收器,在优化大多数新对象的同时,就很难优化到那些老对象,因此你需要权衡各种场景,根据对象生存周期的不同,而使用不同的算法,以便达到最好的效果。所以对于这两块区域,V8 采用了两个不同的垃圾回收器,主垃圾回收器和副垃圾回收器,以便更高效地实施垃圾回收。
- 副垃圾回收器 ( Minor GC ) ,主要负责新生代的垃圾回收。
- 主垃圾回收器 ( Major GC ) ,主要负责老生代的垃圾回收。
垃圾回收器的工作流程:
-
第一步是标记空间中活动对象和非活动对象。所谓活动对象就是还在使用的对象,非活动对象就是可以进行垃圾回收的对象。
V8 通过 GC Root 标记空间中活动对象和非活动对象,采用的可访问性(reachability)算法来判断堆中的对象是否是活动对象。具体地讲,这个算法是将一些 GC Root 作为初始存活的对象的集合,从 GC Roots 对象出发,遍历 GC Root 中的所有对象:通过 GC Root 遍历到的对象,我们就认为该对象是可访问的(reachable),那么必须保证这些对象应该在内存中保留,我们也称可访问的对象为活动对象;通过 GC Roots 没有遍历到的对象,则是不可访问的(unreachable),那么这些不可访问的对象就可能被回收,我们称不可访问的对象为非活动对象。在浏览器环境中,GC Root 有很多,通常包括了以下几种 (但是不止于这几种):全局的 window 对象(位于每个 iframe 中);文档 DOM 树,由可以通过遍历文档到达的所有原生 DOM 节点组成;存放栈上变量。
-
第二步是回收非活动对象所占据的内存。其实就是在所有的标记完成之后,统一清理内存中所有被标记为可回收的对象。
-
第三步是做内存整理。一般来说,频繁回收对象后,内存中就会存在大量不连续空间,我们把这些不连续的内存空间称为内存碎片。当内存中出现了大量的内存碎片之后,如果需要分配较大连续内存的时候,就有可能出现内存不足的情况。所以最后一步需要整理这些内存碎片,但这步其实是可选的,因为有的垃圾回收器不会产生内存碎片,比如接下来我们要介绍的副垃圾回收器。
副垃圾回收器
副垃圾回收器主要负责新生区的垃圾回收。而通常情况下,大多数小的对象都会被分配到新生区,所以说这个区域虽然不大,但是垃圾回收还是比较频繁的。
新生代中的垃圾数据用 Scavenge 算法来处理。所谓 Scavenge 算法,是把新生代空间对半划分为两个区域,一半是对象区域 ( from-space ),一半是空闲区域 ( to-space ),如下图所示:
新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作。
在垃圾回收过程中,首先要对对象区域中的垃圾做标记;标记完成之后,就进入垃圾清理阶段,副垃圾回收器会把这些存活的对象复制到空闲区域中,同时它还会把这些对象有序地排列起来,所以这个复制过程,也就相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了。
完成复制后,对象区域与空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。这样就完成了垃圾对象的回收操作,同时这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去。
由于新生代中采用的 Scavenge 算法,所以每次执行清理操作时,都需要将存活的对象从对象区域复制到空闲区域。但复制操作需要时间成本,如果新生区空间设置得太大了,那么每次清理的时间就会过久,所以为了执行效率,一般新生区的空间会被设置得比较小。
也正是因为新生区的空间不大,所以很容易被存活的对象装满整个区域。为了解决这个问题,JavaScript 引擎采用了对象晋升策略,也就是经过两次垃圾回收依然还存活的对象,会被移动到老生区中。
主垃圾回收器
主垃圾回收器主要负责老生区中的垃圾回收。除了新生区中晋升的对象,一些大的对象会直接被分配到老生区。因此老生区中的对象有两个特点:
- 一个是对象占用空间大
- 一个是对象存活时间长
由于老生区的对象比较大,若要在老生区中使用 Scavenge 算法进行垃圾回收,复制这些大的对象将会花费比较多的时间,从而导致回收执行效率不高,同时还会浪费一半的空间。因次,主垃圾回收器是采用标记 - 清除 ( Mark-Sweep ) 的算法进行垃圾回收的。
首先是标记过程阶段。标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。
接下来就是垃圾的清除过程。它和副垃圾回收器的垃圾清除过程完全不同,主垃圾回收器会直接将标记为垃圾的数据清理掉。
对垃圾数据进行标记,然后清除,这就是标记 - 清除算法,不过对一块内存多次执行标记 - 清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存,于是又引入了另外一种算法——标记 - 整理(Mark-Compact)。
这个算法的标记过程仍然与标记 - 清除算法里的是一样的,先标记可回收对象,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉这一端之外的内存。