这一次,彻底搞懂JavaScript垃圾回收

我心飞翔 分类:javascript

JavaScript 中的原始类型的值存放在栈内存中,由系统自动分配释放。引用类型的值存放于堆内存中,其生命周期由JavaScript引擎的垃圾回收机制决定。

目前最主流的JavaScript引擎,当属Chrome的V8引擎。接下来的论述均基于V8展开。

回收机制的浅析

垃圾回收机制大体可分为引用计数和标记清除两种。其中引用计数由于存在比较明显的问题(主要存在于早期的IE浏览器),现今主流浏览器都采用标记清除,来管理引用值的内存。

引用计数(reference counting)

其基本思路就是对每个值都记录其被引用的次数。当一个值的引用数为0的时候,说明没有任何变量引用它,就可以安全的回收其内存了。垃圾回收器会在下次进行垃圾回收的时候,释放引用次数为0的值的内存。

引用记数的问题

引用计数存在一个严重的问题:循环引用。即对象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"
};
 

2894691528-5c92e57b9339d_article732.png

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

447270667-5c9329b9e28bb_article732.png

famliy变量是全局变量,为,可以通过引用访问{ father: man, mother: woman }对象, 可以通过引用链访问fathermother,此时所有对象都是可达的。
接下来移除一些引用:

delete family.father;
delete family.mother.husband;
 

3595939355-5c932ad1e5d2d_article732.png

移除了引用后,father对象变成了一座孤岛,根无法通过任何引用访问father对象,于是father对象变成不可达的,相关内存也将会被回收。

标记清除的过程

垃圾收集器会定期执行以下步骤,进行垃圾回收。

  • 垃圾收集器找到所有并标记他们
  • 遍历并标记根的引用
  • 然后标记引用的引用,直至标记所有可达的对象
  • 剩余没有被标记的对象都会被删除

434932871-5c9359fc2ac1b_article732.png

深入V8的垃圾回收

实际上由于性能上的要求,需要针对不同场景,采取更加高效的算法,以达到更好的效果,所以实际的垃圾回收策略会更加复杂。V8的垃圾回收策略主要基于分代式垃圾回收机制。

V8的内存分代

在V8中主要将内存分为新生代老生代两代。新生代中的对象为存活时间较短的对象,老生代中的对象为存活时间较长常驻内存的对象。

17080f71f93bd6cb

其中新生代中的对象主要通过Scavenge算法进行垃圾回收。而Scavenge的具体实现采用了Cheney算法。

  • Scavenge 算法

Cheney是一种采用复制方式实现的垃圾回收算法,它将新生代内存一分为二,每部分空间称为semispace。这两个semispace空间,一时刻只有一个处于使用中,另外一个处于空闲状态。我们将使用中的称为From空间,闲置的称为To空间。当分配对象的时候,先在From空间分配,开始垃圾回收时,将From空间存活的对象复制到To空间,而非存活对象占用的空间会被释放。完成复制后,将FromTo角色交换。

新老生代空间分布示意图.jpeg

该算法的缺点显而易见,由于划分空间和复制机制,导致新生代空间只能使用一半,但由于该算法只复制存活对象,而新生代场景下存活对象占比较少,因此在时间效率上有优异表现。

当一个对象经过多次复制依然存活,将会被认为是生命周期较长的对象,会被晋升到老生代空间中,采用新的算法进行管理。

晋升的条件主要有两个,一个是对象是否经历过Scavenge回收,一个是To空间的内存占用比超过25%的限制。设置25%限制值的原因是当这次回收完成后,To空间将变成From空间,接下来内存分配将在这个空间进行,如果占比过高,会影响后续的内存分配效率。

  • Mark-Sweep

在老生代中存活对象会比较多,若再采用Scavenge算法会有两个问题:一是存活对象较多,复制效率会降低,二是会浪费一半空间。因此V8在老生代中采用了Mark-Sweep & Mark-Compact相结合的方式进行垃圾回收。
Mark-Sweep 存在标记和清除两个阶段,与Scavenge算法相比,该算法并不将空间一分为二,因此也不存在浪费一半空间的问题。Mark-Sweep 在标记阶段遍历老生代中的所有对象,并标记存活着的对象。在随后的清除阶段中,只清除没有被标记的对象。可以看出,Scavenge算法只复制存活对象,Mark-Sweep只清除失活对象。而在新生代中存活对象占比较小,老生代中失活对象占比较小,因此可以有更高的效率。

老生代标记清除后.jpeg

  • 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)和在整理阶段引入增量式整理。

17085c0f2fb31d82

  • 并行

引入并行的概念,将部分操作分派给辅助线程执行,加快操作的执行。
00537bdadac433a57c77c56c5cc33c1f.jpg

配合垃圾回收

JavaScript引擎之所以采用复杂的回收机制来回收垃圾,是为了更加高效的利用内存空间。开发者可以结合以下几点,更好的配合JavaScript引擎,实行垃圾回收。

  1. 避免意外声明全局变量
function foo () {
    unexpectedVar = new Array(100);
}
foo();
 

foo函数意外声明了全局变量unexpectedVar,在浏览器环境下,unexpectedVar成为window的一个属性,而window对象是常驻内存的,即便foo函数执行完毕,被弹出调用栈,unexpectedVar依然被window引用,无法被回收,造成内存泄漏。

  1. 使用const/let 提升性能

const/let 都以块级作用域声明变量,相比于使用var(函数作用域)声明变量,前者可能会更早地让垃圾回收程序介入,尽早回收变量的内存空间。

  1. 主动释放全局变量

全局环境中的变量,会常驻内存(老生代空间),无法被垃圾回收器回收,需要主动释放。可以通过delete操作或者赋值为undefined/null来释放相关内存空间。

总结

本文首先浅析了两种垃圾回收机制:引用计数和标记清除,然后深入V8的内存分代机制,其针对新生代、老生代空间的特点,分别采取了不同的垃圾回收算法。Scavenge、Mark-Sweep、Mark-Compact, 其中Mark-Compact又是基于Mark-Sweep的一种衍生。最后谈到了开发者可以如何更好的配合JavaScript引擎,实行垃圾回收。

参考

  • 垃圾回收
  • JavaScript垃圾回收机制
  • JS垃圾回收,这次可以看懂了
  • 图解Google V8
  • 深入浅出Node.js

回复

我来回复
  • 暂无回复内容