V8垃圾回收

118 阅读7分钟

两种简单算法

引用法

引用数为0就被回收; 不为0就不回收。

  • 缺点:两个循环引用的对象都没用了也不会被回收(因为他们的引用计数不为0),从而造成内存泄漏。

标记法

对可达的对象做标记; 不可达的对象被回收。

  • 可达性:从根对象(浏览器中的window/node中的global)开始往子节点搜寻,能找到的就是可达的,找不到的就是不可达的。

JS的内存管理

分为三步:

  1. 分配内存(创建变量时会自动分配内存);
  2. 使用内存(读&写);
  3. 不需要内存时,释放内存。

栈 & 堆

JS数据类型有两种:

  • 基础数据类型:
    1. 大小固定;
    2. 值存在栈中;
    3. 访问速度快。
  • 引用数据类型:
    1. 大小不固定(eg. 对象可以加属性,数组可以改变长度);
    2. 栈中存的是数据在堆中的地址(可以理解为指针,实际数据存在堆中);
    3. 访问速度慢。

因为栈中存的都是基础数据类型,大小固定,所以栈内存由操作系统自动分配、回收; 堆中存的数据大小不固定,所以需要JS引擎来负责释放内存。(这部分就需要垃圾回收算法来完成)

如果没有及时对内存进行释放(内存泄露),就会使浏览器占用的内存不断增加,进而导致应用的性能、响应能力下降。

v8 的垃圾回收机制

1、分代回收:新生代 & 老生代

  • v8将堆空间分为新生代 & 老生代;
  • 新生代中存放存活周期短的对象;
  • 老生代中存放存活周期长的对象。
  • 新生代容量:1-8M;
  • 老生代的容量大很多。
  • 新生代的垃圾回收:副垃圾回收器 + Scavenge算法
  • 老生代的垃圾回收:主垃圾回收器 + Mark-Sweep算法(标记清理)Mark-Compact算法(标记整理)

新生代

  • 新生代分为两个区域:nursery子代 & intermediate子代,任何对象的声明,优先分配新生代中nursery子代的内存,如果经过一次垃圾回收后对象还在,就会被移动到新生代中intermediate子代中,如果又经过一次垃圾回收后还在,副垃圾回收器会将其移动到老生代中。(此过程称为晋升

Scavenge算法

将新生代空间分为两个部分:from-space & to-space;工作过程如下:

  1. 标记活动对象 & 非活动对象(可达即为活动对象;不可达就是非活动对象);
  2. from-space中的活动对象复制到to-space中,并在内存中有序排列;
  3. 清除from-space中的非活动对象;
  4. 互换from-space & to-space的角色。

此算法是用空间换时间,因为在新生代中活动对象是占少数的,所以适合用这种方法。

老生代

不适用 Scavenge算法,因为这里都是存活周期较长的对象,反复复制这些存活率高的对象效率极低。

Mark-Sweep算法(标记清理)

不同于 Scavenge算法Scavenge算法需要先标记,再复制,再清理; Mark-Sweep算法(标记清理)先标记,再清理(不需要复制);

  1. 标记阶段:对老生代对象进行第一次扫描,标记活动对象;
  2. 清理阶段:对老生代对象进行第二次扫描,清除非活动对象(未标记的对象)。
  • 问题:清理后内存不连续,会出现零零散散的空位,如果有新的大对象需要分配内存,会从前面的空位往后找,一直到找到能够放下自己的位置,非常耗时,所以需要接下来的 Mark-Compact算法(标记整理)

Mark-Compact算法(标记整理)

Mark-Sweep算法(标记清理)基础上,加了整理阶段:在清理完非活动对象后,会将全部的活动对象整理到内存的一侧,整理完成直接回收边界上的内存。

2、全停顿(Stop-The-World)

代码执行和垃圾回收都需要JS引擎,垃圾回收优先于代码执行,所以要等垃圾回收完毕再执行JS代码,这个过程称为全停顿

对于新生代来说,存活对象少,停顿时间较短; 而老生代活动对象较多,停顿时间较长,页面容易出现卡顿现象

3、Orinoco优化

Orinoco 是V8垃圾回收器的项目代号,为了解决全停顿问题,提出了增量标记、惰性清理、并发和并行的优化方法。

增量标记(Incremental marking)

在标记阶段进行优化,当垃圾少量时不进行优化,当垃圾达到一定数量时,就会开启增量标记;

简单来说就是:JS引擎执行标记一会儿,再执行JS代码一会儿,从而提高效率

惰性清理(Lazy sweeping)

在清除阶段的优化,在增量标记后,如果发现就算不清理垃圾,剩余的空间也足够让JS代码执行,就会延迟清理,先执行JS代码,或者只清理部分垃圾,就执行JS代码。

  • 问题:增量标记是标记一部分,再运行一会儿JS代码(这就有可能造成,前面标记的活动对象,被后面JS代码设置为非活动对象;或者,前面没有标记为活动的对象,在后面运行的JS代码中将其置为活动对象),这样有可能造成对象引用改变,标记错误的现象,这就需要写屏障技术来记录引用关系的变化。

并发(Concurrent)

并发式GC(garbage collection)允许进行垃圾回收时,不将主线程挂起,两者可以同时进行,只有个别时候需要挂起主线程,让垃圾回收器进行特殊操作

  • 问题:依旧面临前面的对象引用改变,标记错误的问题,需要写屏障技术来记录引用关系的变化。

并行

允许主线陈和辅助线程同时执行GC的工作(辅助线程可以分担主线程的GC工作),使得垃圾回收消耗的时间等于总时间除以线程数量(再加上一些同步开销)

v8 当前的垃圾回收机制

副垃圾回收器

新生代使用并行机制,在将活动对象从from-space复制到 to-space 时,启用多个辅助线程,进行并行整理。

  • 问题:多个线程竞争新生代中的内存资源,有可能出现某个活动对象被多个线程进行复制的问题;
  • 解决:在第一个线程对活动对象进行复制并且复制完成后,要维护这个活动对象复制后的转发地址,便于其他辅助线程找到该活动对象后判断该对象是否已经被复制。

主垃圾回收器

老生代垃圾回收,在堆中使用的内存大小超过某个阈值后,会启用并发机制来标记任务,每个辅助线程都会去追踪每个标记到的对象的指针以及对这个对象的引用,而在JavaScript代码执行时候,并发标记也在后台的辅助进程中进行,当堆中的某个对象指针被JavaScript代码修改的时候,写入屏障(write barriers)技术会在辅助线程在进行并发标记的时候进行追踪。

当并发标记完成或者动态分配的内存到达极限的时候,主线程会执行最终的快速标记步骤,这个时候主线程会挂起,主线程会再一次的扫描根集以确保所有的对象都完成了标记,由于辅助线程已经标记过活动对象,主线程的本次扫描只是进行check操作,确认完成之后,某些辅助线程会进行清理内存操作,某些辅助进程会进行内存整理操作,由于都是并发的,并不会影响主线程JavaScript代码的执行。

参考文章

13张图!20分钟!认识V8垃圾回收机制