V8垃圾回收机制学习笔记 - Shiina's Blog

V8垃圾回收机制学习笔记

2024 年 3 月 28 日 - 17:05:11 发布
4.0K 字 14分钟
本作品采用 CC BY-NC-SA 4.0 进行许可。
本文距离首次发布已经过去了 234 天,请注意文章的时效性!

V8垃圾回收机制学习笔记

个人学习记录,仅供参考

从内存限制开始说起

早期v8设计的初衷是为了给浏览器提供一个运行时核心,而一个浏览器标签页一般来说不会占用特别多的内存,因此 v8 在设计时设置了一个内存限制:在64位系统上,V8的堆内存限制大约是1.4GB,而在32位系统上,限制大约是0.7GB。这些限制是由V8为了优化垃圾回收和整体性能而设置的。

如果单纯从浏览器的角度来说,这些内存已经基本满足日常使用了,但是如果使用 nodejs 作为后端那还可能远远不够,有的时候可能因为你写的代码质量不够高而导致 v8 没有及时回收内存而导致内存泄露了。这就是为啥要了解一下 v8 的回收机制以保证出问题的时候自己能解决,而不是绕了一堆路。

栈与堆

如果你是学习计算机的同学或者是爱好者,那你一定对这两个字并不陌生。v8 如同其他的语言一样,也区分栈和堆,栈会存储一些基本数据类型,因为他们不会占用太多空间 (String 是例外)

而堆一般用于分配对象,数组等体积较大的玩意,当然,如果你的 String 特别长,也会被 v8 扔到堆上。

访问栈上的变量会比访问堆的速度要快,其涉及到计算机组成原理,我们假设一个存储在栈上的数字和一个堆上的数字,并对其做加法操作,看看会发生什么。现假设一个存在三个寄存器,a1 保存了一个常数,a2 存储着指向栈上数字的地址,a3 存储的是一个指向堆上某个位置的指针,现假设其汇编如下,其中 () 代表解引用操作:

add a1, (a2)
add a1, ((a3))

可以看到堆上的数字多走了一次解引用,也就意味着流程如下:

  • 两次解引用(多次访问内存降低速度)
  • 将数字从内存读入寄存器
  • ALU 计算相加
  • 结果送回内存(可能有缓存优化)

不过你在写js时一般不需要担心这种访问速度与内存问题,v8 帮你做了很多优化的事情。但是多了解点不也挺好的嘛。

垃圾回收机制解析

v8 垃圾回收的目标是能够有效处理任何场景下的垃圾回收。但是人们在最佳实践过程中发现并没有一种通用的有效算法能够胜任任何场景,因此 v8 也是采用了主要的几种算法进行垃圾回收处理。

从内存布局说起

v8 堆内存简单示意图如下:

+-------------+---------------+-------------------+-----------------+
|             |               |                                     |
| 新生代内存1  |  新生代内存2   |           老生代内存空间             |
|             |               |                                     |
+-------------+---------------+-------------------+-----------------+

其中老生代内存可以通过 nodejs 启动参数 --max-old-space-size 来设置。

老生代内存大小在 64 位机器上为 1.4GB, 32 位机器上为 0.7GB;新生代内存比老生代会小很多,上图两块加起来在 64 位机器上为 32MB, 32 位机器上为 16MB。

各位笔者到这里可以猜一下各个内存块是干什么用的。猜不到也没关系,后面会解释。

新生代特供回收算法 - Scavenge 算法

虽然被称作 Scavenge 算法,但是在具体实现中使用的则是 Cheney 算法。

该算法结合 v8 内存分配时,工作流程如下:

  • 将内存一分为二
  • 一个设置为使用状态(From),另一个则为闲置状态(To)
  • 当我们分配对象时,对象首先会被分配到 From 空间
  • 当 v8 开始进行垃圾回收时,会对 From 空间的对象进行存活检查,存活对象会被移动到 To 中,非存活对象则被移除
  • From 与 To 角色翻转,原来的 To 空间开始履行 From 空间的职责。

由此往复循环,实现了垃圾回收。

该算法是牺牲空间换取时间的做法,所以对内存利用率并不高,但他非常适合用于新生代

可以看以下代码:

function fn() {
  let a = {};
}

fn()

fn 执行时,在其作用域内创建了 a 对象,此时该对象会被分配到 From 空间,随后离开作用域,该对象没有了引用,因此在下一次垃圾回收触发时可以被立即回收。

那假如某个变量被长时间使用呢?总不可能一直霸占宝贵的新生代空间吧?没错,当某个变量经历了多次回收后,它就会被 v8 认为是个存活时间十分甚至九分长的对象,并发配到老生代内存空间中。

其触发条件如下:

  • 该变量已经经历过一次 Scavenge 算法回收了,但是活了下来
  • 在将存活对象从 From 移动至 To 时,To使用的空间大小已经超过了 25%

为什么是 25%,因为新生代内存实在是太小了。假如比重过高,新 js 执行时所产生的新变量就会更快填满 To 空间,导致提前触发垃圾回收,这会对性能造成非常大的影响!

Mark-Sweep & Mark-Compact

针对老生代则采用了两种清除算法。

先看第一种 Mark-Sweep,即标记清除。

这个没有什么好说的,就是检查每个存活对象的引用次数,为0的直接清除即可,即清理死亡对象。不过这种方式有个缺点,即进行一次回收后会出现内存不连续的情况:

+-----+---------+----+------+
|     |         |    |      |
| xx  | 存活对象 | xx | 存活 |
|     |         |    |      |
+-----+---------+----+------+

假如需要分配一个大对象,但是所有剩余的碎片空间都无法完成任务时,就需要 Mark-Compact 了。

其比 Mark-Sweep 多了一步操作:在整理过程中将存活对象往连续内存的一侧移动,移动完成后,直接将另一侧的内存清理。

+-----+---------+----+------+------+------+
|     |         |    |      |      |      |
| xx  | 存活对象 | xx |      | 存活 |  xx  |
|     |         |    |      |      |      |
+-----+---------+----+------+------+------+

=>

+----------------------+-----------+------+
|                      |           |      |
|                      | 存活对象   | 存活 |
|                      |           |      |
+----------------------+-----------+------+

具体实现不多赘述,此处只探讨原理。

大回收任务分割成小任务

为了避免js应用与垃圾回收器看到的内存不一致情况出现,每次垃圾回收时都需要把js任务暂停下来,这降低了js任务执行的效率。

在新生代中进行一次回收造成的暂停不会对用户造成太大影响,但老生代由于空间较大,需要的时间也较多。v8 针对老生代则采用了任务分割的办法,每回收一部分就暂停一下,让js应用运行一会。

什么变量无法做到立即回收内存

  • 全局变量
  • 闭包内变量

开发者需要学习什么

我们能做的就是让垃圾回收机制更高效工作,和避免内存泄漏。

几个常见的内存泄露场景如下:

  • 使用对象做键值对缓存
  • 事件队列消费不及时
  • 作用域没有及时释放

用对象键值做缓存

在 js 中我们经常会用 {} 来缓存一些东西,这些在网页还好说,但是在 node 制作服务端应用时不太一样,既然他是缓存就意味着会常驻于老生代中,如果使用的越久,其占用的内存可能就越大。假如出现了问题,你可能需要考虑使用一些算法来限制缓存大小的增长,如队列或者 LRU 算法实现的缓存。

其次是进程间无法共享内存,如果需要,可以考虑使用 Redis。

队列

举一个例子:日志收集。

这是一个典型的生产-消费者模型,生产来源于生成的日志,消费来源于写入硬盘文件。

假如在某一时刻因为某些异常涌入了大量的日志,这时可能硬盘写入就来不及消费队列中的日志,可能会发生移除。

总结

少用闭包;少用全局缓存;少用全局变量;区分栈与堆的变量,知道自己写的变量是否会被回收还是进入老生代。

个人信息
avatar
Shiinafan
文章
43
标签
53
归档
4
导航