聊聊V8引擎的垃圾回收

前言

咱们知道,JavaScript之因此能在浏览器环境和NodeJS环境运行,都是由于有V8引擎在幕后保驾护航。从编译、内存分配、运行以及垃圾回收等整个过程,都离不开它。html

在写这篇文章以前,我也在网上看了不少博客,包括一些英文原版的内容,因而想经过这篇文章来作一个概括整理,文中加入了我本身的思考,以及纯手工制做流程图~~node

但愿这篇文章能帮到你,同时本文也会收录到我本身的我的网站算法

为何要有垃圾回收

在C语言和C++语言中,咱们若是想要开辟一块堆内存的话,须要先计算须要内存的大小,而后本身经过malloc函数去手动分配,在用完以后,还要时刻记得用free函数去清理释放,不然这块内存就会被永久占用,形成内存泄露。segmentfault

可是咱们在写JavaScript的时候,却没有这个过程,由于人家已经替咱们封装好了,V8引擎会根据你当前定义对象的大小去自动申请分配内存。浏览器

不须要咱们去手动管理内存了,因此天然要有垃圾回收,不然的话只分配不回收,岂不是没多长时间内存就被占满了吗,致使应用崩溃。bash

垃圾回收的好处是不须要咱们去管理内存,把更多的精力放在实现复杂应用上,但坏处也来自于此,不用管理了,就有可能在写代码的时候不注意,形成循环引用等状况,致使内存泄露。微信

内存结构分配

因为V8最开始就是为JavaScript在浏览器执行而打造的,不太可能遇到使用大量内存的场景,因此它能够申请的最大内存就没有设置太大,在64位系统下大约为1.4GB,在32位系统下大约为700MB。架构

在NodeJS环境中,咱们能够经过**process.memoryUsage()**来查看内存分配。异步

node环境v8内存

process.memoryUsage返回一个对象,包含了 Node 进程的内存占用信息。该对象包含四个字段,含义以下:ide

node环境v8内存

rss(resident set size):全部内存占用,包括指令区和堆栈

heapTotal:V8引擎能够分配的最大堆内存,包含下面的 heapUsed

heapUsed:V8引擎已经分配使用的堆内存

external: V8管理C++对象绑定到JavaScript对象上的内存
复制代码

以上全部内存单位均为字节(Byte)。

若是说想要扩大Node可用的内存空间,可使用Buffer等堆外内存内存,这里不详细说明了,你们有兴趣能够去看一些资料。

下面是Node的总体架构图,有助于你们理解上面的内容:

node环境v8内存

Node Standard Library: 是咱们天天都在用的标准库,如Http, Buffer 模块

Node Bindings: 是沟通JS 和 C++的桥梁,封装V8和Libuv的细节,向上层提供基础API服务

第三层是支撑 Node.js 运行的关键,由 C/C++ 实现:
1. V8 是Google开发的JavaScript引擎,提供JavaScript运行环境,能够说它就是 Node.js 的发动机
2. Libuv 是专门为Node.js开发的一个封装库,提供跨平台的异步I/O能力
3. C-ares:提供了异步处理 DNS 相关的能力
4. http_parser、OpenSSL、zlib 等:提供包括 http 解析、SSL、数据压缩等其余的能力
复制代码

垃圾回收机制

1. 如何判断是否能够回收

1.1 标记清除

当变量进入环境(例如,在函数中声明一个变量)时,就将这个变量标记为“进入环境”。从逻辑上讲,永远不能释放进入环境的变量所占用的内存,由于只要执行流进入相应的环境,就可能会用到它们。而当变量离开环境时,则将其标记为“离开环境”。

可使用任何方式来标记变量。好比,能够经过翻转某个特殊的位来记录一个变量什么时候进入环境,或者使用一个“进入环境的”变量列表及一个“离开环境的”变量列表来跟踪哪一个变量发生了变化。如何标记变量并不重要,关键在于采起什么策略。

  • (1)垃圾收集器在运行的时候会给存储在内存中的全部变量都加上标记(固然,可使用任何标记方式)。
  • (2)而后,它会去掉运行环境中的变量以及被环境中变量所引用的变量的标记
  • (3)此后,依然有标记的变量就被视为准备删除的变量,缘由是在运行环境中已经没法访问到这些变量了。
  • (4)最后,垃圾收集器完成内存清除工做,销毁那些带标记的值并回收它们所占用的内存空间。

目前,IE、Firefox、Opera、Chrome和Safari的JavaScript实现使用的都是标记清除式的垃圾回收策略(或相似的策略),只不过垃圾收集的时间间隔互有不一样。

标记清除

活动对象就是上面的root,若是不清楚活动对象的能够先查一下资料,当一个对象和其关联对象再也不经过引用关系被当前root引用了,这个对象就会被垃圾回收。

1.2 引用计数

引用计数的垃圾收集策略不太常见。含义是跟踪记录每一个值被引用的次数。当声明了一个变量并将一个引用类型值赋给该变量时,则这个值的引用次数就是1。

若是同一个值又被赋给另外一个变量,则该值的引用次数加1。相反,若是包含对这个值引用的变量改变了引用对象,则该值引用次数减1。

当这个值的引用次数变成0时,则说明没有办法再访问这个值了,于是就能够将其占用的内存空间回收回来。

这样,当垃圾收集器下次再运行时,它就会释放那些引用次数为0的值所占用的内存。

Netscape Navigator 3.0是最先使用引用计数策略的浏览器,但很快它就遇到了一个严重的问题:循环引用

循环引用是指对象A中包含一个指向对象B的指针,而对象B中也包含一个指向对象A的引用,看个例子:

function foo () {
    var objA = new Object();
    var objB = new Object();
    
    objA.otherObj = objB;
    objB.anotherObj = objA;
}
复制代码

这个例子中,objA和objB经过各自的属性相互引用,也就是说,这两个对象的引用次数都是2。

在采用标记清除策略的实现中,因为函数执行后,这两个对象都离开了做用域,所以这种相互引用不是问题。

但在采用引用次数策略的实现中,当函数执行完毕后,objA和objB还将继续存在,由于它们的引用次数永远不会是0。

加入这个函数被重复屡次调用,就会致使大量内存没法回收。为此,Netscape在Navigator 4.0中也放弃了引用计数方式,转而采用标记清除来实现其垃圾回收机制。

还要注意的是,咱们大部分人时刻都在写着循环引用的代码,看下面这个例子,相信你们都这样写过:

var el = document.getElementById('#el');
el.onclick = function (event) {
    console.log('element was clicked');
}
复制代码

咱们为一个元素的点击事件绑定了一个匿名函数,咱们经过event参数是能够拿到相应元素el的信息的。

你们想一想,这是否是就是一个循环引用呢? el有一个属性onclick引用了一个函数(其实也是个对象),函数里面的参数又引用了el,这样el的引用次数一直是2,即便当前这个页面关闭了,也没法进行垃圾回收。

若是这样的写法不少不少,就会形成内存泄露。咱们能够经过在页面卸载时清除事件引用,这样就能够被回收了:

var el = document.getElementById('#el');
el.onclick = function (event) {
    console.log('element was clicked');
}

// ...
// ...

// 页面卸载时将绑定的事件清空
window.onbeforeunload = function(){
    el.onclick = null;
}
复制代码

V8垃圾回收策略

自动垃圾回收有不少算法,因为不一样对象的生存周期不一样,因此没法只用一种回收策略来解决问题,这样效率会很低。

因此,V8采用了一种代回收的策略,将内存分为两个生代:新生代(new generation)老生代(old generation)

新生代中的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象,分别对新老生代采用不一样的垃圾回收算法来提升效率,对象最开始都会先被分配到新生代(若是新生代内存空间不够,直接分配到老生代),新生代中的对象会在知足某些条件后,被移动到老生代,这个过程也叫晋升,后面我会详细说明。

分代内存

默认状况下,32位系统新生代内存大小为16MB,老生代内存大小为700MB,64位系统下,新生代内存大小为32MB,老生代内存大小为1.4GB。

新生代平均分红两块相等的内存空间,叫作semispace,每块内存大小8MB(32位)或16MB(64位)。

新生代

1. 分配方式

新生代存的都是生存周期短的对象,分配内存也很容易,只保存一个指向内存空间的指针,根据分配对象的大小递增指针就能够了,当存储空间快要满时,就进行一次垃圾回收。

2. 算法

新生代采用Scavenge垃圾回收算法,在算法实现时主要采用Cheney算法。

Cheney算法将内存一分为二,叫作semispace,一块处于使用状态,一块处于闲置状态。

新老生代

处于使用状态的semispace称为From空间,处于闲置状态的semispace称为To空间

我画了一套详细的流程图,接下来我会结合流程图来详细说明Cheney算法是怎么工做的。 垃圾回收在下面我统称为 GC(Garbage Collection)

step1. 在From空间中分配了3个对象A、B、C

cheney-step1

step2. GC进来判断对象B没有其余引用,能够回收,对象A和C依然为活跃对象

cheney-step1

step3. 将活跃对象A、C从From空间复制到To空间

cheney-step1

step4. 清空From空间的所有内存

cheney-step1

step5. 交换From空间和To空间

cheney-step1

step6. 在From空间中又新增了2个对象D、E

cheney-step1

step7. 下一轮GC进来发现对象D没有引用了,作标记

cheney-step1

step8. 将活跃对象A、C、E从From空间复制到To空间

cheney-step1

step9. 清空From空间所有内存

cheney-step1

step10. 继续交换From空间和To空间,开始下一轮

cheney-step1

经过上面的流程图,咱们能够很清楚的看到,进行From和To交换,就是为了让活跃对象始终保持在一块semispace中,另外一块semispace始终保持空闲的状态。

Scavenge因为只复制存活的对象,而且对于生命周期短的场景存活对象只占少部分,因此它在时间效率上有优异的体现。Scavenge的缺点是只能使用堆内存的一半,这是由划分空间和复制机制所决定的。

因为Scavenge是典型的牺牲空间换取时间的算法,因此没法大规模的应用到全部的垃圾回收中。但咱们能够看到,Scavenge很是适合应用在新生代中,由于新生代中对象的生命周期较短,偏偏适合这个算法。

3. 晋升

当一个对象通过屡次复制仍然存活时,它就会被认为是生命周期较长的对象。这种较长生命周期的对象随后会被移动到老生代中,采用新的算法进行管理。

对象重新生代移动到老生代的过程叫做晋升

对象晋升的条件主要有两个:

  1. 对象从From空间复制到To空间时,会检查它的内存地址来判断这个对象是否已经经历过一次Scavenge回收。若是已经经历过了,会将该对象从From空间移动到老生代空间中,若是没有,则复制到To空间。总结来讲,若是一个对象是第二次经历从From空间复制到To空间,那么这个对象会被移动到老生代中

  2. 当要从From空间复制一个对象到To空间时,若是To空间已经使用了超过25%,则这个对象直接晋升到老生代中。设置25%这个阈值的缘由是当此次Scavenge回收完成后,这个To空间会变为From空间,接下来的内存分配将在这个空间中进行。若是占比太高,会影响后续的内存分配。

老生代

1. 介绍

在老生代中,存活对象占较大比重,若是继续采用Scavenge算法进行管理,就会存在两个问题:

  1. 因为存活对象较多,复制存活对象的效率会很低。
  2. 采用Scavenge算法会浪费一半内存,因为老生代所占堆内存远大于新生代,因此浪费会很严重。

因此,V8在老生代中主要采用了Mark-SweepMark-Sweep相结合的方式进行垃圾回收。

2. Mark-Sweep

Mark-Sweep是标记清除的意思,它分为标记和清除两个阶段。

与Scavenge不一样,Mark-Sweep并不会将内存分为两份,因此不存在浪费一半空间的行为。Mark-Sweep在标记阶段遍历堆内存中的全部对象,并标记活着的对象,在随后的清除阶段,只清除没有被标记的对象。

也就是说,Scavenge只复制活着的对象,而Mark-Sweep只清除死了的对象。活对象在新生代中只占较少部分,死对象在老生代中只占较少部分,这就是两种回收方式都能高效处理的缘由。

咱们仍是经过流程图来看一下:

step1. 老生代中有对象A、B、C、D、E、F

mark-sweep-step1

step2. GC进入标记阶段,将A、C、E标记为存活对象

mark-sweep-step1

step3. GC进入清除阶段,回收掉死亡的B、D、F对象所占用的内存空间

mark-sweep-step1

能够看到,Mark-Sweep最大的问题就是,在进行一次清除回收之后,内存空间会出现不连续的状态。这种内存碎片会对后续的内存分配形成问题。

若是出现须要分配一个大内存的状况,因为剩余的碎片空间不足以完成这次分配,就会提早触发垃圾回收,而此次回收是没必要要的。

2. Mark-Compact

为了解决Mark-Sweep的内存碎片问题,Mark-Compact就被提出来了。

**Mark-Compact是标记整理的意思,**是在Mark-Sweep的基础上演变而来的。Mark-Compact在标记完存活对象之后,会将活着的对象向内存空间的一端移动,移动完成后,直接清理掉边界外的全部内存。以下图所示:

step1. 老生代中有对象A、B、C、D、E、F(和Mark—Sweep同样)

mark-sweep-step1

step2. GC进入标记阶段,将A、C、E标记为存活对象(和Mark—Sweep同样)

mark-sweep-step1

step3. GC进入整理阶段,将全部存活对象向内存空间的一侧移动,灰色部分为移动后空出来的空间

mark-sweep-step1

step4. GC进入清除阶段,将边界另外一侧的内存一次性所有回收

mark-sweep-step1

3. 二者结合

在V8的回收策略中,Mark-Sweep和Mark-Conpact二者是结合使用的。

因为Mark-Conpact须要移动对象,因此它的执行速度不可能很快,在取舍上,V8主要使用Mark-Sweep,在空间不足以对重新生代中晋升过来的对象进行分配时,才使用Mark-Compact。

总结

V8的垃圾回收机制分为新生代和老生代。

新生代主要使用Scavenge进行管理,主要实现是Cheney算法,将内存平均分为两块,使用空间叫From,闲置空间叫To,新对象都先分配到From空间中,在空间快要占满时将存活对象复制到To空间中,而后清空From的内存空间,此时,调换From空间和To空间,继续进行内存分配,当知足那两个条件时对象会重新生代晋升到老生代。

老生代主要采用Mark-Sweep和Mark-Compact算法,一个是标记清除,一个是标记整理。二者不一样的地方是,Mark-Sweep在垃圾回收后会产生碎片内存,而Mark-Compact在清除前会进行一步整理,将存活对象向一侧移动,随后清空边界的另外一侧内存,这样空闲的内存都是连续的,可是带来的问题就是速度会慢一些。在V8中,老生代是Mark-Sweep和Mark-Compact二者共同进行管理的。

以上就是本文的所有内容,书写过程当中参考了不少中外文章,参考书籍包括朴大大的《深刻浅出NodeJS》以及《JavaScript高级程序设计》等。咱们这里并无对具体的算法实现进行探讨,感兴趣的朋友能够继续深刻研究一下。

最后,谢谢你们可以读到这里,若是文中有任何不明确或错误的地方,欢迎给我留言~~

欢迎关注个人公众号

微信公众号

参考连接

medium.com/@_lrlna/gar… alinode.aliyun.com/blog/14 www.ruanyifeng.com/blog/2017/0… segmentfault.com/a/119000000…

相关文章
相关标签/搜索