在V8引擎中可视化内存管理

![[Pasted image 20220718212614.png]]

这是”内存管理”系列的一部分
  1. [[揭开现代编程语言中内存管理的神秘面纱]]
  2. [[在JVM中可视化内存管理(Java、Kotlin、Scala、Groovy、Clojure)]]
  3. [[在V8引擎中可视化内存管理 (Javascript、NodeJS、Deno、WebAssembly)]]
  4. [[在Go语言中可视化内存管理]]
  5. [[在Rust中可视化内存管理]]
  6. [[避免NodeJS中的内存泄露:性能最佳实践]]

在这个多个部分的系列中,我旨在揭开内存管理背后的概念的神秘面纱,并深入了解一些现代编程语言中的内存管理。我希望这个系列能让您对这些语言在内存管理方面发生的事情有所了解。

在本章中,我们将研究用于 ECMAScript 和 WebAssembly 的 V8 引擎的内存管理,它们被 NodeJS、Deno 和 Electron 等运行时以及 Chrome、Chromium、Brave、Opera 和 Microsoft Edge 等 Web 浏览器使用。由于 JavaScript 是一种解释型语言,它需要一个引擎来解释和执行代码。 V8 引擎解释 JavaScript 并将其编译为本机机器代码。 V8 是用 C++ 编写的,可以嵌入到任何 C++ 应用程序中。

如果您还没有阅读本系列的第一部分,请先阅读它,因为我解释了栈和堆内存之间的区别,这将有助于理解本章。


V8 内存结构

首先我们来看看V8引擎的内存结构是怎样的。由于 JavaScript 是单线程的,V8 也为每个 JavaScript 上下文使用一个进程,因此如果您使用服务工作者,它将为每个工作者生成一个新的 V8 进程。运行中的程序总是由 V8 进程中分配的一些内存表示,这称为驻留集。这进一步分为以下不同的部分:
![[Pasted image 20220718214937.png]]
这与我们在上一章中看到的 JVM 内存结构略有相似。让我们看看不同细分市场的用途:

堆内存

这是V8存储对象或动态数据的地方。这是内存区域中最大的一块,也是垃圾收集(GC)发生的地方。整个堆内存不会被垃圾回收,只有Young和Old空间被垃圾回收管理。堆又进一步分为以下几种:
New Space: 新空间或“Young generation”是新对象存在的地方,这些对象中的大多数是短期的。这个空间很小,并且有两个semi-space,类似于JVM中的S0S1。这个空间是由“Scavenger(Minor GC)”管理的,我们稍后会看到它。可以使用--min_semi_space_size(Initial)--max_semi_space_size(Max) V8标志来控制新空间的大小。
Old Space: 旧空间或“旧代”是在“新空间”中存活两个次要 GC 周期的对象被移动到的地方。这个空间由 Major GC(Mark-Sweep & Mark-Compact) 管理”,我们稍后再看。旧空间的大小可以使用 --initial_old_space_size(Initial)--max_old_space_size(Max)V8 标志来控制。这个空间分为两个:
Old pointer space: 包含具有指向其他对象的指针的幸存对象。
Old data space: 包含只包含数据的对象(没有指向其他对象的指针)。字符串、装箱数字和未装箱双精度数组在“新空间”中存活两个次要 GC 周期后被移动到此处。
Large object space: 这是大于其他空间大小限制的对象所在的位置。每个对象都有自己的 mmap 内存区域。垃圾收集器永远不会移动大对象。
Code-space: 这是 Just In Time (JIT) 编译器存储已编译代码块的地方。这是唯一具有可执行内存的空间(尽管代码可能分配在“大对象空间”中,并且它们也是可执行的)。
Cell space, property cell space, and map space: 这些空间分别包含 Cells、PropertyCells 和 Maps。这些空间中的每一个都包含大小相同的对象,并且对它们指向的对象类型有一些限制,这简化了收集。

这些空间中的每一个都由一组页面组成。页面是使用 mmap(或 MapViewOfFile 从操作系统分配的连续内存块视窗)。每页大小为 1MB,大对象空间除外。

这是堆栈内存区域,每个 V8 进程有一个堆栈。这是存储静态数据的地方,包括方法/函数框架、原始值和指向对象的指针。可以使用 --stack_size V8 标志设置堆栈内存限制。

V8 内存使用(堆栈与堆)

现在我们已经清楚了内存是如何组织的,让我们看看在执行程序时如何使用它最重要的部分。

让我们使用下面的 JavaScript 程序,代码没有针对正确性进行优化,因此忽略了不必要的中间变量等问题,重点是可视化堆栈和堆内存使用情况。

class Employee {
  constructor(name, salary, sales) {
    this.name = name;
    this.salary = salary;
    this.sales = sales;
  }
}

const BONUS_PERCENTAGE = 10;

function getBonusPercentage(salary) {
  const percentage = (salary * BONUS_PERCENTAGE) / 100;
  return percentage;
}

function findEmployeeBonus(salary, noOfSales) {
  const bonusPercentage = getBonusPercentage(salary);
  const bonus = bonusPercentage * noOfSales;
  return bonus;
}

let john = new Employee("John", 5000, 5);
john.bonus = findEmployeeBonus(john.salary, john.sales);
console.log(john.bonus);

以下图片展示上述程序如何执行以及如何使用堆栈和堆内存:

![[Pasted image 20220718225505.png]]
![[Pasted image 20220718225523.png]]
![[Pasted image 20220718225552.png]]
![[Pasted image 20220718225608.png]]
![[Pasted image 20220718225616.png]]
![[Pasted image 20220718225635.png]]
![[Pasted image 20220718225643.png]]
![[Pasted image 20220718225651.png]]
![[Pasted image 20220718225701.png]]
![[Pasted image 20220718225709.png]]
![[Pasted image 20220718225719.png]]
![[Pasted image 20220718225728.png]]
![[Pasted image 20220718225737.png]]
![[Pasted image 20220718225748.png]]![[Pasted image 20220718225801.png]]

如你所见:
全局作用域保存在栈上的“全局帧”中
– 每个函数调用都作为帧块添加到堆栈内存中
– 所有的局部变量包括参数和返回值都保存在栈上的函数框架块中
– 所有基本类型,如'int''string'都直接存储在堆栈中。这也适用于全局作用域,是的,String是JavaScript的基本类型
– 所有像'Employee''Function'这样的对象类型都创建在堆上,并使用堆栈指针从堆栈中引用。函数在JavaScript中只是对象。这也适用于全局范围
– 从当前函数调用的函数会push到Stack的顶部
– 当一个函数返回时,它的帧被从堆栈中删除
– 主进程完成后,堆上的对象不再有来自堆栈的指针,成为孤儿
– 除非您显式地进行复制,否则其他对象中的所有对象引用都是使用引用指针完成的

如您所见,栈是由操作系统自动管理的,而不是 V8 本身。因此,我们不必太担心栈。另一方面,堆不是由操作系统自动管理的,因为它是最大的内存空间并保存动态数据,它可能会呈指数增长,导致我们的程序随着时间的推移耗尽内存。随着时间的推移,它也会变得支离破碎,从而减慢应用程序的速度。这就是垃圾收集的用武之地。

区分堆上的指针和数据对于垃圾回收很重要,V8 为此使用”Tagged pointers“方法——在这种方法中,它在每个字的末尾保留一个位来指示它是指针还是数据。这种方法需要有限的编译器支持,但实现起来很简单,而且效率很高。

V8 内存管理: 垃圾回收 (GC)

现在我们知道了 V8 是如何分配内存的,让我们看看它是如何自动管理堆内存的,这对应用程序的性能非常重要。当一个程序试图在堆上分配比自由可用的更多的内存(取决于 V8 标志集)时,我们会遇到内存不足的错误。管理不正确的堆也可能导致内存泄漏。

V8 通过垃圾回收来管理堆内存。简单来说,它释放了孤儿对象使用的内存,即不再直接或间接(通过另一个对象中的引用)从堆栈中引用的对象,以为创建新对象腾出空间。

Orinoco 是 V8 GC 项目的代号,它利用并行、增量和并发技术进行垃圾收集,以释放主线程。

V8 中的垃圾收集器负责回收未使用的内存以供 V8 进程重用。

V8 垃圾收集器是分代的(堆中的对象按其年龄分组并在不同阶段清除)。 V8 用于垃圾收集的两个阶段和三种不同的算法:

次要 GC(清道夫)Minor GC (Scavenger)

这种类型的 GC 使新生代或新生代空间保持紧凑和干净。对象在新空间中分配,该空间相当小(1 到 8 MB 之间,取决于行为启发式)。 “新空间”中的分配非常便宜:每当我们想为新对象保留空间时,我们都会增加一个分配指针。当分配指针到达新空间的末尾时,会触发一次次要 GC。这个过程也称为 Scavenger,它实现了 Cheney’s algorithm。它经常发生并使用并行辅助线程并且非常快。

让我们看一下minor GC过程:
新空间分为两个大小相等的半空间:to-space 和 from-space。大多数分配都是在从空间中进行的(某些类型的对象除外,例如始终在旧空间中分配的可执行代码)。当 from-space 填满时,将触发次要 GC。
![[Pasted image 20220718231457.png]]
![[Pasted image 20220718231503.png]]
![[Pasted image 20220718231509.png]]
![[Pasted image 20220718231513.png]]
![[Pasted image 20220718231519.png]]
![[Pasted image 20220718231523.png]]
![[Pasted image 20220718231529.png]]
![[Pasted image 20220718231533.png]]
![[Pasted image 20220718231538.png]]
![[Pasted image 20220718231543.png]]
![[Pasted image 20220718231547.png]]
![[Pasted image 20220718231555.png]]
![[Pasted image 20220718231600.png]]

  1. 让我们假设当我们开始时,“from-space”上已经有对象(block 01到06标记为已使用内存)
  2. 进程创建一个新对象(07)
  3. V8试图从内存空间中获取所需的内存,但是那里没有空闲空间来容纳我们的对象,因此V8触发了次要的GC
  4. Minor GC递归地从栈指针(GC根)开始遍历“from-space”中的对象图,以找到被使用或活着的对象(已使用内存)。这些对象被移动到“to-space”中的一个页面。这些对象引用的任何对象也被移动到这个页面的“to-space”,它们的指针也被更新。这个过程一直重复,直到“来自空间”中的所有对象都被扫描。最后,“to-space”会自动压缩,减少碎片
  5. Minor GC现在清空“from-space”,因为这里剩余的任何对象都是垃圾
  6. Minor GC交换了“to-space”和“from-space”,所有的对象现在都在“from-space”中,而“to-space”为空
  7. 在“from-space”中为新对象分配内存。
  8. 让我们假设一段时间过去了,现在“from-space”上有更多的对象(block 07 – 09标记为已使用内存)
  9. 应用程序创建一个新对象(10)
  10. V8试图从“from-space”中获取所需的内存,但是那里没有空闲空间来容纳我们的对象,因此V8触发了第二次次要GC
  11. 重复上述过程,并且在第二次小GC中存活下来的任何活动对象都被移动到“旧空间”。第一次的幸存者被转移到“to-space”,剩余的垃圾被清除从“from-space”
  12. Minor GC交换了“to-space”和“from-space”,所有的对象现在都在“from-space”中,而“to-space”为空
  13. 在“from-space”中为新对象分配内存。

因此,我们看到了 Minor GC 如何从年轻代中回收空间并使其保持紧凑。这是一个Stop-The-World的过程,但它是如此快速和高效,以至于在大多数情况下都可以忽略不计。由于此过程不会扫描“旧空间”中的对象以查找“新空间”中的任何引用,因此它使用从旧空间到新空间的所有指针的寄存器。这通过称为write barriers.的过程记录到存储缓冲区。

主要 GC

这种类型的 GC 保持旧年代空间紧凑和干净。当 V8 根据动态计算的限制确定没有足够的旧空间时触发,因为它被次要 GC 周期填满。

Scavenger算法非常适合小数据量,但不适用于大堆,因为它有内存开销,因此主要的GC是使用Mark-Sweep-Compact算法完成的。它使用三色(白-灰-黑)标记系统。因此,主要GC是一个三步过程,第三步是根据分段启发法执行的。

![[rcjSZ0T.gif]]

  • 标记: 第一步,垃圾收集器识别哪些对象正在使用,哪些对象没有使用,这两种算法都很常见。正在使用的对象或从GC根递归可到达的对象(堆栈指针)被标记为活动的。从技术上讲,这是堆的深度优先搜索,可以看作是一个有向图
  • 清除: 垃圾收集器遍历堆并记录任何未被标记为活动的对象的内存地址。这个空间现在在空闲列表中被标记为空闲,可以用来存储其他对象
  • 压实: 清扫后,如果需要,所有幸存的物体将被移动到一起。这将减少碎片并提高向更新对象分配内存的性能

这种类型的 GC 也称为 stop-the-world GC,因为它们在执行 GC 时会在进程中引入暂停时间。为了避免这种情况,V8 使用了类似的技术
![[Pasted image 20220718232928.png]]

  • Incremental GC: GC是在多个增量步骤中完成的,而不是一个。
  • Concurrent marking: 标记是使用多个辅助线程并发完成的,不影响主JavaScript线程。写屏障用于跟踪JavaScript在helper并发标记时创建的对象之间的新引用。
  • Concurrent sweeping/compacting: 清扫和压缩在辅助线程中并发完成,不影响主JavaScript线程。
  • Lazy sweeping: 惰性清理包括延迟删除页中的垃圾,直到需要内存时才删除。

让我们看一下主要的GC流程:
1. 让我们假设通过了许多次要GC周期,旧空间几乎满了,V8决定触发“主要GC”。
2. 主要GC递归地遍历对象图,从栈指针开始,将使用的对象标记为活动的(已用内存),将剩余的对象标记为旧空间中的垃圾(孤儿)。这是使用多个并发助手线程完成的,每个助手跟随一个指针。这不会影响主JS线程。
3. 当完成并发标记或达到内存限制时,GC使用主线程执行标记结束步骤。这将引入一个小的暂停时间。
4. 主GC现在使用并发清除线程将所有孤立对象的内存标记为空闲。还会触发并行压缩任务,将相关的内存块移动到同一页面,以避免碎片化。指针在这些步骤中更新。


结论

这篇文章应该给你一个关于 V8 内存结构和内存管理的概述。这并不详尽,还有很多更高级的概念,您可以从 v8.dev 中了解它们。但是对于大多数 JS/WebAssembly 开发人员来说,这一级别的信息就足够了,我希望它可以帮助您编写更好的代码,考虑到这些,以获得更高性能的应用程序,记住这些将帮助您避免下一个内存泄漏问题您可能会遇到其他情况。

我希望你在学习V8内部结构的过程中获得了乐趣,

引用


已发布

分类

来自

标签:

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注