像C语言这样的底层语言一般都有底层的内存管理接口,比如 malloc()和free()用于分配内存和释放内存。
而对于JavaScript来说,会在创建变量(对象,字符串等)时分配内存,并且在不再使用它们时“自动”释放内存,这个自动释放内存的过程称为垃圾回收。
因为自动垃圾回收机制的存在,让大多Javascript开发者感觉他们可以不关心内存管理,所以会在一些情况下导致内存泄漏。

JavaScript垃圾回收的机制很简单:找出不再使用的变量,然后释放掉其占用的内存,但是这个过程不是时时的,因为其开销比较大,所以垃圾回收器会按照固定的时间间隔周期性的执行。

内存生命周期

JS 环境中分配的内存有如下声明周期:
内存生命周期
内存分配:当我们申明变量、函数、对象的时候,系统会自动为他们分配内存
内存使用:即读写内存,也就是使用变量、函数等
内存回收:使用完毕,由垃圾回收机制自动回收不再使用的内存

JS 的内存分配

JavaScript 在定义变量时就完成了内存分配。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var n = 123; // 给数值变量分配内存
var s = "azerty"; // 给字符串分配内存

var o = {
a: 1,
b: null
}; // 给对象及其包含的值分配内存

// 给数组及其包含的值分配内存(就像对象一样)
var a = [1, null, "abra"];

function f(a){
return a + 2;
} // 给函数(可调用的对象)分配内存

// 函数表达式也能分配一个对象
someElement.addEventListener('click', function(){
someElement.style.backgroundColor = 'blue';
}, false);

JS 的内存使用

使用值的过程实际上是对分配内存进行读取与写入的操作。 读取与写入可能是写入一个变量或者一个对象的属性值,甚至传递函数的参数

1
2
var a = 10; // 分配内存
console.log(a); // 对内存的使用

JS的内存回收

JS 有自动垃圾回收机制,那么这个自动垃圾回收机制的原理是什么呢?
其实很简单,就是找出那些不再继续使用的值,然后释放其占用的内存。
大多数内存管理的问题都在这个阶段。
在这里最艰难的任务是找到不再需要使用的变量
不再需要使用的变量也就是生命周期结束的变量,是局部变量,局部变量只在函数的执行过程中存在,
当函数运行结束,没有其他引用(闭包),那么该变量会被标记回收。
全局变量的生命周期直至浏览器卸载页面才会结束,也就是说全局变量不会被当成垃圾回收。
因为自动垃圾回收机制的存在,开发人员可以不关心也不注意内存释放的有关问题,但对无用内存的释放这件事是客观存在的。
不幸的是,即使不考虑垃圾回收对性能的影响,目前最新的垃圾回收算法,也无法智能回收所有的极端情况

标记清除

标记-清除算法包含三个步骤:

  • 根:一般来说,根指的是代码中引用的全局变量。就拿 JavaScript 来说,window 对象即是根的全局变量。Node.js 中相对应的变量为 “global”。垃圾回收器会构建出一份所有根变量的完整列表。
  • 随后,算法会检测所有的根变量及他们的后代变量并标记它们为激活状态(表示它们不可回收)。任何根变量所到达不了的变量(或者对象等等)都会被标记为内存垃圾。
  • 最后,垃圾回收器会释放所有非激活状态的内存片段然后返还给操作系统。
    sweep

引用计数

这是最初级的垃圾回收算法。

引用计数算法定义“内存不再使用”的标准很简单,就是看一个对象是否有指向它的引用。 如果没有其他对象指向它了,说明该对象已经不再需了。
统计引用类型变量声明后被引用的次数,当次数为 0 时,该变量将被回收。

示例1:

1
2
3
4
5
6
7
function func4 () {
const c = {} // 引用类型变量 c的引用计数为 0
let d = c // c 被 d 引用 c的引用计数为 1
let e = c // c 被 e 引用 c的引用计数为 2
d = {} // d 不再引用c c的引用计数减为 1
e = null // e 不再引用 c c的引用计数减为 0 将被回收
}

示例2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 两个对象被创建,一个作为另一个的属性被引用,另一个被分配给变量o
// 很显然,没有一个可以被垃圾收集
var o = {
a: {
b:2
}
};


var o2 = o; // o2变量是第二个对“这个对象”的引用

o = 1; // 现在,“这个对象”的原始引用o被o2替换了

var oa = o2.a; // 引用“这个对象”的a属性
// 现在,“这个对象”有两个引用了,一个是o2,一个是oa

o2 = "yo"; // 最初的对象现在已经是零引用了
// 他可以被垃圾回收了
// 然而它的属性a的对象还在被oa引用,所以还不能回收

oa = null; // a属性的那个对象现在也是零引用了
// 它可以被垃圾回收了

但这种方式存在缺陷,就是互相引用

1
2
3
4
5
6
7
8

function func5 () {
let f = {}
let g = {}
f.prop = g
g.prop = f
// 由于 f 和 g 互相引用,计数永远不可能为 0
}

只能手动清除

1
2
f.prop = null
g.prop = null

在现代浏览器中,Javascript 使用的方式是标记清除,所以我们无需担心循环引用的问题

内存泄露?

内存泄露就是不再被需要的内存, 由于某种原因, 无法被释放.

意外的全局变量造成内存泄漏

1
2
3
4
5
function fn() {
this.name = "你我贷" // 全局变量 => window.name
bar = 'come on' // 没有声明变量 实际上是全局变量 => window.bar
}
console.log(name)

在这种情况下this指向了全局变量window对象,意外的创建了全局变量. 我们谈到了一些意外情况下定义的全局变量, 代码中也有一些我们明确定义的全局变量. 如果使用这些全局变量用来暂存大量的数据, 记得在使用后, 对其重新赋值为 null.

未销毁的定时器和回调函数造成内存泄露

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var timer
function poll() {
timer = setTimeout(() => {
timer && clearTimeout(timer)
console.log('11')
poll()
}, 400);
}
function destroyer() {
timer && clearTimeout(timer) // 一定要记得销毁定时器,不能只销毁定时器的引用
timer = null
}
poll()
console.log(timer)

闭包造成内存泄露

dom引用造成内存泄漏

1
2
3
4
5
6
7
8
9
10
11
12
 var elements = {
txt: document.getElementById("test")
}
function fn() {
elements.txt.innerHTML = "1111"
}
function removeTxt() {
document.body.removeChild(document.getElementById('test'));
}
fn();
removeTxt()
console.log(elements.txt)

我们对 Dom 的操作, 会把 Dom 的引用保存在一个数组或者 对象 中。上述案例中, 即使我们对于 test 元素进行了移除, 但是仍然有对 test 元素的引用, 依然无法对其进行内存回收。

基本上,如果你要往对象上添加数据,又不想干扰垃圾回收机制,就可以使用 WeakMap。一个典型应用场景是,在网页的 DOM 元素上添加数据,就可以使用WeakMap结构。当该 DOM 元素被清除,其所对应的WeakMap记录就会自动被移除。

1
2
3
4
5
6
const wm = new WeakMap();

const element = document.getElementById('example');

wm.set(element, 'some information');
wm.get(element) // "some information"

上面代码中,先新建一个 Weakmap 实例。然后,将一个 DOM 节点作为键名存入该实例,并将一些附加信息作为键值,一起存放在 WeakMap 里面。这时,WeakMap 里面对element的引用就是弱引用,不会被计入垃圾回收机制。

也就是说,上面的 DOM 节点对象的引用计数是1,而不是2。这时,一旦消除对该节点的引用,它占用的内存就会被垃圾回收机制释放。Weakmap 保存的这个键值对,也会自动消失。

总之,WeakMap的专用场合就是,它的键所对应的对象,可能会在将来消失。WeakMap结构有助于防止内存泄漏。
注意,WeakMap 弱引用的只是键名,而不是键值。键值依然是正常引用。

1
2
3
4
5
6
7
8
const wm = new WeakMap();
let key = {};
let obj = {foo: 1};

wm.set(key, obj);
obj = null;
wm.get(key)
// Object {foo: 1}

上面代码中,键值obj是正常引用。所以,即使在 WeakMap 外部消除了obj的引用,WeakMap 内部的引用依然存在。

拓展

  • echarts 造成内存泄漏
    1
    2
    3
    4
    destroyed () {
    this.chart.destroy()
    this.chart = null
    }
  • g2造成内存泄漏
  • WeakMap 应用的典型场合就是 DOM 节点作为键名
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    let myWeakmap = new WeakMap();

    myWeakmap.set(
    document.getElementById('logo'),
    {timesClicked: 0})
    ;

    document.getElementById('logo').addEventListener('click', function() {
    let logoData = myWeakmap.get(document.getElementById('logo'));
    logoData.timesClicked++;
    }, false);
    document.getElementById(‘logo’)是一个 DOM 节点,每当发生click事件,就更新一下状态。我们将这个状态作为键值放在 WeakMap 里,对应的键名就是这个节点对象。一旦这个 DOM 节点删除,该状态就会自动消失,不存在内存泄漏风险。

其他优化

  • 尽可能避免创建对象,非必要情况下避免调用会创建对象的方法,如 Array.slice、Array.map、Array.filter、字符串相加、$(‘div’)、ArrayBuffer.slice 等。

  • 不再使用的对象,手动赋为 null,可避免循环引用等问题。

  • 使用 Weakmap

  • 生产环境勿用 console.log 大对象,包括 DOM、大数组、ImageData、ArrayBuffer 等。因为 console.log 的对象不会被垃圾回收。详见Will console.log prevent garbage collection?。

  • 合理设计页面,按需创建对象/渲染页面/加载图片等。

避免如下问题:

为了省事儿,一次性请求全部数据。
为了省事儿,一次性渲染全部数据,再做隐藏。
为了省事儿,一次性加载/渲染全部图片。
使用重复 DOM 等,如重复使用同一个弹窗而非创建多个。

如 Vue-Element 框架中,PopOver/Tooltip 等组件用于表格内时会创建 m * n 个实例,可优化为只创建一个实例,动态设置位置及数据。

  • ImageData 对象是 JS 内存杀手,避免重复创建 ImageData 对象。

  • 重复使用 ArrayBuffer(前端的一个通用的二进制缓冲区,类似数组,但在API和特性上却有诸多不同)

  • 压缩图片、按需加载图片、按需渲染图片,使用恰当的图片尺寸、图片格式,如 WebP 格式。

参考文章