Node.js 垃圾回收器

转自:
http://newhtml.net/v8-garbage-collection/#more-1521

垃圾回收器(GC)对于程序员来说并不算太陌生,它可以协助开发人员大幅简化程序的内存管理代码,因为内存管理不再需要由开发人员来维护,所以随之也减少了(并不是根除)长时间运转程序所可能造成的内存泄露问题。

利弊相依,选择了 GC 也就意味着应用程序无法完全掌控内存(这也正是移动终端开发的症结所在),对于 JavaScript 语言来说,程序中没有任何内存管理的可能 —— ECMA Script 标准中并没有暴露出任何的 GC 接口,网页应用既没有办法管理内存,也没有办法给 GC 以任何的提示。

严格意义上来说,使用了 GC 的编程语言在性能方面并不一定会比不使用 GC 的语言更好或者更差。例如:在 C 语言中,分配和释放内存有可能会是一个成本非常昂贵的操作 —— 为了使得分配的内存能够在将来的某个时间节点被释放,堆的管理就会趋近于复杂。在托管内存的语言中,分配内存往往可能只是增加一个指针,但随着内存的耗尽,GC 介入回收所产生的代价会是巨大的。一个不恰当的 GC 有可能
会致使程序在运行中出现长时间的、甚至是无法预期的停顿,这可能会直接影响到系统交互层面上的体验。

对于 JavaScript 语言来说,V8 引擎的 GC 在实现上已经成熟,其性能优异、停顿短暂,在性能负担上也非常可控。

基本概念

GC 要解决的最基本的问题就是如何辨别出要回收的内存,一旦辨别完毕,那么这些内存区域即可在未来的内存分配中被重用或者是直接返还给操作系统。

一个对象处于活跃状态,当且仅当它被一个根对象(根对象被定义为是处于活跃状态的,它是 V8 引擎或浏览器所引用的对象,例如:被局部变量所指向的对象属于根对象,因为它们的栈被视为根对象;全局对象也属于根对象,因为它们始终可以被访问;浏览器对象如 DOM 元素也属于是根对象)或另一个活跃对象指向时。

总而言之,当一个对象可以被程序引用时,那么它就是处于活跃状态的。例如在下面这个例子中,obj 和 obj.x 都是属于活跃状态的,尽管对它的再度引用是发生在死循环之后:

function func() {
	var obj = { x: 12 };
	do something... // 这里可能包含一个死循环
	return obj.x;
}

综上所述,我们可以做一个等价约定:如果一个对象可以经由某个被定义为活跃状态的对象,通过某个指针链所访问,那么它就可以被看作是活跃状态的,否则就将其视为垃圾。

堆的构成

V8 引擎将堆划分成不同的区域:

  • 新生区:绝大多数的对象都会被分配在新生区。新生区是一个很小的区域,它与其他区域相独立,GC 在这个区域内活动地非常频繁;
  • 老生指针区:包含大多数可能存在指向其他对象的指针的对象。大多数在新生区存活了一定时间后的对象都会被挪到这里;
  • 老生数据区:只存放包含原始数据的对象(即没有指向其他对象的指针,例如:字符串、封箱的数字以及未封箱的双精度数字数组,它们在新生区存活了一定时间后就会被挪至此处);
  • 大对象区:存放体积远超越其他区域大小的对象。其中每个对象都有自己 mmap(一种内存映射文件的方法,即将一个文件或其他对象映射到进程的地址空间,以实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对应关系)产生的内存,GC 也从不移动大对象;
  • 代码区:即代码对象,也就是包含 JIT(即时编译)之后指令的对象会被分配到这里。这是唯一一个拥有执行权限的内存区(如果代码对象因为自身过大而被放至大对象区,那么该大对象所对应的内存也是可执行的,需要注意的是大对象内存区本身并不是可执行的内存区);
  • Cell 区、属性 Cell 区、Map 区:存放 Cell、属性 Cell 以及 Map。每个区域因为都是存放着相同大小的元素,所以相对的,这些内存结构很简单。

上述每个区域都是由一组内存页所构成。内存页是一块连续的内存,经 mmap 或者 Windows 的类似等价物由操作系统(OS)分配而来。除去大对象区的内存页较大之外,其余每个区的内存页都是 1MB 大小,并且按照 1MB 内存对齐。除了存储对象,内存页还含有一个页头(包含一些元数据和标识信息)以及一个位图区(用来标记哪些对象是活跃状态的)。另外,每个内存页还有一个单独分配在其他内存区的槽缓冲区(里面存放着一组对象,这些对象可能指向其他存储在该内存页的对象)。

GC 的指针识别

一个设计良好的 GC 首先要解决的就是如何在堆中识别出指针和数据,因为指针指向着活跃的对象,而且大多数 GC 会将对象在内存中挪动以便减少内存碎片,使得内存更加紧凑,因此即使不区分指针和对象,我们也需要对指针进行改写。

目前识别指针的方法大致分为三种:

  • 保守法。这种方法对于在缺少编译器支持的情况下非常重要。C/C++ 的 GC 会采用这种方式,比如 Boehm-Demers-Weiser。C/C++ 由于指针算术的存在,编译器无法确定出哪些内存是真正的垃圾,从而无法给 GC 以有效的提示,最终导致 GC 不得不采用这样的保守策略。大体上我们将堆上对齐的字(内存对齐)都认为是指针,而这也就意味着有些数据可能也会被误认为是指针(于是某些实际上是数字的假指针会被误认为是指向活跃的对象,从而造成一些奇异的内存泄露,因为 GC 会以为死对象仍然还有指针指向,从而错误地将死对象误认为是活跃对象)。同时为了避免数据遭到破坏,我们无法通过移动内存区域、紧凑内存来进行内存分配、内存访问或者内存局部性缓存(如果内存是紧凑的,那么在内存分配时便可以更容易地分配较大片的内存,而无需因为内存碎片而不断地去查找。同时因为已分配的内存是连续的或者近似于连续的,Cache 所能缓存的内存又是有限的,那么如果内存被 Cache 缓存起来则无需频繁地迫使 Cache 更换缓存的内存);
  • 编译器提示法。对于静态语言来说,编译器能够准确地告知我们每个类中指针的具体位置,一旦我们知道对象是由哪个类实例化而来的时,我们便能够知道对象中所有的指针,JVM 就是采用了这样的策略。而对于像 Javascript 这样的动态语言来说,其对象中的任何属性既可以是指针,也可以是数据,便显得不太好使了;
  • 标记指针法。这种方法需要一定的编译器支持,实现简单且性能不俗,它要求在每个字的末位预留出一位用来标记这个字所代表的是指针还是数据,V8 引擎便是采用了这种方式。

GC 的基本实现

分代回收

在绝大多数程序中,对象的生存期很短,只有部分对象的生存期会较长。

正是利用这一特性,V8 引擎将堆进行了分代。对象最开始会被分配在新生区(通常很小,只有 1~8MB,具体大小需要依据行为来进行启发)。在新生区进行内存分配非常容易:我们只需保有一个指向内存区的指针并不断根据新对象的大小来对其进行递增即可。

而当该指针达到了新生区的末尾时,就会有一次清理(小周期),清理掉在新生区中不活跃的死对象。对于活跃周期超过了 2 个小周期的对象,则会将其移动至老生区。老生区在 标记-清除 或者 标记-紧缩(大周期)的过程中才会进行回收。大周期进行的并不频繁,一次大周期通常是在移动了足够多的对象至老生区后才会发生,至于这里的足够多具体是多少,则要根据老生区自身的大小和程序的动向来决定了。

由于清理发生地很频繁,所以清理必须进行地非常快速。V8 引擎的清理过程被称为 Scavenge 算法,它是按照 Cheney 算法实现的。这个算法的大致流程是:新生区被划分为两个等大的子区(出区和入区),绝大多数的内存分配都会发生在出区(但某些特定类型的对象是分配在老生区的,例如上述所提到的可执行的代码对象)。当出区的内存耗尽时,我们会交换出区和入区,这样所有的对象就都归属在入区当中了,然后再将入区中活跃的对象复制到出区或者老生区中,在此过程中我们就将活跃对象进行紧缩,以便提升 Cache 的内存局部性,以保持内存分配的简洁快速。

以下是这个算法的伪代码描述:

def scavenge():
	swap(fromSpace, toSpace)
	allocationPtr = toSpace.bottom
	scanPtr = toSpace.bottom

	for i = 0..len(roots):
		root = roots[i]
	    if inFromSpace(root):
	      rootCopy = copyObject(&allocationPtr, root)
	      setForwardingAddress(root, rootCopy)
	      roots[i] = rootCopy

	while scanPtr < allocationPtr:
		obj = object at scanPtr
	    scanPtr += size(obj)
	    n = sizeInWords(obj)
	    for i = 0..n:
			if isPointer(obj[i]) and not inOldSpace(obj[i]):
	        	fromNeighbor = obj[i]
			if hasForwardingAddress(fromNeighbor):
				toNeighbor = getForwardingAddress(fromNeighbor)
			else:
				toNeighbor = copyObject(&allocationPtr, fromNeighbor)
				setForwardingAddress(fromNeighbor, toNeighbor)
			obj[i] = toNeighbor

	def copyObject(*allocationPtr, object):
		copy = *allocationPtr
		*allocationPtr += size(object)
		memcpy(copy, object, size(object))
		return copy

在这个算法的执行过程中,我们始终维护着两个出区中的指针:allocationPtr 指向我们即将为新对象分配内存的地方,scanPtr 指向我们即将进行活跃检查的下一个对象。scanPtr 指向的地址之前的对象是已经处理过的,它们及其邻接的都在出区,其指针都是更新过的。位于scanPtr 和 allocationPtr 之间的对象会被复制至出区,但如果这些对象内部所包含的指针指向了入区中的对象,那么这些入区中的对象就不会被复制。

我们可以将 scanPtr 和 allocationPtr 之间的对象想象成一个广度优先搜索的对象队列。

在算法的初始时,我们会复制新生区中所有的可从根对象到达的对象,之后进入一个大的循环,在循环的每一轮我们都会从队列中删除一个对象(也就是对 scanPtr 增量),然后再跟踪访问对象内部的指针,如果指针不指向入区则不管它(因为它必然指向老生区),而如果指针指向了入区中的某个对象,但我们还没有对其复制(也就是还未设置转发地址),则将这个对象复制至出区(即增加到队列的末端,同时也是对 allocationPtr 增量)。

这时候我们还会在出区对象的首字中存储一个指向新副本的转发地址,用来替换掉映射指针。GC 可以通过检查低位轻易地区分出转发地址和映射指针,因为映射指针经过了标记(低位被设置),而转发地址没有被标记(低位被清除)。如果我们能够找到一个指针并且它所指向的对象已经被复制过了(即设置了转发地址),那么我们就把这个指针更新为转发地址,然后再打上标记。

算法在所有对象都处理完毕时终止(即 scanPtr 和 alllocationPtr 相遇时),这时入区中的内容都可以被视作是垃圾,它们将在将来的时间节点被释放或者重用。

在上述逻辑处理中,我们可能会想到这样一个问题:如果在新生区中存在这样一个对象,它只有一个指向它的指针,而这个指针又恰好是在老生区中的对象当中,那么我们如何才能知道新生区的这个对象是活跃的呢?显然我们不可能再遍历一遍老生区对象,因为老生区的对象很多,这样带来的成本太高了。

为了解决上述问题,实际上在写缓冲区中有一个列表,列表中记录了所有老生区对象指向新生区的情况。在新对象刚刚诞生的时候,并不会有指向它的指针,而当有老生区中的对象出现指向新生区对象的指针时,我们便会记录下这样的跨区指向。由于这种记录行为总是会发生在写操作时,所以我们称之为写屏障。

由于每次进行写操作都需要经过写屏障,所以每次都需要执行一堆额外的指令,这正是其中的一个代价之一。但我们并不需要太过担心,因为写操作毕竟比读操作相对要少。

“标记-清除”算法和“标记-紧缩”算法

虽然 Scavenge 算法在快速回收、紧缩小片内存上的效果很好,但是对于大片内存则消耗过大,因为 Scavenge 算法需要出区和入区两个区域(这对于小片内存尚可,但是对于大小超过数 MB 的内存就开始变得不切实际了)。对此我们采取另外两种相互较为接近的算法:“标记-清除”算法和“标记-紧缩”算法,这两种算法都包含两个阶段:标记阶段、清除/紧缩阶段。

在标记阶段,所有堆上的活跃对象都会被发现并且被标记。每个页都会包含一个用来标记的位图,位图中的每一位对应页中的一字(一个指针就是一字大小)。这个标记非常重要,因为指针可能会在任何字对齐的地方出现。显然这样的位图需要占据一定的内存空间(32 位系统上占据 3.1%,64 位系统上占据 1.6%),但所有的内存管理机制都需要这样占用,所以这种做法并不过分。

此外,另有 2 位来表示标记对象的状态,由于对象至少有 2 字长,所以这些位不会重叠。对象的状态共分为三种:

  • 如果一个对象的状态为白,那么它尚未被 GC 发现;
  • 如果一个对象的状态为灰,那么它已经被 GC 发现,但它的邻接对象仍未全部处理完毕;
  • 如果一个对象的状态为黑,那么它不仅已经被 GC 发现,而且它的邻接对象也全部都已处理完毕。

如果将堆中的对象看作是由指针相互联系的有向图,标记算法的核心实际上就是深度优先搜索。

在标记的初期,位图是空的,所有的对象也都是白的。从根对象可以达到的对象都会被设置为灰色,并且被放入标记用的一个单独分配的双端队列中。在标记阶段的每次循环中,GC 会将一个对象从双端队列中取出并设置为黑色,然后将它的邻接对象设置为灰色,并把邻接对象放入双端队列(这一过程将在双端队列为空并且所有对象都设置为黑色时结束)。

对于长数组这样特别大的对象,可能会在处理时分片以防止溢出双端队列,而如果双端队列溢出了,则对象仍然会被设置为灰色,但是不会再被放入队列以确保它们的邻接对象不会再被设置。所以当双端队列为空时,GC 可能仍然需要再扫描一次,以确保所有的灰对象都变成了黑对象,而对于未被设置为黑色的灰对象,GC 会将其再次放入队列后处理。

标记算法结束时,所有的活跃对象都被设置成了黑色,而所有的死对象仍然是白色的,这正是后续清理和紧缩两个阶段所预期的结果。

在标记算法结束后,我们可以选择是清理还是紧缩,这两种算法都可以回收内存,并且两者都作用于页级(V8 引擎的内存页是 1MB 的连续内存块,这点与虚拟内存页不同)。

以下是标记算法的伪码:

markingDeque = []
overflow = false

def markHeap():
	for root in roots:
		mark(root)

	do:
		if overflow:
			overflow = false
			refillMarkingDeque()

		while !markingDeque.isEmpty():
			obj = markingDeque.pop()
			setMarkBits(obj, BLACK)
			for neighbor in neighbors(obj):
				mark(neighbor)
	while overflow
	    
	def mark(obj):
		if markBits(obj) == WHITE:
			setMarkBits(obj, GREY)
		if markingDeque.isFull():
			overflow = true
		else:
			markingDeque.push(obj)

	def refillMarkingDeque():
		for each obj on heap:
			if markBits(obj) == GREY:
				markingDeque.push(obj)
			if markingDeque.isFull():
				overflow = true
			return

清理算法

扫描连续存放的死对象,将其变为空闲空间并添加进空闲内存链表中。每一页都包含着数个空闲内存链表,其分别代表小内存区(< 256字)、中内存区(< 2048 字)、大内存区(< 16384 字)以及超大内存区(其他更大的内存)。

清理算法的实现非常简单,只需遍历页的标记位图,搜索连续的白色对象(未标记对象的范围)。空闲内存链表大量被 Scavenge 算法用于分配存活下来的活跃对象,同时也被紧缩算法用于移动对象,由于有些类型的对象只能被分配在老生区,因此空闲内存链表也可以被它们所使用。

紧缩算法

尝试将对象从碎片页(包含小量小空闲内存的页)中迁移整合在一起,用来释放内存。这些对象会被迁移至其他的页上,因此也有可能会新分配一些页,一旦碎片页被迁出,则可以返还给 OS 了。

迁移整合的过程非常复杂,首先先对目标碎片页中的每个活跃对象,在空闲内存链表中分配一块其他页的区域,将该对象复制至新页,并在碎片页中的该对象上记录转发地址。在迁出过程中对象中的旧地址会被记录下来,这样在迁出结束后 V8 引擎会遍历它所记录的地址并将其更新为新的地址,由于在标记过程中也同样记录了不同页之间的指针位置,因此此时这些指针的指向也会被更新(如果一个页非常“活跃”,即其中有太多需要记录的指针,那么地址记录便会跳过它,等到下一轮的垃圾回收再进行处理)。

相对的,当一个堆很大而且有着很多活跃对象时,“标记-清除”算法和“标记-紧缩”算法就会执行地很慢,这就引出了另外两种概念:增量标记和惰性清理。

增量标记

允许堆的标记发生在几次 5~10 毫秒(移动设备)的小停顿中。增量标记在堆的大小达到了一定的阈值后启用,启用后,每当一定量的内存分配后,程序的执行就会停顿并进行一次增量标记。

就像普通的标记那样,增量标记也是一个深度优先搜索,并且同样是采用白灰黑机制来分类对象。但与普通的标记不同的是,对象的图谱关系可能会发生变化:我们需要留意那些从黑对象指向白对象的新指针,由于黑对象表示其已经完全被 GC 扫描,所以它不会再进行二次扫描,而且标记过程结束后剩余的白对象我们都认为是死对象,因此如果有“黑 -> 白”这样的指针出现时,我们就有可能会将那个白对象漏掉,从而将其错当成死对象处理掉。于是我们不得不再度启用写屏障:现在写屏障不仅需要记录“老 -> 新”指针,还要记录“黑 -> 白”指针,一旦我们发现有这样的指针时,黑对象就会被重新设置为灰对象,并重新放回到双端队列中去。当算法将该对象取出时,它所包含的指针便会被重新扫描,这样一来,活跃的白对象就不会被遗漏掉了。

在增量标记完成后,惰性清理就开始了。

惰性清理

由于所有的对象都已经被处理,因此它们非死即活,堆上有多少内存空间可以变为空闲已经成为了定局,所以此时我们可以不急着去释放那些空间,而是选择将清理的过程延迟处理。因此我们无需一次性清理所有的页,GC 会视具体需要进行逐一清理,直到所有的页都已经清理完毕,而这时又是新一轮的增量标记。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值