可视化讲解 V8 引擎中内存管理
目录
前言
在本文中,我们将介绍用于 ECMAScript 和 WebAssembly 的 V8 引擎的内存管理,V8 引擎用于 NodeJS、Deno&Electron 等运行时(runtimes),以及 Chrome、Chromium、Brave、Opera 和 Microsoft Edge 等 web 浏览器。由于 JavaScript 是一种解释性语言(interpreted language),它需要一个引擎来解释和执行代码。V8 引擎解释 JavaScript 并将其编译为机器代码(machine code)。V8 是用 C++ 编写的,可以嵌入任何 C++ 应用程序中。
V8 的内存结构
首先,我们来看看 V8 引擎的内存结构(memory structure)。由于 JavaScript 是单线程(single-threaded)语言,所以 V8 为每一个 JavaScript 上下文(context)分配一个进程。如果你使用 service worker
,V8 会为每个 service worker
开启一个新的进程。在 V8 进程中,一个正在运行的程序总是由一些分配的内存来表示,这称为 常驻集 ( Resident Set )。它可以进一步划分以下不同的部分:
这和我们在上一篇文章中提到的 JVM 有些相似。我们来看一看每一个部分都是做什么的:
堆内存(Heap memory)
这是 V8 存储对象和动态数据的地方。这是内存中区域中最大的块,也是垃圾回收(GC)发生的地方。不是整个堆内存都是垃圾回收的,只有新旧空间(New space、Old space)是垃圾回收管理的。堆内存可以进一步划分为以下几部分:
新空间(New space):新空间(或者说叫:新生代,Young generation),是存储新对象的地方,并且大部分对象的生命周期都很短(short-lived)。这个空间很小,有两个半空间(semi-space),类似于 JVM 中的 S0,S1。这片空间是由 Scavenger(Minor GC) 来管理的,稍后会介绍。新生代空间的大小可以由
--min_semi_space_size
(初始值) 和--max_semi_space_size
(最大值) 两个 V8 标志来控制。老空间(Old space):老空间(或者说叫:老生代,Old generation),存储的是在新生代空间中经过了两次 Minor GC 后存活下来的数据。这片空间是由 Major GC(Mark-Sweep & Mark-Compact)” 管理的,稍后会介绍。老生代空间的大小可以
--initial_old_space_size
(初始值) and--max_old_space_size
(最大值) 两个 V8 标志来控制。这片空间被分成了两个部分:- 老指针空间( Old pointer space ):包含了存活下来的包含指向其他对象指针的对象。
- 老数据空间( Old data space ):包含了仅保存数据的对象(没有指向其他对象的指针)。字符串,已装箱的数字(boxed numbers),未装箱的双精度数组(arrays of unboxed doubles),在新生代空间经过两轮
Minor GC
后存活下来的,会被移到老数据空间。
大对象空间(Large object space):这是大于其他空间大小限制的对象存储的地方。每个对象都有自己的 Mmap 内存区域。大对象是不会被垃圾回收的。
mmap
在计算机领域, mmap(2)
是一个符合 POSIX 标准的 Unix 系统调用,它将文件或设备映射到内存中。它是一种内存映射文件 I/O 的方法。它实现了需求分页,因为文件内容不会立即从磁盘上读取,最初根本不使用物理 RAM。从磁盘的实际读取是在访问一个特定的位置后,以一种懒惰的方式进行的。在不再需要映射后,必须用 munmap(2)
解除指针的映射。s
在 Linux、macOS 和 BSD 中, mmap
可以创建几种类型的映射。其他操作系统可能只支持其中的一个子集;例如,共享映射在一个没有全局 VFS 或 I/O 缓存的操作系统中可能不实用。
代码空间(Code-space):这就是即时(JIT,Just In Time)编译器存储编译代码块的地方。这是唯一有可执行内存的空间(尽管代码可能被分配在 “大对象空间” 中,它们也是可执行的)。
单元空间、属性单元空间、映射空间(Cell space, property cell space, and map space)。这些空间分别包含 Cell,PropertyCell 和 Map。 这些空间中的每一个都包含相同大小的对象,并且对它们指向的对象类型有一些限制,这简化了收集。
每个空间都由一组页(pages)组成。页是使用 mmap
或者 Windows 上的 MapViewOfFile 从操作系统分配的连续内存块。每页大小为 1MB,但大对象空间较大。
栈(Stack)
这是栈内存区域,每个 V8 进程有一个栈。这里存储静态数据,函数帧(function frames)、原语值(primitive values)和指向对象的指针(pointers)。栈内存限制可以使用 --stack_size
V8 标志设置。
V8 的内存使用(栈 VS 堆)
既然我们已经清楚了内存是如何组织的,让我们看看其中最重要的部分在执行程序时是如何使用的。
让我们使用下面的 JavaScript 程序,代码没有针对正确性进行优化,因此忽略了不必要的中间变量(intermediatory variables)等问题,重点是可视化栈和堆内存的使用情况。
js
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);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
可以通过下面的 ppt 看一下在上面的代码执行的过程中,栈内存和堆内存是如何使用的。
如你所见:
- 全局作用域(Global scope)保存在栈上的全局帧(Global frame)中。
- 每个函数调用都作为帧块(frame-block)添加到堆栈内存中。
- 所有局部变量(包括参数和返回值)都保存在栈的函数帧块(function frame-block)中。
- 像 int&string 这样的所有原始类型(primitive type)都直接存储在栈上。这同样适用于全局作用域。String 也是 JavaScript 的一个原始类型。
- 所有的对象类型如 Employee 和 Function 都是在堆上创建的,并使用堆指针从堆上引用。函数只是 JavaScript 中的对象。这也适用于全局作用域。
- 当前函数调用的函数将被推到栈的顶部。
- 当函数返回时,它的帧块将被移除。
- 一旦主进程完成,堆上的对象就不再有来自栈的指针,成为孤立的对象(orphan)。
- 除非显式复制,否则其他对象中的所有对象引用都是使用引用指针(reference pointers)完成的。
如你所见,栈是由操作系统自动管理的,而不是 V8。因此,我们不必太担心栈。另一方面,堆并不是由操作系统自动管理的,因为堆是最大的内存空间,并保存动态数据,它可能会随着时间的推移呈指数(exponentially)增长,导致我们的程序内存耗尽。随着时间的推移,它也变得支离破碎,减慢了应用程序的速度。这就是为什么需要垃圾回收。
区分堆上的指针和数据对于垃圾收集很重要,V8 使用 “标记指针”(Tagged pointers)方法来实现这一点。在这种方法中,它在每个单词的末尾保留一个位,以指示它是指针还是数据。这种方法需要有限的编译器支持,但实现起来很简单,同时效率也相当高。
V8 内存管理 - 垃圾回收(GC)
现在我们知道了 V8 如何分配内存,让我们看看它如何自动管理堆内存,这对应用程序的性能非常重要。当一个程序试图在堆上分配比自由可用的更多的内存(取决于 V8 标志集)时,我们会遇到内存不足的错误(out of memory errors)。错误管理的堆也可能导致内存泄漏(memory leak)。
V8 通过垃圾收集来管理堆内存。简单地说,它释放孤立对象(即不再直接或间接从堆栈中引用的对象(通过另一个对象中的引用)使用的内存,以便为创建新对象腾出空间。
Orinoco 是 V8 GC 项目的代码名,用于使用并行、增量和并发的垃圾回收技术来释放主线程。
V8 中的垃圾回收器负责回收未使用的内存,供 V8 进程重用。
V8 垃圾回收器是分代的(generational)(堆中的对象按其年龄分组并在不同阶段清除)。V8 有两个阶段和三种不同的垃圾收集算法:
Minor GC (Scavenger)
这种类型的 GC 保持新生代空间的紧凑和清洁。对象被分配到相当小的空间(1 到 8MB 之间,取决于行为启发(behavior heuristics))。新生代空间的分配成本很低:有一个分配指针(allocation pointer),每当我们想为新对象保留空间时,它都会递增。当分配指针到达新生代空间的末尾时,将触发次 Minor GC。这个过程被称为 Scavenger,实现了 “Cheney's algorithm”。Minor GC 频繁发生并使用并行的辅助线程,而且速度非常快。
Cheney's algorithm
Cheney's algorithm,是一种跟踪计算机软件系统中垃圾收集的停止和复制方法(stop and copy method)。在这个方案中,堆被分为两个相等的部分,在任何时候都只有其中的一个在使用。垃圾收集是通过将活的对象(live objects)从一个半空间(from-space)复制到另一个半空间(to-space),然后成为新的堆来执行的。然后,整个旧堆被一次性地丢弃。这是对以前的停止和复制技术的改进。参见 Cheney's algorithm - Wikiwand。
让我们来看一看 Minor GC 的过程:
新生代空间被分成两个大小相等的半空间:from-space 和 to-space。大多数分配都是在 to-space 中进行的(除了某些类型的对象,例如总是在老生代空间中分配的可执行代码(executable Codes))。当 to-space 填满时,将触发 Minor GC。完成过程如下:
- 让我们假设,当我们开始时,"from-space" 上已经有对象了(01 到 06 块被标记为已用内存)。
- 该进程创建了一个新的对象 (07)。
- V8 试图从 from-space 获取所需的内存,但是那里没有空闲空间来容纳我们的对象,因此 V8 触发了 minor GC。
- Minor GC 递归地遍历 "from-space" 中的对象图(object graph),从堆栈指针(stack pointers,GC 根)开始,找到被使用或活着的对象(已用内存)。这些对象被移到 "to-space" 的一个页面上。这些对象所引用的任何对象也被移到 "to-space" 中的这一页,它们的指针也被更新。这样重复进行,直到 "from-space" 中的所有对象都被扫描。到此为止,"to-space" 会被自动压缩(compacted),以减少碎片化(fragmentation)。
- Minor GC 现在清空了 "from-space",因为这里的任何剩余对象都是垃圾。
- Minor GC 交换 "to-space" 和 "from-space",所有的对象现在都在 "from-space" 中,"to-space" 是空的。
- 新的对象在 "from-space" 中被分配内存。
- 让我们假设一段时间过去了,现在 "from-space" 上有更多的对象(07 到 09 块被标记为已用内存)。
- 应用程序创建了一个新的对象 (10)。
- V8 试图从 "from-space" 获取所需的内存,但是那里没有空闲的空间来容纳我们的对象,因此 V8 触发了第二次 Minor GC。
- 上述过程重复进行,任何在第二次 Minor GC 中存活的对象都被移到 "Old space"。第一次存活的对象被移到 "to-space",剩余的垃圾被从 "from-space" 清除。
- Minor GC 交换 "to-space" 和 "from-space",所有对象现在都在 "from-space","to-space" 为空。
- 新的对象在 "from-space" 中被分配内存。
我们看到了 Minor GC 如何从新生代内存空间那里回收空间并使其保持紧凑的。这个过程虽然会停止其他操作(stop-the-world process),但是这个过程是十分迅速而有效的,大部分时候其时间成本都微不足道。由于此进程不扫描老生代空间中的对象以获取新生代空间中的任何引用,因此它使用从老生代空间到新生代空间的所有指针的寄存器(register)。这将由一个名为 write barriers 的进程记录到存储缓冲区(store buffer)。
写屏障
写屏障是阻止某些线程或进程对某些内存位置进行写操作的内存块。写屏障用于增量或并发的垃圾收集。它们也被用来维护分代收集器的记忆集合。
Major GC
这种类型的 GC 保持了老生代空间的紧凑和干净。当 V8 根据动态计算的限制确定没有足够的老生代空间时,就会触发此操作,因为它是从 Minor GC 周期中填充的。
Scavenger 算法非常适合于较小的数据量,但对于较大的老生代空间来说是不实际的,因为它有较大的内存开销(memory overhead),因此 GC 主要是使用 Mark-Sweep-Compact
算法完成的。它使用三色(白灰黑,white-grey-black,tri-color)标记系统。因此,Major GC 是一个三步过程,第三步是根据分段启发(fragmentation heuristic)执行的。
- 标记(Marking):第一步,两种算法都通用,其中垃圾回收器标识哪些对象正在使用,哪些对象未在使用。递归地从 GC 根(栈指针)开始将使用中或可访问的对象标记为活动的(alive)。从技术上讲,这是对堆的深度优先搜索(depth-first-search),可以看作是有向图(directed graph)。
- 清理(Sweeping):垃圾回收器遍历堆并记录任何未标记为活动的对象的内存地址。这些空间现在在空闲列表(free list)中被标记为空闲(free),可用于存储其他对象。
- 压缩(Compacting):清理后,如果需要,将所有剩下的对象移动到一起。这将减少碎片(fragmentation)并提高向较新对象分配内存的性能。
这种类型的 GC 也称为 stop-the-world GC
,因为它们在执行 GC 的过程中引入了暂停时间(pause-times)。为了避免这个 V8 使用了如下技术:
- 增量 GC(Incremental GC):GC 是以多个增量步骤而不是一个步骤完成的。
- 并发标记(Concurrent marking):标记是在不影响 JavaScript 主线程的情况下使用多个辅助线程(helper threads)并发完成的。
Write barriers
用于跟踪 JavaScript 在辅助程序并发标记时创建的对象之间的新引用。 - 并发扫描 / 压缩(Concurrent sweeping/compacting):扫描和压缩在辅助线程中同时完成,而不影响主 JavaScript 线程。
- 延迟清理(Lazy sweeping):延迟清理,包括延迟删除页中的垃圾,直到需要内存为止。
让我们来看一下 major GC 的过程:
- 让我们假设许多 Minor GC 周期已经过去,老生代空间几乎满了,V8 决定触发一个 Major GC。
- Major GC 从栈指针开始递归地遍历对象图,以标记在老生代空间中被使用的对象(已用内存)为 alive 和剩余对象为垃圾(Orphans,孤立的对象)。这是使用多个并发辅助线程完成的,每个辅助线程都追踪一个指针。这不会影响主线程。
- 当并发标记完成或达到内存限制时,GC 使用主线程执行标记终结步骤(mark finalization step)。这将引入一个小的暂停时间(pause time)。
- Major GC 现在使用并发扫描线程(concurrent sweep threads)将所有孤立对象的内存标记为空闲。并行压缩任务(Parallel compaction tasks)也会被触发,以将相关内存块移动到同一页以避免碎片化(fragmentation)。在这些步骤中会更新指针。
结论
本文将为您提供 V8 内存结构和内存管理的概述。这里没有做到面面俱到的,还有很多更高级的概念,您可以从 v8.dev 中了解它们。但是对于大多数 JS/WebAssembly 开发人员来说,这一级别的信息就足够了,我希望它能帮助您编写更好的代码,考虑到这些因素,对于更高性能的应用程序,记住这些可以帮助您避免下一个可能遇到的内存泄漏问题。