[date: 2018-03-21 22:56] [visits: 21]

V8垃圾回收

自己使用Node.js有差不多4年了,限于环境、个人等因素,对Node.js中V8的垃圾回收一直只有一个模糊的概念,趁写博客这个契机,好好整理一下垃圾回收相关知识,加深认识。

垃圾回收的由来

垃圾回收,简称GC(Garbage Collection),指回收内存中的不再使用的内存。在C/C++等语言里,需要通过malloc/free申请和释放内存,稍不注意,申请的内存忘了释放,进程运行久了就会导致内存居高不下,即出现了内存泄漏,甚至有时过早的释放内存,导致程序奔溃。

如今绝大部分高级语言,已经不需要手动申请和释放内存,但还是会存在频繁申请与释放内存的操作,其中释放内存的操作主要由垃圾自动回收算法完成,简单来说就是通过既定的规则,判断出某块内存不会再使用,然后释放掉,标记成可再次分配或交还给操作系统。

Node.js中的GC

我们在说Node.js中的GC时,实际上指的是V8的GC,因为Node.js内部使用的是Chrome V8引擎,V8是一个JavaScript的运行时,负责编译执行JS代码、内存管理、GC等工作。

V8的学习有一定门槛,但要深入理解Node.js原理,成为真大神,V8是一道必须要迈过去的坎。

V8内存管理

Node.js在64位系统下,可使用的堆内存上限为1432M,之所以有上限约束,主要是因为如果可使用内存太大,V8在GC时将要耗费更多的资源和时间,而Stop-the-world方式会导致进程暂停执行,可能带来业务损失。

堆内空间内存管理,分为新生代、老生代两大类,同时可细分为以下几种:

除去部分大对象(大于1MB),大部分的新对象都诞生在新生代

大部分是从New Space中晋升过来的

大于1MB的内存分配请求,会直接归类为Large Object Space,存放在更大的内存页中,GC时不会被移动或复制

所有在堆上分配的对象都带有指向它的隐藏类的指针,隐藏类保存在Map Space

隐藏类主要目的是为了优化对象访问速度,因为JS是动态类型语言,编译后,无法通过内存相对偏移快速访问属性,而借助隐藏类可以优化对象属性访问速度

代码对象,会分配在这,唯一拥有执行权限的内存

除了New Space,其他均在老生代空间内,在64位系统上老生代空间上限为1400MB。

之所以把堆内空间分为新生代与老生代,是基于一个经验的总结:“越是刚分配内存的对象,往往死的早,而长期在内存中的对象,更有可能长命百岁”,把堆内空间分为两类,应用不同的GC算法,触发频率也有所不同。

新生代的GC

64位系统,新生代内存大小上限默认为32MB,由于只有一半可以使用,实际能用分配给程序的只有16MB。新生代在较小空间约束下,GC一般在0~3ms内,V8设计的目标在1ms以下,超过1ms的一般是bug或者用户代码发生了内存问题。

Scavenge算法

新生代空间被分为两个相等的部分:from与to,分别用来使用和GC复制。在整理GC算法的过程中,发现有两个版本的Scavenge算法描述,其中一个是alinode官方博客中的文章,另外一个是大部分其他文章。

alinode的博客文章中,细节详细,感觉可信度更高,但不排除是V8版本不同导致的差异。

通用版:

内存分配发生在from部分,当from没有足够空间可分配时,触发一次GC,检查from中的对象,将存活的copy到to中,二次存活的对象会晋升到Old Space,当from中对象copy完成,from与to对调角色。

alinode博客版本:

内存分配发生在to部分,当to没有足够空间可分配时,触发一次GC,先对调from与to,然后检查from中的对象,将存活的copy到to中,二次存活的对象会晋升到Old Space。

两个版本的差异是使用中的部分称为to还是from,以及角色对调的时机是回收前还是回收后,不影响理解算法。

老生代的GC

写屏障

写屏障是一种为了新生代GC更快速的技巧,用专门的数据结构记录老生代对象中指向新生代对象的指针,这种数据结构即称为写屏障。之所以需要写屏障,因为新生代GC时,判断对象存活的标准是通过遍历其他存活对象判断目标是否可达,而如果遍历发生在老生代空间,由于其大小是新生代的N倍,需要的时间过长,所以利用写屏障这种手段可以避免在进行GC时遍历老生代空间。

写屏障发生在往对象写入指针的过程中,会检查被写入的指针是否由老生代对象指向新生代对象,其判断依据是检查指针两端的内存页所属新、老生代空间。

由于V8的内存页按照1MB对齐,通过位运算将指针的后20位置0,得到的就是其所指向地址的内存页地址,然后再获取内存页的头信息,可快速判断地址所属空间是新生代还是老生代。

标记

存在两个标记位图:

GC标记阶段采用三色标记法,将颜色信息记录在状态标记位图中:

初始状态,内存页中所有对象都是白色,标记采用深度优先搜索算法,步骤如下:

  1. 根可达对象标记为灰,并push进栈
  2. pop出栈一个对象,标记为黑
  3. 将对象的邻接对象标记为灰,并push进栈,回到步骤二直至栈为空

上述步骤遇到大对象可能导致栈溢出,做法是当出现溢出时只标记为灰但不push进栈,栈为空后GC会再次扫描,将之前的灰色对象push进栈继续处理。因此若程序创建过多的大对象,就会触发多次堆扫描,影响GC效率。

最终状态,内存页中的对象全部被标记,不存在灰色,白色为可回收,黑色为不可回收。

Sweeping回收

扫描内存页的对象标记位图,将白色-死亡对象对应的的内存地址添加到空闲内存链表中,同时将对应的已分配位图标志更新为未分配状态。

Compacting回收

将页中的所有黑色-存活对象全部转移到另外一个内存页中,原先的内存页可以交还给操作系统。

增量标记与惰性清理

V8之前的GC,会停止程序的执行,然后扫描整个堆,回收完内存后才能重新运行程序,每次暂停时间可以到几百甚至上千毫秒。2012年,Google引入了两项改进措施:增量标记和惰性清理。

当堆大小达到一定阈值后启用,启用之后每当分配一定量的内存时,程序暂停执行几十毫秒并进行一次增量标记,采用与普通标记一样的三色标记算法。由于增量标记并不会完整标记堆中所有对象的状态,在程序恢复执行后,对象状态可能发生变化。

在普通标记中,黑色对象不会出现白色邻接对象,而在增量标记中有可能出现这样的情况,导致回收存活对象。为避免这种情问题,增加了与写屏障一样的机制,在黑对象中有指针指向白对象时,把黑对象重新设置回灰色。

增量标记完成后,就开始惰性清理,也就是将内存页中死亡对象逐步清理,而不是一次清理全部堆空间。

其他优化

V8 5.x引入了black allocation,将所有新出现在Old Space的对象直接标记为黑色,放在特殊的内存页中,可躲过一次GC的标记,因为根据经验,新出现在Old Space的对象继续存活可能性极大

其他线程负责清理,不影响住主线程的执行

多个其他线程负责清理,提高单位时间GC吞吐量

指针识别

计算机内存中都是0与1的组合,任意取连续N位,其字面含义都是一个数,但对计算机而言有两种含义:

数本身,其含义就是一个二进制形式的数

指向内存中另一块地址,目标地址内容也是数值或指针

如果没有额外信息,单单给出内存中N位的内容,无法判断其是数值还是指针,V8通过其它方式可实现无需其它信息,判断内容是数字还是指针,称为指针精准识别。

V8按字对齐的方式在内存中存储对象,64位系统,一个字长是8个字节,按字对齐可以保证指针的后三位必定为0。对于整数,64位系统下,字的前32位用于表示有符号整数,后32位置0。32位系统下,字的前31位用于存放整数,最后一位置0。

基于上述前提,V8按字对齐并且让每个字的最后一位空了出来,这空出来的一位用做于数值与指针的标志位,0表示字的内容为整数,1表示字的内容为指针。这对于GC来说是一个非常大的帮助,堆扫描时可以快速识别指针与数值。

V8GC触发

当程序触发内存申请,发现内存不够时会触发一次GC,然后再次尝试申请,最多重试3次,若最后一次申请失败,程序OOM异常退出。