基本介绍
首先我们来简单介绍一下市面上常提到的几种回收机制。
引用计数法
核心概念
存在一个计数器,记录所有对象以及引用信息,如果一个对象被引用,他的引用数量加一,如果引用关系被移除,引用数量减一,当引用数量为 0 时,立即清除该对象。
优点
优点非常的明显,就是当出现垃圾对象可以即时清除。
缺点
缺点主要有以下两个:
- 我们需要有一个计数器。他需要记录所有对象的引用信息,当我们扫描前,并不能知道对象有多大,因此要预设一个足够大的计数器,会浪费比较多的内存
- 经典的循环引用问题。当 a 引用 b,b 引用 a,那么他们之间的引用数量永远不可能置 0,也就不会被回收,造成内存泄漏。
标记-清除法 Mark-Sweep
核心概念
存储一个标识位,你可以理解成 0 是白色,1 是黑色。所有对象初始标识都设置为白色,从一组根对象开始,把活动对象标记为黑色,当标记结束后清除标记为白色的对象,最后再重置所有对象标识为白色以便下一轮回收。
问题:清理后的内存碎片如何处理?
很显然会引出一个问题,思考一轮回收清理了垃圾的内存后,垃圾的内存位置都是不连续的,就会产生很多个内存碎片,当我们需要存入下一个对象时,并不是每个内存碎片都足以放入该对象,此时我们需要一个足够大的内存来分配该对象的内存,考虑以下三个方法:
- first-fit:找到第一个大于该对象的内存
- best-fit:找到内存碎片中大于等于且最小的块
- worst-fit:找到内存碎片中最大的块
从理论上来看,第三种方式好像是最好的选择,但是我们的最优解往往要结合实际场景判断,第二和第三种算法的时间复杂度都是固定的 O(N),只有第一种的最佳复杂度可以达到 O(1),因此处于效率考虑,实际我们一般采用 first-fit 的方式来进行碎片处理。
标记-整理法 Mark-Compact
从名称可以看出,分为标记和整理两部分,标记这部分和标记-清除法没有什么区别,关键是整理,标记结束后,他把所有的活动对象朝着一端移动,后续清理边界内存变得非常方便。
但是他的效率显然是比标记-清除差了很多的,因此后文所提到的老生代一般使用标记-清除法,当空间不足以处理新生代提升上来的对象时才进行整理操作。
V8 优化
V8 对于垃圾回收建立了一套自己的机制,他把内存分为新生代和老生代两个大块,顾名思义,老生代的内存大,存活时间长,新生代的内存小,存活时间短。
全停顿 (Stop-The-World)
在讲述新老生代之前,我们要先了解一下全停顿的概念,我们知道,JS 是单线程执行操作的,当我们执行垃圾回收时,就会阻塞 JS 加载,直到回收结束才会继续执行 JS。因此,V8 中也是做了以下许多的优化。
新生代
新生代在 64 位系统中所占的内存仅为 32M,内存非常小。
新生代主要使用的是 Scavenge 算法,而他的具体实现中,有一个用以复制的 cheney 算法,将新生代的堆空间分为 Form,To 两个区,每个区各占一半的内存,也就是 16M。
操作流程
新加入的对象都会放入到 From 区中,当内存快要占满时,会开启回收机制,它将标记 From 的所有活动对象,将其移动到 To 区并排序,然后删除非活动对象,此时 From 区置空,最后两个区角色互换,现有的活动对象存于 From 区中,以待下次回收。
内存升级
让我们考虑一个场景,如果一个对象,复制了 n 多次还依然存在,那么每次移动的无端消耗也是很大的,因此 V8 对这类对象也做了一定的优化,也就是内存升级,在上述情况下,该对象会被提升到老生代中存储。
还有一种情况也会产生内存升级,当一个对象加入 To 区时,To 区的内存超过了25%,而后续我们还会进行翻转的操作,性能影响比较大,为了不影响后续内存分配,我们也会直接把对象升级到老生代中。
扫描方式
新生代中的内存转移主要使用了以下两个指针:
- 扫描指针:它将依次扫描所有的活动对象,这是一个广度优先的算法哦!
- 分配指针:它指向的是对象即将被分配到的内存位置。
并行回收
首先我们需要了解到,JS 是单线程没错,但是我们可以在 JS 主线程上执行回收的过程中,开启多个辅助线程同时工作,那样会大大增加回收的效率,而新生代中就是使用的并行回收,而将对象数据移动到 To 区时,数据地址可能会发生改变,因此其中也实现了同步更新引用的指针的工作。
老生代
老生代在 64 位系统中所占的内存最高可达 1.4G,相对新生代大了许多。
增量标记 incremental marking
新生代中,我们使用了并行清理的方式,但它实际上还是一个全停顿的执行方式,对于老生代中的大内存,并不能做到有效的优化,因此,V8 对于老生代的标记方式从全停顿标记变为了增量标记,它将一次标记的过程分成很多小步,JS 与 GC 交替执行,类似于我们常常使用到的 rIC 方法。
而此时,我们又引入了两个问题:
- 如何对执行中的 GC 进行暂停与恢复回收的操作 —— 三色标记法。
- 如果在执行 GC 过程中变量的引用关系发生了变化如何调整 —— 写屏障。
三色标记法
核心概念
此前我们使用一位标志位对活动对象进行区分,而目前的情况显然是不足够的,三色标记法使用两位标志位扩展了一个新的颜色 - 灰色。更改如下:由两个编码位表示白,黑,灰三个颜色,白色代表未被标记的对象,黑色代表自身与内部与成员变量都已被标记,灰色代表自身已被标记,成员变量未被标记。通俗来说,灰色标记的就是正在标记的变量,GC 恢复执行时迅速找到灰色位即可。
操作流程
对象在推入标记工作表时变灰色,当被弹出的时候标记为黑,然后把下一个引用对象标记为灰,直到没有可标记为灰色的对象结束,从而开始清理工作。
写屏障 Write-Barrier
当我们在增量中修改了引用关系,如果上下文颜色不改变的话,垃圾回收的正确性就会受到干扰。写屏障的概念由此而来:当一个黑色对象引用白色对象,强制将其变灰,保证下一次增量 GC 标记的正确执行,同时也叫强三色不变性。
三色标记法和写屏障使得 GC 标记工作得以渐进执行,减少了全停顿的时间。
惰性清理
这是一个类似于懒加载的实现,当标记结束后,如果判断当前内存足够我们快速执行代码,可以延迟一会儿执行,清理白色对象也不用全量执行,大大优化了执行时机。
并发回收
增加标记与惰性清理的确帮助我们减少了全停顿的时间,优化了交互体验,但是每个小增量标记之间都执行了 JS,当堆中指针有变动,就需要写屏障来处理,而且总暂停时间实际没有减少甚至可能增加。
因此,V8 中使用了并发回收的机制,在主进程继续执行 JS 的条件下。开启多个辅助线程执行 GC,但也是难点,需要特殊的读写锁机制来控制堆中的引用关系。
回收方式
新生代中主要使用并行回收,老生代中使用并发标记 + 并行回收。并发标记完全由辅助线程执行,并行回收由主线程与辅助线程共同执行。
总结
垃圾回收目前都使用的是标记清除法,各大浏览器厂商会有不同的实现方式,V8 中依据内存使用情况区分了新老生代两块区域,新生代中使用并行回收,老生代中使用并发标记加并行回收,以此优化了 GC 的效率。
本文参考自 「硬核JS」你真的了解垃圾回收机制吗,欢迎大家前去拜读。