浅谈V8引擎中的垃圾回收机制

这篇文章的全部内容均来自 朴灵的《深刻浅出Node.js》及A tour of V8:Garbage Collection,后者还有中文翻译版V8 之旅: 垃圾回收器,我在这里只是作了个记录和结合html

垃圾回收器

JavaScript的垃圾回收器

JavaScript使用垃圾回收机制来自动管理内存。垃圾回收是一把双刃剑,其好处是能够大幅简化程序的内存管理代码,下降程序员的负担,减小因长时间运转而带来的内存泄露问题。但使用了垃圾回收即意味着程序员将没法掌控内存。ECMAScript没有暴露任何垃圾回收器的接口。咱们没法强迫其进行垃圾回收,更没法干预内存管理node

Node的内存管理问题

在浏览器中,V8引擎实例的生命周期不会很长(谁没事一个页面开着几天几个月不关),并且运行在用户的机器上。若是不幸发生内存泄露等问题,仅仅会影响到一个终端用户。且不管这个V8实例占用了多少内存,最终在关闭页面时内存都会被释放,几乎没有太多管理的必要(固然并不表明一些大型Web应用不须要管理内存)。但若是使用Node做为服务器,就须要关注内存问题了,一旦内存发生泄漏,长此以往整个服务将会瘫痪(服务器不会频繁的重启)程序员

V8的内存限制

存在限制

Node与其余语言不一样的一个地方,就是其限制了JavaScript所能使用的内存(64位为1.4GB,32位为0.7GB),这也就意味着将没法直接操做一些大内存对象。这很使人匪夷所思,由于不多有其余语言会限制内存的使用算法

为什么限制

V8之因此限制了内存的大小,表面上的缘由是V8最初是做为浏览器的JavaScript引擎而设计,不太可能遇到大量内存的场景,而深层次的缘由则是因为V8的垃圾回收机制的限制。因为V8须要保证JavaScript应用逻辑与垃圾回收器所看到的不同,V8在执行垃圾回收时会阻塞JavaScript应用逻辑,直到垃圾回收结束再从新执行JavaScript应用逻辑,这种行为被称为“全停顿”(stop-the-world)。若V8的堆内存为1.5GB,V8作一次小的垃圾回收须要50ms以上,作一次非增量式的垃圾回收甚至要1秒以上。这样浏览器将在1s内失去对用户的响应,形成假死现象。若是有动画效果的话,动画的展示也将显著受到影响浏览器

突破限制

固然这个限制是能够打开的,相似于JVM,咱们经过在启动node时能够传递--max-old-space-size或--max-new-space-size来调整内存限制的大小,前者肯定老生代的大小,单位为MB,后者肯定新生代的大小,单位为KB。这些配置只在V8初始化时生效,一旦生效不能再改变服务器

V8的堆构成

V8的堆其实并不仅是由老生代和新生代两部分构成,能够将堆分为几个不一样的区域:
* 新生代内存区:大多数的对象被分配在这里,这个区域很小可是垃圾回特别频繁
* 老生代指针区:属于老生代,这里包含了大多数可能存在指向其余对象的指针的对象,大多数重新生代晋升的对象会被移动到这里
* 老生代数据区:属于老生代,这里只保存原始数据对象,这些对象没有指向其余对象的指针
* 大对象区:这里存放体积超越其余区大小的对象,每一个对象有本身的内存,垃圾回收其不会移动大对象
* 代码区:代码对象,也就是包含JIT以后指令的对象,会被分配在这里。惟一拥有执行权限的内存区
* Cell区、属性Cell区、Map区:存放Cell、属性Cell和Map,每一个区域都是存放相同大小的元素,结构简单post

每一个区域都是由一组内存页构成,内存页是V8申请内存的最小单位,除了大对象区的内存页较大之外,其余区的内存页都是1MB大小,并且按照1MB对齐。内存页除了存储的对象,还有一个包含元数据和标识信息的页头,以及一个用于标记哪些对象是活跃对象的位图区。另外每一个内存页还有一个单独分配在另外内存区的槽缓冲区,里面放着一组对象,这些对象可能指向其余存储在该页的对象。垃圾回收器只会针对新生代内存区、老生代指针区以及老生代数据区进行垃圾回收性能

V8的垃圾回收机制

如何判断回收内容

如何肯定哪些内存须要回收,哪些内存不须要回收,这是垃圾回收期须要解决的最基本问题。咱们能够这样假定,一个对象为活对象当且仅当它被一个根对象或另外一个活对象指向。根对象永远是活对象,它是被浏览器或V8所引用的对象。被局部变量所指向的对象也属于根对象,由于它们所在的做用域对象被视为根对象。全局对象(Node中为global,浏览器中为window)天然是根对象。浏览器中的DOM元素也属于根对象优化

如何识别指针和数据

垃圾回收器须要面临一个问题,它须要判断哪些是数据,哪些是指针。因为不少垃圾回收算法会将对象在内存中移动(紧凑,减小内存碎片),因此常常须要进行指针的改写动画

目前主要有三种方法来识别指针:
1. 保守法:将全部堆上对齐的字都认为是指针,那么有些数据就会被误认为是指针。因而某些实际是数字的假指针,会背误认为指向活跃对象,致使内存泄露(假指针指向的对象多是死对象,但依旧有指针指向——这个假指针指向它)同时咱们不能移动任何内存区域。
2. 编译器提示法:若是是静态语言,编译器可以告诉咱们每一个类当中指针的具体位置,而一旦咱们知道对象时哪一个类实例化获得的,就能知道对象中全部指针。这是JVM实现垃圾回收的方式,但这种方式并不适合JS这样的动态语言
3. 标记指针法:这种方法须要在每一个字末位预留一位来标记这个字段是指针仍是数据。这种方法须要编译器支持,但实现简单,并且性能不错。V8采用的是这种方式。V8将全部数据以32bit字宽来存储,其中最低一位保持为0,而指针的最低两位为01

V8的回收策略

自动垃圾回收算法的演变过程当中出现了不少算法,可是因为不一样对象的生存周期不一样,没有一种算法适用于全部的状况。因此V8采用了一种分代回收的策略,将内存分为两个生代:新生代和老生代。新生代的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象。分别对新生代和老生代使用不一样的垃圾回收算法来提高垃圾回收的效率。对象起初都会被分配到新生代,当新生代中的对象知足某些条件(后面会有介绍)时,会被移动到老生代(晋升)

V8的分代内存

默认状况下,64位环境下的V8引擎的新生代内存大小32MB、老生代内存大小为1400MB,而32位则减半,分别为16MB和700MB。V8内存的最大保留空间分别为1464MB(64位)和732MB(32位)。具体的计算公式是4*reserved_semispace_space_ + max_old_generation_size_,新生代由两块reserved_semispace_space_组成,每块16MB(64位)或8MB(32位)

新生代

新生代的特色

大多数的对象被分配在这里,这个区域很小可是垃圾回特别频繁。在新生代分配内存很是容易,咱们只须要保存一个指向内存区的指针,不断根据新对象的大小进行递增便可。当该指针到达了新生代内存区的末尾,就会有一次清理(仅仅是清理新生代)

新生代的垃圾回收算法

新生代使用Scavenge算法进行回收。在Scavenge算法的实现中,主要采用了Cheney算法。

Cheney算法算法是一种采用复制的方式实现的垃圾回收算法。它将内存一分为二,每一部分空间称为semispace。在这两个semispace中,一个处于使用状态,另外一个处于闲置状态。处于使用状态的semispace空间称为From空间,处于闲置状态的空间称为To空间,当咱们分配对象时,先是在From空间中进行分配。当开始进行垃圾回收算法时,会检查From空间中的存活对象,这些存活对象将会被复制到To空间中(复制完成后会进行紧缩),而非活跃对象占用的空间将会被释放。完成复制后,From空间和To空间的角色发生对换。也就是说,在垃圾回收的过程当中,就是经过将存活对象在两个semispace之间进行复制。能够很容易看出来,使用Cheney算法时,总有一半的内存是空的。可是因为新生代很小,因此浪费的内存空间并不大。并且因为新生代中的对象绝大部分都是非活跃对象,须要复制的活跃对象比例很小,因此其时间效率十分理想。复制的过程采用的是BFS(广度优先遍历)的思想,从根对象出发,广度优先遍历全部能到达的对象

具体的执行过程大体是这样:

首先将From空间中全部能从根对象到达的对象复制到To区,而后维护两个To区的指针scanPtr和allocationPtr,分别指向即将扫描的活跃对象和即将为新对象分配内存的地方,开始循环。循环的每一轮会查找当前scanPtr所指向的对象,肯定对象内部的每一个指针指向哪里。若是指向老生代咱们就没必要考虑它了。若是指向From区,咱们就须要把这个所指向的对象从From区复制到To区,具体复制的位置就是allocationPtr所指向的位置。复制完成后将scanPtr所指对象内的指针修改成新复制对象存放的地址,并移动allocationPtr。若是一个对象内部的全部指针都被处理完,scanPtr就会向前移动,进入下一个循环。若scanPtr和allocationPtr相遇,则说明全部的对象都已被复制完,From区剩下的均可以被视为垃圾,能够进行清理了

举个栗子(以及凑篇幅),若是有相似以下的引用状况:

+----- A对象
          |
根对象----+----- B对象 ------ E对象
          |
          +----- C对象 ----+---- F对象 
                           |
                           +---- G对象 ----- H对象

    D对象

在执行Scavenge以前,From区长这幅模样

+---+---+---+---+---+---+---+---+--------+
| A | B | C | D | E | F | G | H |        |
+---+---+---+---+---+---+---+---+--------+

那么首先将根对象能到达的ABC对象复制到To区,因而乎To区就变成了这个样子:

allocationPtr
             ↓ 
+---+---+---+----------------------------+
| A | B | C |                            |
+---+---+---+----------------------------+
 ↑
scanPtr

接下来进入循环,扫描scanPtr所指的A对象,发现其没有指针,因而乎scanPtr移动,变成以下这样

allocationPtr
             ↓ 
+---+---+---+----------------------------+
| A | B | C |                            |
+---+---+---+----------------------------+
     ↑
  scanPtr

接下来扫描B对象,发现其有指向E对象的指针,且E对象在From区,那么咱们须要将E对象复制到allocationPtr所指的地方并移动allocationPtr指针:

allocationPtr
                 ↓ 
+---+---+---+---+------------------------+
| A | B | C | E |                        |
+---+---+---+---+------------------------+
     ↑
  scanPtr

B对象里全部指针都已被复制完,因此移动scanPtr:

allocationPtr
                 ↓ 
+---+---+---+---+------------------------+
| A | B | C | E |                        |
+---+---+---+---+------------------------+
         ↑
      scanPtr

接下来扫描C对象,C对象中有两个指针,分别指向F对象和G对象,且都在From区,先复制F对象到To区:

allocationPtr
                     ↓ 
+---+---+---+---+---+--------------------+
| A | B | C | E | F |                    |
+---+---+---+---+---+--------------------+
         ↑
      scanPtr

而后复制G对象到To区

allocationPtr
                         ↓ 
+---+---+---+---+---+---+----------------+
| A | B | C | E | F | G |                |
+---+---+---+---+---+---+----------------+
         ↑
      scanPtr

这样C对象内部的指针已经复制完成了,移动scanPtr:

allocationPtr
                         ↓ 
+---+---+---+---+---+---+----------------+
| A | B | C | E | F | G |                |
+---+---+---+---+---+---+----------------+
             ↑
          scanPtr

逐个扫描E,F对象,发现其中都没有指针,移动scanPtr:

allocationPtr
                         ↓ 
+---+---+---+---+---+---+----------------+
| A | B | C | E | F | G |                |
+---+---+---+---+---+---+----------------+
                     ↑
                  scanPtr

扫描G对象,发现其中有一个指向H对象的指针,且H对象在From区,复制H对象到To区,并移动allocationPtr:

allocationPtr
                             ↓ 
+---+---+---+---+---+---+---+------------+
| A | B | C | E | F | G | H |            |
+---+---+---+---+---+---+---+------------+
                     ↑
                  scanPtr

完成后因为G对象没有其余指针,且H对象没有指针移动scanPtr:

allocationPtr
                             ↓ 
+---+---+---+---+---+---+---+------------+
| A | B | C | E | F | G | H |            |
+---+---+---+---+---+---+---+------------+
                             ↑
                           scanPtr

此时scanPtr和allocationPtr重合,说明复制结束

能够对比一下From区和To区在复制完成后的结果:

//From区
+---+---+---+---+---+---+---+---+--------+
| A | B | C | D | E | F | G | H |        |
+---+---+---+---+---+---+---+---+--------+
//To区
+---+---+---+---+---+---+---+------------+
| A | B | C | E | F | G | H |            |
+---+---+---+---+---+---+---+------------+

D对象没有被复制,它将被做为垃圾进行回收

写屏障

若是新生代中的一个对象只有一个指向它的指针,而这个指针在老生代中,咱们如何判断这个新生代的对象是否存活?为了解决这个问题,须要创建一个列表用来记录全部老生代对象指向新生代对象的状况。每当有老生代对象指向新生代对象的时候,咱们就记录下来

对象的晋升

当一个对象通过屡次新生代的清理依旧幸存,这说明它的生存周期较长,也就会被移动到老生代,这称为对象的晋升。具体移动的标准有两种:
1. 对象从From空间复制到To空间时,会检查它的内存地址来判断这个对象是否已经经历过一个新生代的清理,若是是,则复制到老生代中,不然复制到To空间中
2. 对象从From空间复制到To空间时,若是To空间已经被使用了超过25%,那么这个对象直接被复制到老生代

老生代

老生代的特色

老生代所保存的对象大多数是生存周期很长的甚至是常驻内存的对象,并且老生代占用的内存较多

老生代的垃圾回收算法

老生代占用内存较多(64位为1.4GB,32位为700MB),若是使用Scavenge算法,浪费一半空间不说,复制如此大块的内存消耗时间将会至关长。因此Scavenge算法显然不适合。V8在老生代中的垃圾回收策略采用Mark-Sweep和Mark-Compact相结合

Mark-Sweep(标记清除)

标记清除分为标记和清除两个阶段。在标记阶段须要遍历堆中的全部对象,并标记那些活着的对象,而后进入清除阶段。在清除阶段总,只清除没有被标记的对象。因为标记清除只清除死亡对象,而死亡对象在老生代中占用的比例很小,因此效率较高

标记清除有一个问题就是进行一次标记清楚后,内存空间每每是不连续的,会出现不少的内存碎片。若是后续须要分配一个须要内存空间较多的对象时,若是全部的内存碎片都不够用,将会使得V8没法完成此次分配,提早触发垃圾回收。

Mark-Compact(标记整理)

标记整理正是为了解决标记清除所带来的内存碎片的问题。标记整理在标记清除的基础进行修改,将其的清除阶段变为紧缩极端。在整理的过程当中,将活着的对象向内存区的一段移动,移动完成后直接清理掉边界外的内存。紧缩过程涉及对象的移动,因此效率并非太好,可是能保证不会生成内存碎片

算法思路

标记清除和标记整理都分为两个阶段:标记阶段、清除或紧缩阶段

在标记阶段,全部堆上的活跃对象都会被标记。每一个内存页有一个用来标记对象的位图,位图中的每一位对应内存页中的一个字。这个位图须要占据必定的空间(32位下为3.1%,64位为1.6%)。另外有两位用来标记对象的状态,这个状态一共有三种(因此要两位)——白,灰,黑:
* 若是一个对象为白对象,它还没未被垃圾回收器发现
* 若是一个对象为灰对象,它已经被垃圾回收器发现,但其邻接对象还没有所有处理
* 若是一个对象为黑对象,说明他步进被垃圾回收器发现,其邻接对象也所有被处理完毕了

若是将对中的对象看作由指针作边的有向图,标记算法的核心就是深度优先搜索。在初始时,位图为空,全部的对象也都是白对象。从根对象到达的对象会背染色为灰色,放入一个单独的双端队列中。标记阶段的每次循环,垃圾回收器都会从双端队列中取出一个对象并将其转变为黑对象,并将其邻接的对象转变为灰,而后把其邻接对象放入双端队列。若是双端队列为空或全部对象都变成黑对象,则结束。特别大的对象,可能会在处理时进行分片,防止双端队列溢出。若是双端队列溢出,则对象仍然会成为灰对象,但不会被放入队列中,这将致使其邻接对象没法被转变为灰对象。因此在双端队列为空时,须要扫描全部对象,若是仍有灰对象,将它们从新放入队列中进行处理。标记结束后,全部的对象都应该非黑即白,白对象将成为垃圾,等待释放

清除和紧缩阶段都是之内存页为单位回收内存

清除时垃圾回收器会扫描连续存放的死对象,将其变成空闲空间,并保存到一个空闲空间的链表中。这个链表常被scavenge算法用于分配被晋升对象的内存,但也被紧缩算法用于移动对象

紧缩算法会尝试将碎片页整合到一块儿来释放内存。因为页上的对象会被移动到新的页上,须要从新分配一些页。大体过程是,对目标碎片页中的每一个活跃对象,在空闲内存链表中分配一块内存页,将该对象复制过去,并在碎片页中的该对象上写上新的内存地址。随后在迁出过程当中,对象的旧地址将会被记录下来,在迁出结束后,V8会遍历全部它所记录的旧对象的地址,将其更新为新地址。因为标记过程当中也记录了不一样页之间的指针,这些指针在此时也会进行更新。若是一个页很是活跃,如其中有过多须要记录的指针,那么地址记录会跳过它,等到下一轮垃圾回收进行处理

结合使用标记清除和标记整理

V8的老生代使用标记清除和标记整理结合的方式,主要采用标记清除算法,若是空间不足以分配重新生代晋升过来的对象时,才使用标记整理

V8的优化

Incremental Marking(增量标记)

因为全停顿会形成了浏览器一段时间无响应,因此V8使用了一种增量标记的方式,将完整的标记拆分红不少部分,每作完一部分就停下来,让JS的应用逻辑执行一会,这样垃圾回收与应用逻辑交替完成。通过增量标记的改进后,垃圾回收的最大停顿时间能够减小到原来的1/6左右

惰性清理

因为标记完成后,全部的对象都已经被标记,不是死对象就是活对象,堆上多少空间格局已经肯定。咱们能够没必要着急释放那些死对象所占用的空间,而延迟清理过程的执行。垃圾回收器能够根据须要逐一清理死对象所占用的内存页

其余

V8后续还引入了增量式整理(incremental compaction),以及并行标记和并行清理,经过并行利用多核CPU来提高垃圾回收的性能

相关文章
相关标签/搜索