垃圾回收机制
垃圾:无任何引用的对象
回收:清理被垃圾占用的内存
区域:堆内存(栈内存有自己的回收机制)
发生时间:程序空闲时间
垃圾如何产生的
1 | var a = 1; |
将变量 a 赋值为 2 的时候,会在内存中开辟一个新的空间,变量 a 指向这个新的地址,那么虚线部分的数据失去了连接,这时候它就成为了一个垃圾。如果不清理垃圾,等所以内存被占满时,内存就会溢出,所以有了垃圾回收机制。
垃圾回收机制是什么
垃圾回收机制是 V8 引擎实现的一套清理垃圾的方案,它会在程序空闲时,周期性的清理被标记为是垃圾的内存。
识别垃圾(标记方式)
识别垃圾有两种标记法,引用计数法和根搜索算法。
引用计数法
通过添加一个计数变量的方法,当对象被初始化赋值后,该变量计数为 1
1 | var a = { n: 1 }; // 计数变量 = 1 |
当有一个地方引用它时,变量计数+1
1 | var b = a; // 计数变量 +1 = 2 |
引用失效时,变量计数-1
1 | a = null; // 计数变量 -1 = 2 |
当计数器为 0 时,则表示失去了所有引用,该对象成为垃圾。
关联场景:改实现原理和数组 length 的实现一样。
优点就是简单、高效。缺点就是无法解决循环引用的问题,这会导致内存泄漏。这个一个重大缺陷,所以 V8 没有采用这种标记法。
1 | var p = { |
![引用计数法的内存泄漏.png]
对象无法访问,计数也不为 0,无法被回收,导致内存泄漏。
循环引用的问题并不是没有办法解决,这需要开发者手动删除引用,即let a = null
这种方式。
根搜索算法(标记法)
V8 采用根搜索算法,根搜索算法会对堆内存进行遍历,找到GC Root(根对象)引用的其他对象,能访问到的都标记为活跃对象,其余则为非活跃对象。
如果不考虑循环引用,GC Roots Set(根集)会表现为一颗树状结构。考虑循环引用,则会呈现出图结构,所以不存在内存泄露问题。
![根搜索算法.png]
根对象 Root:
- 所有正在运行的栈上的引用变量
- 全局对象 Global、window(对于浏览器来说,Glocal 等于 window)
- 所有内置对象
关于标记阶段指的注意的是,开始标记之前,需要先暂停应用线程(stop-the-world
)
代际假说(The Generational Hypothesis)
代际假说是垃圾回收中的一个重要术语,他主要有两个特点:
- 大部分对象在内存中的存活周期都很短暂。例如执行上下文
- 不死的对象,存活周期比较漫长。例如函数声明,全局对象
V8 回收器 Orinoco
V8 回收器名叫 Orinoco
。垃圾回收器在进行标记或回收行为时,会暂停 JS 主线程的执行。
任何垃圾回收器的基本任务:
- 识别死/活对象
- 回收/重用死对象占用的内存
- 压缩碎片内存(可选)
Orinoco
利用代际假说这一假设,大多数对象都会在新生代中死亡,只有少数对象能在新生代中存活下来,然后移动到老生代。
所以,GC 复制算法得以在 V8 中被使用。
V8 回收算法(GC 复制算法)
分代回收
在Orinoco
中,存在两个不同的 GC。Minor GC: 用于回收新生代的垃圾,Major: 用于回收老生代的垃圾。
对于存活周期漫长的对象,那他需要的空间肯定也比较大,对应的算法也就不同。
新生代(Minor GC)
Scavenge(深度优先 存在递归问题)
Scavenge 是典型的牺牲空间换时间的复制算法。
在to-space
中,存在$free
指针,用于指向当前对应空间可分配内存的起始地址。当我们从复制完一个对象后,$free
会移动到新的起始位置。
具体步骤为以下 4 步:
- 从 Root 引用开始查询,通过根搜索算法标记活动对象和非活动对象
- 复制
from-space
的活动对象到to-space
中 - 清空
from-space
from-space
和to-space
角色互换,以便下次回收
第二步还有个细节,将某个活动对象复制到to-space
后,会在from-space
中将该对象标记为已复制,而不是马上清空该对象占用的空间。这是因为该对象可能被其他对象引用。
还有一点,在from-space
中找到某个活动对象(B)后,如果该活动对象下还有别的活动对象(A),就会将 A 复制到to-space
。这是一个递归过程。
递归的问题:
- 持续占用栈内存,而且可能出现爆栈的情况
- 执行一个函数就会创建一个执行上下文,也会持续占用内存
递归带来的负担不可忽视,V8 并没有完全采用Scavenge
算法,之后引入了cheney
算法,用迭代来解决该问题。
cheney(广度优先 迭代)
cheney
算法引入了新的指针$scan
,该指针用于标记to-space
中,还没有被向下搜索过子对象的起始位置。
- 从 Root 引用开始查询,通过根搜索算法标记活动对象和非活动对象
- 依次将活动对象复制到
to-space
中(不再进行递归查找) $scan
指针不变,$free
指针向右移动- Root 节点中没有找到别的引用后,我们从
to-space
(可以看做队列)头部开始搜索。to-space
头部的对象成为了新的 Root 节点,$scan
指针向右移动(出队)如果发现新节点存在引用,则复制进入队列。 $scan
指针依次向右移动,发现新对象就复制入队,直到$scan
指针与$free
指针重合,表示所有活动对象查询完毕。- 剩下的就是清理空间然后互换。
知识体系关联:与 Promise 的任务队列方式相似
1 | const PromiseJobs = []; |
cheney 算法采用的是广度优先遍历,这就是迭代,这样把堆用做队列的方式,消除了 scavenge
算法的递归风险。代价就是访问速度上,与scavenge
相比可能会稍微慢一点。
知识体系关联:队列优先级的算法,在 React Fiber 架构中也有用到。
老生代(Major GC)
scavenge
算法为什么不合适老生代,因为
scavenge
是复制算法,反复复制存活率高的对象没有意义scavenge
是以空间换时间的算法,老生代内存空间很大,所以空间资源非常浪费
所以老生代使用了mark-sweep(标记清理)
和mark-Compact(标记清理整理)
Mark-Sweep
Mark-Sweep
算法分为两个阶段,标记和清理。标记依旧是通过根搜索算法。清理阶段与scavenge
算法不同,scavenge
算法是复制后再清理,而Mark-Sweep
是标记后直接清理。
Mark-Compact
Mark-Compact
是为了解决Mark-Sweep
算法带来的内存缝隙而提出的解决方案。
compact 主要做两件事:
- 把活动对象移动到该去的位置
- 修改引用,让他们指向新的地址
![Mark-Compact.png]
全停顿(Stop-The-World)
JS 代码的运行需要用到 JS 引擎,垃圾回收也要用到 JS 引擎,如果这两者同时运行怎么办?答案是垃圾回收优于代码执行。代码会暂停执行,等待垃圾回收完毕再执行。这个过程称为全停顿。
但这样的代价就是页面明细卡顿,因此Orinoco
还继续做了优化。
Orinoco 进一步优化
增量标记+惰性清理
2011 年,V8 从早期的stop-the-world
切换到 Incremental
增量标记 + Lazy Sweeping
惰性清理的模式。
垃圾回收任务被拆分为多个小任务,然后在主线程空隙中执行这个小任务。
增量标记是针对标记阶段的优化。只有当垃圾达到一定数量是,增量标记就会开启:标记一点,JS代码运行一段
惰性清理是针对清除阶段的优化。假如当前可用内存足以让代码快速执行,那就延迟清理,或清理部分垃圾。
结合浏览器的requestIdleCallback
,增量标记与惰性清理的出现,使主线程的最大停顿时间减少了 80%。页面更流畅了。
但是也带来了问题,标记和代码执行的穿插,可能会造成对象引用改变、标记错误的现象。这就需要写屏障技术来记录这些引用关系的变化。
2018 年,V8 同时引入了并行与并发,让垃圾回收的时间进一步缩短。
并行(Parallel)
并行指的是主线程和辅助线程同时执行大致数量相等的清理任务。依旧采用stop-the-world
的方式,但是将清理任务交给多个线程来执行。这是实现起来最简单的方案。
![多线程并行减少主线程等待时间.png]
并发(Concurrent)
并发指的是在不暂停 JS 代码执行的同时,辅助线程在后台执行垃圾回收工作。除了需要写屏障技术,还可能会存在辅助线程与 JS 主线程同时读取或修改同一对象的问题,这就能能处理了。这是三种技术中实现起来最难的。
![并发执行(不暂停js的执行).png]
知识体系关联:React 18 的并发(Concurrent)
V8 当前的垃圾回收机制
在新生代中,使用并行机制。在将活动对象从from-space
赋值到to-space
时,启用多个辅助线程并行整理。由于多个线程可能会竞争同一个对象,因此第一个线程对该对象操作之后,都必须维护这个对象的转发地址,以便其他线程能够快速判断该对象是否已被复制。
新生代:并行方式+cheney 算法
在老生代中,如果堆中的内存大小超过某个阈值,会启用并发标记任务。每个辅助线程都会去追踪每个标记到的对象的指针以及对这个对象的引用,JS 代码执行的之后,并发标记也在后台的辅助进程中进行,当堆中的某个对象被修改的时候,写入屏障技术会在辅助线程在进行并发标记时进行追踪。
并发标记完成后,辅助线程会进行内存整理,不影响 JS 代码的执行。
从 V8 到实践
了解 V8 垃圾回收的内部机制,可以帮助我们考虑内存使用。例如,从垃圾回收角度来看,存活周期漫长的对象维护成本会偏高。因为
- 对于无效函数的声明就应该更严谨
- 使用 shaking 技术
- 减少闭包对象的大小(Redux 的 Provider 注入,Provider 只放少量必要的数据)
参考: