# js中的内存机制


# 前言

开发人员一般不必操心内存管理的问题。但是,JavaScript在进行内存管理及垃圾收集时面临的问题还是有点与众不同。其中最主要的一个问题,就是分配给Web浏览器的可用内存数量通常要比分配给桌面应用程序的少。这样做的目的主要是出于安全方面的考虑,目的是防止运行JavaScript的网页耗尽全部系统内存而导致系统崩溃。内存限制问题不仅会影响给变量分配内存,同时还会影响调用栈以及在一个线程中能够同时执行的语句数量。

# 内存生命周期


JS 环境中分配的内存有如下声明周期:

  • 1.内存分配:当我们申明变量、函数、对象的时候,系统会自动为他们分配内存
  • 2.内存使用:即读写内存,也就是使用变量、函数等
  • 3.内存回收:使用完毕,由垃圾回收机制自动回收不再使用的内存

分配内存 -> 使用内存 -> 释放内存

# JS 的内存分配

在js中为了不让程序员费心分配内存, js在定义变量时就完成了分配

让我们根据代码来分析下的内存分配

var n = 123; // 给数值变量分配内存
var s = "前端自学驿站"; // 给字符串分配内存
 
var o = {
 a: 1,
 b: null
}; // 给对象及其成员的值分配内存
 
// 给数组及其包含的值分配内存(就像对象一样)
var a = [1, null, "前端自学驿站"]; 
 
function f(a){
 return a + 2;
} // 给函数(可调用的对象)分配内存
 
// 给点击事件监听回调分配内存
document.body.addEventListener('click', function(){
 someElement.style.backgroundColor = 'blue';
}, false);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

有些函数调用结果是分配对象内存:

var d = new Date(); // 分配一个 Date 对象
 
var e = document.createElement('div'); // 分配一个 DOM 元素
1
2
3

有些方法分配新变量或者新对象:

var s = "前端自学驿站";
var s2 = s.substr(0, 3); // s2 是一个新的字符串
// 因为字符串是不变量,
// JavaScript 可能决定不分配内存,
// 只是存储了 [0-3] 的范围。
 
var a = ["one one", "two two"];
var a2 = ["generation", "nan nan"];
var a3 = a.concat(a2); 
// 新数组有四个元素,是 a 拼接 a2 的结果
1
2
3
4
5
6
7
8
9
10

# JS 的内存使用

使用值的过程实际上是对分配内存进行读取与写入的操作。

读取与写入可能是写入一个变量或者一个对象的属性值,甚至传递函数的参数。

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

# JS 的内存回收

JS 有自动垃圾回收机制,那么这个自动垃圾回收机制的原理是什么呢? 其实很简单,就是找出那些不再继续使用的值,然后释放其占用的内存。

# 垃圾回收算法

对垃圾回收算法来说,核心思想就是如何判断内存已经不再使用,常用垃圾回收算法有下面两种。

  • 引用计数(现代浏览器不再使用)
  • 标记清除(常用)

# 引用计数

引用计数算法定义“内存不再使用”的标准很简单,就是看一个对象是否有指向它的引用。如果没有其他对象指向它了,说明该对象已经不再需要了。

// 创建一个对象person,他有两个指向属性age和name的引用
var person = {
    age: 12,
    name: '前端自学驿站'
};

person.name = null; // 虽然name设置为null,但因为person对象还有指向name的引用,因此name不会回收

var p = person; 
person = 1;         //原来的person对象被赋值为1,但因为有新引用p指向原person对象,因此它不会被回收

p = null;           //原person对象已经没有引用,很快会被回收
1
2
3
4
5
6
7
8
9
10
11
12

引用计数有一个致命的问题,那就是循环引用

如果两个对象相互引用,尽管他们已不再使用,但是垃圾回收器不会进行回收,最终可能会导致内存泄露。

function cycle() {
    var o1 = {};
    var o2 = {};
    o1.a = o2;
    o2.a = o1; 

    return "cycle reference!"
}

cycle();
1
2
3
4
5
6
7
8
9
10

cycle函数执行完成之后,对象o1o2实际上已经不再需要了,但根据引用计数的原则,他们之间的相互引用依然存在,因此这部分内存不会被回收。所以现代浏览器不再使用这个算法。

但是IE依旧使用。

var div = document.createElement("div");
div.onclick = function() {
    console.log("click");
};
1
2
3
4

上面的写法很常见,但是上面的例子就是一个循环引用。

变量div有事件处理函数的引用,同时事件处理函数也有div的引用,因为div变量可在函数内被访问,所以循环引用就出现了。

# 标记清除(常用)

标记清除算法将“不再使用的对象”定义为“无法到达的对象”。即从根部(在JS中就是全局对象)出发定时扫描内存中的对象,凡是能从根部到达的对象,保留。那些从根部出发无法触及到的对象被标记为不再使用,稍后进行回收。

无法触及的对象包含了没有引用的对象这个概念,但反之未必成立。

所以上面的例子就可以正确被垃圾回收处理了。

所以现在对于主流浏览器来说,只需要切断需要回收的对象与根部的联系。最常见的内存泄露一般都与DOM元素绑定有关:

email.message = document.createElement(“div”);
displayList.appendChild(email.message);

// 稍后从displayList中清除DOM元素
displayList.removeAllChildren();
1
2
3
4
5

上面代码中,div元素已经从DOM树中清除,但是该div元素还绑定在email对象中,所以如果email对象存在,那么该div元素就会一直保存在内存中。

# 内存泄漏

对于持续运行的服务进程(daemon),必须及时释放不再用到的内存。否则,内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。 对于不再用到的内存,没有及时释放,就叫做内存泄漏(memory leak)

# 内存泄漏识别方法

# 1、浏览器方法

  1. 打开开发者工具,选择 Memory
  2. 在右侧的Select profiling type字段里面勾选 timeline
  3. 点击左上角的录制按钮。
  4. 在页面上进行各种操作,模拟用户的使用情况。
  5. 一段时间后,点击左上角的 stop 按钮,面板上就会显示这段时间的内存占用情况。

# 2、WeakMap

ES6 新出的两种数据结构:WeakSetWeakMap,表示这是弱引用,它们对于值的引用都是不计入垃圾回收机制的。

const wm = new WeakMap();
const element = document.getElementById('example');

wm.set(element, 'some information');
wm.get(element) // "some information"
1
2
3
4
5

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

# 常见的几种内存泄露

# 1、意外的全局变量

function foo() {
  bar1 = '前端自学驿站'; // 没有声明变量 实际上是全局变量 => window.bar1
  this.bar2 = '北歌' // 全局变量 => window.bar2
}
foo();
1
2
3
4
5

在这个例子中,意外的创建了两个全局变量 bar1 和 bar2

# 2、被遗忘的定时器和回调函数

在很多库中, 如果使用了观察者模式, 都会提供回调方法, 来调用一些回调函数。

要记得回收这些回调函数。举一个 setInterval的例子:

var serverData = loadData();
setInterval(function() {
  var renderer = document.getElementById('renderer');
  if(renderer) {
    renderer.innerHTML = JSON.stringify(serverData);
  }
}, 5000); // 每 5 秒调用一次
1
2
3
4
5
6
7

如果后续 renderer 元素被移除,整个定时器实际上没有任何作用。

但如果你没有回收定时器,整个定时器依然有效, 不但定时器无法被内存回收,

定时器函数中的依赖也无法回收。在这个案例中的 serverData 也无法被回收。

# 3、闭包

在 JS 开发中,我们会经常用到闭包,一个内部函数,有权访问包含其的外部函数中的变量。

下面这种情况下,闭包也会造成内存泄露:

var theThing = null;
var replaceThing = function () {
 var originalThing = theThing;
 var unused = function () {
  if (originalThing) // 对于 'originalThing'的引用
   console.log("hi");
 };
 theThing = {
  longStr: new Array(1000000).join('*'),
  someMethod: function () {
   console.log("message");
  }
 };
};
setInterval(replaceThing, 1000);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

这段代码,每次调用 replaceThing 时,theThing 获得了包含一个巨大的数组和一个对于新闭包 someMethod 的对象。 同时 unused 是一个引用了 originalThing 的闭''包。

这个范例的关键在于,闭包之间是共享作用域的,尽管 unused 可能一直没有被调用,但是 someMethod 可能会被调用,就会导致无法对其内存进行回收。

当这段代码被反复执行时,内存会持续增长。

# 4、DOM引用

很多时候, 我们对 Dom 的操作, 会把 Dom 的引用保存在一个数组或者 Map 中

var elements = {
  image: document.getElementById('image')
};
function doStuff() {
  elements.image.src = 'http://resource.beige.world/imgs/logo.png';
}
function removeImage() {
  document.body.removeChild(document.getElementById('image'));
  // 这个时候我们对于 #image 仍然有一个引用, Image 元素, 仍然无法被内存回收.
}
1
2
3
4
5
6
7
8
9
10

上述案例中,即使我们对于 image 元素进行了移除,但是仍然有对 image 元素的引用,依然无法对齐进行内存回收。

另外需要注意的一个点是,对于一个 Dom 树的叶子节点的引用。

举个例子: 如果我们引用了一个表格中的td元素,一旦在 Dom 中删除了整个表格,我们直观的觉得内存回收应该回收除了被引用的 td 外的其他元素。

但是事实上,这个 td 元素是整个表格的一个子元素,并保留对于其父元素的引用。

这就会导致对于整个表格,都无法进行内存回收。所以我们要小心处理对于 Dom 元素的引用。

# 总结

JavaScript是一门具有自动垃圾收集机制的编程语言,开发人员不必关心内存分配和回收问题。

可以对JavaScript的垃圾收集例程作如下总结:

  • 离开作用域的值将被自动标记为可以回收,因此将在垃圾收集期间被删除。
  • “标记清除”是目前主流的垃圾收集算法,这种算法的思想是给当前不使用的值加上标记,然后再回收其内存。
  • 另一种垃圾收集算法是“引用计数”,这种算法的思想是跟踪记录所有值被引用的次数。JavaScript引擎目前都不再使用
  • 当代码中存在循环引用现象时,“引用计数”算法就会导致问题。
  • 解除变量的引用不仅有助于消除循环引用现象,而且对垃圾收集也有好处。为了确保有效地回收内存,应该及时解除不再使用的全局对象、全局对象属性以及循环引用变量的引用。

如何避免内存泄漏

记住一个原则:不用的东西,及时归还。

  • 1.减少不必要的全局变量,使用严格模式避免意外创建全局变量。
  • 2.在你使用完数据后,及时解除引用(闭包中的变量,dom引用,定时器清除)。
  • 3.组织好你的逻辑,避免死循环等造成浏览器卡顿,崩溃的问题。

# 参考

关注作者公众号

自学路上一起进步!

加入前端自学交流群

扫描二维码回复 加群 学习