JavaScript垃圾回收机制

前言

我们知道,JavaScript中的变量主要分为两种类型:基本类型和引用类型。基本类型的值存储在栈(stack)内存中,而引用类型值的存储需要用到栈内存和堆(heap)内存,栈内存保存着变量的堆内存地址,地址指向的堆内存空间保存着具体的值。栈中变量的值在使用完后会被立即回收,而堆中变量的值不会立即回收,需要手动回收或使用某种策略进行回收。

JavaScript具有自动垃圾回收机制,不需要像C/C++语言那样需要开发者手动跟踪内存的使用情况。原理很简单:找出那些不再继续使用的变量,然后释放其占用的内存。为此,垃圾收集器会按照固定的时间间隔(或代码执行中预定的收集时间),周期性地执行这一操作。

那么,怎么判断哪些变量有用哪些变量没用呢?以函数来说,函数中的局部变量只在函数执行过程中存在,在这个过程中,会为局部变量在栈或堆内存上分配相应的空间,以便存储它们的值。然后在函数中使用这些变量,直到函数执行结束。这时,局部变量就没有存在的必要了,因此可以释放它们的内存以供将来使用。这种情况下很容易判断变量是否有还有存在的必要;但并非所有情况下(如闭包)都这么容易就能得出结论。垃圾收集器必须跟踪有用或无用的变量,对不再有用的变量打上标记,以备将来收回其占用的内存。用于标识无用变量的策略可能会因实现而不同,但具体到浏览器中但实现,通常有两个策略:“标记清除”和“引用计数”

注:以上内容摘自《JavaScript高级程序设计(第3版)》

标记清除

“标记清除”算法是目前广泛使用最广泛的垃圾收集算法,它的策略是,用某种标记方法将有用变量和无用变量进行区分,做好标记后,下一次垃圾收集器工作的时候,就将标记为“垃圾”的变量进行回收,释放其所占内存。

垃圾收集器如何给变量打上标记呢?要理解它的工作方式,我们要理解一个JavaScript内存管理的重要的概念:可达性。

可达性

所谓“可达性”,是指变量可以由“根”出发,经过一层或多层可以被访问到。如果一个变量从根出发可以被访问到,那么它就是“可达”的。垃圾回收器将可达的变量视为有用的变量,将那些不可达的变量视为无用的变量,并给无用的变量打上“垃圾”的标记,便于之后的回收操作。

在JavaScript中,有一组基本的固有可达值,由于显而易见的原因无法删除。例如:

  • 本地函数的局部变量和参数
  • 当前嵌套调用链上的其他函数的变量和参数
  • 全局变量
  • 还有一些其他内部的值

我们以一段代码为例:

function marry(man,woman){
  man.wife = woman
  woman.husban = man
}
var man = {
  name:'Tom'
}
var woman = {
  name:'Mary'
}
var family = marry(man,woman)

这段代码在浏览器中运行时,内存表示如下:

 JavaScript垃圾回收机制_第1张图片

可以看到,manwomanfamily这三个全局变量都挂在到了window对象上,根据我们对“可达性”的定义,这些变量的值都可以从window出发被访问到,它们都是“可达”的,垃圾回收器不会对它们进行回收。

现在,我们尝试让垃圾回收器回收man指向的对象

man = null

这时候的内存图变为

JavaScript垃圾回收机制_第2张图片

可以看到,此时之前创建的man对象的值依然可以通过window.woman.husbandwindow.family.father访问到,因此这个对象仍被视为有用变量,不会被回收。接下来我们“切断”所有访问路径:

delete woman.husband delete family.father

此时内存图变为:

JavaScript垃圾回收机制_第3张图片

此时,因为man对象已经无法从“根”出发访问到了,因此它将来要被垃圾回收器当成“垃圾”回收。

为了加深理解,我们换一种操作,直接从根开始切断它们的访问路径:

man = null 
woman = null 
family = null

此时很容易得出内存图示:

JavaScript垃圾回收机制_第4张图片

如图所见,虽然之前定义的manwomanfamily相互之间还有关联(引用),但它们已经无法从根出发访问到了,成为了一座“孤岛”。垃圾回收器在运行的时候会将它们标记为“不可达”变量或“垃圾”,在回收的时候将它们“捡起来”。

这便是“可达性”。

说完了“可达性”,我们来说说垃圾回收器是如何进行标记的。如上文所述,标记只是一种策略,如何标记要看各浏览器具体的实现。

下面我们以《JavaScript高级程序设计》中的一段话为例:

垃圾收集器在运行的时候会给存储在内存中的所有变量都加上标记(当然,可以使用任何标记方式)。然后,它会去掉环境中的变量以及被环境中的变量引用的变量的标记。而在此之后再被加上标记的变量将被视为准备删除的变量,原因是环境中的变量已经无法访问到这些变量来。最后,垃圾收集器完成内存清除工作,销毁那些带标记的值并回收它们所占用的内存空间。

标记清除算法分为两个阶段:标记阶段和清除阶段。

上文引用这段话我们可以这么理解:在标记阶段,垃圾收集器先给内存中的所有变量都打上一个的标记,接着,从所有的“根”出发,沿着引用链路把沿途的所有变量的标记取消;而那些无法被访问到的变量身上仍带有标记,这些变量就是之后要回收的“垃圾”,我们将它们标记为“垃圾”;在清除阶段,垃圾回收器将那些被标记为“垃圾”的变量收集起来,在适当的时机将它们销毁,并回收它们占用的内存空间。

以上文彻底删除man为例描述这一过程。

首先,给内存中所有变量打上标记(途中黄色部分):

JavaScript垃圾回收机制_第5张图片

接着,沿着根出发,将沿途可访问到的变量的标记去掉:

JavaScript垃圾回收机制_第6张图片

这时候可以很容易识别到,之前man指向的对象是“垃圾”,我们给他打个更显眼的标记:

JavaScript垃圾回收机制_第7张图片

这样垃圾回收器就很容易地找到这个“垃圾”将它回收了。

为了加深理解,我们再以一段简单的闭包代码为例:

function makeGirlfriend(name){
  var girlfriend = {
    name:name
  }
  return function(newName){
    girlfriend.name = newName
  }
}
var makeAGirlfriend = makeGirlfriend('fanbingbing')
makeAGirlfriend('libingbing')

这段代码执行后,函数makeGirlfriend中的局部变量girlfriend并不会被立即回收,因为它将来可能会被再次使用。换一种说法,代码 var myGirlfriend = makeGirlfriend('fanbingbing')返回了一个函数,这个函数的内部的作用域链上保存了makeGirlfriend函数的活动对象上的girlfriend的引用,也就是JS内部可以以myGirlfriend->scopeChain->makeGirlfriend->girlfriend的路径访问到它,它是“可达”的,它所占用的内存不会被回收。这也是我们常说的闭包容易引起内存泄漏,要善用/慎用闭包的原因。

引用计数

另一种不太常见的垃圾回收策略叫引用计数。引用计数的含义是跟踪记录每个引用类型值被引用的次数,每多1次引用,次数加1,每取消1次引用,次数减1。当这个值的引用次数变成0时,说明没办法再访问这个值了,这个值将被视为“垃圾”并回收。

Netspace Navigator是最早使用引用计数策略的浏览器,但它很快就遇到一个严重但问题:循环引用。循环引用是指对象之间互相引用的现象,使得它们的引用计数永远不可能变为0,如以下代码:

function problem(){
  var boy = {}
    var girl = {}
    boy.sister = girl
    girl.brother = boy
}

boy和girl分别通过各自的属性sister、brother引用对方,在函数执行完后,boy和girl指向的对象仍然存在,因为它们的引用次数永远不会是0。如果这个函数被重复多次调用,就会导致大量内存得不到回收。为此,Netspace在 Navigator的后续版本中弃用了引用计数的策略,改用标记清除来实现垃圾回收器。

另外,IE的早期版本(IE8及以前)版本中,有一部分对象并不是原生JavaScript对象,如BOM和DOM对象。它们是使用C++以COM(component Object Model,组件对象模型)对象的形式实现的,而COM对象的收集机制采用的就是引用计数策略,因此,当在这些版本的浏览器中,当出现JavaScript对象与DOM或BOM对象相互引用的现象时,即使IE的JS引擎是用标记清除策略来实现的,此时这些DOM和BOM对象仍不会被回收。

性能问题

垃圾回收器是周期性运行的,而且如果为变量分配的内存数量很可观,那么回收工作量也将是相当大的。在这种情况下,确定垃圾回收器的运行间隔/运行时机是一个非常重要的问题。IE6也是因此而声名狼藉的:IE6的垃圾收集器是根据内存分配量运行的,具体来说就是,当内存分配量达到预设的临界值时,垃圾回收器就会运行。这导致的问题就是,一个应用中可能一直保有这么多变量,这样就会频繁地出发垃圾回收器的运行。我们知道,JS引擎是单线程运行的,当垃圾回收器运行的时候,为了保证逻辑的正确性,其他的JavaSript脚本将被暂停执行。垃圾回收器频繁的运行将会导致正常的业务代码得不到有效执行,产生很“卡”的现象。

为了解决IE6卡顿的问题,IE7改变了垃圾回收器的工作方式:将触发垃圾回收器运行的临界值变为动态调整,从而有效避免了垃圾回收器的频繁触发,极大提升了IE在运行包含大量JavaScript的页面的性能。

v8引擎的优化

虽说目前主流浏览器都是使用的“标记清除”的垃圾回收策略,但单纯的标记清除还是有它的缺点。我们知道,引用类型的值存储在堆中,JS引擎在创建这些值的时候会给它们分别开辟独立的堆内存空间。因为不是每个引用类型的值占用的内存大小都一样,因此在保存这些值时和垃圾收集器将它们回收后,都不可避免地在它们之间存在一些未使用的内存空间,也就是内存碎片,就像背包里装了很多东西但总有间隙一样。这些内存碎片有可能很小而不足以用于将来存放新的对象,为此可能会提前触发垃圾回收,而这次回收原本是不必要的。如何避免这样的资源浪费是一个待解决的问题。

下面我们以google的V8引擎为例,说说它做了哪些优化。

分代回收

V8引擎的垃圾回收基于分带回收机制,它将内存分为“新生代”和“老生代”。新生代,顾名思义,新生代中存放的就是值那些存活时间比较短,来来即走的对象;相反的,老生代内存中存放的是那些存活时间比较长,或者新生代中存放不下的大对象。

新生代和老生代使用了不同的回收策略,并且它们被分配了不同大小的内存空间:

  • 新生代使用了较小的内存大小,用Scavenge算法将新生代内存一分为二:from区和to区,分配内存时,对象存储在from区,进行垃圾回收时,将from区的存活对象复制到to区,非存活对象占用内存被释放,然后二者角色发生对换。
  • 老生代使用了较大的内存大小,而且老生代中的对象可能从新生代中“晋升”过来。简单说就是,新生代中的存活对象在复制过程中,“年龄”会逐渐增长,当它足够“老”时,就会“晋升”为老对象,存放到老生代内存中。老生代内存使用了Mark-Sweep(标记清除)和Mark-Compact(标记整理)相结合的策略进行垃圾回收。Mark-Sweep正如上文所述;Mark-Compact是对Mark-Sweep的补充,主要解决内存碎片的问题,具体做法是在整理过程中,将活着的对象往一边移动,移动完成后,活着对象那一侧之外的内存会被回收。

这部分更详细的内容请移步https://blog.csdn.net/wu_xianqiang/article/details/90736087

增量标记

进行垃圾回收时,JS引擎会暂停其他代码的执行。如果垃圾回收的时间过长,将会给应用程序带来明显的等待。V8引擎为此做了“增量标记”的优化,即垃圾回收器进行垃圾回收时先标记一部分/一段时间,然后停下来让程序代码继续运行,之后垃圾回收器再次运行时继续标记。直到标记工作完成后,垃圾回收器再进行清理工作。

参考资料

  1. 《JavaScript高级程序设计 第3版》
  2. https://blog.csdn.net/wu_xianqiang/article/details/90736087

你可能感兴趣的:(垃圾回收机制)