likes
comments
collection
share

V8引擎的垃圾回收机制

作者站长头像
站长
· 阅读数 11

V8引擎的垃圾回收机制

垃圾的产生

在说清楚垃圾的产生之前我们回顾两个概念,一个是栈,一个是函数的作用域

栈是一种特殊的列表,栈内的元素只能通过列表的一端访问,这一端称为栈顶。 栈被称为是一种后入先出(LIFO,last-in-first-out)的数据结构。 由于栈具有后入先出的特点,所以任何不在栈顶的元素都无法访问。 为了得到栈底的元素,必须先拿掉上面的元素。

作用域

写过JS的小伙伴都知道,每一个函数都有自己的作用域,在函数中定义的变量在外部是不可访问的,内部函数可以访问外部函数的作用域。

我们来看看JS引擎是如何实现这种机制的。

JS引擎每遇到一个函数,将函数以及相关的内容(如入参:全局变量等)我们称之为函数的执行上下文推入栈中,等函数执行完毕,将函数及执行上下文推出栈,这样就可以保证了函数的顺序执行。

函数的执行上下文中不仅包含了当前函数作用域下的变量,同时也包含了,引用的外部函数的变量,这就是所谓的闭包,闭包的完整定义如下:

闭包(closure)是一个函数以及其捆绑的周边环境状态(lexical environment,词法环境)的引用的组合,

如图所示,可以查看函数的作用域启用就包含了他的 closure

如上就是JS执行的过程

在上述过程中变量初始化时会分配对应的内存,有些变量在当前函数中使用完成后不会再使用了,有的变量会在某一次子函数使用后不会再使用了,这个时候垃圾就产生了。

我们在写JS的时候通常不会刻意释放内存,是因为JS引擎帮我们做了这些事情,这个就是JS引擎的垃圾回收机制

垃圾回收

1、如何找到垃圾

理论上如果一个内存没有任何一个变量指向它,那么理论上它就是废弃的,那具体如何实现呢?

一种比较直观的做法是

我们维护一个表格,这个表格记录内存地址当前被引用的次数,如果有新增的引用+1,如果某个引用断开了-1 即可,这个表格中引用数为0 的内存地址理论上都可以回收掉,看上去这是一种简单高效的做法,这种机制就是引用计数,PHP采用的就是这种机制。

但是有一种稍微复杂的场景(循环依赖)

function foo() {
    let a = {};
    let b = {};
    a.a1 = b;
    b.b1 = a;
}
foo();

由上面的执行过程我们可以判断,当函数foo 出栈后,a和b 不会再被使用了,理论上应该进入垃圾回收,如果按照我们上述的引用计数来看,a和b的引用数量并没有消失,因为他们相互引用了。

看来引用计数确实不完美,一不小心就可能导致内存无法被回收。

除此之外有没有其他办法呢?

聪明的工程师们又想到一个办法,从一组已知的对象指针开始。这包括执行栈和全局对象。然后它跟随每个指针到达一个JavaScript对象,并将该对象标记为可达。遍历该对象中的每个指针,并以递归的方式继续这个过程,直到找到并标记运行时中的每个可达对象,除了这些可达的变量,其他的都是可以被回收的,不管性能如何,至少分析结果是准确的。这种方案叫做可达性分析。

2、如何回收垃圾

在垃圾收集中,有一个重要的术语叫做“世代假设”(The Generational Hypothesis)。它说的是大多数对象年轻时就会死亡。换句话说,从垃圾收集器的角度来看,大多数对象被分配后几乎立即变得不可达。这不仅适用于V8或JavaScript,而是适用于大多数动态语言。

基于上述假说在V8中,堆被分成不同的区域,称为世代(generations)。其中包括一个年轻代(进一步分为“nursery”(幼儿)和“intermediate”(中年)子世代)以及一个老年代。对象首先被分配到年轻代(nursery)。如果它们在下一次垃圾收集中幸存下来,它们仍然留在年轻代,但被视为“intermediate”。如果它们再次幸存下来,在另一次垃圾收集中,它们将被移动到老年代。

V8引擎的垃圾回收机制

2.1新生代——Scavenger算法

对于新生代,采用的是Scavenger算法

Scavenger算法将幸存的对象总是被转移到一个区域中。V8对于年轻代采用了“半空间”设计。这意味着总空间的一半始终为空,以便进行这个转移步骤。在进行一次Scavenger过程时,这个最初为空的区域被称为“To-Space”。我们从中复制对象的区域称为“From-Space”。在最坏的情况下,每个对象都可能幸存下来,我们需要复制每个对象。

V8引擎的垃圾回收机制

对于Scavenger过程,我们有一组额外的根引用,即旧空间中指向年轻代对象的指针。与为每次Scavenger过程遍历整个堆图形不同,我们使用写屏障来维护一个旧到新引用的列表。结合栈和全局变量,我们知道每个指向年轻代的引用,而无需遍历整个旧代。

转移步骤将所有幸存的对象移动到内存中的一个连续块(在一个页面内)。这具有彻底消除碎片化的优势。然后我们交换这两个空间,即To-Space变为From-Space,反之亦然。一旦垃圾收集完成,新的分配将在From-Space中的下一个空闲地址上进行。

仅仅采用这个策略,年轻代的空间很快就会耗尽。第二次垃圾收集中幸存的对象会被转移到老年代,而不是To-Space。

Scavenger的最后一步是更新引用原始对象的指针,因为这些对象已经被移动。每个被复制的对象都会留下一个转发地址,用于将原始指针更新为指向新位置。

2.2老生代——Mark-Compact

对于老生代V8采用了另外一种算法

整个过程分为三个阶段标记——清理——整理

V8引擎的垃圾回收机制

标记(Marking)

标记算法采用的是上述提到的可达性分析,从根集合出发,标记出正在被使用的指针

清理(Sweeping)

一旦标记(Marking)完成,垃圾收集器会找到不可达对象留下的连续间隙,并将它们添加到适当的空闲列表中。空闲列表按照内存块的大小进行分离,以便进行快速查找。在将来需要分配内存时,我们只需查看空闲列表,并找到适当大小的内存块即可。

V8引擎的垃圾回收机制

整理(Compaction)

在上述标记清楚后,内存大概率会变成碎片化,为了解决这种内存碎片的问题,Mark-Compact(标记整理)算法被提了出来,该算法主要就是用来解决内存的碎片化问题的,回收过程中将死亡对象清除后,在整理的过程中,会将活动的对象往堆内存的一端进行移动,移动完成后再清理掉边界外的全部内存,我们可以用如下流程图来表示:

V8引擎的垃圾回收机制

综上所述就是V8引擎的垃圾回收过程

如何避免内存溢出

  • console.log/error 切勿在正式环境中使用,即便不打开控制台也会导致,变量有可能无法被清理
  • 尾递归优化,避免递归原因导致
  • 避免被遗忘的定时器和回调函数
  • 避免被遗忘的DOM引用

参考文章:

一文搞懂V8引擎的垃圾回收 - 掘金 (juejin.cn)

javaScript中变量到底是存储在「栈」还是「堆」上? - 知乎 (zhihu.com)

「硬核JS」你真的了解垃圾回收机制吗 - 掘金 (juejin.cn)

浅析 PHP7 的垃圾回收机制 | Laravel China 社区 (learnku.com)

聊聊V8引擎的垃圾回收 - 掘金 (juejin.cn)