垃圾回收

GC


# 垃圾回收策略

解除对一个值的引用并不会自动导致相关内存被回收。解除引用的关键在于确保相关 的值已经不在上下文里了,因此它在下次垃圾回收时会被回收

# 引用计数

这种方式常常会引起内存泄漏,低版本的 IE 使用这种方式。

# 工作原理

跟踪记录每个值被引用的次数。如果存在循环引用会导致内存无法释放

let a = {}
let b = {}
a.prop = b
b.prop = a
1
2
3
4

# 标记清除

JS 中最常见的垃圾回收方式是标记清除。

# 工作原理

当变量进入环境时,将这个变量标记为“进入环境”。当变量离开环境时,则将其标记为“离开环境”。标记“离开环境”的就回收内存。

# 工作流程:

  1. 垃圾回收器,在运行的时候会给存储在内存中的所有变量都加上标记。

  2. 去掉 环境中的变量以及被环境中的变量引用的变量的 标记。

  3. 再被加上标记的会被视为准备删除的变量。

  4. 垃圾回收器完成内存清除工作,销毁那些带标记的值并回收他们所占用的内存空间。

# V8 中内存分类

在讲内存分配之前,先了解一下弱分代假说,V8 的垃圾回收主要建立在这个假说之上。

概念:

  • 绝大部分的对象生命周期都很短,即存活时间很短
  • 生命周期很长的对象,基本都是常驻对象

基于以上两个概念,将内存分为新生代(new space)老生代(old space)两个区域。划重点,记一下。

# 新生代垃圾回收

新生代(32 位系统分配 16M 的内存空间,64 位系统翻倍 32M,不同浏览器可能不同,但是应该差不了多少)。

新生代对应存活时间很短的假说概念,这个空间的操作,非常频繁,绝大多数对象在这里经历一次生死轮回,基本消亡,没消亡的会晋升至老生代内。

新生代算法为 Scavenge 算法,典型牺牲空间换时间,怎么说呢?

首先他将新生代分为两个相等的半空间( semispace ) from space  与  to space,来看看这个败家玩意,是怎么操作的,他使用宽度优先算法,是宽度优先。两个空间,同一时间内,只会有一个空间在工作( from space ),另一个在休息( to space )。

  1. 首先,V8 引擎中的垃圾回收器检测到 from space 空间快达到上限了,此时要进行一次垃圾回收了

  2. 然后,从根部开始遍历,不可达对象(即无法遍历到的对象)将会被标记,并且复制未被标记的对象,放到 to space 中

  3. 最后,清除 from space 中的数据,同时将 from space 置为空闲状态,即变成 to space,相应的 to space 变成 from space,俗称翻转

    图片

当然优秀的 V8 是不可能容忍,一个对象来回的在 form space 和 to space 中蹦跶的,(晋升条件 ①)当经历一次 form => to 翻转之后,发现某些未被标记的对象居然还在,会直接扔到老生代里面去。

除了上面一种情况,还有一个情况也会晋级,(晋升条件 ②)当一个对象,在被复制的时候,大于 to space 空间的 25% 的时候,也会晋级了,这种自带背景的选手,那是不敢动的,直接晋级到老生代。

# 老生代垃圾回收

老生代( 32 位操作系统分配大约 700M 内存空间,64 位翻倍 1.4G,一样,每个浏览器可能会有差异,但是差不了多少)。

老生代比起新生代可是要复杂的多,所谓能者多劳,空间大了,责任就大了,老生代可以分为以下几个区域:

  • old object space  即大家口中的老生代,不是全部老生代,这里的对象大部分是由新生代晋升而来
  • large object space  大对象存储区域,其他区域无法存储下的对象会被放在这里,基本是超过 1M 的对象,这种对象不会在新生代对象中分配,直接存放到这里,当然了,这么大的数据,复制成本很高,基本就是在这里等待命运的降临不可能接受仅仅是知其然,而不知其所以然
  • Map space  这个玩意,就是存储对象的映射关系的,其实就是隐藏类 (opens new window)
  • code space  简单点说,就是存放代码的地方,编译之后的代码,最大限制为 512MB,也是唯一拥有执行权限的内存 图片

# 标记和清除/整理

老生代垃圾回收器会先使用标记 - 清除(Mark-Sweep)算法进行垃圾回收。

# 第一步,进行标记-清除

这个过程在《JavaScript 高级程序设计(第三版)》中有过详细的介绍,主要分成两个阶段,即标记阶段和清除阶段。首先会遍历堆中的所有对象,对它们做上标记,然后对于代码环境中使用的变量以及被强引用的变量取消标记,剩下的就是要删除的变量了,在随后的清除阶段对其进行空间的回收。

当然这又会引发内存碎片的问题,存活对象的空间不连续对后续的空间分配造成障碍。老生代又是如何处理这个问题的呢

# 第二步,整理内存碎片

V8 的解决方式非常简单粗暴,在清除阶段结束后,把存活的对象全部往一端靠拢。

图片

# 垃圾回收优化策略(Orinoco)

在以往,新/老生带都包括在内,为了防止逻辑和垃圾回收的情况不一致,需要停止 JS 的运行,专门来遍历去遍历/复制,标记/清除,这个停顿就是:全停顿

这就比较恶心了,新生代也就算了,本身内存不大,时间上也不明显,但是在老生代中,如果遍历的对象太多,太大,用户在此时,是有可能明显感到页面卡顿的,体验嘎嘎差。

所以在 V8 引擎在名为 Orinoco 项目中,做了三个事情,当然只针对老生代,新生代这个后浪还是可以的,效率贼拉的高,优化空间不大。三个事情分别是:

  • 增量标记

将原来一口气去标记的事情,做成分步去做,每次内存占用达到一定的量或者多次进入写屏障的时候,就暂时停止 JS 程序,做一次最多几十毫秒的标记 marking,当下次 GC 的时候,反正前面都标记好了,开始清除就行了

  • 并行回收

从字面意思看并行,就是在一次全量垃圾回收的过程中,就是 V8 引擎通过开启若干辅助线程,一起来清除垃圾,可以极大的减少垃圾回收的时间,很优秀,手动点赞

  • 并发回收

并发就是在 JS 主线程运行的时候,同时开启辅助线程,清理和主线程没有任何逻辑关系的垃圾,当然,需要写屏障来保障

# 参考文章

  1. V8 中内存分类 (opens new window)
  2. V8 引擎详解(七)——垃圾回收机制 (opens new window)
Last Updated: 10/21/2024, 4:15:17 PM