简单了解JavaScript垃圾回收机制

234 阅读7分钟

这是我参与更文挑战的第1天,活动详情查看: 更文挑战

前言

学习并使用闭包的时候总会在各博客里面看到闭包的坏处有一条:

使用不当的闭包将会在IE(IE9之前)中造成内存泄漏

为什么在IE9之前会造成这样的结果呢?我就开始继续寻找答案,找到一条比较满意的:

IE9的JavaScript引擎使用的垃圾回收算法是引用计数法,对于循环引用将会导致GC无法回收“应该被回收”的内存。造成了无意义的内存占用,也就是内存泄漏。

先科普一下:内存泄漏(Memory Leak)是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。

那么,我有了以下几个疑问:

  • 什么是循环引用?
  • GC是什么?它是怎么工作的?
  • 为什么引用计数算法将会导致内存无法释放?
  • JavaScript(或者说JavaScript引擎)还有多少垃圾回收算法?

什么是GC

GC是Garbage Collection的缩写,意为垃圾回收.在现实生活中我们会产生很多垃圾,总有一群人在我们还在睡觉或者外出工作的时候悄无声息地把垃圾收走,那么对于程序也是这样的,在程序工作的过程中,总会产生很多'垃圾',这些垃圾是程序不用的内存空间(可能是在之前用过了,以后不会再用的)。那么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里面是引用类型。

  • 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。