V8 垃圾收集

267 阅读4分钟

1. 为什么需要垃圾回收

​ 我们知道JS目前的数据分为 7种基本数据类型 (Null、Undefined、Boolean、Number、String、Stymol、BigInt)和 引用数据类型 ,以及V8有两个存储数据的区域——栈空间和堆空间。

​ 栈空间一旦被分配了就无法改变大小,它用来存储 执行上下文 ,而JS的基本数据类型就存储在执行上下文的 变量/词法环境 中。引用数据类型的大小是可变的,同时引用类型可能很大不利于栈的维护,因此 变量/词法环境 只保留引用数据类型的引用,其值被存储在可变的堆中。

数据在栈和堆中的分布图

​ 数据是有生命周期的,在某个数据的生命周期结束后就应该被销毁掉,否则会一直占用内存空间,这些数据被称为垃圾数据,所以我们需要对这些垃圾数据进行回收,以释放有限的内存空间。

​ 针对上面这张图,假设以下代码:

function print() {
  var c = 'World';
  var b = 'Hello';
  var a = { name: 'Rskmin' };
}
print();
// print finished, todo others;

​ 在 print 函数执行完毕后,其所分配的资源就应该被回收掉。

​ 对于调用栈资源,我们只需要将栈顶指针下移即可,指针上方的空间都属于未分配空间,下次调用其他函数可以直接覆盖print所占用的栈空间。

​ 对于堆资源,我们无法实现和栈一样的回收方法,因此就需要针对堆实现特定的 垃圾回收 ,也就是我们所要关注的垃圾回收机制。

2. V8是如何回收堆内存的

​ 垃圾回收的算法有很多,针对不同的情况使用不同的回收策略才能达到最佳的效果。因此要先对内存中的垃圾进行分类,干、湿垃圾分开处理。

​ 如何分类?V8的垃圾分类是建立在 代际假说 这个理论上的。

代际假说

  • 大部分对象在内存中存在的时间很短,简单来说,就是很多对象一经分配内存,很快就变得不可访问,例如上面函数中的a,函数执行完成后a变量就失去作用了。

  • 不死的对象,在内存总存在的时间长,例如挂在window下的变量。

    根据代际假说,堆内存中的数据大致被分为 新生代老生代 ,新生区通常只支持 1~8M 的容量,而老生区的容量就大很多了。对于这两块区域,V8 分别使用两个不同的垃圾回收器,以便更高效地实施垃圾回收。

引用计数

  • 语言引擎有一张引用表,保存了内存里面所有的资源的引用次数
  • 如果一个值的引用次数是0,就可以释放这块内存

新生代垃圾回收

  • 新生代对半分为两个区域,一个使用一个空闲

垃圾收集

  • 开始垃圾回收的时候,会检查(检查引用表) FROM 区域中的存活对象,如果还存活,拷贝到 TO 区域,完成后释放 FROM 区域

垃圾收集_1

  • 然后释放 From 区域内存,再互换 FromTo 区域(改个名)

垃圾收集_2

  • 当一个对象经历过多次的垃圾回收依然存活的时候,生命周期比较长的对象会被移动到老生代,这个移动过程称为晋升或升级
    • 经过5次以上的回收还存在
    • TO 的空间使用占比超过25%,或者超大对象

老生代垃圾回收

  • 老生代使用 mark-sweep(标记清除)mark-compact(标记整理) 两种方案,大多数时候使用标记清除,空间不足以存放对象时使用标记整理
  • 老生代空间大,GC耗时较长
  • 在GC期间无法响应, stop-the-world
  • V8优化方案:增量处理(increment-GC),把大的暂停时间分割成小的暂停时间

收集流程

  • mark-sweep(标记清除 快 )[会造成内存碎片]
    • 标记存活的对象
    • 标记完成后清理未标记的对象

垃圾收集_3

垃圾收集_4

  • mark-compact(标记整理 慢 )[解决内存碎片问题]
    • 标记存活的对象
    • 标记完成后将活着的对象移动到内存的一侧,移动完成后清除另一侧的内存

垃圾收集_5

垃圾收集_6

​ 由于 标记整理法 会移动存储位置因此性能上更差一点,正常情况下使用 标记回收法 回收,当内存大小不足的时候会使用 标记整理法 整理内存碎片

​ 由于老生代区域很大每次收集消耗的时间很长,在这段时间内(stop-the-world)浏览器无法响应,V8还对清除做了优化

  • 增量标记法 将一次回收的过程分解成多次,分解无法响应的时间
  • 并行回收 启动多个辅助线程帮助回收
回收算法Mark-SweepMark-CompactScavenge
速度中等最慢最快
空间开销双倍空间
是否移动对象