目前最主流的JavaScript引擎,当属Chrome的V8引擎。接下来的论述均基于V8展开。
回收机制的浅析
垃圾回收机制大体可分为引用计数和标记清除两种。其中引用计数由于存在比较明显的问题(主要存在于早期的IE浏览器),现今主流浏览器都采用标记清除,来管理引用值的内存。
引用计数(reference counting)
引用记数的问题
引用计数存在一个严重的问题:循环引用。即对象A有一个属性指向B,B也有一个属性指向A。这样A、B对象的引用计数都为2,就永远不会被垃圾回收器回收。久而久之,会造成大量的内存无法被回收,造成内存泄漏。
const A = new Object();
const B = new Object();
A.ref2B = B;
B.ref2A = A;
标记清除(mark and sweep)
关于标记清除,就不得不提及可达性
。可达性
是判断一个值是否会被垃圾回收的重要依据。
可达性
可达值是那些以某种方式可访问或可用的值,他们不会被垃圾回收机制清除、释放。 下面是一些固有可达值的集合,所占用的空间不能被释放。
- 当前函数的局部变量和参数
- 嵌套调用时调用链上所有函数的变量与参数
- 全局变量
- (还有一些内部的)
这些值称为根
。
如果一个值可以从根
通过引用
或引用链
被访问,就被认为是可达
的。
举例一
// user 具有对这个对象的引用
let user = {
name: "John"
};
user是全局变量,是根
,{ name: 'John' }可以通过user引用到,于是{ name: 'John' }就是可达的
,不会被垃圾回收机制回收。
如果user = null;
就会重写user变量,切断了与对象之间的引用,对象就会变为不可达
,垃圾回收机制会在下次垃圾回收的时候,释放相关内存。
举例二
function marry(man, woman) {
woman.husband = man;
man.wife = woman;
return {
father: man,
mother: woman
}
}
let family = marry({
name: 'John'
}, {
name: 'Ann'
});
famliy变量是全局变量,为根
,可以通过引用访问{ father: man, mother: woman }
对象, 可以通过引用链访问father
和mother
,此时所有对象都是可达
的。
接下来移除一些引用:
delete family.father;
delete family.mother.husband;
移除了引用后,father对象变成了一座孤岛
,根无法通过任何引用访问father
对象,于是father
对象变成不可达
的,相关内存也将会被回收。
标记清除的过程
垃圾收集器会定期执行以下步骤,进行垃圾回收。
- 垃圾收集器找到所有
根
并标记他们 - 遍历并标记根的引用
- 然后标记引用的引用,直至标记所有可达的对象
- 剩余没有被标记的对象都会被删除
深入V8的垃圾回收
实际上由于性能上的要求,需要针对不同场景,采取更加高效的算法,以达到更好的效果,所以实际的垃圾回收策略会更加复杂。V8的垃圾回收策略主要基于分代式垃圾回收机制。
V8的内存分代
在V8中主要将内存分为新生代
和老生代
两代。新生代中的对象为存活时间较短
的对象,老生代中的对象为存活时间较长
或常驻内存
的对象。
其中新生代中的对象主要通过Scavenge
算法进行垃圾回收。而Scavenge
的具体实现采用了Cheney算法。
- Scavenge 算法
Cheney是一种采用复制方式实现的垃圾回收算法,它将新生代内存一分为二,每部分空间称为semispace
。这两个semispace空间,一时刻只有一个处于使用中,另外一个处于空闲状态。我们将使用中的称为From
空间,闲置的称为To
空间。当分配对象的时候,先在From
空间分配,开始垃圾回收时,将From
空间存活的对象复制到To
空间,而非存活对象占用的空间会被释放。完成复制后,将From
和To
角色交换。
该算法的缺点显而易见,由于划分空间和复制机制,导致新生代空间只能使用一半,但由于该算法只复制存活对象,而新生代场景下存活对象占比较少,因此在时间效率上有优异表现。
当一个对象经过多次复制依然存活,将会被认为是生命周期较长的对象,会被晋升
到老生代空间中,采用新的算法进行管理。
晋升
的条件主要有两个,一个是对象是否经历过Scavenge回收,一个是To空间的内存占用比超过25%的限制。设置25%限制值的原因是当这次回收完成后,To空间将变成From空间,接下来内存分配将在这个空间进行,如果占比过高,会影响后续的内存分配效率。
- Mark-Sweep
在老生代中存活对象会比较多,若再采用Scavenge算法会有两个问题:一是存活对象较多,复制效率会降低,二是会浪费一半空间。因此V8在老生代中采用了Mark-Sweep & Mark-Compact
相结合的方式进行垃圾回收。
Mark-Sweep 存在标记和清除两个阶段,与Scavenge算法相比,该算法并不将空间一分为二,因此也不存在浪费一半空间的问题。Mark-Sweep 在标记阶段遍历老生代中的所有对象,并标记存活着的对象。在随后的清除阶段中,只清除没有被标记的对象。可以看出,Scavenge算法只复制存活对象,Mark-Sweep只清除失活对象。而在新生代中存活对象占比较小,老生代中失活对象占比较小,因此可以有更高的效率。
- Mark-Compact
Mark-Sweep 最大的问题是进行标记清除后,会造成内存不连续的情况,如果之后需要分配一个大内存的对象,虽然整体剩余空间足够,但由于不是连续的空间,导致无法完成内存分配,就会提前触发垃圾回收。
为了解决Mark-Sweep存在的内存碎片化严重的问题,Mark-Compact横空出世。该算法从Mark-Sweep演变而来,区别在于对象失活被标记死亡后,在整理的过程中,将存活的对象往一端移动,移动完成后,直接清理掉边界外的内存。解决了Mark-Sweep 存在的内存碎片化的问题。
在V8中,主要采用Mark-Sweep,在空间不足时对从新生代中晋升过来的对象进行分配时才采用Mark-Compact。
V8对垃圾回收的优化
由于JavaScript是单线程的,以上三种算法都需要将应用逻辑暂停下来,待执行完垃圾回收之后再执行应用逻辑,此行为被称为全停顿
。在V8的分代式垃圾回收中,如果只回收新生代的话,因为新生代默认配置较小,且其中存活的更少,所以即使全停顿也影响不大。但老生代通常配置的较大,且存活对象较多,进行一次全堆垃圾回收
,标记、清除、整理就会造成较大停顿,影响体验。
为了降低全堆垃圾回收的停顿时间,V8对垃圾回收过程做了一些优化。
- 增量
将标记阶段改为增量标记(incremental marking),将标记切片,让标记和应用逻辑交替执行。在清除阶段引入延迟清除(lazy sweeping)和在整理阶段引入增量式整理。
- 并行
引入并行的概念,将部分操作分派给辅助线程执行,加快操作的执行。
配合垃圾回收
JavaScript引擎之所以采用复杂的回收机制来回收垃圾,是为了更加高效的利用内存空间。开发者可以结合以下几点,更好的配合JavaScript引擎,实行垃圾回收。
- 避免意外声明全局变量
function foo () {
unexpectedVar = new Array(100);
}
foo();
foo函数意外声明了全局变量unexpectedVar,在浏览器环境下,unexpectedVar成为window的一个属性,而window对象是常驻内存的,即便foo函数执行完毕,被弹出调用栈,unexpectedVar依然被window引用,无法被回收,造成内存泄漏。
- 使用const/let 提升性能
const/let 都以块级作用域声明变量,相比于使用var(函数作用域)声明变量,前者可能会更早地让垃圾回收程序介入,尽早回收变量的内存空间。
- 主动释放全局变量
全局环境中的变量,会常驻内存(老生代空间),无法被垃圾回收器回收,需要主动释放。可以通过delete操作或者赋值为undefined/null来释放相关内存空间。
总结
本文首先浅析了两种垃圾回收机制:引用计数和标记清除,然后深入V8的内存分代机制,其针对新生代、老生代空间的特点,分别采取了不同的垃圾回收算法。Scavenge、Mark-Sweep、Mark-Compact, 其中Mark-Compact又是基于Mark-Sweep的一种衍生。最后谈到了开发者可以如何更好的配合JavaScript引擎,实行垃圾回收。
参考
- 垃圾回收
- JavaScript垃圾回收机制
- JS垃圾回收,这次可以看懂了
- 图解Google V8
- 深入浅出Node.js
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!