JS GC机制
什么是垃圾回收机制以及垃圾产生的原因?
垃圾回收是一种内存自动管理机制,它会检测并清除掉不再使用的对象,从而释放内存空间.当一个对象不再被引用时,垃圾回收机制会将其判定为垃圾,并在适当的时候将其回收掉给其他系统使用.以减少内存泄漏和提高程序性能.
在JS中,垃圾产生的方式主要有以下几种情况:
- 对象不再被引用. 包括函数中的局部变量
- 对象之间形成循环引用. 虽然对象之间被引用,但是他们并不会被其他对象引用,也就是说此为无效引用
- 内存泄漏. 比如没有合理的使用闭包 或者定时器等未及时清除
常见的垃圾回收方法
-
引用-计数法
引用计数法就是给每一个对象都设定一个引用计数器,当一个对象被创建的时候,引用计数器为0,被引用之后+1,被其他对象解除引用则-1.如果引用计数器清0了,则表示该对象变为了垃圾.该方法有一个弊端就是无法解决循环引用的问题.
-
标记-清除法
标记清除法分为两个阶段:
- 标记阶段:垃圾回收器会从根对象开始,循环遍历所有的对象,对于每一个被访问到的对象都会给它打上标记.表示该对象是可达的,即表明该对象被引用,并不是垃圾.
- 清楚阶段:垃圾回收器会遍历整个内存空间,对于没有被标记的对象会当成垃圾回收掉.
该方法可以处理掉循环引用的问题,但是该方法会暂停程序的执行(JS是单线程的),如果内存中的对象较多时,可能会造成明显的停顿.另外,该方法在回收的过程中还会产生大量的不连续的碎片化的内存空间,会导致内存的利用率低.
-
标记-整理法
该方法是对于标记清除法的改进,它分为三个阶段:
- 标记阶段:同标记清除法.
- 整理阶段:垃圾回收器会先将所有活动的对象进行移动,使其空间连续,且没有碎片化.
- 清除阶段:同上.
通过加入整理阶段,使的碎片化的内存空间问题得以解决,但是还是无法有效解决卡顿问题.
V8引擎中的垃圾回收策略
那么对于JS解析引擎V8而言,其使用了分代式垃圾回收方式.其基本思想就是根据内存中对象的存活时间不同分为不同的代,主要有两代:新生代和老生代. 新生代用来存储声明周期较短的对象,老生代用来存储声明周期较长的对象.新生代和老生代采用了不同的垃圾回收方法,从而提升垃圾回收的效率和性能.
-
新生代
在V8引擎中,副垃圾回收器主要用来负责新生代的垃圾回收.其具体的垃圾回收方式是将新生代的内存空间分为两个等大小的空间,分别是From空间和To空间.新对象被首先分配到from空间中,当from空间中的内存占满时,就会触发垃圾回收机制.主要分为以下几个阶段:
- 标记阶段:从根对象开始循环遍历并标记所有活动对象.
- 复制阶段:将from空间中所有活动对象复制到to空间中并进行排序(
引用重定向到to内存中
). - 清除阶段:清除掉from空间中的非活动对象,由于原本的活动对象引用丢失,变成了垃圾.因此from空间清空.
- 交换阶段:将from空间和to空间进行交换.新的产生的对象依次在from空间中后排,并循环整个流程.
新生代 -> 老生代的晋级条件:
- 每个对象中都有一个年龄计数器,每次垃圾回收后年龄+1,当年龄达到阈值则晋升到老生代内存中.
- To空间达到一定打到比例,也会将年老对象晋级,防止新生代内存过快填满,频繁垃圾回收.
-
老生代
老生代垃圾回收就是采用标记整理法来进行垃圾回收.
V8引擎垃圾回收的优化方法
执行垃圾回收时,JS脚本的解析需要暂停,等待垃圾回收完毕之后再恢复脚本执行.这种行为叫做 全停顿.为了解决全停顿问题,V8引擎提出了几种优化方法.
-
并行垃圾回收
引入多个辅助线程并行处理,加快垃圾回收的执行速度.
-
增量标记
标记-清除算法会随着堆中对象的大小而增加标记时间.增量标记就是将标记过程分为多个阶段,每个阶段之间都插入一段脚本的执行,在每个脚本执行阶段完毕后,垃圾回收器都会扫描一部分对象进行标记,然后继续执行一段脚本.这样可以减少阻塞时间.那么此时就会有几个问题:
-
如何做到垃圾回收器的随时暂停和重启?并且重启后可以恢复到上一步执行到的地方?
-
如果标记好的数据在暂停垃圾回收的过程中被修改了如何处理?
-
何时进行垃圾清理?
-
三色标记法(解决问题1)
在标记清理/标记整理算法中,仅用两种状态来标记数据(例如用白色来代表未标记状态,对象可回收;黑色代表已标记状态,对象不可回收).那么对于三色标记法,则需要再用到一种状态来表示当前垃圾回收标记进行到了哪一步(灰色,代表对象已被垃圾回收器访问过,但是它引用的下一个对象还没有被访问过.灰色对象不可回收) 例如:
初始状态如下所示:
垃圾回收器标记了一部分,接下来需要执行一段脚本前的状态:
引入灰色标记之后,垃圾回收器就可以依据当前内存中有没有灰色节点,来判断整个标记是否完成,如果没有灰色节点了,就可以进行清理工作了。如果还有灰色标记,当下次恢复垃圾回收器时,便从灰色的节点开始继续执行。
经过若干次反复标记和脚本执行后,最终的标记状态:
-
写屏障(解决问题2)
如果在一次标记结束后,脚本执行时将标记好的对象引用关系修改掉了.例如一个被标记的对象A引用了一个对象B,并且该对象B也已经被标记了,此时脚本执行,对象A停止引用对象B,转而引用对象C.
那么V8引擎就会使用写屏障机制:当发生了黑色的节点引用了白色的节点,写屏障机制会强制将被引用的白色节点C变成灰色的,这样就保证了下一次增量标记阶段可以正确标记,这个方法也被称为强三色不变性。
未解决的问题:B节点已经不可达,那么B节点及其后续节点是怎么处理的?
-
惰性处理以及并发垃圾回收(解决问题3)
标记阶段完成之后,就要进入到垃圾清理阶段.采用这种延迟清理的原因是因为在增量标记之后,要进行清理非活动对象的时候,垃圾回收器发现了其实就算是不清理,剩余的空间也足以让JS代码跑起来,所以就延迟了清理,让JS代码先执行,或者只清理部分垃圾,而不清理全部。
- 上面讲到的并行垃圾回收和增量垃圾回收依然会阻塞主线程,并发垃圾回收就可以解决这个问题。
- 并发回收机制:指主线程在执行 JavaScript 的过程中,辅助线程能够在后台完成执行垃圾回收的操作。
-
垃圾回收器采用策略
- 副垃圾回收器(新生代)所采用的是并行回收策略,它在执行垃圾回收的过程中,启动了多个线程来负责新生代中的垃圾清理操作,这些线程同时将对象空间中的数据移动到空闲区域。由于数据的地址发生了改变,所以还需要同步更新引用这些对象的指针。
- **主垃圾回收器(老生代)**融合了以上机制,来实现垃圾回收.
- 首先主垃圾回收器主要使用并发标记,在主线程执行 JavaScript时,辅助线程就开始执行标记操作了,所以标记是在辅助线程中完成的。
- 标记完成之后,再执行并行整理和清理操作。主线程在执行整理和清理操作时,多个辅助线程也在执行整理整理和清理操作。
- 另外,主垃圾回收器还采用了增量回收的方式,整理和清理的任务会穿插在各种 JavaScript 任务之间执行。