垃圾回收
一、认识
数据是存储在栈
和堆
两种内存空间中的,所以接下来我们就来分别介绍栈中的垃圾数据和堆中的垃圾数据是如何回收的。
二、栈数据的垃圾回收
执行函数时,JavaScript
引擎会创建函数的上下文,并将函数的执行上下压入到调用栈中。与此同时,还有一个记录当前执行状态的指针ESP
,指向调用栈中当前执行的函数上下文。当函数执行完毕,JavaScript
会将 ESP
下移,这个下移的操作就是销毁函数上下文的过程。
三、堆数据的垃圾回收
目前,V8
采用两个垃圾回收器:
-
主垃圾回收器 -
Major GC
: 主要负责老生代的垃圾回收 -
副垃圾回收器 -
Minor GC (Scavenger)
: 主要负责新生代的垃圾回收
V8
之所以使用了两个垃圾回收器,主要是受到了 代际假说(The Generational Hypothesis) 的影响,代际假说是垃圾回收领域中一个重要的术语,它有以下两个特点:
-
第一个是大部分对象都是“朝生夕死”的,也就是说大部分对象在内存中存活的时间很短,比如函数内部声明的变量,或者块级作用域中的变量,当函数或者代码块执行结束时,作用域中定义的变量就会被销毁。因此这一类对象一经分配内存,很快就变得不可访问;
-
第二个是不死的对象,会活得更久,比如全局的 window、DOM、Web API 等对象。
V8
的垃圾回收策略,就是建立在该假说的基础之上的。接下来,我们来分析下 V8
是如何实现垃圾回收的。如果我们只使用一个垃圾回收器,在优化大多数新对象的同时,就很难优化到那些老对象,因此你需要权衡各种场景,根据对象生存周期的不同,而使用不同的算法,以便达到最好的效果。所以,在 V8
中,会把堆
分为新生代
和老生代
两个区域,新生代
中存放的是生存时间短
的对象,老生代
中存放生存时间久
的对象。新生代
通常只支持 1~8M
的容量,而老生代
支持的容量就大很多了。对于这两块区域,V8
分别使用两个不同的垃圾回收器,以便更高效地实施垃圾回收。
3.1 副垃圾回收器 - Minor GC
副垃圾回收器主要负责新生代
的垃圾回收。通常情况下,大多数小的对象都会被分配到新生代,所以说这个区域虽然不大,但是垃圾回收还是比较频繁
的。新生代
中的垃圾数据
用 Scavenge
算法来处理。所谓 Scavenge
算法,是把新生代
空间对半划分为两个区域,一半是对象区域 (from-space)
,一半是空闲区域 (to-space)
, 如下图所示:
新加入
的对象都会存放到对象区域
,当对象区域
快被写满时,就需要执行一次垃圾清理
操作。
在垃圾回收过程中,首先要对对象区域
中的垃圾做标记
;标记完成之后,就进入垃圾清理
阶段。副垃圾回收器
会把这些存活的对象
复制到空闲区域
中,同时它还会把这些对象有序地排列
起来,所以这个复制过程
,也就相当于完成了内存整理
操作,复制后空闲区域就没有内存碎片
了。
完成复制
后,对象区域
与空闲区域
进行角色翻转,也就是原来的对象区域
变成空闲区域
,原来的空闲区域
变成了对象区域
。这样就完成了垃圾对象的回收
操作,同时,这种角色翻转
的操作还能让新生代
中的这两块区域无限重复
使用下去。
不过,副垃圾回收器
每次执行清理操作时,都需要将存活的对象从对象区域
复制到空闲区域
,复制操作需要时间成本,如果新生区空间
设置得太大了,那么每次清理的时间就会过久,所以为了执行效率,一般新生区的空间
会被设置得比较小。
也正是因为新生区的空间
不大,所以很容易被存活的对象装满整个区域,副垃圾回收器一旦监控对象装满了,便执行垃圾回收。同时,副垃圾回收器
还会采用对象晋升策略
,也就是移动那些经过两次垃圾回收依然还存活的对象到老生代
中。
3.2 主垃圾回收器 - Major GC
主垃圾回收器主要负责老生代
中的垃圾回收
。除了新生代
中晋升
的对象,一些大的对象会直接被分配
到老生代里。因此,老生代中的对象有两个特点:
-
一个是对象占用空间大
-
另一个是对象存活时间长
由于老生代的对象比较大,若要在老生代中使用 Scavenge
算法进行垃圾回收,复制这些大的对象将会花费比较多的时间,从而导致回收执行效率不高,同时还会浪费一半的空间。所以,主垃圾回收器
是采用 标记 - 清除(Mark-Sweep)
的算法进行垃圾回收的。标记 - 清除
算法工作如下:
-
首先是标记过程阶段,
标记阶段
就是从一组根元素
开始,递归
遍历这组根元素,在这个遍历过程中,能到达
的元素称为活动对象
,没有到达
的元素就可以判断为垃圾数据
-
接下来就是垃圾的清除过程, 它和副垃圾回收器的垃圾清除过程完全不同,主垃圾回收器会直接将
标记为垃圾的数据清理掉
。
对垃圾数据进行标记
,然后清除
,这就是标记 - 清除
算法,不过对一块内存多次执行标记 - 清除
算法后,会产生大量不连续的内存碎片
。而碎片过多会导致大对象无法分配到足够的连续内存,于是又引入了另外一种算法——标记 - 整理
(Mark-Compact)。这个算法的标记过程
仍然与标记 - 清除
算法里的是一样的,先标记可回收对象
,但后续步骤不是直接对可回收对象进行清理
,而是让所有存活的对象都向一端移动
,然后直接清理掉这一端之外的内存
。
四、V8 是如何优化垃圾回收执行效率
V8
最开始的垃圾回收器有两个特点:
- 第一个是垃圾回收在主线程上执行
- 第二个特点是一次执行一个完整的垃圾回收流程
由于这两个原因,很容易造成主线程卡顿,所以 V8
采用了很多优化执行效率的方案:
-
第一个方案是
并行回收
: 在执行一个完整的垃圾回收过程中,垃圾回收器会使用多个辅助线程来并行执行垃圾回收。采用并行回收时
,垃圾回收所消耗的时间,等于总体辅助线程所消耗的时间(辅助线程数量乘以单个线程所消耗的时间),再加上一些同步开销的时间。这种方式比较简单,因为在执行垃圾标记的过程中,主线程
并不会同时执行JavaScript
代码,因此JavaScript
代码也不会改变回收的过程。 虽然并行策略
能增加垃圾回收的效率,能够很好地优化副垃圾回收器,但是这仍然是一种全停顿
的垃圾回收方式
,在主线程执行回收工作的时候才会开启辅助线程,这依然还会存在效率问题。 -
第二个方案是
增量式垃圾回收
: 所谓增量式垃圾回收
是指:垃圾回收器
将标记
工作分解为更小的块,并且穿插在主线程不同的任务之间执行。采用增量垃圾回收
时,垃圾回收器没有必要一次执行完整的垃圾回收过程
,每次执行的只是整个垃圾回收过程中的一小部分工作。增量标记的算法
,比全停顿
的算法要稍微复杂,这主要是因为增量回收
是并发
的(concurrent),要实现增量执行,需要满足两点要求:- 垃圾回收可以被随时暂停和重启,暂停时需要保存当时的扫描结果,等下一波垃圾回收来了之后,才能继续启动。
- 在暂停期间,被标记好的垃圾数据如果被 JavaScript 代码修改了,那么垃圾回收器需要能够正确地处理
-
第三个方案是
并发回收
: 所谓并发回收,是指回收线程在执行JavaScript
的过程,辅助线程
能够在后台
完成的执行垃圾回收的操作。并发回收
的优势非常明显,主线程
不会被挂起,JavaScript
可以自由地执行 ,在执行的同时,辅助线程
可以执行垃圾回收操作。但是并发回收
却是这三种技术中最难的一种,这主要由以下两个原因导致的:- 第一,当主线程执行
JavaScript
时,堆
中的内容随时都有可能发生变化
,从而使得辅助线程之前做的工作完全无效 - 第二,
主线程
和辅助线程
极有可能在同一时间去更改同一个对象,这就需要额外实现读写锁
的一些功能了
- 第一,当主线程执行
主垃圾回收器就综合采用了所有的方案,副垃圾回收器也采用了部分方案。
-
主垃圾回收器:
- a. 首先主垃圾回收器主要使用
并发标记
,我们可以看到,在主线程执行JavaScript
,辅助线程
就开始执行标记
操作了,所以说标记是在辅助线程中完成的 - b. 标记完成之后,再执行
并行清理
操作。主线程在执行清理操作时,多个辅助线程
也在执行清理操作。 - c. 另外,主垃圾回收器还采用了
增量标记
的方式,清理的任务会穿插在各种JavaScript
任务之间执行
- a. 首先主垃圾回收器主要使用
-
副垃圾回收器:
V8
的副垃圾回收器所采用的就是并行策略
,它在执行垃圾回收的过程中,启动了多个线程
来负责新生代中的垃圾清理操作,这些线程同时将对象空间中的数据移动到空闲区域。由于数据的地址发生了改变,所以还需要同步更新引用这些对象的指针。
五、V8 是如何实现垃圾回收器的暂停和恢复执行的
-
没有增量算法之前的垃圾回收:
V8
使用黑色
和白色
来标记数据。在执行一次完整的垃圾回收之前,垃圾回收器会将所有的数据
设置为白色
,用来表示这些数据还没有被标记
,然后垃圾回收器在会从GC Roots
出发,将所有能访问到的数据标记为黑色
。遍历结束之后,被标记为黑色
的数据就是活动数据
,那些白色
数据就是垃圾数据
。问题: 如果内存中的数据只有两种状态,非黑即白
,那么当你暂停
了当前的垃圾回收器之后,再次恢复
垃圾回收器,那么垃圾回收器就不知道从哪个位置
继续开始执行了。 -
有增量算法之后的垃圾回收:
V8
采用了三色标记法
,除了黑色和白色,还额外引入了灰色:黑色
表示这个节点被 GC Root 引用到了,而且该节点的子节点都已经标记完成了灰色
表示这个节点被 GC Root 引用到,但子节点还没被垃圾回收器标记处理,也表明目前正在处理这个节点白色
表示这个节点没有被访问到,如果在本轮遍历结束时还是白色,那么这块数据就会被收回。
引入
灰色标记
之后,垃圾回收器就可以依据当前内存中有没有灰色节点,来判断整个标记是否完成,如果没有灰色节点了,就可以进行清理工作了。如果还有灰色标记,当下次恢复垃圾回收器时,便从灰色的节点开始继续执行。但是,仅用
三色标记法
有一个问题: 当垃圾回收器将某个节点标记成了黑色,然后这个黑色的节点被续上了一个白色节点,那么垃圾回收器不会再次将这个白色节点标记为黑色节点了,因为它已经走过这个路径了,但是这个新的白色节点的确被引用了,所以我们还是需要想办法将其标记为黑色。为了解决这个问题,增量垃圾回收器
添加了一个约束条件
:不能让黑色节点指向白色节点,通常我们使用写屏障 (Write-barrier)
机制实现这个约束条件,也就是说,当发生了黑色的节点
引用了白色的节点
,写屏障机制
会强制
将被引用的白色节
点变成灰色
的,这样就保证了黑色节点不能指向白色节点的约束条件。这个方法也被称为强三色不变性
,它保证了垃圾回收器能够正确地回收数据,因为在标记结束时的所有白色对象,对于垃圾回收器来说,都是不可到达的,可以安全释放。