聊聊JS垃圾回收机制

1,495 阅读9分钟

一晃眼到了2020年下半年,之前开通的blog现在已经长草了。懊恼之余,还是痛定思痛,奋起直追,好好写写博客和专栏。

今天聊聊JavaScript的垃圾回收机制

首先明确一点,不同于C、C++这类需要人工管理内存的语言,JavaScript里的内存管理是自动进行的。正因如此,才有了今天我们介绍的GC(Garbage Collection,垃圾回收)算法。所谓垃圾回收机制,意思是把内存中不再有用的空间进行释放并再次利用起来。为了实现这个逻辑,在引擎内部势必存在一种判断内存空间是否“不再有用”的标准,这种不同的标准即不同的GC算法。在JavaScript实现中出现过多种GC算法,下面我们列举出最常见的几种。

1. 引用计数算法

核心思想:在内部设置一个引用计数器,当每个对象空间的引用关系发生变化时,修改计数器的数值。当某个对象空间的引用数为0时,
会立即触发垃圾回收机制,对这个对象空间进行回收。

引用计数算法出现在早期的实现中,现在已经很少被用到。但我们依然可以通过学习这种算法,了解它的思想。下面举个例子来说明:

	let obj = {name:'zhangsan'} // 对象zhangsan被obj引用了一次
    let obj2 = obj1 // 对象zhangsan被第二次引用,计数器值为2
    
    obj = null // 解除obj到对象zhangsan的引用,zhangsan的引用计数器值变为1
    obj2 = null // 此时对象zhangsan的引用变为0,堆内存中存储zhangsan的空间将被当成垃圾回收

引用计数算法优缺点说明

优点:

  1. 由于引用计数算法实时监控着内存空间的引用数,当引用数为0时,能立即回收该内存空间
  2. 引用计数算法能最大程度减少程序的暂停

缺点:

  1. 由于引用计数算法维护着一个计数器,它要时刻监控该数值是否需要修改,因此时间开销较大。
  2. 不能解决循环引用的问题

所谓的循环引用,可以用一个例子说明:

	let obj1={value:obj2}
    let obj2={value:obj1}
    // 这样就构成了一对循环引用。即使obj1和obj2所代表的对象空间在其他地方再没有被引用到,
    // 由于他们互相引用,这两个内存空间也不会被销毁。

2. 标记清除算法

核心思想:将垃圾回收过程分为 标记 和 清除 两个阶段。在标记阶段,从根对象(全局对象)出发遍历所有对象,将所有可达对象
做上标记。在清除阶段,同样会遍历所有对象,对没有标记的对象进行清除操作。

这段定义里有两个关键概念:根对象可达对象,下面依次给出解释:

  • 根对象:在JavaScript中可以认为是全局对象
  • 可达对象:从根对象出发,能够通过层层引用被访问到的对象。 好比全局对象上定义了一个变量obj1,其内部属性value指向了另一个对象obj2,在obj2中,又引用了obj3。在这条引用链上的所有对象,obj1,obj2,obj3都是可达对象,反之,如果某个对象不能通过根对象的引用找到,则为不可达对象,其内存空间将被当做垃圾回收。

标记清除算法优缺点说明

优点:

可以回收循环引用的对象

缺点:

  1. 容易产生碎片化空间,浪费空间
  2. 不会立即回收垃圾对象

3.标记整理算法

标记整理算法是一种运用于v8引擎中的GC算法,它可以看做是标记清除算法的升级版。

标记整理算法分为 标记 和 整理 两个阶段。其中的标记阶段和 标记清除 算法相同,都是将所有可达对象做上标记。 在整理阶段,将所有标记过的可达对象在内存空间上进行移动,使其占有连续的内存空间。

应用: V8中的垃圾回收策略

V8引擎是chrome浏览器内部和Node的JS执行引擎,其特性是运行高效,即时编译,并且内存设置了上限

  • 对64位操作系统上: 上限大约为1.5G
  • 对32位操作系统上: 上限大约为800M

V8引擎的垃圾回收采用了 分代回收 的回收策略

在V8内部,把内存空间分成了新生代老生代区域,针对不同代采用不同的GC算法

semispace

其中新生代区域空间较小,在64位操作系统上为32M,在32位操作系统上为16M,其中保存着存活时间较短的对象

v8将新生代空间均分为两个等大空间,使用中的空间称为from,空闲的空间称为to。当from空间使用达到一定上限,就会触发垃圾回收机制。V8新生代的垃圾回收采用的是Scavenge策略。

新生代对象回收实现

  1. 标记阶段。将from空间中的活跃对象(使用中对象)进行标记,识别出等待回收的对象 step1

  2. 排序整理阶段。

    • 将from空间中的活跃对象拷贝到to空间中 step2.1
    • 将from空间完全释放 step2.2
  3. 交换阶段。 将from空间和to空间进行互换,完成本次垃圾回收操作。 step3

与新生代区域不同,老生代区域存放的是活动时间较长的对象,比如全局对象、闭包等。

与新生代区域一样,老生代内存区同样也存在大小限制,和进行垃圾回收的特有策略

在64位操作系统中,老生代内存的存储上限是1.4G

在32位操作系统中,老生代内存代存储上限是700M

新生代对象向老生代晋升

当新生代中一个对象多次出现在to空间里,或者当to空间内存超过25%时,该对象会被移入老生代空间。这种操作被成为晋升

默认情况下,程序生成的对象会首先被放置在from空间中。当进行垃圾回收,将对象们从from移至to时,会通过检查该对象的内存地址来判断该对象是否已经经历过一次Scavenge运算,如果是,则将该对象移至老生代空间。接下来,判断to空间是否超过25%,如果是,则仍将该对象移至老生代空间。

晋升

老生代对象回收实现

新生代对象中之所以能使用Scavenge算法这种利用空间来交换时间的算法,是因为新生代对象存储空间本身比较小,即使被一分为二,损失的存储空间也很有限。相比新生代的这种策略,老生代对象的存储空间更大,使用分代回收的话将会损失很大的存储空间,得不偿失。另外,由于老生代内存存储了大量对象,用这种算法复制对象,会使效率大幅降低。

因此,老生代内存的垃圾回收,采用了与新生代不同的策略。具体来说,主要有标记清除(mark-sweep)标记整理(mark-compact)两种算法。没错,就是文章前面提到的两种基本算法。 在此,我们用图示进一步说明这两种算法:

  • 标记清除算法在老生代空间内的应用:

    1. 开始阶段,其中ABCDEF为已使用内存 sweep1

    2. 标记阶段,其中ACE为活跃对象,其余为待清除对象 sweep2

    3. 清除阶段,将待清除对象的空间释放 sweep3

    由以上图示不难发现,标记清除算法在每次清除过后,内存会出现不连续的状态。后续继续在老生代空间里分配内存时,如果需要分配一个大内存,由于剩余的碎片空间不足以完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。

    因此,在标记清除算法的基础上,又发展出标记整理算法。标记整理算法在标记阶段和清除算法是一样的。不同的是,在标记完活跃对象后,会将活跃对象移动到堆内存的另一端,然后将边界外的内存全部清除。

  • 标记整理算法在老生代空间内的应用:

    1. 标记阶段,和标记清除算法一致。 compact1
    2. 整理阶段,将活跃对象拷贝直堆内存的另一端 compact2
    3. 清除阶段,将拷贝对象边界外的内存清空 compact3
  • 标记清除算法和标记整理算法的结合使用

    在取舍上,由于标记整理算法需要移动对象,所以速度不会很快;因此,老生代算法主要采用标记清除,当新生代晋升过来的对象大小大于老生代可用空间时,才启动标记整理算法。

总结

以上就是关于JS垃圾回收机制的内容。总的来说,JavaScript中存在多种GC算法,在V8引擎中,主要利用了标记清除、标记整理和scavenge算法,分别对新生代和老生代内存进行垃圾回收。

  • 其中新生代采用scavenge算法,利用空间的损失换取时间上的优势。

  • 老生代混合采用标记清除和标记整理算法,对保存至老生代空间的对象进行回收。

  • 新生代空间内的对象满足一定条件,可用晋升至老生代空间。

  • 另外在老版本的js引擎中,还使用过引用计数算法,目前已经不再使用。

写作时参考到的文章,感谢你们的帮助:

一文搞懂V8引擎的垃圾回收 by 小维FE

聊聊V8引擎的垃圾回收 by leocoder