V8垃圾回收机制

前言

在类似C/C++等语言的开发中,程序员不可避免需要跟踪内存的使用情况以此来降低内存占用,提高程序的性能,而对于JavaScript,JavaScript具有自动垃圾收集机制,这就意味着程序员不用关心内存使用问题,垃圾收集器会按照固定的时间间隔,找出不再继续使用的内存,自动将其释放。
本文就一同来探索垃圾回收的原理。

垃圾收集两种策略

标记清除

当变量进入环境(例如,在函数中声明一个变量)时,就将这个变量标记为“进入环境”。从逻辑上讲,永远不能释放进入环境的变量所占用的内存,因为只要执行流进入相应的环境,就可能会用到它们。而当变量离开环境时,则将其标记为“离开环境”。
可以使用任何方式来标记变量。比如,可以通过翻转某个特殊的位来记录一个变量何时进入环境,或者使用一个“进入环境的”变量列表及一个“离开环境的”变量列表来跟踪哪个变量发生了变化。如何标记变量并不重要,关键在于采取什么策略。

运行机制:

  • 垃圾收集器在运行时给存储在内存中的所有变量都加上标记。
  • 去掉环境中的变量以及被环境中的变量引用的变量的标记。
  • 被加上标记的变量被视为准备删除的变量。
  • 垃圾收集器完成内存清楚,销毁带标记的值并回收所占用的内存空间。

目前,IE、Firefox、Opera、Chrome和Safari的JavaScript实现使用的都是标记清除式的垃圾回收策略(或类似的策略),只不过垃圾收集的时间间隔互有不同。

引用计数

引用计数含义是跟踪记录每个值被引用的次数。当声明了一个变量并将一个引用类型值赋给该变量时,则这个值的引用次数就是1。如果同一个值又被赋给另一个变量,则该值的引用次数加1。相反,如果包含对这个值引用的变量改变了引用对象,则该值引用次数减1。当这个值的引用次数变成0时,则说明没有办法再访问这个值了,因而就可以将其占用的内存空间回收回来。这样,当垃圾收集器下次再运行时,它就会释放那些引用次数为0的值所占用的内存。

运行机制:

  • 将引用类型复制给变量,引用次数+1。
  • 包含该引用的变量获取了其他值,引用次数-1。
  • 引用次数为0的变量为需要回收的值。
  • 垃圾收集器释放引用次数为0的值所占用内存。

循环引用

引用计数遇到的最大问题,就是循环引用。循环引用指的是对象A中包含一个指向对象B的指针,而对象B中也包含一个指向对象A的引用。这个现象在使用BOM和DOM对象时尤其明显。

1
2
3
4
var element = document.getElementById("some_element");
element.onlick = function(e){
console.log(e.target);
};

在element对象的onclick属性上指向了一个函数,而在函数传入的变量对象上,可以访问到element的信息,这里就产生了循环引用,element将永远不会被收回。如想要使其被回收则需要作如下操作。

1
element.onclick = null;

弱引用

在上个小节里,我们提到了BOM和DOM对象大多存在循环引用的问题,这里就顺便补充下关于ES6的知识。
我们知道在ES6里新提出了两种数据集合类型: SetMap 。他们类似于数组,但他们的成员的值一定是唯一的,没有重复值。而为了针对浏览器的垃圾回收机制,这两个数据结构引入了 弱引用 概念又扩展出了 WeakSetWeakMap

弱引用指的是垃圾回收机制不考虑WeakSet和WeakMap对该对象的引用,也就是说,如果其他对象都不再引用该对象,那么垃圾回收机制会自动回收该对象所占用的内存,不考虑该对象还存在于WeakSet或WeakMap之中。WeakSet和WeakMap里面的引用,都不计入垃圾回收机制。

  • WeakSet不能遍历,是因为成员都是弱引用,随时可能消失,遍历机制无法保证成员的存在,很可能刚刚遍历结束,成员就取不到了。WeakSet的一个用处,是储存DOM节点,而不用担心这些节点从文档移除时,会引发内存泄漏。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    const foos = new WeakSet() 
    class Foo {
    constructor() {
    foos.add(this)
    }
    method () {
    if (!foos.has(this)) {
    throw new TypeError('Foo.prototype.method 只能在Foo的实例上调 用!');
    }
    }
    }
    //保证了Foo的实例方法,只能在Foo的实例上调用。
  • 在网页的DOM元素上添加数据,就可以使用WeakMap结构。当该DOM元素被清除,其所对应的WeakMap记录就会自动被移除。

    1
    2
    3
    4
    5
    6
    const e1 = document.getElementById('foo'); 
    const e2 = document.getElementById('bar');
    const arr = [
    [e1, 'foo 元素'],
    [e2, 'bar 元素'],
    ];

V8垃圾回收策略

V8采用了一种代回收的策略,将内存分为两个生代:新生代(new generation)老生代(old generation)
新生代中的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象,分别对新老生代采用不同的垃圾回收算法来提高效率,对象最开始都会先被分配到新生代(如果新生代内存空间不够,直接分配到老生代),新生代中的对象会在满足某些条件后,被移动到老生代,这个过程也叫晋升。

分代内存

默认情况下,32位系统新生代内存大小为16MB,老生代内存大小为700MB,64位系统下,新生代内存大小为32MB,老生代内存大小为1.4GB。

新生代平均分成两块相等的内存空间,叫做semispace,每块内存大小8MB(32位)或16MB(64位)。

新生代

介绍

新生代存的都是生存周期短的对象,分配内存也很容易,只保存一个指向内存空间的指针,根据分配对象的大小递增指针就可以了,当存储空间快要满时,就进行一次垃圾回收。

Scavenge算法

新生代采用Scavenge垃圾回收算法,在算法实现时主要采用Cheney算法。

Cheney算法将内存一分为二,叫做semispace,一块处于使用状态,一块处于闲置状态。
处于使用状态的semispace称为From空间,处于闲置状态的semispace称为To空间。

  • 在From空间中分配了3个对象A、B、C。
    Alt text
  • GC进来判断对象B没有其他引用,可以回收,对象A和C依然为活跃对象。
    Alt text
  • 将活跃对象A、C从From空间复制到To空间。
    Alt text
  • 清空From空间的全部内存。
    Alt text
  • 交换From空间和To空间。
    Alt text
  • 在From空间中又新增了2个对象D、E。
    Alt text
  • 下一轮GC进来发现对象D没有引用了,做标记。
    Alt text
  • 将活跃对象A、C、E从From空间复制到To空间。
    Alt text
  • 清空From空间全部内存。
    Alt text
  • 继续交换From空间和To空间,开始下一轮。
    Alt text

通过上面的流程图,我们可以很清楚的看到,进行From和To交换,就是为了让活跃对象始终保持在一块semispace中,另一块semispace始终保持空闲的状态。
Scavenge由于只复制存活的对象,并且对于生命周期短的场景存活对象只占少部分,所以它在时间效率上有优异的体现。Scavenge的缺点是只能使用堆内存的一半,这是由划分空间和复制机制所决定的。
由于Scavenge是典型的牺牲空间换取时间的算法,所以无法大规模的应用到所有的垃圾回收中。但我们可以看到,Scavenge非常适合应用在新生代中,因为新生代中对象的生命周期较短,恰恰适合这个算法。

晋升

当一个对象经过多次复制仍然存活时,它就会被认为是生命周期较长的对象。这种较长生命周期的对象随后会被移动到老生代中,采用新的算法进行管理。
对象从新生代移动到老生代的过程叫作晋升。

对象晋升的条件主要有两个:

  • 对象从From空间复制到To空间时,会检查它的内存地址来判断这个对象是否已经经历过一次Scavenge回收。如果已经经历过了,会将该对象从From空间移动到老生代空间中,如果没有,则复制到To空间。总结来说,如果一个对象是第二次经历从From空间复制到To空间,那么这个对象会被移动到老生代中。
  • 当要从From空间复制一个对象到To空间时,如果To空间已经使用了超过25%,则这个对象直接晋升到老生代中。设置25%这个阈值的原因是当这次Scavenge回收完成后,这个To空间会变为From空间,接下来的内存分配将在这个空间中进行。如果占比过高,会影响后续的内存分配。

老生代

介绍

在老生代中,存活对象占较大比重,如果继续采用Scavenge算法进行管理,就会存在两个问题:

  • 由于存活对象较多,复制存活对象的效率会很低。
  • 采用Scavenge算法会浪费一半内存,由于老生代所占堆内存远大于新生代,所以浪费会很严重。

所以,V8在老生代中主要采用了Mark-Sweep和Mark-Compact相结合的方式进行垃圾回收。

Mark-Sweep

Mark-Sweep是标记清除的意思,它分为标记和清除两个阶段。
与Scavenge不同,Mark-Sweep并不会将内存分为两份,所以不存在浪费一半空间的行为。Mark-Sweep在标记阶段遍历堆内存中的所有对象,并标记活着的对象,在随后的清除阶段,只清除没有被标记的对象。
也就是说,Scavenge只复制活着的对象,而Mark-Sweep只清除死了的对象。活对象在新生代中只占较少部分,死对象在老生代中只占较少部分,这就是两种回收方式都能高效处理的原因。

  • 老生代中有对象A、B、C、D、E、F
    Alt text
  • GC进入标记阶段,将A、C、E标记为存活对象
    Alt text
  • GC进入清除阶段,回收掉死亡的B、D、F对象所占用的内存空间
    Alt text

可以看到,Mark-Sweep最大的问题就是,在进行一次清除回收以后,内存空间会出现不连续的状态。这种内存碎片会对后续的内存分配造成问题。
如果出现需要分配一个大内存的情况,由于剩余的碎片空间不足以完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。

Mark-Compact

为了解决Mark-Sweep的内存碎片问题,Mark-Compact就被提出来了。
Mark-Compact是标记整理的意思,是在Mark-Sweep的基础上演变而来的。Mark-Compact在标记完存活对象以后,会将活着的对象向内存空间的一端移动,移动完成后,直接清理掉边界外的所有内存。

  • 老生代中有对象A、B、C、D、E、F(和Mark—Sweep一样)
    Alt text
  • GC进入标记阶段,将A、C、E标记为存活对象(和Mark—Sweep一样)
    Alt text
  • GC进入整理阶段,将所有存活对象向内存空间的一侧移动,灰色部分为移动后空出来的空间
    Alt text
  • GC进入清除阶段,将边界另一侧的内存一次性全部回收
    Alt text

两者结合

在V8的回收策略中,Mark-Sweep和Mark-Conpact两者是结合使用的。
由于Mark-Conpact需要移动对象,所以它的执行速度不可能很快,在取舍上,V8主要使用Mark-Sweep,在空间不足以对从新生代中晋升过来的对象进行分配时,才使用Mark-Compact。

参考链接:聊聊V8引擎的垃圾回收(leocoder)