Skip to content
On this page

垃圾回收机制

为什么需要垃圾回收机制

我们执行完函数,函数的作用域也会随之销毁,如果在这个作用域被销毁的过程中,其中的变量不被回收,持久占用内存,那么必然会导致内存暴增,从而引发内存泄漏导致程序的性能直线下降甚至崩溃,因此内存在使用完毕之后理当归还给操作系统以保证内存的重复利用。

V8引擎的内存限制

起初只是作为浏览器端JavaScript的执行环境,在浏览器端我们其实很少会遇到使用大量内存的场景,因此也就没有必要将最大内存设置得过高。

另外两个原因:

  • JS单线程机制:作为浏览器的脚本语言,JS的主要用途是与用户交互以及操作DOM,那么这也决定了其作为单线程的本质,单线程意味着执行的代码必须按顺序执行,在同一时间只能处理一个任务。由于JS的单线程机制,垃圾回收的过程阻碍了主线程逻辑的执行。
  • JS中的垃圾数据都是由垃圾回收器(GC)自动回收的,不需要手动释放。

JS引擎中有一个后台进程称为垃圾回收器,它监视所有对象,观察对象是否可被访问,然后按照固定的时间间隔周期性的删除那些不可访问的对象。

在IE7之后优化:

  • 如果垃圾回收程序回收的内存不到已分配的15%(说明内存使用的比较多,活动对象多),那么程序设定的阈值就会翻倍(给出更多的内存空间);

  • 如果有一次回收的内存达到分配的85%(说明使用的内存少了),那么阈值就会重置为默认值。

现在通常采用的垃圾回收有两种方法:

  • 引用计数
  • 标记清除

引用计数

最早最简单的垃圾回收机制。

给一个占用物理空间的对象附加一个引用计数器,当有其它对象引用这个对象时,这个对象的引用计数加一,反之解除时就减一,当该对象引用计数为 0 时就会被回收。

但对于循环引用,会引起内存泄漏:

js
// 循环引用的问题
function temp(){
    var a = {};
    var b = {};
    a.o = b;
    b.o = a;
}
temp()

这种情况下每次调用 temp 函数,a 和 b 的引用计数都是 2 ,会使这部分内存永远不会被释放,即内存泄漏。如果大量调用该函数,就会导致大量内存永远不会被释放。现在已经很少使用了,只有低版本的 IE 使用这种方式。

引用计数的缺点 需要一个计数器,而此计数器需要占很大的位置,因为我们也不知道被引用数量的上限,还有就是无法解决循环引用无法回收的问题,这也是最严重的。

标记清除

V8 中主垃圾回收器就采用标记清除法进行垃圾回收。主要流程如下:

  • 标记:遍历调用栈,看老生代区域堆中的对象是否被引用,被引用的对象标记为活动对象,没有被引用的对象(待清理)标记为垃圾数据。
  • 垃圾清理:将所有垃圾数据清理掉

在我们的开发过程中,如果我们想要让垃圾回收器回收某一对象,就将对象的引用直接设置为 null

js
var a = {}; // {} 可访问,a 是其引用

a = null; // 引用设置为 null
// {} 将会被从内存里清理出去

但如果一个对象被多次引用时,例如作为另一对象的键、值或子元素时,将该对象引用设置为 null 时,该对象是不会被回收的,依然存在

js
var a = {}; 
var arr = [a];

a = null; 
console.log(arr)
// [{}]

如果作为 Map 的键?

js
var a = {}; 
var map = new Map();
map.set(a, '三分钟学前端')

a = null; 
console.log(map.keys()) // MapIterator {{}}
console.log(map.values()) // MapIterator {"三分钟学前端"}

如果想让 a 置为 null 时,该对象被回收,该怎么做?

使用WeakMap。

标记引用的缺点

  • 内存碎片化,空闲内存块是不连续的,容易出现很多空闲内存块,还可能会出现分配所需内存过大的对象时找不到合适的块
  • 分配速度慢,因为即便是使用 First-fit 策略,其操作仍是一个 O(n) 的操作,最坏情况是每次都要遍历到最后,同时因为碎片化,大对象的分配效率会更慢

标记整理(Mark-Compact)算法 就可以有效地解决,它的标记阶段和标记清除算法没有什么不同,只是标记结束后,标记整理算法会将活着的对象(即不需要清理的对象)向内存的一端移动,最后清理掉边界的内存

V8对GC的优化

分代式垃圾回收

新生代回收

  • 新生代对象通过Scavenge算法,主要采用了一种复制式的方式Cheney算法,将堆内存一分为二,一个是使用区,一个是空闲区
  • 新建的对象会存储在使用区,当使用区快写满时,进行垃圾清理。
  • 对使用区的对象进行标记,标记完将活动对象复制到空闲区并排序,随后清理垃圾,最后使用区和空闲区互换。
  • 对于经过多次复制依然存活的对象,会移动到老生代中。还有一种情况,当一个对象移动到空闲区时,空闲区空间占用已经超过25%,这个对象也会直接被晋升到老生代。

老生代回收

  • 标记清除算法,使用标记整理算法对内存碎片进行空间优化

分代式机制把一些新、小、存活时间短的对象作为新生代,采用一小块内存频率较高的快速清理,而一些大、老、存活时间长的对象作为老生代,使其很少接受检查,新老生代的回收机制及频率是不同的,可以说此机制的出现很大程度提高了垃圾回收机制的效率

并行回收和并发回收

  • 新生代使用并行回收
    • 增量标记
      • 三色标记法——解决暂停与恢复的问题
        • 三色标记法即使用每个对象的两个标记位和一个标记工作表来实现标记,两个标记位编码三种颜色:白、灰、黑
          • 白色指的是未被标记的对象
          • 灰色指自身被标记,成员变量(该对象的引用对象)未被标记
          • 黑色指自身和成员变量皆被标记
      • 写屏障——增量中修改引用
        • 一旦有黑色对象引用白色对象,该机制会强制将引用的白色对象改为灰色,从而保证下一次增量 GC 标记阶段可以正确标记,这个机制也被称作 强三色不变性
    • 懒性清理
  • 老生代使用并发标记,并行清理

Weakmap VS map

ES6 考虑到了这一点,推出了: WeakMap 。它对于值的引用都是不计入垃圾回收机制的,所以名字里面才会有一个"Weak",表示这是弱引用(对对象的弱引用是指当该对象应该被GC回收时不会阻止GC的回收行为)。

Map 相对于 WeakMap

  • Map 的键可以是任意类型,WeakMap 只接受对象作为键(null除外),不接受其他类型的值作为键
  • Map 的键实际上是跟内存地址绑定的,只要内存地址不一样,就视为两个键; WeakMap 的键是弱引用,键所指向的对象可以被垃圾回收,此时键是无效的
  • Map 可以被遍历,WeakMap 不能被遍历

我们通过 process.memoryUsage 测试一下:

js
//map.js
global.gc(); // 0 每次查询内存都先执行gc()再memoryUsage(),是为了确保垃圾回收,保证获取的内存使用状态准确

function usedSize() {
    const used = process.memoryUsage().heapUsed;
    return Math.round((used / 1024 / 1024) * 100) / 100 + "M";
}

console.log(usedSize()); // 1 初始状态,执行gc()和memoryUsage()以后,heapUsed 值为 1.64M

var map = new Map();
var b = new Array(5 * 1024 * 1024);

map.set(b, 1);

global.gc();
console.log(usedSize()); // 2 在 Map 中加入元素b,为一个 5*1024*1024 的数组后,heapUsed为41.82M左右

b = null;
global.gc();

console.log(usedSize()); // 3 将b置为空以后,heapUsed 仍为41.82M,说明Map中的那个长度为5*1024*1024的数组依然存在

执行 node --expose-gc map.js 命令:

其中,--expose-gc 参数表示允许手动执行垃圾回收机制

js
// weakmap.js
function usedSize() {
    const used = process.memoryUsage().heapUsed;
    return Math.round((used / 1024 / 1024) * 100) / 100 + "M";
}

global.gc(); // 0 每次查询内存都先执行gc()再memoryUsage(),是为了确保垃圾回收,保证获取的内存使用状态准确
console.log(usedSize()); // 1 初始状态,执行gc()和 memoryUsage()以后,heapUsed 值为 1.64M
var map = new WeakMap();
var b = new Array(5 * 1024 * 1024);

map.set(b, 1);

global.gc();
console.log(usedSize()); // 2 在 Map 中加入元素b,为一个 5*1024*1024 的数组后,heapUsed为41.82M左右

b = null;
global.gc();

console.log(usedSize()); // 3 将b置为空以后,heapUsed 变成了1.82M左右,说明WeakMap中的那个长度为5*1024*1024的数组被销毁了

执行 node --expose-gc weakmap.js 命令:

上面代码中,只要外部的引用消失,WeakMap 内部的引用,就会自动被垃圾回收清除。由此可见,有了它的帮助,解决内存泄漏就会简单很多。

**## 内存管理

1. 解除引用

最优内存占用的最佳手段就是保证在执行代码时只保存必要的数据。如果数据不再必要,则把它设置为null,从而释放引用,也叫解除引用其关键在于确保相关的值已经不在上下文中了,所以会在下一次垃圾回收时被回收。 这种方式最适用于全局变量金额全局对象的属性。

2. 隐藏类和删除操作

当我们new一个类,生成两个对象,V8会在两个类实例共享相同的隐藏类;而但我们给其中一个对象新增属性时,两个对象就会对应两个不同的隐藏类,根据操作频率和对象的大小,可以会对性能有所损耗。

我们可以通过在构造函数中一次性声明所有属性来解决,避免 先创建再补充 式的动态属性赋值。

动态删除对象属性也会导致一样的后果,最佳实践是把不想要的属性设置为null。

3. 内存泄漏

以下四种情况会造成内存的泄漏:

  • 意外的全局变量:由于使⽤未声明的变量,⽽意外的创建了⼀个全局变量,⽽使这个变量⼀直留在内存中⽆法被回收。

  • 被遗忘的计时器或回调函数:设置了 setInterval 定时器,⽽忘记取消它,如果循环函数有对外部变量的引⽤的话,那么这个变量会被⼀直留在内存中,⽽⽆法被回收。

  • 脱离 DOM 的引⽤:获取⼀个 DOM 元素的引⽤,⽽后⾯这个元素被删除,由于⼀直保留了对这个元素的引⽤,所以它也⽆法被回收。

  • 闭包:不合理的使⽤闭包,从⽽导致某些变量⼀直被留在内存当中。

4. 静态分配和对象池

关于提升JavaScript性能,一个关键问题是如何减少浏览器执行垃圾回收的次数。

该问题的解决办法就是不要动态的创建矢量对象。在初始化的时候,可以创建个对象池,用来管理一组可回收的对象。

对于Javascript中,数组大小是动态可变的,初始化可以就创建一个合适大小够用的数组,可以避免数组被程序向被删除再重新创建。

参考链接

「硬核JS」你真的了解垃圾回收机制吗

WeakMap 和 Map 的区别,WeakMap 原理,为什么能被 GC?

MIT Licensed | Copyright © 2021 - 2022