垃圾回收机制
内部算法
基本的垃圾回收算法称为“标记-清除”,定期执行以下“垃圾回收”步骤:
- 有一组基本的固有可达值,由于显而易见的原因无法删除。例如:
- 本地函数的局部变量和参数
- 当前嵌套调用链上的其他函数的变量和参数
- 全局变量
- 还有一些其他的,内部的 这些值称为根。
- 如果引用或引用链可以从根访问任何其他值,则认为该值是可访问的。
- 垃圾回收器获取根并“标记”(记住)它们。
- 然后它访问并“标记”所有来自它们的引用。
- 然后它访问标记的对象并标记它们的引用。所有被访问的对象都被记住,以便以后不再访问同一个对象两次。
- 以此类推,直到有未访问的引用(可以从根访问)为止。
- 除标记的对象外,所有对象都被删除。
一些优化:
- 分代回收——对象分为两组:“新对象”和“旧对象”。许多对象出现,完成它们的工作并迅速结 ,它们很快就会被清理干净。那些活得足够久的对象,会变“老”,并且很少接受检查。
- 增量回收——如果有很多对象,并且我们试图一次遍历并标记整个对象集,那么可能会花费一些时间,并在执行中会有一定的延迟。因此,引擎试图将垃圾回收分解为多个部分。然后,各个部分分别执行。这需要额外的标记来跟踪变化,这样有很多微小的延迟,而不是很大的延迟。
- 空闲时间收集——垃圾回收器只在 CPU 空闲时运行,以减少对执行的可能影响。
堆的构成
在我们深入研究垃圾回收器的内部工作原理之前,首先来看看堆是如何组织的。V8将堆分为了几个不同的区域:
- 新生区:大多数对象被分配在这里。新生区是一个很小的区域,垃圾回收在这个区域非常频繁,与其他区域相独立。
- 老生指针区:这里包含大多数可能存在指向其他对象的指针的对象。大多数在新生区存活一段时间之后的对象都会被挪到这里。
- 老生数据区:这里存放只包含原始数据的对象(这些对象没有指向其他对象的指针)。字符串、封箱的数字以及未封箱的双精度数字数组,在新生区存活一段时间后会被移动到这里。
- 大对象区:这里存放体积超越其他区大小的对象。每个对象有自己mmap产生的内存。垃圾回收器从不移动大对象。
- 代码区:代码对象,也就是包含JIT之后指令的对象,会被分配到这里。这是唯一拥有执行权限的内存区(不过如果代码对象因过大而放在大对象区,则该大对象所对应的内存也是可执行的。译注:但是大对象内存区本身不是可执行的内存区)。
- Cell区、属性Cell区、Map区:这些区域存放Cell、属性Cell和Map,每个区域因为都是存放相同大小的元素,因此内存结构很简单。
新生区的垃圾回收, 复制算法 垃圾回收算法实现之 - 复制
说得简单点,就是只把某个空间里的活动对象复制到其他空间,把原空间里的所有对象都回收掉。
对象起初会被分配在新生区(通常很小,只有1-8 MB,具体根据行为来进行启发)。在新生区的内存分配非常容易:我们只需保有一个指向内存区的指针,不断根据新对象的大小对其进行递增即可。当该指针达到了新生区的末尾,就会有一次清理(小周期),清理掉新生区中不活跃的死对象。对于活跃超过2个小周期的对象,则需将其移动至老生区.
复制算法利用From空间进行分配。当From空间被完全占满无法分配时,GC会将活动对象全部复制到To空间。当复制完成后,会将From/To空间互换,为下次GC做准备。 复制时,需从GC ROOTS开始遍历对象图,对每一个存活的对象进行复制;复制后对象地址改变,还需要更新GC ROOTS引用的地址;
优点
- 吞吐量高,不需要遍历全堆,只需要处理活动对象
- 分配速度快,和free-list分配法相比,顺序分配不需要搜索free-list,只需要移动free pointer即可
- 不会有碎片化的问题,因为每次复制都将存活对象从from复制到to的一端
年轻代 GC(Minor GC)
由于新生代对象特点是 “朝生夕死”,所以对年轻代使用复制算法;Eden 区存放的是新生成的对象,当 Eden 满了之后,年轻代 GC 就会启动,将生成空间的所有活动对象复制,不过目标区域是 Survivor 区。
Survivor 区分为了两个空间,每次回收只会使用其中的一个。当执行年轻代 GC 的时候,Eden 区的活动对象会被复制到 From 中;当第二次年轻代 GC 时,会将 Eden 和 From 区内存活的对象一起复制到 To 区,之后再把 From/To 功能 “互换”
老生区的垃圾回收, 标记清除算法 垃圾回收算法实现之 - 标记-清除
基本的垃圾回收算法称为“标记-清除”,定期执行以下“垃圾回收”步骤:
- 垃圾回收器获取根并“标记”(记住)它们。
- 然后它访问并“标记”所有来自它们的引用。
- 然后它访问标记的对象并标记它们的引用。所有被访问的对象都被记住,以便以后不再访问同一个对象两次。
- 以此类推,直到有未访问的引用(可以从根访问)为止。
- 除标记的对象外,所有对象都被删除。
总体, 对象晋升与分代回收. 垃圾回收算法实现之 - 分代回收
分代垃圾回收(Mark-Sweep GC),并不是一个具体的算法,只是结合了几种垃圾回收算法,把对象按特点进行了分类,对每种特点的对象集执行不同的回收算法,从而提升回收效率
大部分的对象在生成后马上就变成了垃圾, 很少有对象能活得很久。
对象中有一个 age 字段,代表对象经历的年轻代 GC 次数,新创建的对象年龄为 0,每经历一次年轻代 GC 还存活的对象年龄会加 1;在年轻代 GC 时,每次会检查对象的年龄,当超过一定限制(AGE_MAX)时,会将对象晋升到老年代。
跨代引用
既然有晋升的操作,那么这里会有一个问题:当对象晋升后,引用关系如何处理,对于老年代到年轻代的引用,可达性分析时怎么处理,是否还需要从 GC ROOTS 开始遍历老年代呢?
由于存在跨代引用的可能,所以在年轻代 GC 时,只从 GC ROOTS 开始遍历年轻代对象是不够的,还需要将老年代中引用年轻代的那部分对象也作为 GC ROOTS,这样才能保证完整的回收年轻代
使用一个额外的 Remembered Set 来存储引用着年轻代那部分的老年代对象,当发生年轻代 GC 时,除了要遍历 GC ROOTS 中那部分年轻代对象,还要遍历 Remembered Set 中的这部分存在跨代引用的对象,这样就避免了额外扫描老年代的问题
至于这个 Remembered Set 的添加时机也很简单,只需要在发生晋升时检查晋升对象是否还包含年轻代对象的引用即可,如果包含就将晋升的对象添加到 Remembered Set。
常见的内存泄漏
- 意外的全局变量
- 未清除的计时器
- 删除不完全的对象
- addEventListener 监听的对象已不可达,但监听没有移除。现代浏览器会自动移除
- JS 中引用了 DOM 对象,对象已从 DOM Tree 中移除,但 JS 中依旧保持引用
- 闭包中未使用函数引用了可从根访问的父级变量
var global = 0
function closure () {
let fromGlobal = global // 引用全局变量 global
function unused () { // 闭包内不使用的函数
if (fromGlobal) return // 引用父级变量 fromGlobal,导致 unused 占用内存
}
global = {} // 每次调用闭包 global 都会重新赋值
/** 避免 **/
fromGlobal = null
closure()
}