注:本文主要针对初学GC的读者,笔者对于GC的了解比较疏漏,有学习的欲望但终究时间太少,为了达到一个大致了解的程度,才写笔记以理解之。文中有众多用词不当之处望读者指正。
前言
学习并使用闭包的时候总会在各博客里面看到闭包的坏处有一条:
使用不当的闭包将会在IE(IE9之前)中造成内存泄漏
uhhh,为什么在IE9之前会造成这样的结果呢?我就开始继续寻找答案,找到一条比较满意的:
IE9的JavaScript引擎使用的垃圾回收算法是引用计数法,对于循环引用将会导致GC无法回收“应该被回收”的内存。造成了无意义的内存占用,也就是内存泄漏。
先科普一下:内存泄漏(Memory Leak)是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。
那么,我有了以下几个疑问:
- 什么是循环引用?
- GC是什么?它是怎么工作的?
- 为什么引用计数算法将会导致内存无法释放?
- JavaScript(或者说JavaScript引擎)还有多少垃圾回收算法?
所以我就“科学上网”去不存在的Google搜了一下“JS的垃圾回收机制”,但是,前5篇出来的结果一眼就能看出是互相粘贴复制别人博客的,里面无不提到这样一段话:
在变量进入执行环境时,会添加一个进入标记,当变量离开时,会添加一个离开标记,标记清除是GC在运行时会给所有变量加上标记,然后去掉那些还在环境中或还被环境中变量引用的变量,清除剩下还被标记的所有变量。
抱歉,我理解能力有限,不明白“离开标记”是什么,“然后去掉”是什么时候去掉的,具体怎么触发的还是自动运行的。对此,我只能:

所以,不得不自己看书了,在图灵社区找到一本比较好的电子书《垃圾回收的算法与实现》,看了部分之后,基本对GC有了初步了解(起码知道是怎么工作的了)。
那么下面,就逐步解决之前提到的几点疑惑,并且用图文结合的方式给大家提供一个快速理解的方法。当然,建议最好还是能够去读一读上面提到的那边电子书。
什么是GC
GC是Garbage Collection的缩写,意为垃圾回收,Uhh,说到垃圾回收,我想到的是下面这个场景:

对!就是这个,在现实生活中我们会产生很多垃圾,总有一群人在我们还在睡觉或者外出工作的时候悄无声息地把垃圾收走,那么对于程序也是这样的,在程序工作的过程中,总会产生很多'垃圾',这些垃圾是程序不用的内存空间(可能是在之前用过了,以后不会再用的)。那么GC就是负责收走垃圾的,因为他工作在JavaScript引擎内部,所以对于我们前端开发者来说,GC在“一定程度上”是悄无声息工作的(注意此处的加引号部分)。
那么,我们明确了GC做什么
了:
- 找到内存空间中的垃圾。
- 回收垃圾,让程序员能再次利用这部分空间。
这里要注意的是,不是所有语言的世界里面都有GC
,相对来说,高级语言里面一般会带GC,比如Java
,JavaScript
,Python
,在没有GC的世界里,需要程序员手动管理内存,比如C语言我们常见的malloc/free
,其实就是memory allocation
的缩写。当然,还有C++里面的new/delete
。
还有一点,GC是一门古老但不过时的技术,在1960年就首次发布了GC算法,但是时至今日,我们仍然需要研究更为优秀的GC算法来让程序“更优秀”。
为什么要使用GC
一句话:“省事儿”。
省去开发者手动管理内存的麻烦(不是每个开发者都能管理好),从而减少BUG的产生,把精力留给更本质的编程工作。
为什么要学点GC
一句话:“为了更好地找BUG”。
作为前端开发者,其实是比较欠缺计算机体系知识的(以我自己举例),比如操作系统,计算机组成原理和计算机网络。但是这些知识,确实解决实际开发问题的根基所在,所以,有一个更好的基础能带来更快的开发/维护效率,而学习GC能为我们提供部分计算机体系知识的思想。比如"标记-清除法"就和操作系统页面置换第二次机会算法类似,所以知识是融汇贯通的,这里我们接触了,那么以后需要学习更多知识的时候就会更得心应手。
必备的基础概念
-
堆(HEAP)是用哦关于动态存放
对象
的内存空间 而对象
在JavaScript里面是引用类型
,之前在我的另一篇博客有讲JavaScript的类型。 -
mutator,这个词意思晦涩,在GC里面代表应用程序本身,我们暂且理解为
mutator
需要大量的内存。 -
allocator,mutator将需要内存的申请提交到此,
allocator
负责从堆中调取足够内存空间供mutator
使用。
一张图理解之:

- 活动对象/非活动对象:代表通过
mutator
引用的对象,举个例子:
var a = {name: 'bar'} // '这个对象'被a引用,是活动对象。
a=null; // ‘这个对象’没有被a引用了,这个对象是非活动对象。
常用的几种GC算法
引用计数法

(图源自《垃圾回收算法和实现》)
顾名思义,让所有对象实现记录下有多少“程序”在引用自己,让各对象都知道自己的“人气指数”。举一个简单的例子:
var a = new Object(); // 此时'这个对象'的引用计数为1(a在引用)
var b = a; // ‘这个对象’的引用计数是2(a,b)
a = null; // reference_count = 1
b = null; // reference_count = 0
// 下一步 GC来回收‘这个对象’了
这个方法有优势也有劣势:
优势
- 可即刻回收垃圾,当被引用数值为0时,对象马上会把自己作为空闲空间连到空闲链表上,也就是说。在变成垃圾的时候就立刻被回收。
- 因为是即时回收,那么‘程序’不会暂停去单独使用很长一段时间的GC,那么最大暂停时间很短。
- 不用去遍历堆里面的所有活动对象和非活动对象
劣势
- 计数器需要占很大的位置,因为不能预估被引用的上限,打个比方,可能出现32位即2的32次方个对象同时引用一个对象,那么计数器就需要32位。
- 最大的劣势是无法解决循环引用无法回收的问题 这就是前文中IE9之前出现的问题
一个简单的例子:
function f(){
var o = {};
var o2 = {};
o.a = o2; // o 引用 o2,o2的引用次数是1
o2.a = o; // o2 引用 o,o的引用此时是1
return "azerty";
}
f();
fn在执行完成之后理应回收fn作用域里面的内存空间,但是因为o
里面有一个属性引用o2
,导致o2
的引用次数始终为1,o2
也是如此,而又非专门当做闭包
来使用,所以这里就应该使o
和o2
被销毁。
因为算法是将引用次数为0
的对象销毁,此处都不为0,导致GC不会回收他们,那么这就是内存泄漏
问题。
该算法已经逐渐被 ‘标记-清除’ 算法替代,在V8引擎里面,使用最多的就是
标记-清除算法
标记清除算法

主要将GC的垃圾回收过程分为两个阶段
- 标记阶段:把所有活动对象做上标记。
- 清除阶段:把没有标记(也就是非活动对象)销毁。
标记阶段

根可以理解成我们的全局作用域,GC从全局作用域的变量,沿作用域逐层往里遍历(对,是深度遍历),当遍历到堆中对象时,说明该对象被引用着,则打上一个标记,继续递归遍历(因为肯定存在堆中对象引用另一个堆中对象),直到遍历到最后一个(最深的一层作用域)节点。

标记完成之后,就是这样的:

清除阶段
又要遍历,这次是遍历整个堆,回收没有打上标记的对象。
这里我们不细讲如何将获得的内存空间再分配的问题,这个地方有点类似磁盘管理或者内存管理,比如
best-fit,First-fit,Worst-fit
。以及碎片化问题的产生和解决方法。
这种方法可以解决循环引用问题,因为两个对象从全局对象出发无法获取。因此,他们无法被标记,他们将会被垃圾回收器回收。正如图:

优势:
- 实现简单,打标记也就是打或者不打两种可能,所以就一位二进制位就可以表示
- 解决了循环引用问题
缺点
- 造成碎片化(有点类似磁盘的碎片化)
- 再分配时遍次数多,如果一直没有找到合适的内存块大小,那么会遍历空闲链表(保存堆中所有空闲地址空间的地址形成的链表)一直遍历到尾端
这种GC方式是一个定时运行的任务,也就是说当程序运行一段时间后,统一GC,类似如图:

复制算法

复制算法配合这张图理解起来非常简单,就是只把某个空间的活动对象复制到其他空间。
将一个内存空间分为两部分,一部分是From空间,另一部分是To空间,将From空间里面的活动对象复制到To空间,然后释放掉整个From空间,然后此刻将From空间和To空间的身份互换,那么就完成了一次GC。
如图所示:

还有
还有几个GC算法,准备在下一篇博客用图解的方式总结一下V8实现的GC算法(复制,标记-清除,压缩)。
总结
回顾我们之前的问题
- 什么是循环引用?
- GC是什么?它是怎么工作的?
- 为什么引用计数算法将会导致内存无法释放?
- JavaScript(或者说JavaScript引擎)还有多少垃圾回收算法?
好好想想我们是不是都应该有个比较清楚的答案了呢?