🚀 可视化 V8 引擎中的内存管理(JavaScript、NodeJS、Deno、WebAssembly)V8 内存结构 V8 内存使用情况(堆栈与堆)V8 内存管理:垃圾收集 结论 参考

2025-05-24

🚀 可视化 V8 引擎中的内存管理(JavaScript、NodeJS、Deno、WebAssembly)

V8内存结构

V8 内存使用情况(堆栈与堆)

V8 内存管理:垃圾收集

结论

参考

最初发表于deepu.tech

在这个由多部分组成的系列文章中,我旨在揭开内存管理背后的概念,并深入探讨一些现代编程语言中的内存管理。我希望本系列文章能让您深入了解这些语言在内存管理方面的底层原理。

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

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

V8内存结构

首先,我们来看看 V8 引擎的内存结构。由于 JavaScript 是单线程的,V8 引擎也为每个 JavaScript 上下文使用一个进程。因此,如果你使用 Service Worker,它会为每个 Worker 进程生成一个新的 V8 进程。一个正在运行的程序总是由 V8 进程中分配的一块内存来表示,这块内存被称为驻留集。驻留集进一步细分为以下几个部分:

V8 内存结构

这与我们在上一章中看到的JVM内存结构略有相似。让我们看看不同段的用途:

堆内存

这是 V8 存储对象或动态数据的地方。这是最大的一块内存区域,也是垃圾回收 (GC)发生的地方。整个堆内存不会被垃圾回收,只有年轻代和老年代会被垃圾回收管理。堆进一步划分为以下几部分:

  • 新生代空间:新生代空间或称“年轻代”是新生代对象存放的地方,这些对象大多寿命较短。新生代空间较小,包含两个半空间,类似于JVM 中的S0S1 。该空间由“Scavenger(Minor GC)”管理,我们稍后会详细介绍。新生代空间的大小可以通过V8 的--min_semi_space_size(Initial) 和--max_semi_space_size(Max) 标志来控制。
  • 老生代空间:老生代空间或“老生代”是指在“新生代空间”中,经过两次 Minor GC 循环后仍存活下来的对象被移动到的空间。该空间由Major GC(标记-清除和标记-压缩)进行管理,我们稍后会讲到。老生代空间的大小可以通过V8 的--initial_old_space_size(Initial) 和--max_old_space_size(Max) 标志来控制。该空间分为两部分:
    • 旧指针空间:包含具有指向其他对象的指针的幸存对象。
    • 旧数据空间:包含仅包含数据(没有指向其他对象的指针)的对象。字符串、装箱的数字以及未装箱的双精度浮点数组在“新空间”中存活两个 Minor GC 周期后,会被移至此处。
  • 大对象空间:大于其他空间大小限制的对象存放在此空间。每个对象都有自己的mmap'd内存区域。大对象永远不会被垃圾收集器移动。
  • 代码空间:这是即时 (JIT)编译器存储已编译代码块的地方。这是唯一具有可执行内存的空间(尽管也Codes可以在“大对象空间”中分配,但这些也是可执行的)。
  • 单元格空间、属性单元格空间和映射空间:这些空间分别包含CellsPropertyCellsMaps。每个空间都包含大小相同的对象,并且对它们指向的对象类型有一些限制,从而简化了收集。

每个空间由一组页面组成。页面是由操作系统mmap(或[MapViewOfFile](https://docs.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-mapviewoffile)Windows 系统)分配的一块连续的内存。除大对象空间外,每个页面的大小为 1MB。

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


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);
Enter fullscreen mode Exit fullscreen mode

单击幻灯片并使用箭头键向前/向后移动,查看上述程序如何执行以及如何使用堆栈和堆内存:

注意:如果幻灯片的边缘看起来被切断了,请单击幻灯片的标题或此处直接在 SpeakerDeck 中打开它。

如你看到的:

  • 全局范围保存在堆栈上的“全局框架”中
  • 每个函数调用都作为帧块添加到堆栈内存中
  • 所有局部变量(包括参数和返回值)都保存在堆栈上的函数框架块内
  • 所有原始类型(例如int&)string都直接存储在栈中。这也适用于全局作用域,而且 String 是 JavaScript 的原始类型。
  • 所有对象类型(例如Employee&)Function都是在堆上创建的,并使用栈指针从栈中引用。函数在 JavaScript 中只是对象。这也适用于全局作用域。
  • 当前函数调用的函数被压入栈顶
  • 当函数返回时,其框架将从堆栈中删除
  • 一旦主进程完成,堆上的对象就不再有来自栈的指针,并变为孤儿
  • 除非明确进行复制,否则其他对象中的所有对象引用都是使用引用指针完成的

如您所见,堆栈是自动管理的,由操作系统而不是 V8 本身负责。因此,我们无需过多担心堆栈。另一方面,堆并非由操作系统自动管理,而且由于它是最大的内存空间,并且存储动态数据,因此它可能会呈指数级增长,导致我们的程序随着时间的推移耗尽内存。随着时间的推移,它还会变得碎片化,从而降低应用程序的运行速度。这时,垃圾收集器就派上用场了。

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


V8 内存管理:垃圾收集

现在我们已经了解了 V8 是如何分配内存的,接下来让我们看看它是如何自动管理堆内存的,这对于应用程序的性能至关重要。当程序尝试在堆上分配超过可用内存(取决于 V8 设置的标志)时,就会遇到内存不足的错误。错误管理的堆也可能导致内存泄漏。

V8 通过垃圾回收机制来管理堆内存。简单来说,它会释放孤儿对象(即不再被栈直接或间接(通过其他对象中的引用)引用的对象)占用的内存,为新对象的创建腾出空间。

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

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

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

小型 GC(清道夫)

这种类型的 GC 可以保持年轻代或新生代空间紧凑且干净。对象分配在新空间中,该空间相当小(介于 1 到 8 MB 之间,具体取决于行为启发式算法)。在“新空间”中分配空间非常便宜:有一个分配指针,每当我们想要为新对象预留空间时,我们都会递增该指针。当分配指针到达新空间的末尾时,会触发一次次级 GC。此过程也称为Scavenger,它实现了Cheney 算法。它频繁发生,使用并行辅助线程,速度非常快。

我们来看一下minor GC的流程:

新空间被划分为两个大小相等的半空间:目标空间 (to-space)起始空间 (from-space)。大多数分配操作都在起始空间中进行(某些类型的对象除外,例如可执行代码,它们总是分配在旧空间中)。当起始空间填满时,会触发 Minor GC。

单击幻灯片并使用箭头键向前/向后移动以查看流程:

注意:如果幻灯片的边缘看起来被切断了,请单击幻灯片的标题或此处直接在 SpeakerDeck 中打开它。

  1. 让我们假设当我们启动时“起始空间”上已经有对象(块 01 到 06 标记为已用内存)
  2. 该过程创建一个新对象(07)
  3. V8 尝试从源空间获取所需的内存,但源空间中没有可用空间来容纳我们的对象,因此 V8 触发了 minor GC
  4. Minor GC 会从堆栈指针(GC 根)开始递归遍历“源空间”中的对象图,以查找已使用或处于活动状态的对象(已用内存)。这些对象会被移动到“目标空间”中的某个页面。这些对象引用的任何对象也会被移动到“目标空间”中的此页面,并更新它们的指针。此过程重复进行,直到扫描完“源空间”中的所有对象。扫描完成后,“目标空间”会自动压缩,从而减少碎片。
  5. Minor GC 现在清空“来自空间”,因为这里剩余的任何对象都是垃圾
  6. Minor GC 交换“目标空间”和“源空间”,所有对象现在都在“源空间”中,“目标空间”为空
  7. 新对象在“起始空间”中分配内存
  8. 让我们假设已经过去了一段时间,现在“源空间”上有更多的对象(块 07 到 09 标记为已用内存)
  9. 应用程序创建一个新对象(10)
  10. V8 尝试从“源空间”获取所需内存,但那里没有可用空间来容纳我们的对象,因此 V8 触发第二次 minor GC
  11. 重复上述过程,在第二次 Minor GC 中幸存的对象将被移至“老生代”空间。第一次 Minor GC 中幸存的对象将被移至“目标空间”,剩余的垃圾则从“源空间”清除。
  12. Minor GC 交换“目标空间”和“源空间”,所有对象现在都在“源空间”中,“目标空间”为空
  13. 新对象在“起始空间”中分配内存

以上我们了解了 Minor GC 如何从年轻代回收空间并保持其紧凑。这是一个 Stop-the-world 过程,但它非常快速高效,大多数情况下可以忽略不计。由于此过程不会扫描“旧空间”中的对象以查找“新空间”中的任何引用,因此它使用一个寄存器来记录从旧空间指向新空间的所有指针。这些指针由一个称为“写屏障”的过程记录到存储缓冲区中。

主要 GC

这种类型的 GC 可以保持老年代空间紧凑且干净。当 V8 根据动态计算的限制判断老年代空间不足时,就会触发这种 GC,因为老年代空间已被 Minor GC 周期填满。

Scavenger 算法非常适合处理小规模数据,但对于较大的堆(例如旧空间)来说,由于内存开销较大,因此并不实用。因此,Major GC 使用标记-清除-压缩算法进行。该算法使用三色(白-灰-黑)标记系统。因此,Major GC 包含三个步骤,其中第三步根据碎片启发式算法执行。

标记-清除-压缩 GC

  • 标记:第一步,两种算法都采用相同的步骤,垃圾收集器会识别哪些对象正在使用,哪些对象未使用。正在使用或可从 GC 根(堆栈指针)递归访问的对象会被标记为“活动”。从技术上讲,这是对堆的深度优先搜索,可以将其视为一个有向图。
  • 清除:垃圾收集器遍历堆,并记录任何未标记为活动对象的内存地址。该空间现在在空闲列表中标记为空闲,可用于存储其他对象
  • 压缩:清理完成后,如果需要,所有幸存的对象将被移动到一起。这将减少碎片,并提高向新对象分配内存的性能。

这种类型的 GC 也称为“stop-the-world GC”,因为它们在执行 GC 时会引入暂停时间。为了避免这种情况,V8 使用了以下技术:

主要 GC

  • 增量 GC:GC 通过多个增量步骤而不是一个步骤完成。
  • 并发标记:标记使用多个辅助线程并发完成,不会影响主 JavaScript 线程。写入屏障用于跟踪辅助线程并发标记期间 JavaScript 创建的对象之间的新引用。
  • 并发清除/压缩:清除和压缩在辅助线程中并发完成,而不会影响主 JavaScript 线程。
  • 惰性清除。惰性清除是指延迟删除页面中的垃圾,直到需要内存为止。

我们先看一下主要的GC流程:

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

结论

这篇文章应该会给你一个关于 V8 内存结构和内存管理的概述。它并非详尽无遗,还有很多更高级的概念,你可以在v8.dev上学习。但对于大多数 JS/WebAssembly 开发者来说,这种程度的信息已经足够了。我希望它能帮助你编写更好的代码,考虑到这些,从而打造性能更高的应用程序。记住这些,可以帮助你避免下次可能遇到的内存泄漏问题。

我希望您在学习 V8 内部结构时能获得乐趣,请继续关注本系列的下一篇文章。


参考


如果您喜欢这篇文章,请点赞或留言。

您可以在TwitterLinkedIn上关注我。

文章来源:https://dev.to/deepu105/visualizing-memory-management-in-v8-engine-javascript-nodejs-deno-web assembly-105p
PREV
让我们谈谈青少年
NEXT
🚀 JVM 内存管理可视化(Java、Kotlin、Scala、Groovy、Clojure)JVM 内存结构 JVM 内存使用情况(栈 vs 堆)JVM 内存管理:垃圾回收 结论 参考