垃圾回收是一种自动的内存管理机制。当计算机上的动态内存不再需要的时候,就应当予以释放,以让出内存。直白点讲,就是程序是运行在内存里的,当声明一个变量、定义一个函数时都会占用内存。内存的容量是有限的,如果变量函数等只有产生没有消亡的过程,那内存总有会被完全占用的时候。这个时候,不仅程序自己无法正常运行,连其他程序都会受到影响。所以,在计算机当中,我们需要垃圾回收。需要注意的时候,JavaScript定义中的“自动”的意思是预言可以帮助我们回收内存垃圾,但并不代表我们不用关心内存管理,如果使用不当,JS中依旧会出现内存溢出的情况

— 知乎


简介

像C预言这样的底层语言一般都有底层的内存管理接口,比如malloc()和free()。而JavaScript(以下简称JS)是在创建变量(对象,字符串等)时自动进行了内存分配,并且在不使用他们时“自动释放”。释放的过程称为垃圾回收。这个“自动”是混乱的根源,并让JavaScript(和其他高级语言)开发者错误的感觉他们可以不关心内存管理。

内存生命周期

不管什么程序语言,内存生命周期基本是一致的:

  1. 分配你所需要的内存

  2. 使用分配到的内存(读,写)

  3. 不需要时将其归还释放

所有语言的第一,第二部分都很清晰,第三步在低级语言(例如C语言)很清晰,但是在JavaScript这样的高级语言中,大部分是隐含的。因为JS具有自动垃圾回收机制(Garbage Collected)。但是作为开发者仍然需要关系内存的管理,因为这种自动回收机制,并不咋任何情况下都是智能的,仍会有发生内存泄漏的风险。

JavaScript的内存分配

值的初始化

JS在定义变量的时候完成内存分配

1
2
3
4
5
6
7
8
9
10
11
12
13
var n = 123;        // 给数值变量分配内存
var str = "hello"; //给字符串分配内存
var obj = {
index: 0,
name: null
}; //给对象和对象的值分配内存

var array = [1,2,3]; //给数组分配内存

function func(num){ //给函数分配内存
return num + 1;
}

通过函数调用分配内存

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

1
2
3
var data = new Data();          //分配一个Data对象

var element = document.createElement('div'); //分配一个DOM对象

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

1
2
3
4
5
6
7
8
9
10
var s = "azerty";
var s2 = s.substr(0, 3); // s2 是一个新的字符串
// 因为字符串是不变量,
// JavaScript 可能决定不分配内存,
// 只是存储了 [0-3] 的范围。

var a = ["ouais ouais", "♂ ♂"];
var a2 = ["generation", "♂ ♂"];
var a3 = a.concat(a2);
// 新数组有四个元素,是 a 连接 a2 的结果

值的使用

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

内存不再需要使用时释放

大多数内存管理的问题都在这个阶段。在这里最艰难的任务是找到“哪些被分配的内存确实已经不再需要了”。它往往要求开发人员来确定在程序中哪一块内存不再需要并且释放它。

高级语言解释器嵌入了“垃圾回收器”,它的主要工作是跟踪内存的分配和使用,以便当分配的内存不再使用时,自动释放它。这只能是一个近似的过程,因为要知道是否仍然需要某块内存是无法判定的(无法通过某种算法解决)。

垃圾回收

引用

垃圾回收算法主要依赖于引用的概念。在内存管理的环境中,一个对象如果有访问另一个对象的权限(隐式或者显式),叫做一个对象引用另一个对象。例如,一个Javascript对象具有对它原型的引用(隐式引用)和对它属性的引用(显式引用)。

在这里,“对象”的概念不仅特指 JavaScript 对象,还包括函数作用域(或者全局词法作用域)。

引用计数(Reference Counting)

这是最初级的垃圾收集算法。此算法把“对象是否不再需要”简化定义为“对象有没有其他对象引用到它”。如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收。

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


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
9
10
function f(){
var o = {};
var o2 = {};
o.a = o2; // o 引用 o2
o2.a = o; // o2 引用 o

return "azerty";
}

f();

标记清除(Mark and Sweep)

JavaScript 中有个全局对象,浏览器中是 window。定期的,垃圾回收期将从这个全局对象开始,找所有从这个全局对象开始引用的对象,再找这些对象引用的对象…对这些活着的对象进行标记,这是标记阶段。清除阶段就是清除那些没有被标记的对象。
标记-清除法的一个问题就是不那么有效率,因为在标记-清除阶段,整个程序将会等待,所以如果程序出现卡顿的情况,那有可能是收集垃圾的过程。
2012 年起,所有现代浏览器都使用了这个方法,所有的改进也都是基于这个方法,比如标记-整理方法:
标记清除有一个问题,就是在清除之后,内存空间是不连续的,即出现了内存碎片。如果后面需要一个比较大的连续的内存空间时,那将不能满足要求。而标记-整理方法可以有效地解决这个问题。标记阶段没有什么不同,只是标记结束后,标记-整理方法会将活着的对象向内存的一边移动,最后清理掉边界的内存。不过可以想象,这种做法的效率没有标记-清除高。计算机中的很多做法都是互相妥协的结果,哪有什么十全十美的事儿呢。

那标记清除具体是如何呢?有以下几种算法:

  • 在JavaScript 中,全局变量(Global)和window 对象会一直存在,不会被垃圾收集器回收;
  • 递归所用到的所有(包括变量和方法),都不会被回收;
  • 所有没有被标记为“活跃(active)”的,都会被认为是垃圾,收集器释放会回收垃圾,并把内存还给操作系统。

内存泄露

什么是内存泄漏?内存泄漏指的是计算机可用内存越来越少,主要是因为程序无法自动释放那些不再使用的内存。

导致内存泄漏的原因有几种。

1 循环引用

就是上面介绍的循环引用的例子🌰。标记-清除算法可以解决这个问题。在上面的示例中,函数调用返回之后,两个对象从全局对象出发无法获取。因此,他们将会被垃圾回收器回收。

2 无意的全局变量

有时候本希望声明一个局部变量却无意声明了一个全局变量,这个全局变量会得到window的引用(在浏览器中),bar 实际上是window.bar,它的作用域在window上,所以当foo方法执行结束后,它也不会被回收。

1
2
3
4
5
function foo(arg) {
bar = "";
}

foo();

同理:

1
2
3
function foo() {
this.bar = "";
}

foo方法为全局方法,其中的this指向window,在类中指向类。

3 未取消的计时器和回调函数

1
2
3
4
5
6
7
let someResource = getData();
setInterval(() => {
const node = document.getElementById('Node');
if(node) {
node.innerHTML = JSON.stringify(someResource));
}
}, 1000);

上面的例子中,我们每隔一秒就将得到的数据放入到文档节点中去。但在 setInterval 没有结束前,回调函数里的变量以及回调函数本身都无法被回收。那什么才叫结束呢?就是调用了 clearInterval。如果回调函数内没有做什么事情,并且也没有被 clear 掉的话,就会造成内存泄漏。不仅如此,如果回调函数没有被回收,那么回调函数内依赖的变量也没法被回收。上面的例子中,someResource 就没法被回收。同样的,setTiemout 也会有同样的问题。所以,当不需要 interval 或者 timeout 时,最好调用 clearInterval 或者 clearTimeout。

4 闭包

  1. 在闭包中引入闭包外部的变量时,当闭包结束时,此对象无法被垃圾回收(GC)
  2. 闭包可以维持函数内局部变量,使其得不到释放。
    例子:
1
2
3
4
5
6
var a = function() {
var largeStr = new Array(1000000).join('x');
return function() {
return largeStr;
}
}();

5 自动类型装箱

1
2
var s = "test test";
alert(s.length);

s 本身是一个 string 而非 object,它没有 length 属性,所以当访问 length 时,JS 引擎会自动创建一个临时 String 对象封装 s,而这个对象一定会泄漏。这个 bug 匪夷所思,所幸解决起来相当容易,记得所有值类型做.运算之前先显示转换一下:

1
2
3
var s = "test test";
alert(new String(s).length);

确保不会内存泄漏

在游戏开发中,需要尽量避免内存泄漏的可能性,作为开发者,在编码过程中至少应当注意:

  1. 对于不需要使用的数据,将其置为null,来“解除引用”。注意:解除一个值的引用不代表它会立刻被垃圾回收期回收内存,而是在下一个垃圾回收周期的时候,垃圾回收期能知道这个值是可以被回收的。
  2. 清空数组时建议的做法是array.length = 0,而不是array = [],因为这种写法会创建新的数组去覆盖原来的数组。
  3. 用完的setTimeout和setInterval一定要记得clearTimeout和clearInterval,避免内存泄漏。

其他

垃圾回收机制的触发时间

  1. 什么时候触发垃圾回收?

    垃圾回收周期性运行,如果分配的内存非常多,那么回收工作也会很艰巨,确定垃圾回收时间间隔就变成了一个值得思考的问题。IE6 的垃圾回收是根据内存分配量运行的,当环境中存在 256 个变量、4096 个对象、64K 的字符串任意一种情况的时候就会触发垃圾回收器工作,看起来很科学,不用按一段时间就调用一次,有时候会没必要,这样按需调用不是很好嘛?但是如果环境中就是有这么多变量一直存在,现在脚本如此复杂,很正常,那么结果就是垃圾回收器一直在工作,这样浏览器就没法玩了。

    微软在 IE7 中做了调整,触发条件不再是固定的,而是动态修改的,初始值和IE6相同,如果垃圾回收器回收的内存分配量低于程序占用内存的 15%,说明大部分内存不可被回收,设的垃圾回收触发条件过于敏感,这时候把临界条件翻倍,如果回收的内存高于 85%,说明大部分内存早就该清理了,这时候把触发条件置回。这样就使垃圾回收工作智能了很多。

  2. 合理的 GC 方案

    1. JavaScript 引擎基础 GC 方案是(simple GC):mark and sweep(标记清除),即:
      遍历所有可访问的对象。
      回收已不可访问的对象。
    2. GC 的缺陷
      和其他语言一样,JavaScript 的 GC 策略也无法避免一个问题:GC 时,停止响应其他操作,这是为了安全考虑。而 JavaScript 的 GC 在 100ms 甚至以上,对一般的应用还好,但对于 JS 游戏,动画连贯性要求比较高的应用,就麻烦了。这就是新引擎需要优化的点:避免 GC 造成的长时间停止响应(造成卡顿)。

摘录、总结内容来自于微信公众号/知乎/Google
如有侵权,请联系删除,谢谢!

评论