V8的垃圾回收机制

现在开始实施垃圾分类,每个垃圾都应该扔到正确的回收桶。JS 中的不同的数据类型,也有不一样的回收机制。

首先,我们需要明确在 JS 中,有两种数据类型,基本数据类型和引用数据类型,两种类型在内存中的存储方式也是不一样的。基本数据类型是存储在栈内存中的,引用数据类型是存储在堆内存中的。所以针对于这两种数据有不同的回收机制。

什么叫垃圾数据

有些数据被使用之后,就不再被需要了,我们把这种数据称之为垃圾数据。
如果这些数据一直保存在内存之中,内存会越来越多,直到内存爆满。所以时常需要对垃圾数据进行回收,释放内存。

不同的回收策略

一般的情况下,垃圾回收策略分为手动回收自动回收

在 C/C++中就是采用的手动回收,当你使用这个数据使用 malloc 函数去手动分配,在用完之后,还要时刻记得用 free 函数去清理释放。
但是在 JavaScript/Java/Python 等语言中,产生的垃圾数据都是由垃圾回收器来释放的。

栈内存中的数据回收

接下来的解释中,将通过下列代码进行分析。

function foo() {
var age = 21;
var info = {
name: "FBB",
};
function showName() {
var sex = "famale";
var info1 = {
name: "LuckyFBB",
};
}
showName();
}
foo();

首先我们先分析一下,代码执行到 var info1 这句声明时的栈内存和堆内存。

普通二叉树

从图中也体现出了,在上文提到的,基本数据类型都是存储在栈内存中,引用数据类型存储在堆内存中。

上诉代码执行到 showNanme 函数的时候,JS 引擎会创建 showName 函数的执行上下文,并将 showName 函数的执行上下文压入调用栈中,当执行到 showName 函数时,会有一个记录当前状态的指针(称为 ESP),指向调用栈的 showName 函数的执行上下文,表示当前正在执行 showName 函数。

当 shouName 函数执行完成之后,函数流程进入了 foo 函数,这时候就需要销毁 showName 的函数执行上下文,这时候 ESP 就会下移到 foo 的函数执行上下文,这个下移的操作就叫做销毁 showName 函数执行上下文的过程。

普通二叉树

ESP 已经下移到 foo 函数的执行上下文中,虽然 showName 的执行上下文还是保存在栈内存中,但是已经是无效内存了,没有办法再次访问了。

当一个函数执行结束之后,JavaScript 引擎会通过下移 ESP 来销毁保存在栈内存的执行上下文。

堆内存中的数据回收

如下图,当 foo 函数执行完毕之后,ESP 指向了全局执行上下文,foo 和 showName 的函数执行上下文就处于无效状态,不过存储在堆内存的两个对象仍然占据着空间。要回收堆中的垃圾数据,就需要用到JavaScript 的垃圾回收器

普通二叉树

代际假说和分代收集

代际假说有下面两个特点:

  • 大部分对象在内存中的时间很短,很多对象一旦分配内存就变得不可访问
  • 不死的对象,会活的更久

在 V8 中,会把堆分为新生代老生代两个区域,新生代中存放生存时间短的对象,老生代中存放生存时间久的对象。

新生代区通常只支持 1~8M 的容量,但是老生代支持的容量就打很多。对于这两块不同的区域,V8 就使用了两个不同的垃圾回收器,以便高效的回收垃圾数据。

  • 副垃圾回收器,主要是负责新生代的垃圾回收
  • 主垃圾回收器,主要是负责老生代的垃圾回收

垃圾回收器的工作流程

在上文中,我们提到 V8 会把堆分成两个区域(新生代和老生代),虽然他们两个使用的不痛的垃圾回收器,但是不管是什么类型的垃圾回收器,它们都有一套共同的执行流程。

  1. 标记空间中的活动与非活动对象。活动对象是还在使用的对象,非活动对象就是可以被回收的对象。
  2. 回收非活动对象所占据的内存。其实是在所有标记完成之后,统一清理内存中的非活动对象。
  3. 内存整理。频繁的回收对象之后,内存中会有大量的不连续空间,一般把这些不连续的内存空间成为内存碎片。如果当内存中出现了大量的内存碎片,当需要较大连续的内存时,就会出现内存不足的现象,所以需要整理内存。(这一步并不是必选,副垃圾回收器就不需要)

副垃圾回收器

上文提到副垃圾回收器主要是负责新生区的垃圾回收,小内存的对象会被分配到新生区,虽然该区域不大,但是垃圾回收比较频繁。

新生代中使用Scavenge算法来处理。所谓的 Scavenge 算法,就是把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域。

普通二叉树

新写入的对象都会放到对象区域,当对象区域被写满时就会进行一次垃圾清理。在垃圾清理的时候,会按照我们上文介绍的三个步骤。首先会对对象区域中的垃圾做标记;标记完成之后,就会进行垃圾清理,副垃圾回收器会把这些存活的对象放到空闲区域中,在这个复制过程中,它还会把这些对象有序排列在一起,等同于一并完成内存整理的操作,之后的空闲区域不存在内存碎片。

完成复制之后,对象区域和空闲区域就进行了角色互换,原本的空闲区域变成了对象区域,对象区域变成了空闲区域。经过这一系列操作之后,就完成了垃圾对象的回收清理,同时两个区域互换的操作让新生代无限重复使用下去

在上文中,我们提到新生代的空间都比较小。从它的垃圾清理操作中可以看出来,因为会有复制操作,需要时间成本,如果空间设置得太大,那么每次清理都会花大量的时间,所以,为了执行效率,新生代得空间都设置得比较小。

因为新生代的内存不大,所以存活的对象很容易装满整个区域。为了解决这个问题,JavaScript 引擎采用了对象晋升策略,经过两次垃圾清理依旧存活的对象,就会被移到老生区。

主垃圾回收器

主垃圾回收器主要是负责老生代中的垃圾回收。除了上文提到的晋升对象,一些内存大的对象会直接被分配到老生代。所以在老生代中存储的对象具有两个特点:

  • 对象占用空间大
  • 对象存活时间长

因为老生代的对象都比较大,所以不能够使用 Scavenge 算法,花费的时间比较多,会导致效率不高。在老生代中主要采取Mark-Sweep(标记-清除)的算法进行垃圾清理。

Mark-Sweep 就是标记清除的意思,它被分为标记和清除两个阶段。
第一步是标记阶段。标记阶段是从一组元素开始,递归遍历这组根元素,在这个阶段能够到达的元素标记为活动对象,没有达到的元素可以断定为垃圾数据。
第二部是清除阶段。清除没有被标记为活动对象。

Mark-Sweep 最大的问题就是清楚完垃圾数据之后,就是会产生大量的内存碎片。上文也提到过如果内存碎片过多,会导致大对象无法分配内存。

由于上段提到的问题,又产生了新的算法Mark-Compact(标记-整理),它是在 Mark-Sweep 的基础上演变而来的。Mark-Compact 在标记完存活对象以后,会将活着的对象向内存空间的一端移动,移动完成后,直接清理掉边界外的所有内存。

总结

在本文中,介绍了 JavaScript 的垃圾回收,将它分为了栈和堆来分别分析。

对于栈内存中的数据,JavaScript 引擎会采用下移 ESP 指针来销毁保存在栈中的内存。

对于堆内存来说,我们主要采用垃圾回收器,其中又分为副垃圾回收器和主垃圾回收器。

新生代主要使用副垃圾回收器,采用 Scavenge 算法;老生代主要使用主垃圾回收器,采用 Mark-Sweep 和 Mark-Compact 算法。

以上就是本文的全部内容,感谢阅读。

0%