V8引擎下的垃圾回收

今天看了一下关于垃圾回收的知识,来总结一下~
我们知道,JavaScript之所以能在浏览器环境和NodeJS环境运行,都是因为有V8引擎在幕后保驾护航。从编译、内存分配、运行以及垃圾回收等整个过程,都离不开它。

为什么要有垃圾回收

在C语言和C++语言中,我们如果想要开辟一块堆内存的话,需要先计算需要内存的大小,然后自己通过malloc函数去手动分配,在用完之后,还要时刻记得用free函数去清理释放,否则这块内存就会被永久占用,造成内存泄露。

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

不需要我们去手动管理内存了,所以自然要有垃圾回收,否则的话只分配不回收,岂不是没多长时间内存就被占满了吗,导致应用崩溃。

垃圾回收的好处是不需要我们去管理内存,把更多的精力放在实现复杂应用上,但坏处也来自于此,不用管理了,就有可能在写代码的时候不注意,造成循环引用等情况,导致内存泄露。
内存泄漏:不再用到的内存,没有及时释放,内存被占用,这就叫做内存泄漏

1. 什么是垃圾?

即没有被引用的对象都是垃圾
  1. 所有全局变量都不是垃圾
  2. 如果是存在函数内的局部变量,当这个函数执行完了,这个变量就消除了,但是在执行过程中不能被消除,因为这个函数引用着它,全局变量又引用着这个函数
  3. 如果存在双引用,把其中一个引用给置为null,还有另一个引用,也不会成为垃圾
  4. 如果存在环引用,如果引用你的对象全松开了手,你就成为了垃圾
    环引用与外界至少要有一个桥梁,外界有人引用着环,不然整个环就成了垃圾

2. 如何判断是否可以回收

1) 标记清除

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

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

(1)垃圾收集器在运行的时候会给存储在内存中的所有变量都加上标记(当然,可以使用任何标记方式)。
(2)然后,它会去掉运行环境中的变量以及被环境中变量所引用的变量的标记
(3)此后,依然有标记的变量就被视为准备删除的变量,原因是在运行环境中已经无法访问到这些变量了。
(4)最后,垃圾收集器完成内存清除工作,销毁那些带标记的值并回收它们所占用的内存空间。
目前,IE、Firefox、Opera、Chrome和Safari的JavaScript实现使用的都是标记清除式的垃圾回收策略(或类似的策略),只不过垃圾收集的时间间隔互有不同。

当一个对象和其关联对象不再通过引用关系被当前root引用了,这个对象就会被垃圾回收。

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;
}

3. 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

image.png

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

image.png

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

image.png

step4. 清空From空间的全部内存

image.png

step5. 交换From空间和To空间

image.png

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

image.png

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

image.png

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

image.png

step9. 清空From空间全部内存

image.png

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

image.png

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

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

由于Scavenge是典型的牺牲空间换取时间的算法,所以无法大规模的应用到所有的垃圾回收中。但我们可以看到,Scavenge非常适合应用在新生代中,因为新生代中对象的生命周期较短,恰恰适合这个算法。

3. 晋升

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

对象从新生代移动到老生代的过程叫作晋升。

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

对象从From空间复制到To空间时,会检查它的内存地址来判断这个对象是否已经经历过一次Scavenge回收。如果已经经历过了,会将该对象从From空间移动到老生代空间中,如果没有,则复制到To空间。总结来说,如果一个对象是第二次经历从From空间复制到To空间,那么这个对象会被移动到老生代中。
当要从From空间复制一个对象到To空间时,如果To空间已经使用了超过25%,则这个对象直接晋升到老生代中。设置25%这个阈值的原因是当这次Scavenge回收完成后,这个To空间会变为From空间,接下来的内存分配将在这个空间中进行。如果占比过高,会影响后续的内存分配。

老生代

1. 介绍

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

由于存活对象较多,复制存活对象的效率会很低。
采用Scavenge算法会浪费一半内存,由于老生代所占堆内存远大于新生代,所以浪费会很严重。
所以,V8在老生代中主要采用了Mark-Sweep和Mark-Sweep相结合的方式进行垃圾回收。

2. Mark-Sweep

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

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

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

我们还是通过流程图来看一下:

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


image.png

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


image.png

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


image.png

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

如果出现需要分配一个大内存的情况,由于剩余的碎片空间不足以完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。

2. Mark-Compact

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

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

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

[图片上传失败...(image-6e1023-1555941392151)]

step2. GC进入标记阶段,将A、C、E标记为存活对象(和Mark—Sweep一样)
[图片上传失败...(image-54ff18-1555941392151)]

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

[图片上传失败...(image-e1f701-1555941392151)]

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

[图片上传失败...(image-c2342f-1555941392151)]

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高级程序设计》等。我们这里并没有对具体的算法实现进行探讨,感兴趣的朋友可以继续深入研究一下。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,298评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,701评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,078评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,687评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,018评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,410评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,729评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,412评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,124评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,379评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,903评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,268评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,894评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,014评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,770评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,435评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,312评论 2 260

推荐阅读更多精彩内容

  • 前言 我们知道,JavaScript之所以能在浏览器环境和NodeJS环境运行,都是因为有V8引擎在幕后保驾护航。...
    liuxuan阅读 470评论 1 1
  • 本文引用自这里这篇文章的所有内容均来自 朴灵的《深入浅出Node.js》及A tour of V8:Garbage...
    楠小忎阅读 1,335评论 0 2
  • Java引用的种类 1.对象在内存中的状态 对于JVM的垃圾回收机制来说,是否回收一个对象的标准在于:是否还有引用...
    Jack921阅读 3,766评论 0 3
  • JVM内存区域 JVM将其管理的内存分为若干数据区域,这些数据区域分布情况如下图所示: 程序计数器:一块较小内存区...
    luoxn28阅读 647评论 0 0
  • JVM架构 当一个程序启动之前,它的class会被类装载器装入方法区(Permanent区),执行引擎读取方法区的...
    cocohaifang阅读 1,575评论 0 7