前言
我们知道,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)
这段代码在浏览器中运行时,内存表示如下:
可以看到,man
、woman
、family
这三个全局变量都挂在到了window
对象上,根据我们对“可达性”的定义,这些变量的值都可以从window
出发被访问到,它们都是“可达”的,垃圾回收器不会对它们进行回收。
现在,我们尝试让垃圾回收器回收man
指向的对象
man = null
这时候的内存图变为
可以看到,此时之前创建的man
对象的值依然可以通过window.woman.husband
或window.family.father
访问到,因此这个对象仍被视为有用变量,不会被回收。接下来我们“切断”所有访问路径:
delete woman.husband delete family.father
此时内存图变为:
此时,因为man
对象已经无法从“根”出发访问到了,因此它将来要被垃圾回收器当成“垃圾”回收。
为了加深理解,我们换一种操作,直接从根开始切断它们的访问路径:
man = null
woman = null
family = null
此时很容易得出内存图示:
如图所见,虽然之前定义的man
、woman
、family
相互之间还有关联(引用),但它们已经无法从根出发访问到了,成为了一座“孤岛”。垃圾回收器在运行的时候会将它们标记为“不可达”变量或“垃圾”,在回收的时候将它们“捡起来”。
这便是“可达性”。
说完了“可达性”,我们来说说垃圾回收器是如何进行标记的。如上文所述,标记只是一种策略,如何标记要看各浏览器具体的实现。
下面我们以《JavaScript高级程序设计》中的一段话为例:
垃圾收集器在运行的时候会给存储在内存中的所有变量都加上标记(当然,可以使用任何标记方式)。然后,它会去掉环境中的变量以及被环境中的变量引用的变量的标记。而在此之后再被加上标记的变量将被视为准备删除的变量,原因是环境中的变量已经无法访问到这些变量来。最后,垃圾收集器完成内存清除工作,销毁那些带标记的值并回收它们所占用的内存空间。
标记清除算法分为两个阶段:标记阶段和清除阶段。
上文引用这段话我们可以这么理解:在标记阶段,垃圾收集器先给内存中的所有变量都打上一个的标记,接着,从所有的“根”出发,沿着引用链路把沿途的所有变量的标记取消;而那些无法被访问到的变量身上仍带有标记,这些变量就是之后要回收的“垃圾”,我们将它们标记为“垃圾”;在清除阶段,垃圾回收器将那些被标记为“垃圾”的变量收集起来,在适当的时机将它们销毁,并回收它们占用的内存空间。
以上文彻底删除man
为例描述这一过程。
首先,给内存中所有变量打上标记(途中黄色部分):
接着,沿着根出发,将沿途可访问到的变量的标记去掉:
这时候可以很容易识别到,之前man指向的对象是“垃圾”,我们给他打个更显眼的标记:
这样垃圾回收器就很容易地找到这个“垃圾”将它回收了。
为了加深理解,我们再以一段简单的闭包代码为例:
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引擎为此做了“增量标记”的优化,即垃圾回收器进行垃圾回收时先标记一部分/一段时间,然后停下来让程序代码继续运行,之后垃圾回收器再次运行时继续标记。直到标记工作完成后,垃圾回收器再进行清理工作。
参考资料
- 《JavaScript高级程序设计 第3版》
- https://blog.csdn.net/wu_xianqiang/article/details/90736087