V8的垃圾回收机制与内存限制

阅读:1034


1、写在前面

这是一篇长达八千多字的文章,而且内容可能更加的偏向与后端开发,或者说仅仅是前端开发的DOM操作或者样式讲解之内的知识点来了解关于内存的使用还是有一定的难度。当我们初入手javascript这门语言的时候,我们或许就听说过,它有自己的垃圾回收机制和内存管理,一门语言只是在定义它自身的规范和特征,那么真正在做垃圾回收或内存管理的工作则是它所运行在的环境。如何去分配语言逻辑中所占用的空间,在何时释放对应的空间。


文章的内容主要摘自于《深入浅出Node js》—— 朴灵这本书中的第五章内存控制的内容,下面被引用的部分都是来自于这本书的内容,这里我们主要是用到了node来查看我们的详细信息,但同时我们也可以借助node中的V8的一些详细思路来了解到我们浏览器中所用到的V8来深入的理解一些前端相关的知识点,并结合对内存的控制,来更好的理解我们真实的业务中所用到的javascript在执行的过程是如何利用内存的,这在我们前端进阶的过程中有相当大的帮助。


很多人看到这里就准备退缩了,或者是对node的不熟悉,或者是觉得一个前端工程师为什么要学习node关于这个问题我就不再详细的阐述了,不了解不代表你不优秀,了解之后一定能让你锦上添花。

2、V8的内存限制

我想我可能得先从物理内存说起,也就是我们电脑上所安装的内存条的总大小,这仅仅是我们电脑的物理内存,提电脑所有需要内存的进程或线程等使用,在V8中,我们要使用一块内存的时候,对于内存的限制在这里做一个初步的介绍。后面我们再来说明为什么要限制内存,可能在其它的后端语言中并没有内存限制这个说法,但是再node中通过javascript在使用内存的时候就会发现只能使用部分的内存。

64位的操作系统下约为1.4GB,32位操作系统下约为0.7GB

在这样的限制下,导致了node无法直接的操作大内存的对象,也就是不能处理超过内存限制的对象,这时候尽管我们的物理内存远远超过了这个大小,这样再单个进程的情况下,我们并不能充分的利用计算机的内存资源。

再来说nodenode主要是基于V8构建的,虽然最底层的核心内建模块依然是C++,这样在我们使用node中使用javascript对象基本上都是用过V8自己的方式来分配和管理的。

在前端开发的过程中,我们充分了解到的无非是chrome浏览器环境下使用的是V8来执行,由于前端的浏览器基本上不会遇到大内存的对象,所以这些限制在浏览器中基本不存在什么影响,但是使用面更广泛的node中来说,虽然会很少遇到,但也难免存在,在实际的使用中如果达到了这个限制,将会造成进程退出,要明白其中的道理,这样我们才能更好的解决问题。

在V8中,所有的javascript对象都是通过堆来进行分配的,在node中提供了内存使用量的查询方式:

//v8.js
process.memoryUsage()

///-------
{ 
    rss: 21225472,
    heapTotal: 7159808,    // 已申请到的堆内存
    heapUsed: 4413328,      //当前的使用量
    external: 8224
}

当我们声明对象并赋值时,所使用的对象的内存就会被分配在内存中,如果已申请的堆空闲内存不够分配新的对象的时候,将会继续申请堆内存,直到堆的大小超过V8的限制为止。


  • 那么为什么V8要限制堆内存的大小呢?

表层原因是由于V8最初为浏览器设计,不太可能遇到大量内存的场景,对于网页来说,V8的限制可能已经绰绰有余了,深层原因是V8的垃圾回收机制。按照官方的说法,以1.5GB的垃圾回收堆内存为例,V8做一次小的垃圾回收需要50毫秒以上,做一次非增量式的垃圾回收甚至要1s以上。这是垃圾回收引起javascript线程暂停执行的时间,在这样的花销下,应用的性能和相应力都会直线下降,这样的情况下前后端都无法接收较长的时间消耗,因此,在当时的考虑下直接限制了内存的大小。

当然这个限制也是可以被打开的,在node中可以通过--max-old-space-size--max-new-space-size来自定义的调整内存的大小。

node --max-old-space-size=1700 test.js      //单位MB
node --max-new-space-size=1700 test.js      //单位MB

3、V8内存回收机制

在说到V8的垃圾回收机制的时候,我们先要了解到V8如何分配内存?并对这些内存如何管理?在回收的时候执行的什么样的算法?不要觉得这些问题很难很深奥什么的,任何一个问题的解决方式都可以通过现实的生活进行类似的举例说明。

V8中的垃圾回收策略主要基于分代式垃圾回收机制。在自动垃圾回收的演变过程中。人们发现没有一种垃圾算法能够胜任所有的场景,因为在实际的运用场景中,对象的生存周期长短不一,不同的算法只能针对特定的情况具有最好的效果,为此,统计学在垃圾回收算法的发展中产生了较大的作用,现代的垃圾回收算法中按对象的存活时间将内的垃圾回收进行不同的分代,然后分别对不同的分代内存施以更高效的算法。


3.1、V8的内存分代

在V8中主要就分为新生代和老生代两种,从字面意思也可以理解,新生代中主要是对象存活时间较短的对象,老生代中的对象则主要是存活时间较长的对象。我们也叫它们为常驻对象。

V8分代式回收机制

新生代所用的内存空间和老生代所用的内存控件只和为V8的堆内存的大小,V8的堆内存大小在我们进程启动的时候就已经设置好了,之后不能再改变,只能在我们启动之前通过前面所说的参数的形式修改(在node中),所以按照机器的位数不同,我们的新生代和老生代所占用的空间大小也不同。


3.2、Scavenge算法

在分代的基础上,新生代中的对象主要通过Scavenge算法进行垃圾回收。在Scavenge的具体实现中,主要采用了Cheney算法,该算法由C.J.Cheney于1970年首次发表在ACM论文上。




Cheney算法是一种采用复制的方式实现的垃圾回收算法,它将堆内存一分为二,每一部分空间称为semispace。在这两个semispace空间中,只有一个处于使用中,另一个处于闲置状态。处于使用状态的semispace空间称为From空间,处于闲置状态的空间称为To空间。


当我们分配对象时,首先是在From空间中进行分配,当开始进行垃圾回收时,会检查From空间中的存活对象,这些存活对象将会被复制到To空间中,而非存活的对象占用的空间将会被释放。完成复制之后,From空间和To空间的角色发生互换。简而言之,在垃圾回收的过程中,就是通过将存活的对象在两个semispace空间之间进行复制。


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


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

V8中堆内存示意图

  • 那么老生代中的对象是如何来的呢?

答案很简单,当一个对象在新生代中多次复制的时候就会被认为是生命周期较长的对象,直接被复制到老生代中去,常驻的形式保存下来,方便后续的使用。


在老生代中的对象管理和回收将采用新的算法进行回收。我们将对象从新生代中移动到老生代中的这个过程称之为晋升。


对象晋升的条件主要有两个,一个是对象是否经历过Scavenge回收,一个是To控件的内存占用超过限制。

Scavenge回收过程

也正如上图所示,一般V8主要分配集中在From空间中,当对象从From复制到To控件的时候它会检查他的内存地址是否经过一次Scavenge回收,如果已经经过了则直接保存到老生代中,如果没有则复制到To空间中。


还有一种情况就是在To空间的内存占用比超过了25%的时候,会直接晋升到老生代的空间中,之所以设置为25%是因为当这次Scavenge回收完成之后,两个空间的角色会发生互换,接下来的内存空间将在空间中进行,占比过高会直接影响到后续的内存分配。

在上述的环节中,新生代的垃圾回收基本上就是按照这个逻辑进行的,接下来我们开始来说老生代中的垃圾回收,其步骤可能要先对比较复杂一点,而且在回收的过程中相对消耗的时间也会较多一些。


3.3、Mark-Sweep & Mark-Compact

相对于新生代中的垃圾回收机制,我们可以简单的理解为通过牺牲更多的空间换来时间上的高效,但是在老生代中再使用这种回收机制就会显得有些不太合理了。一方面是存活的对象相对较多,复制存活对象的效率会降低,而且空间的占用也相对较大。

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

Mark-Sweep是标记清除的意思,它分别为标记和清除两个阶段,相比新生代中的Scavenge并不会将内存空间划分两半,而Mark-Sweep在标记阶段依然会遍历老生代中的所有对象,并标记存活的对象,在随后的清除阶段中,只清除没有被标记的全部对象,可以看出Scavenge只复制活着的对象,而Mark-Sweep只清理死亡的对象,活着的对象在新生代中只占一小部分,死亡的对象在老生代中只占一小部分,这是两种回收方式能够高效处理的原因。

老生代中的对象标记清除

但是在上图所示的标记并删除中,会出现一种删除之后,那么内存空间中会出现一种不连续的状态,这种碎片化的内存会在后续的内存分配中造成问题,有可能在需要分配一个较大的内存的时候,这时所有的碎片化空间都无法完成这种分配,就会提前出发垃圾回收,这种回收就是不必要的回收。

这时候Mark-Compact就被提出来了,被用来做标记整理,Mark-Compact是在Mark-Sweep之后将活着的对象往一段进行移动,移动完成之后直接清理掉边界的内存。

老生代中整理过程

在这里将Mark-Sweep 和 Mark-Compact结合介绍不仅仅是因为两种策略的递进关系,在V8的回收策略中两者使用时结合使用的。

由于Mark-Compact在整理过程中需要移动对象,所以他的执行速度不可能很快,所以在V8中并不是每次都回去执行Mark-Compact,更多的是使用Mark-Sweep,在遇到较大对象,内存空间活着碎片化的空间不足以从新生代中晋升过来的时候才会执行Mark-Compact

3.4、Incremental Marking(增量标记)

为了避免javascript应用逻辑中和垃圾回收器看到的对象不一致的情况,所以在执行三种基本的垃圾回收机制的时候都要讲应用逻辑停下来,等待执行完成之后再次恢复应用逻辑的执行,这种行为被称为 “全停顿”(stop-the-world)在V8的分代垃圾回收中,一次小垃圾回收纸手机新生代,由于新生代默认配置都比较小,并且存活的对象相对比较少,所以即便是它全停顿的影响也不大,但是V8的老生代中做一次垃圾回收需要标记、清理、整理等过程,而且对象相对较多,在全停顿的情况下清理是比较耗时的。所以如何更好的优化,此时我们就需要从我们的编写的代码入手了。


3.5、查看垃圾回收日志

查看垃圾回收日志的方式主要是在启动时添加--trace_gc参数,在进行垃圾回收的时候,将会从标准输出中打印垃圾回收的日志信息,可以通过垃圾回收的日志了解垃圾回收的运行状况,找出垃圾回收在那些阶段比较耗时。

在通过node启动时添加--prof参数,可以得到V8执行时的性能分析数据,其中包含垃圾回收执行时占用的时间。

在通常情况下这些日志的可读性是比较差的,所以我们会借助其它的日志分析工具进行分析,得到我们想要的数据即可。


4、高效使用内存

在V8面前,开发者所要具备的责任是如何让垃圾回收机制更加高效的工作。

4.1、作用域

在这里首先要说的就是作用域(scope)这个概念,在javascript这门语言中中能够形成作用域的有函数级作用域、with及全局作用域,以及ES6之后提出的块及作用域。

这些作用域的概念的提出更好的使V8进行垃圾回收,比如函数及作用域中创建的变量,正常情况下会在函数执行结束的时候随着作用域的销毁而销毁,块及作用域也是如此,只被局部变量引用的对象存活周期相对较短,例如下的函数在每次调用的时候回去创建对应的作用域,同时也由于创建的对象非常小,而被分配在from空间中,在作用域释放之后局部变量失效,其引用的对象会在下一次新生代的垃圾回收时被释放。

var foo = function(){ var local = {} }

  • 标识符查找

与作用域相关的便是标识符查找,所谓标识符,可以理解为变量名,在下面的代码中,执行bar()函数时,将会遇到变量local变量:

var bar = function(){ console.log(local) }

javascript在执行时会去查找该变量定义在哪里。它最先查找的是当前作用域,如果在当前作用域无法找到该变量的声明,将会向上级的作用域里查找,直到查到为止。


  • 作用域链

var foo = function(){ var local = 'local foo' var bar = function(){ var local = 'local bar' var boo = function(){ console.log(local) } } }

正如上面的代码中,boo()函数形成的作用域中找不到对应的local变量,便会往父级bar()函数中去查找,这时在bar()函数中找到了,便会使用这个指针所指向的内存地址中的对象,如果找不到便会继续向上查找,直到最后查找到全局的global对象中,如果还是找不到,便会抛出未定义的错误。这样的查找形式像一个链条,由于标识符的查找方式是一直向上的,所以变量只能向外访问,而不能向内访问。如下图所示。

![]](http://photo.xiangzongliang.com/1534069826466WX20180812-164313@2x.png)

  • 变量的主动释放

如果变量是全局变量,即不通过声明或者定义在global变量上,由于全局作用域直到进程退出才会被释放,此时所产生的对象都会在生代中(常驻在老生代中)存在,如果需要释放常驻内存的对象,可以通过delete操作来删除引用关系,活着将变量重新赋值,让就对象脱离引用关系,在接下来的老生代内存清理和整理的过程中,会被释放。在非全局对象中需要主动释放引用的对象也可以如此。

虽然delete操作符与重新赋值具有相同的效果,但是在V8中使用delete操作符删除对象的属性有可能干扰V8的优化,通常情况下我们采用重新赋值的方式进行释放。


4.2、闭包

在前面我们说了作用域链上的对象访问只能向上,这样外部就无法访问内部的对象。

javascript中,实现外部作用域访问内部作用域的方法叫做 闭包(closure),这得益于高阶函数的特性:函数可以作为参数或者返回值。如下:

var foo = function(){ var bar = function(){ var local = "局部变量" return function(){ return local; } } var boo = bar() console.log(boo()) }

一般而言bar()函数执行完成之后,局部变量local将会随着作用域的销毁被回收。但是这里的特点在返回值是一个匿名函数,且这个函数中具备了访问local的条件。虽然在后续的执行中,外部作用域依然不能直接访问local但是若要访问它,只需要通过中间函数稍作周转即可。

闭包是`javascript`的高级特性,利用它可以产生很多巧妙的效果,它的问题在于,一旦有变量引用这个中间函数,这个中间函数将不会被释放,同时也会使原始的作用域不会得到释放,作用域中产生的内存占用也不会得到释放,除非不在引用,才会得到释放。

所以在正常的javascript执行中,无法立即回收内存有闭包和全局变量引用这两种情况。由于V8的内存限制,要十分小心此类变量是否无限制的增加,因为它会导致老生代中的对象增多。

一般而言,在应用中存在一些全局变量是很正常的情况,而且在正常的使用中,变量都会自动释放回收。但是也会存在一些我们认为会回收却没有被回收的对象,这会导致内存占用的无限增长,一旦达到了V8的内存限制,将会得到内存溢出的错误,进而导致进程退出。

加载中...