通过前面的学习我们知道,当一个网页运行时,浏览器会给网页分配一段连续的内存空间以供网页使用。
并且通过使用方式的不同,内存空间会被分为栈内存与堆内存。栈内存只用于管理函数的执行顺序,堆内存用于存储其他所有对象。
我们还知道,程序的运行过程中,会使用内存。而内存空间是有限的,因此,内存空间的重复利用就变得非常重要。垃圾回收的概念也因此而生。
在学习垃圾回收机制之前,我们明确几个概念。
引用:内存的起始地址
弱引用:WeakMap WeakSet
垃圾:无任何引用的对象
回收:清空被垃圾占用的内存
垃圾回收区域:堆内存
发生时间:程序空闲时间时
第一个问题:如何识别垃圾
ECMAScript 规范中并没有明确指出 JS 引擎必须使用哪种算法来识别垃圾,因此我们这里介绍几种常用的方式。
引用计数法
堆中的每个对象都有一个引用计数器。当一个对象被创造初始化赋值之后,该变量计数就设置为1
var a = new Object() // 计数变量 = 1
每当有一个地方引用它时,计数器的值就加1
var a = new Object() // 计数变量 = 1 var b = a // 计数变量 + 1 = 2
当引用失效时,计数器的值就减1
var a = new Object() // 计数变量 = 1 var b = a // 计数变量 + 1 = 2 var c = a // 计数变量 + 1 = 3 a = null // 引用失效,计数变量 -1 = 2 b = {} // 引用失效,计数变量 -1 = 1
当该对象的计数值为0时,就表示失去了所有的引用,该对象就成为了垃圾。
var a = new Object() // 计数变量 = 1 var b = a // 计数变量 + 1 = 2 var c = a // 计数变量 + 1 = 3 a = null // 引用失效,计数变量 -1 = 2 b = {} // 引用失效,计数变量 -1 = 1 c = null // 引用失效,计数变量 -1 = 0
知识体系关联:这样的管理方式,类似于数组的 length 字段。
优点:引用计数收集器执行简单,实现简单,判定效率高,无延迟,对程序不被长时间打断的实时环境比较有利。
缺点:赋值时需要更新计数器,增加了微量时间开销,影响不大。最严重的问题是引用计数器无法处理循环引用的问题。
var p = { n: 1, next: { n: 2, next: p } } p = null
对象不可访问,计数也不为0,无法被回收,导致内存泄漏。
引用计数法虽然有这样致命的缺陷,但是由于其性能的优越性,依然有开发语言采用该算法,例如早期的 Java,以及现在的 Python。并通过手动解除、或者在循环引用的环节使用弱引用的方式。
根搜索算法 Tracing Collector
首先了解一个概念:GC Roots Set(根集),他是可访问的引用集合。Roots Set 中的引用变量可以用于访问对象的属性以及调用对象的方法。
这种算法的基本思路就是:先通过一系列 GC Roots 的对象作为起点,遍历寻找对应的引用节点。找到这些节点之后,继续向下递归寻找节点。
搜索所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连时,就证明该对象是不可用的。
如果不考虑循环引用,Roots Set 会表现出一棵棵树状结构,如果考虑循环引用,则会呈现出图结构。
哪些对象可以作为根节点:
- 所有正在运行的栈上的引用变量
- 所有的全局对象全局变量
- 所有的内置对象
在内存中对整个堆进行遍历,先从 GC 根对象开始,然后找到根对象引用的其它对象,能访问到的所有对象都标记为存活。
关于标记阶段有几个关键点是值得注意的:
- 开始进行标记前,需要先暂停应用线程,否则如果对象图一直在变化的话是无法真正去遍历它的。这就是后面我们会提到的 stop-the-world
- 暂停时间的长短并不取决于堆内对象的多少也不是堆的大小,而是存活对象的多少。因此,调高堆的大小并不会影响到标记阶段的时间长短。
- 在 Blink 引擎的垃圾回收器 Oilpan 中,则某个对象在被回收之前,可能会执行一个回收之前需要做什么的生命周期函数 finalize。
如果该对象被判定为有必要执行 finalize() 方法,那么这个对象将会被放置在一个名为 finalization-queue 队列中,并在稍后由一条低优先级的 Finalizer 线程去执行这些任务。finalize 方法是对象逃脱死亡命运的最后一次机会,稍后 GC 将对 finalization-queue 中的对象进行第二次小规模的标记,如果要在 finalize() 中成功拯救自己,只要让该对象重新引用链上的任何一个对象建立关联即可。而如果对象这时还没有关联到任何链上的引用,那它就会被回收掉。
而在 V8 引擎的实现中,由于我们无法访问垃圾回收器,因此就没有提供这样的生命周期函数让 JavaScript 开发者有所作为。
- GC 判断对象是否可达看的是强引用,而非弱引用
V8 的垃圾回收器
V8 的垃圾回收器名为 Orinoco。上面我们也提到,垃圾回收器无论在进行标记或者回收行为时,我们都会暂停 JS 主线程的执行。因此早期的 Orinoco 采用了这种 stop-the-world 的方式。
任何垃圾收集器都有一些必须定期执行的基本任务:
- 识别活/死对象
- 回收/重用死对象占用的内存
- 压缩/碎片整理内存(可选)
这些任务可以按顺序执行,也可以任意交错执行。stop-the-world 的方式暂停 JavaScript 执行并在主线程上按顺序执行这些任务。当然这种方式的副作用就是会导致主线程出现卡顿和延迟,用户感知明显。
那么这种方式会做什么事呢?
首先,标记存活对象。
GC 通过根搜索算法验证活跃对象的可达性,在这个过程中,GC 可以收集任何无法访问的对象。
收集到了所有无法访问的对象之后,就会清空对应的内存空间。与此同时,会在一个 free-list 的列表记录这些清理出来的内存位置与大小,当有新的对象需要分配内存空间时,就会在 free-list 中查找。
如果不做任何特殊的处理,新的对象所需要的内存空间不可能完整的跟 free-list 的空闲内存大小一致,因此最后就会存在许多难以利用的内存缝隙。为了解决这个问题,我们还需要在回收过程中,对内存进行碎片整理。以确保我们总能够得到连续的空闲内存分配给新的对象。
在过去几年中,Orinoco 有了很大的转变。我们接着往下继续了解。
V8 中的堆内存区域划分
V8 主要将堆内存划分为两个区域,新生代 Young Generation 与 老生代 Old Generation。从概念上来说,新生代主要用于存储生命短暂的对象,例如执行上下文,老生代用于存储生命漫长的对象例如函数声明。
新生代又被进一步划分为两个区域,如下图,在后面的分析中,我们用 From、To 来称呼他们
在 GC 中有一个重要的术语:The Generational Hypothesis。也就是说,我们大胆的预测大多数对象都会在新生代中死亡,实际上也是这样,这是 Orinoco 具体实现的大前提。V8 的内存区域分布则利用了这一假设,只有少数对象能在新生代中存活下来,然后移动到老生代中。所以大多数对象都是隐式垃圾,用完即走。
所以,GC 复制算法得以在 V8 中被使用,因为被复制的对象一定是少数。后面我们分析复制算法。
Major GC (Full Mark-Compact)
在 Orinoco 中,存在两个不同的 GC。Major GC:用于回收老生代的垃圾, 与 Minor GC:用于回收新生代的垃圾。
Major GC 管理整个堆内存,主要是对老生代区域的内存进行回收。Major GC 采用了 Mark-compact 算法「标记-整理」来管理内存。
他是为了解决 Mark-Sweep 算法所带来的内存缝隙而提出来的优化方案。标记方式依然通过根搜索算法进行标记,compact 整理算法我们用图例来讲解一下。
在这之前,我们要明确 compact 要做的两件事情
- 把存活的对象移动到该去的位置
- 修改引用,让他们指向新的地址
通过这样的方式之后,我们就得到一个整理之后的新布局。不过这样的方式也存在一些问题,因为要对堆内存遍历很多遍,因此内存越大,性能消耗就越大。不过得益于老生代中的内存对象比较少,并且变动比较小,因此 V8 依然选中该方法来管理老生代对象。