V8引擎的内存管理
在本章中,我们将介绍用于ECMAScript和WebAssembly的V8引擎的内存管理,这些引擎用于NodeJS、Deno&Electron等运行时,以及Chrome、Chromium、Brave、Opera和Microsoft Edge等web浏览器。由于JavaScript是一种解释性语言,它需要一个引擎来解释和执行代码。V8引擎解释JavaScript并将其编译为机器代码。V8是用C++编写的,可以嵌入任何C++应用程序中。
首先,我们来看看V8引擎的内存结构。由于JavaScript是单线程语言,所以V8为每一个JavaScript上下文使用一个进程。如果你使用service worker,V8会为每个service worker开启一个新的进程。在V8进程中,一个正在运行的程序总是由一些分配的内存来表示,这称为常驻集(Resident Set)。可以进一步划分以下不同的部分:
这和我们在上一篇文章中提到的JVM有些相似。我们来看一看每一个部分都是做什么的:
堆内存(Heap memory)
这是V8存储对象和动态数据的地方。这是内存中区域中最大的块,也是垃圾回收(GC)发生的地方。整个堆内存不是垃圾回收的,只有新旧空间(New space、Old space)是垃圾回收管理的。堆内存可以进一步划分为以下几部分:
新空间(New space)
新空间(或者说叫:新生代),是存储新对象的地方,并且大部分对象的声明周期都很短。这个空间很小,有两个半空间,类似于JVM中的S0,S1。这片空间是由Scavenger(Minor GC)来管理的,稍后会介绍。新生代空间的大小可以由--min_semi_space_size(初始值) 和 --max_semi_space_size(最大值) 两个V8标志来控制。
老空间(Old space)
老空间(或者说叫:老生代),存储的是在新生代空间中经过了两次Minor GC后存活下来的数据。这片空间是由Major GC(Mark-Sweep & Mark-Compact)”管理的,稍后会介绍。老生代空间的大小可以--initial_old_space_size(初始值) and --max_old_space_size(最大值) 两个V8标志来控制。这片空间被分成了两个部分:
老指针空间(Old pointer space):包含了存活下来的包含指向其他对象指针的对象。
老数据空间(Old data space):包含了仅保存数据的对象(没有指向其他对象的指针)。字符串,已装箱的数字,未装箱的双精度数组,在新生代空间经过两轮Minor GC后存活下来的,会被移到老数据空间。
大对象空间(Large object space)
这是大于其他空间大小限制的对象存储的地方。每个对象都有自己的内存区域。大对象是不会被垃圾回收的。
代码空间(Code-space)
这就是即时(JIT)编译器存储编译代码块的地方。这是唯一有可执行内存的空间(尽管代码可能被分配在“大对象空间”中,它们也是可执行的)。
单元空间、属性单元空间、映射空间(Cell space, property cell space, and map space)
这些空间分别包含Cell,PropertyCell 和 Map. 这些空间中的每一个都包含相同大小的对象,并且对它们指向的对象类型有一些限制,这简化了收集。
每个空间都由一组页组成。页是使用 mmap从操作系统分配的连续内存块。每页大小为1MB,但大对象空间较大。
栈(Stack)
这是栈内存区域,每个V8进程有一个栈。这里存储静态数据,包括方法/函数框架、原语值和指向对象的指针。栈内存限制可以使用--stack_size V8标志设置。
V8的内存使用(栈 VS 堆)
既然我们已经清楚了内存是如何组织的,让我们看看在执行程序时如何使用其中最重要的部分。
让我们使用下面的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);
可以通过下面的ppt看一下在上面的代码执行的过程中,栈内存和堆内存是如何使用的。
如你所见:
全局作用域保存在栈上的全局框架(Global frame)中。
每个函数调用都作为帧块添加到堆栈内存中。
所有局部变量(包括参数和返回值)都保存在栈的函数框块中。
像int&string这样的所有基元类型都直接存储在栈上。这同样适用于全局作用域。
当前函数调用的函数将被推到栈的顶部。
当函数返回时,它的框架帧块将被移除。
一旦主进程完成,堆上的对象就不再有来自栈的指针,成为孤立的对象。
除非显式复制,否则其他对象中的所有对象引用都是使用引用指针完成的。
如你所见,栈是由操作系统自动管理的,而不是V8。因此,我们不必太担心栈。另一方面,堆并不是由操作系统自动管理的,因为堆是最大的内存空间,并保存动态数据,它可能会随着时间的推移呈指数增长,导致我们的程序内存耗尽。随着时间的推移,它也变得支离破碎,减慢了应用程序的速度。这就是为什么需要垃圾回收。
区分堆上的指针和数据对于垃圾收集很重要,V8使用“标记指针”方法来实现这一点。在这种方法中,它在每个单词的末尾保留一个位,以指示它是指针还是数据。这种方法需要有限的编译器支持,但实现起来很简单,同时效率也相当高。
V8内存管理 - 垃圾回收(GC)
现在我们知道了V8如何分配内存,让我们看看它如何自动管理堆内存,这对应用程序的性能非常重要。当一个程序试图在堆上分配比自由可用的更多的内存(取决于V8标志集)时,我们会遇到内存不足的错误。错误管理的堆也可能导致内存泄漏。
V8通过垃圾收集来管理堆内存。简单地说,它释放孤立对象(即不再直接或间接从堆栈中引用的对象(通过另一个对象中的引用)使用的内存,以便为创建新对象腾出空间。
Orinoco是V8 GC项目的代码名,用于使用并行、增量和并发的垃圾回收技术来释放主线程。
V8中的垃圾回收器负责回收未使用的内存,供V8进程重用。
V8垃圾回收器是分代的(堆中的对象按其年龄分组并在不同阶段清除)。V8有两个阶段和三种不同的垃圾收集算法:
Minor GC (Scavenger)
这种类型的GC保持新生代空间的紧凑和清洁。对象被分配到相当小的空间(1到8MB之间,取决于行为启发)。新生代空间的分配成本很低:有一个分配指针,每当我们想为新对象保留空间时,它都会递增。当分配指针到达新生代空间的末尾时,将触发次Minor GC。这个过程被称为Scavenger,实现了“切尼算法”。Minor GC经常出现并使用并行的辅助线程,而且速度非常快。
让我们来看一看Minor GC的过程:
新生代空间被分成两个大小相等的半空间:from-space和to-space。大多数分配都是在to-space中进行的(除了某些类型的对象,例如总是在老生代空间中分配的可执行代码)。当to-space填满时,将触发Minor GC。完成过程如下:
当我们开始时,假设to-space里已经有对象了。
进程创建了一个新的对象。
V8试图从to-space获取所需的内存,但其中没有可用空间来容纳我们的对象,因此V8触发了Minor GC。
Minor GC交换to-space和from-space,所有对象现在都在from-space中,to space为空。
Minor GC递归地从堆栈指针(GC根)开始遍历from-space中的对象图,以查找已使用或活动的对象(已用内存)。这些对象将移动到to-space的页中。由这些对象引用的任何对象也会在to-space中移动到此页,并且它们的指针会更新。重复此操作,直到from-space中的对象都被扫描一次。最终,to-space被自动压缩以减少碎片。
Minor GC现在清空from-space,因为这里的任何剩余对象都是垃圾。
新对象被分配到to-space的内存空间中。
让我们假设过了一段时间,to-space中的对象更多了。
应用又新建了一个对象。
V8试图从to-space获取所需的内存,但其中没有可用空间来容纳我们的对象,因此V8触发了第二次Minor GC。
重复上述过程,并将第二个Minor GC中幸存的任何活动对象移动到老生代空间。第一次Minor GC的幸存者被转移到to-space,剩余的垃圾从from-space中被清除。
新对象被分配到to-space的内存空间中。
我们看到了Minor GC如何从新生代内存空间那里回收空间并使其保持紧凑的。这个过程虽然会停止其他操作,但是这个过程是十分迅速而有效的,大部分时候都微不足道。由于此进程不扫描老生代空间中的对象以获取新生代空间中的任何引用,因此它使用从老生代空间到新生代空间的所有指针的寄存器。这将由一个名为write barriers的进程记录到存储缓冲区。
Major GC
这种类型的GC保持了老生代空间的紧凑和干净。当V8根据动态计算的限制确定没有足够的老生代空间时,就会触发此操作,因为它是从Minor GC周期中填充的。
Scavenger算法非常适合于较小的数据量,但对于较大的老生代空间来说是不实际的,因为它有内存开销,因此主要的GC是使用Mark-Sweep-Compact算法完成的。它使用三色(白灰黑)标记系统。因此,Major GC是一个三步过程,第三步是根据分段启发执行的。
标记:第一步,两种算法都通用,其中垃圾回收器标识哪些对象正在使用,哪些对象未在使用。递归地从GC根(栈指针)中使用中或可访问的对象被标记为活动的。从技术上讲,这是对堆的深度优先搜索,可以看作是有向图。
清理:垃圾回收器遍历堆并记录任何未标记为活动的对象的内存地址。这些空间现在在空闲列表中被标记为空闲,可用于存储其他对象。
压缩:清理后,如果需要,将所有剩下的对象移动到一起。这将减少碎片并提高向较新对象分配内存的性能。
这种类型的GC也称为stop-the-world GC,因为它们在执行GC的过程中引入了暂停时间。为了避免这个V8使用了如下技术:
增量GC:GC是以多个增量步骤而不是一个增量步骤完成的。
并发标记:标记是在不影响主JavaScript线程的情况下使用多个辅助线程并发完成的。Write barriers用于跟踪JavaScript在帮助程序并发标记时创建的对象之间的新引用。
并发扫描/压缩:扫描和压缩在助手线程中同时完成,而不影响主JavaScript线程。
延迟清理:延迟清理,包括延迟删除页中的垃圾,直到需要内存为止。
让我们来看一下 major GC的过程:
让我们假设许多Minor GC周期已经过去,旧空间几乎满了,V8决定触发一个Major GC
Major GC从栈指针开始递归地遍历对象图,以标记在老生代空间中用作活动(已用内存)和剩余对象作为垃圾(孤立)的对象。这是使用多个并发助手线程完成的,每个助手都跟随一个指针。这不会影响主JS线程。
当并发标记完成或达到内存限制时,GC使用主线程执行标记终结步骤。这将引入一个小的暂停时间。
Major GC现在使用并发扫描线程将所有孤立对象的内存标记为空闲。并行压缩任务也会被触发,以将相关内存块移动到同一页以避免碎片化。在这些步骤中会更新指针。
结论
本文将为您提供V8内存结构和内存管理的概述。这里没有做到面面俱到的,还有很多更高级的概念,您可以从v8.dev中了解它们。但是对于大多数JS/WebAssembly开发人员来说,这一级别的信息就足够了,我希望它能帮助您编写更好的代码,考虑到这些因素,对于更高性能的应用程序,记住这些可以帮助您避免下一个可能遇到的内存泄漏问题。