浅谈JS引擎内存管理及垃圾回收机制

本文目录

  • 1.JS引擎的内存
  • 2.新生代和老生代的内存回收
  • 3.可达性
  • 4.两个引用的情况
  • 5.相互关联的对象
  • 6.无法访问的数据块
  • 7.内部算法
  • 8.总结

1.JS引擎的内存

JavaScript引擎的内存空间主要分为栈和堆。
栈是临时存储空间,主要存储局部变量和函数调用。
基本类型数据(Number, Boolean, String, Null, Undefined, Symbol, BigInt)保存在在栈内存中。
引用类型数据保存在堆内存中,引用数据类型的变量是一个指向堆内存中实际对象的引用,存在栈中。
栈虽然很轻量,在使用时创建,使用结束后销毁,但是不是可以无限增长的,被分配的调用栈空间被占满时,就会引起”栈溢出“的错误。
为什么基本数据类型存储在栈中,引用数据类型存储在堆中?
JavaScript引擎需要用栈来维护程序执行期间的上下文的状态,如果栈空间大了的话,所有数据都存放在栈空间里面,会影响到上下文切换的效率,进而影响整个程序的执行效率。

堆空间存储的数据比较复杂,大致可以划分为下面 5 个区域:代码区(Code Space)、Map 区(Map Space)、大对象区(Large Object Space)、新生代(New Space)、老生代(Old Space)。本篇文章主要讨论新生代和老生代的内存回收算法。
新生代内存是临时分配的内存,存活时间段,老生代内存是常驻内存,存活时间长。

2.新生代和老生代的内存回收

新生代中用 Scavenge 算法来处理。所谓 Scavenge 算法,是把新生代空间对半划分为两个区域,一半是对象区域(from),一半是空闲区域 (to)。
新的对象会首先被分配到 from 空间,当进行垃圾回收的时候,会先将 from 空间中的 存活的对象复制到 to 空间进行保存,对未存活的对象的空间进行回收。
复制完成后, from 空间和 to 空间进行调换,to 空间会变成新的 from 空间,原来的 from 空间则变成 to 空间。这种算法称之为 ”Scavenge“。
新生代内存回收频率很高,速度也很快,但是空间利用率很低,因为有一半的内存空间处于"闲置"状态。

新生代中多次进行回收仍然存活的对象会被转移到空间较大的老生代内存中,这种现象称为晋升。
因为老生代空间较大,如果仍然用 Scavenge 算法来频繁复制对象,那么性能开销就太大了。
老生代采用的是”标记清除“来回收未存活的对象。
分为标记和清除两个阶段。标记阶段会遍历堆中所有的对象,并对存活的对象进行标记,清除阶段则是对未标记的对象进行清除。
标记清除不会对内存一分为二,所以不会浪费空间。但是经过标记清除之后的内存空间会生产很多不连续的碎片空间,这种不连续的碎片空间中,在遇到较大的对象时可能会由于空间不足而导致无法存储。
为了解决内存碎片的问题,需要使用另外一种算法 - 标记-整理(Mark-Compact)。标记整理对待未存活对象不是立即回收,而是将存活对象移动到一边,然后直接清掉端边界以外的内存。
为了避免出现JavaScript应用程序与垃圾回收器看到的不一致的情况,进行垃圾回收的时候,都需要将正在运行的程序停下来,等待垃圾回收执行完成之后再回复程序的执行,这种现象称为“全停顿”。如果需要回收的数据过多,那么全停顿的时候就会比较长,会影响其他程序的正常执行。
为了避免垃圾回收时间过长影响其他程序的执行,V8将标记过程分成一个个小的子标记过程,同时让垃圾回收和JavaScript应用逻辑代码交替执行,直到标记阶段完成。我们称这个过程为增量标记算法。

3.可达性

JavaScript 中内存管理的主要概念是可达性。
简单地说,“可达性” 值就是那些以某种方式可访问或可用的值,它们被保证存储在内存中。

  1. 有一组基本的固有可达值,由于显而易见的原因无法删除。例如:
  • 本地函数的局部变量和参数
  • 当前嵌套调用链上的其他函数的变量和参数
  • 全局变量
  • 还有一些其他的,内部的

上面这些值这些值称为根。

  1. 如果引用或引用链可以从根访问任何其他值,则认为该值是可访问的。
    例如,如果局部变量中有对象,并且该对象具有引用另一个对象的属性,则该对象被视为可达性, 它引用的那些也是可以访问的。
    JavaScript 引擎中有一个后台进程称为垃圾回收器,它监视所有对象,并删除那些不可访问的对象。
    一个简单的例子
    下面是最简单的例子:
// user 具有对象的引用
let user = {
  name: "John"
};
2339205508.png

这里箭头表示一个对象引用。全局变量“user”引用对象 {name:“John”} (为了简洁起见,我们将其命名为John)。John 的 “name” 属性存储一个基本类型,因此它被绘制在对象中。

如果 user 的值被覆盖,则引用丢失:

user = null;
2754125172.png

现在 John 变成不可达的状态,没有办法访问它,没有对它的引用。垃圾回收器将丢弃 John 数据并释放内存。

4.两个引用的情况

现在让我们假设我们将引用从 user 复制到 admin:

// user具有对象的引用
let user = {
  name: "John"
};

let admin = user;
2521562821.png

现在如果我们做同样的事情:

user = null;

该对象仍然可以通过 admin 全局变量访问,所以它仍会在内存中。如果我们也覆盖admin,那么它会变得不可达,然后被释放。

5.相互关联的对象

现在来看一个更复杂的例子, family 对象:

function marry (man, woman) {
  woman.husban = man;
  man.wife = woman;

  return {
    father: man,
    mother: woman
  }
}

let family = marry({
  name: "John"
}, {
  name: "Ann"
})

函数 marry 通过给两个对象彼此提供引用来“联姻”它们,并返回一个包含两个对象的新对象。

产生的内存结构:


2957464299.png

到目前为止,所有对象都是可访问的。

现在让我们删除两个引用:

delete family.father;
delete family.mother.husband;
751991047.png

仅仅删除这两个引用中的一个是不够的,因为所有对象仍然是可访问的。

但是如果我们把这两个都删除,那么我们可以看到 John 不再有传入的引用:


2488601557.png

输出引用无关紧要。只有传入的对象才能使对象可访问,因此,John 现在是不可访问的,并将从内存中删除所有不可访问的数据。
垃圾回收之后:

2964376557.png

6.无法访问的数据块

有可能整个相互连接的对象变得不可访问并从内存中删除。

源对象与上面的相同。然后:

family = null;

内存中的图片变成:


166343030.png

这个例子说明了可达性的概念是多么重要。

很明显,John和Ann仍然链接在一起,都有传入的引用。但这还不够。

“family”对象已经从根上断开了链接,不再有对它的引用,因此下面的整个块变得不可到达,并将被删除。

7.内部算法

基本的垃圾回收算法称为“标记-清除”,定期执行以下“垃圾回收”步骤:

  • 垃圾回收器获取根并“标记”(记住)它们。
  • 然后它访问并“标记”所有来自它们的引用。
  • 然后它访问标记的对象并标记它们的引用。所有被访问的对象都被记住,以便以后不再访问同一个对象两次。
  • 以此类推,直到有未访问的引用(可以从根访问)为止。
  • 除标记的对象外,所有对象都被删除。

这就是垃圾收集的工作原理。JavaScript引擎应用了许多优化,使其运行得更快,并且不影响执行。
一些优化:

  • 分代回收——对象分为两组:“新对象”和“旧对象”。许多对象出现,完成它们的工作并迅速结束 ,它们很快就会被清理干净。那些活得足够久的对象,会变“老”,并且很少接受检查。
  • 增量回收——如果有很多对象,并且我们试图一次遍历并标记整个对象集,那么可能会花费一些时间,并在执行中会有一定的延迟。因此,引擎试图将垃圾回收分解为多个部分。然后,各个部分分别执行。这需要额外的标记来跟踪变化,这样有很多微小的延迟,而不是很大的延迟。
  • 空闲时间收集——垃圾回收器只在 CPU 空闲时运行,以减少对执行的可能影响。

8.总结

1.什么是垃圾
一般来说没有被引用的对象就是垃圾,就是要被清除, 有个例外如果几个对象引用形成一个环,互相引用,但根访问不到它们,这几个对象也是垃圾,也要被清除。
2.如何检垃圾
常见的一种算法就是 标记-清除 算法。

你可能感兴趣的:(浅谈JS引擎内存管理及垃圾回收机制)