一文带你快速掌握V8的垃圾回收机制

833 阅读8分钟

本文正在参加「金石计划」

javascript语言不同于c语言(使用malloc等内存分配函数获取内存即是从堆中分配内存)和C++有以malloc包为基础垃圾收集器,而是在创建变量时通过自动内存管理实现内存分配和闲置资源回收,并且这个过程是周期性的(每隔一定时间会自动运行)。而问题是,垃圾回收程序必须跟踪记录某个内存是否中的变量是否要被清除,毕竟靠算法时判定不来的?

那么接下来,就得好好聊聊了。

一、v8引擎的内存如何分配?

在v8引擎内部实际上有栈内存和堆内存两大块空间。栈内存是线性连续存储的,并且栈空间的分配和回收都是由系统来做的,栈空间的大小也是固定的,而堆内存还划分为多个不同的区,其中有新生代、老生代、大对象空间、代码空间等,如下图所示:

v8-1.jpg

v8引擎的内存大小实际上和操作系统有关。如果操作系统以64位存储,那么新生代空间时64MB,老生代空间是1400MB,Node环境下是1.4G;而32位存储下,新生代的空间是32MB,老生代的空间是700MB,Node环境的空间大小是0.7G。如下表所示:

64位操作系统32位操作系统
v8引擎内存大小1.4G(1464MB)0.7G(732MB)
新生代空间64MB32MB
老生代空间1400MB700MB
最新版的Node(V14)内存为2GB,内存空间可扩充

通过以上我们需要解决的问题是:

  1. 栈内存是如何回收的?
  2. 堆中的数据是如何回收的?
  3. 为什么需要知道内存大小?
  4. 为什么新生代和老圣代内存大小不一样?
  5. 新生代和老生代的回收算法一致嘛?为什么?

二、关于内存

之所以要关注内存,是为了防止页面占用内存过大,引起客户端卡顿,甚至无响应;Node使用的也是v8,内存对于后端服务更容易造成内存溢出(后端宕机)。

三、栈内存如何回收

当函数执行完毕,js引擎是通过移动ESP(ESP:记录当前执行状态的指针)来销毁函数保存在栈当中的执行上下文的,栈顶的空间会被自动回收,不需要V8引擎的垃圾回收机制出面。然而,堆内存的大小是不固定的,那堆内存中的数据是如何回收的呢?

四、堆内存如何回收

了解堆内存是如何回收的之前,先了解以下概念:

1、新生代和老生代

新生代(副垃圾回收器)和老生代(主垃圾回收器)都还划分为两个区域,新生代划分为是Semi space FromSemi space To,简称一个From区域,一个To区域,以64位操作系统存储的话,两个区域各占32MB。

image.png

而老生代划分为Old pointer spacePld data space,以64位操作系统存储的话,两个区域各占700MB。

image.png

另一方面,新生代用于存放生存时间较短的对象,而老生代用于存放时间长的对象。那么问题来了,如何判定一个对象存活时间是否长久?

2、代际假说和分代收集

(1)大部分对象在内存中存在的时间很短,对象已经分配内存,很快就会变得不可访问。例如,在函数作用域内部定义的变量,只要这些变量没有被引用,那么等函数执行完毕,虽然这些变量不会立马被垃圾回收,但是当垃圾回收机制需要使用这块内存的时候,这些变量的内存就会被回收。

(2)不死的对象,会活得更久。怎么样才算是不死的对象,实际上,被循环引用的对象就是不死的对象,因为被循环引用,意味着永远也不会被垃圾回收机制回收。

3、垃圾回收器的流程

(1)标记空间中活动的对象和非活动的对象

(2)回收非活动对象的所占据的内存

(3)内存中整理(将内存碎片整理成连续的内存空间)

垃圾回收机会的大致流程就是这样,实际上也还有一些细节。

五、垃圾回收器的原理

1、新生代的回收

其实,实现垃圾回收机制的算法有很多种,但是比较常用的就是标记整理和引用计数。新生代简单来说就是搬运(或者说是复制),即使用Scavenge算法将新生代两个区域无限翻转互换。新生代区域=对象区域+空闲区域或者说新生代区域=From+To。这就意味着,总有一半的区域是空闲的,浪费的。

接下来看看Scanvenge算法的流程:

1)新对象进入对象区域

(2)对象区域快要存满时进行垃圾回收

(3)对象区域中剩下的对象转移到空闲区域

(4)两个区域对调

对象晋升策略来了:

经过两次垃圾回收还存活的对象,会被移入老生代区。如图所示:

image.png

2、老生代的回收

(1)标记整理(mark-and-compact)

Javascript最常用的垃圾回收策略就是标记清理(mark-and-sweep),顾名思义,当变量进入执行上下文,这个变量会被加上存在于执行上下文的标记,这样垃圾回收机制可能永远不会释放存在于执行上下文的变量,而当变量离开执行上下文时,也会给变量添加离开执行上下文的标记,这样的话,等到垃圾回收程序做一次内存清理的时候,首先会进行广度扫描,把相关联的变量节点使用存在执行上下文的标记,然后就会回收带离开执行上下文标记的内存。如下图所示:

image.png 标记清理后很明显的一个缺点就是,内存是一个连续的内存,但是经过标记清理后,留下很多碎片化的内存,这样的话,就会导致内存的浪费。另外,在整个过程其实是全停顿标记:js是运行在主线程上的,一旦垃圾回收生效,js脚本就会暂停执行,等到垃圾回收完成,再继续执行

所以经过优化,不再使用标记清理,而是标记整理,一字之差,但是效果就完全不一样了。如下图所示:

image.png

image.png 这么一看,标记整理后的内存就不是碎片化了,也即是说,标记整理中的整理,实际上是针对碎片的整理。另外,这个过程也不是全停顿标记,而是增量标记和三色标记法。所谓增量标记就是,不再像标记清理里面的,一次性标记所有进入执行上下文的变量,而是当垃圾回收程序运行时,将GC Root相关的下一个节点标记h黑色,然后执行代码,等到下一次垃圾回收程序运行时,看见这个被标记黑色的节点,则再将与其相关的节点标记为黑色,本身标记成白色.....,如此往复,等到最后,根据三色标记,整理后清理不再被需要的变量,释放内存,如下图所示:

image.png

image.png

我们知道,javascrript和nodejs底层都是c++实现的,那么nodejs的内存使用情况又是如何?这就要扯皮到process.memoryUsage()方法

process.memoryUsage()方法是进程模块的内置方法,提供有关Node.js程序的当前进程或运行时的信息。内存使用情况方法返回一个对象,该对象以Node.js进程的字节数描述内存使用情况,用法如下:

process.memoryUsage()
参数:此方法不接受任何参数:
返回值:此方法返回一个带有内存使用说明的对象

试一试:

let process = require('process')
​
console.log(process.memoryUsage())

image.png

另外,我们也可以手动扩充内存大小,如下:

node --max_old_space_size=5000 index.js
(Old Space的单位是MB,New Space的单位是KB)
node --max_new_space_size=5000 index.js
(2)引用计数

引用计数的大致过程是:对每个变量值都记录被引用的次数,声明变量并赋引用值时,引用数为1;如果同一个值被赋值给另外一个变量,那么引用数加1。类似地,如果保存该值引用的变量被其他值给覆盖了,那么引用数减1,当一个值的引用数为0时,就说明垃圾回收程序可以回收变量了。但是这个有一个很严重的问题就是,循环引用的变量的引用数永远也不会为0。举个例子:

let obj = {
    name: 'hello world',
    age: 19,
}
let data = {
    gender: 'female',
}
obj['gender'] = data
data['other'] = obj