简单聊聊V8内存管理

内存这个概念,大家都很熟悉了,无论做什么都需要内存,自然的,程序运行也是需要内存的。既然运行需要内存,那么V8就会申请内存去运行,而内存分为两种,堆和栈。

  • 栈用于存放js中的基本类型和引用类型指针
  • 栈的空间是连续的,增加删除只需要移动指针,操作速度非常快
  • 栈的空间是有限的,当栈满了,就会抛出错误
  • 栈一般是在执行函数时创建的,在函数执行完毕后,栈就会被摧毁

  • 如果不需要连续空间,或者申请的内存较大,可以使用堆
  • 堆主要用于存储js中的引用类型

简单说几个空间:

  • 运行时代码空间:用于存放JIT已编译的代码,唯一拥有执行权限的内存。
  • 大对象空间:为了避免大对象的拷贝,使用该空间专门存储大对象,GC(垃圾回收)不会回收这部分的内存
  • map空间:存放对象的Map信息(隐藏类)

图中这几种类型的内存都可以通过下面这段代码看到。

js 复制代码
const v8 = require('v8');
const heapSpace = v8.getHeapSpaceStatistics();
console.log(heapSpace);

我们都知道,内存总是会用完的,所以对于一些没用的却占用着内存的垃圾,我们是要进行回收的,好释放内存,让程序继续运行下去。那么V8是如何进行垃圾回收的呢?

V8的垃圾回收叫分代式垃圾回收,它分为老生代和新生代,而本文今天主要介绍新生代,老生代放到下一篇去讲。

新生代概念

新生代有两个特点:

  • 新生代中存在两个区域,一个叫对象区域(from)区域,一个叫空闲(to)区域
  • 新生代用于放一些生命周期比较短的对象数据

由于新生代放的数据都是比较短的对象数据,所以新生代的垃圾回收比较轻松。

新生代垃圾回收

新生代使用的是广度优先遍历算法来管理内存的,主要分为三步:

  1. 广度优先遍历from中对象,从根对象出发,广度优先遍历所有能到达的对象,把存活的对象复制到to中
  2. 遍历完成后,清空from
  3. from和to互换

上面三个步骤是主要行为,下面我们通过一个小例子来深入点看看细节和全过程吧。

假设我们代码如下:

js 复制代码
global.a = {};
global.b = { e: {} };
global.c = { f: {}, g: { h: {} } };
global.d = {};
global.d = null;

上面代码,可以画出下面这张关系图:

那么我们开始垃圾回收,一步一步来(图片较多警告!):

  1. 首先,程序刚运行,那么from和to自然都是空的
  2. 运行了代码后,我们的from中出现了abcdefg
  3. 广度优先算法,双端队列,完成第一层拷贝到to空间。看下图,图中有两个指针,初始他们执行同一个位置(初始位置),然后在拷贝的时候,分配指针指向的地方就是下一个数据存储的位置,看from的顺序,依次进入abc等,而扫描指针是进行下一层的遍历的,依次扫描abc等,看他们有没有下一层(如e就是b的下一层,注意!仅仅是一层,比如扫描到c的时候,只会扫描出来f和g,至于h,那是扫描g的时候才能扫出来),有下一层就拷贝,无就扫描下一个。
  4. 扫描指针依次扫描,完成后面的层的拷贝,到to空间,两个指针再次指到同一个地方的时候说明结束了(图中同颜色的代表为同一层,很明显能看出来这个广度优先的算法)
  5. 清空from,from和to互换

接下来聊聊老生代。

老生代概念

老生代有以下特点:

  • 老生代内存用于存放一些生命周期比较长的对象数据
  • 当新生代的对象进行两个周期的垃圾回收后,如果数据还存在于新生代中,那么就会将他们转到老生代来
  • 老生代又可以分成两部分,分别是指针空间和数据空间
  • 指针空间存放GC后存活的指针对象
  • 数据空间存放GC后存活的数据对象
  • 老生代使用标记清除和标记整理的方式进行垃圾回收

老生代垃圾回收

老生代里的对象有些是新生代晋升过来的,有一些是比较大的对象直接分配到老生代里的,所以老生代的对象空间大,活的时间长。

正是因为这特点,这决定了老生代不能继续使用新生代的那种垃圾回收算法,因为新生代的垃圾回收算法是会浪费一半空间的,对于新生代那种小内存(8M/16M 主要看系统)的地方来说,一半并不大,所以空间换时间比较划算,但老生代的地方是很大的,再拿一半的空间去换时间就很亏了,所以老生代的垃圾回收策略采用的是标记清除(Mark-Sweep)和标记整理(Mark-Compact)相结合。

标记清除(Mark-Sweep)

标记清除,正如其名,它分为标记和清除两个阶段,主要步骤如下:

  1. 标记阶段,需要遍历堆中的所有对象,并标记那些活着的对象。
  2. 进入清除阶段,只清除那些没被标记的对象。

在这里,V8采用的是黑白两色来标记数据,垃圾收集之前,会把所有的数据设置为白色,用来标记所有尚未标记的对象,然后在标记阶段,它会从GC根出发,以深度优先的方式把所有能访问到的数据都标记为黑色,遍历完成后,黑色的就是活着的数据,白色的就是可以清理的垃圾数据。

这种方式有着一个优点,那就是只清除死亡对象,而死亡对象在老生代中占用的比例是很小的,所以效率比较高。

但它也有着一个缺点,那就是进行标记清除后,内存空间往往不是连续的,会出现很多内存碎片,如果后续需要分配一个内存空间较大的对象时,如果所有的内存碎片都小于整个大对象所需要的空间,那么就会出现内存溢出的问题。

这里涉及到一个基础知识点,内存是连续的,这点大家在电脑分盘分空间的时候想必有体验过,不多赘述这点。

标记整理(Mark-Compact)

上面标记清除抛出了一个问题,那就是内存碎片不好处理,运行久了之后,内存空间都是些零零散散的小碎片,稍微有点大的数据都无法存下,导致内存利用率降低不少。为了解决这个问题,标记整理正是个好手,我们来看看标记整理是如何工作的。

标记整理是在标记清除的基础上进行一定的修改,它将清除阶段改为了紧缩阶段,所谓的紧缩阶段,就是在整理的过程中,让活着的内存进行一段移动,让内存碎片所占用的内存都被挪动到边边去,好整合他们。

可以看出,整个过程就是为了整合内存碎片,但这种内存的移动效率是很低的,所以一般10次标记清理才会有一次标记整理,这也正好说明了前面的老生代的垃圾回收策略是标记清除和标记整理的结合。

上面说了这么多,可能大家会有点懵,或者说很难完整想象出来这个过程。

没关系,我们来看下例子(图片较多警告!):

假设我们的代码如下:

js 复制代码
global.a = {};
global.b = { e: {} };
global.c = { f: {}, g: { h: {} } };
global.d = {};
global.d = null;

代码关系图:

在老生代中,可以看出来d是要被清理的,但是由于新生代是广度遍历,导致了他们进入老生代的时候,d占用的内存是在c之后的,那么如果d被清理了,就会出现一个内存碎片,看下图。

为了解决这个内存碎片,我们来看看标记整理的工作步骤吧。

  1. 深度优先,先标记a和b为黑色,表示他们存活

2. 由于是深度优先,所以我们进入到b中,发现了也要存活的e,新进行标记,表示存活

3.标记完毕之后,我们进行整理(这里解释下什么时候要进行整理,那就是标记完后发现黑色不连续就需要整理),把e一点一点的挪动,直到它到b的右边

4.继续重复标记动作,遇到不连续的就进行整理

5.最后发现就只有d是白的(死亡的),直接清除即可

上面这一过程,可以很明显的感觉到,剩余的内存空间又连续了,就解决了内存碎片的问题了。

那么老生代的垃圾回收过程到此就基本讲完了。

优化

我们通过上文和本文,了解完了新老生代的垃圾回收后,我们该考虑另外一个问题了,那就是优化。

我们都知道,js是单线程的,在执行垃圾回收期间,js脚本是需要停止运行的,这种停止我们称之为全停顿(Stop the world)

所以,如果垃圾回收过程太长,那么就会引起卡顿,这是非常不好的体验了。

js单线程这个问题导致的其他问题,属于是非常经典了,所以一提到这个问题的解决方案,大家必然能像膝跳反应一样想到两个解决方法:

  • 大任务拆成小任务,分步执行,类似React中的fiber
  • 将一些任务放到后台执行,不占用主线程

当然,V8也是采用了这两种方法进行优化,我们来具体看看。

新生代优化:并行执行

对于新生代的优化,采用的是并行执行,它会开启多个辅助线程来执行新生代的垃圾回收工作,因为是开启多个辅助线程,所以最终的执行时间只是等同于最耗时的那个线程的时间。

注意一点,开启辅助线程执行垃圾回收的时候,主线程也是全停顿状态,也就是垃圾回收导致的全停顿是不可避免的。

这种并行执行表现如下图:

老生代优化1:增减标记

增减标记就是把标记工作分成了多个阶段(主要思想就是把大任务分成多个小任务),每个阶段都只标记一部分对线,和主要线程的执行穿插进行。

大任务分成小任务的主要目的是为了减少单次全停顿的时间,所以我们分成小任务后,必须支持暂停和恢复,也就是说这个小任务结束了,可以解除全停顿,下一次再进行全停顿执行下一个小任务。

为了做到这种暂停和恢复,标记的方式从黑白两色标记改进为黑白灰三色标记。灰色标记主要用于表示进度点。

黑白灰三色标记主要说明如下:

  1. 黑色标识这个节点被GC根引用到了,而且该节点的子节点都已经标记完成了
  2. 灰色标识这个节点被GC根引用到了,但子节点还没被垃圾回收器标记处理,表示正在处理这个节点(这里也说明了为什么老生代会采用深度优先的原因)
  3. 白色标识此节点还未被垃圾回收器发现,如果本轮遍历结束时还是白色,那么这块数据就会被回收

我们简单来看一个图文例子:

深度优先,当一个黑色节点下存在白色节点时,那么这个黑色节点会变成灰色,表示正在处理这个节点,那么此时如果停止了全停顿,我们等下一次进来的时候,垃圾回收器会优先找到灰色节点,继续处理,直到其下没有白色节点。

老生代优化2:惰性清理

当增量标记完成后,如果内存够用,那么先不进行清理,等js代码执行完慢慢清理。

老生代优化3:并发和并行回收

增减标记和惰性清理并没有减少暂停的总时间,只不过优化了体验。

为了减少暂停的总时间,我们再次拿出多个辅助线程,我们进行并发回收,并发回收就是在主线程执行过程中,辅助线程可以在后台完成垃圾回收工作,由于是多个辅助线程执行垃圾回收,所以减少了暂停的总时长。

再有一点是,标记操作将全部由辅助线程完成,清理操作由主线程和辅助线程配合完成(为什么说是配合完成呢,主要是因为清理和整理的时候会和主线程产生冲突,因为主线程可能会不断制造垃圾)

具体表现如图:

看到这图是不是会疑惑,全停顿去哪了?其实全停顿是一直都在的,只是变成了很小的切片,穿插在了js执行过程中。这里就要解释下并发的实现了,并发是通过不断切换上下文,使得看起来好像多个进程在同时进行,也就是说js执行时我们就在各种上下文中进行切换,切换到谁的上下文谁就执行,大家都是可暂停可恢复的,这种切换上下文的方式就是可行的。所以整个js过程中的零碎全停顿只是没画出来,大家明白有就行了。

结尾

到此为止,新老生代的垃圾回收都聊完了,本文通过图文的方式进行讲解,虽然可能会耗费大家不少流量,但希望看到这里的各位都有所收获,同时也欢迎在评论区内补充更多的细节。

最后的最后,也希望看到此处的大家能给个小赞🌹

相关推荐
Nan_Shu_61414 分钟前
学习: Threejs (2)
前端·javascript·学习
G_G#23 分钟前
纯前端js插件实现同一浏览器控制只允许打开一个标签,处理session变更问题
前端·javascript·浏览器标签页通信·只允许一个标签页
@大迁世界38 分钟前
TypeScript 的本质并非类型,而是信任
开发语言·前端·javascript·typescript·ecmascript
GIS之路1 小时前
GDAL 实现矢量裁剪
前端·python·信息可视化
是一个Bug1 小时前
后端开发者视角的前端开发面试题清单(50道)
前端
Amumu121381 小时前
React面向组件编程
开发语言·前端·javascript
持续升级打怪中1 小时前
Vue3 中虚拟滚动与分页加载的实现原理与实践
前端·性能优化
GIS之路1 小时前
GDAL 实现矢量合并
前端
hxjhnct1 小时前
React useContext的缺陷
前端·react.js·前端框架
冰暮流星1 小时前
javascript逻辑运算符
开发语言·javascript·ecmascript