前言
想要了解 V8 引擎如何实现垃圾回收的,那么前提是你得知道 JavaScript 中的数据是如何存储在内存中,那么下面就一起来了解数据是如何在内存中的存储的。
数据是如何存储的?
内存空间
在 JavaScript 的执行过程中, 主要有三种类型内存空间:
- 代码空间:主要是存储可执行代码的,这里不是主要介绍的内容
- 栈空间
- 堆空间
首先来观察如下 代码片段一 的输出结果:
function foo(){ var a = 1 var b = a a = 2 console.log(a) console.log(b) } foo() 复制代码
显然这对你来说应该没什么难度,输出结果为:2 1
接下来在看 代码片段二 代码的输出结果:
function foo(){ var a = { msg: "hello world" } var b = a a.msg = "hello juejin" console.log(a) console.log(b) } foo() 复制代码
这是不是也没什么难度,输出结果为:hello juejin
也许你知道输出的结果是什么,但是你能说清楚这是为什么吗?如果你不能有一个很好的回答思路和方向,那就继续往下看吧。
栈空间
栈空间
其实就是经常提到的调用栈
,是用来存储执行上下文的,它一般是用来存储原始类型数据
.
下面通过 代码片段一 的例子来具象化这个内容,如下:
function foo(){ var a = 1 var b = a a = 2 // 执行到此处 console.log(a) console.log(b) } foo() 复制代码
当代码执行到 a = 2
处时,对应的 调用栈 内容可以具象化为:
从图中可以看出来,原始类型数据是存放在栈空间的,当 JavaScript 需要访问该数据的时候,可以直接访问到栈中值。
堆空间
堆空间
内存比栈空间更大,一般用来存储引用类型数据
,栈空间中一般存储的是堆内存对应的引用地址.
下面通过 代码片段二 的例子来具象化这个内容,如下:
function foo(){ var a = { msg: "hello world" } var b = a // 执行到此处 console.log(a) console.log(b) } foo() 复制代码
当代码执行到 var b = a
处时,对应的 调用栈 内容可以具象化为:
从图中可以看出来,对象类型是存放在堆空间的,在栈空间中只是保留了对象的引用地址,当 JavaScript 需要访问该数据的时候,是通过栈中的引用地址来访问的。
为什么要分 “堆空间” 和 “栈空间”?全部都使用栈空间存储可不可以?
全部都使用栈空间存储的方案是不可行的,因为 JavaScript 引擎需要用 栈 来维护程序执行期间上下文的状态,如果栈空间设置太大,所有的数据都存放在栈空间里面,那么会影响到 上下文切换的效率,这又会影响到 整个程序的执行效率。
所以通常情况下,栈空间都不会设置太大,主要用来存放一些原始类型的小数据。而引用类型的数据占用的空间都比较大,所以这一类数据会被存放到堆中,堆空间很大,能存放很多大的数据,不过缺点是分配内存和回收内存都会占用一定的时间。
垃圾数据是如何自动回收的?
通常情况下,垃圾数据回收分为两种策略:
- 手动回收:如
C/C++
就是使用手动回收策略,何时分配内存、何时销毁内存都是由代码控制的 - 自动回收:如
JavaScript、Java、Python
等语言,产生的垃圾数据是由垃圾回收器来释放的,并不需要手动通过代码来释放
如何回收调用栈中的数据?
仍然通过示例代码的执行流程具象化回收机制:
function foo(){ var a = 1 var b = { msg: "hello world" } function bar(){ var c = 2 var d = { msg: "hello juejin" } // 执行到此处 } bar() } foo() 复制代码
当代码执行到 var d = { msg: "hello juejin" }
处时,其调用栈和堆空间状态图如下所示:
除了上图中显示内容外,还有一个用于记录当前执行状态的指针(称为 ESP),指向调用栈中 bar 函数的执行上下文,表示当前正在执行 bar 函数.
当 bar 函数执行完成后,ESP 指针就会从 bar 函数执行上下文下移到 foo 函数执行上下文,如下图所示:
从上图可知,当 bar 函数执行结束之后,ESP 向下移动到 foo 函数的执行上下文中,上面 bar 的执行上下文虽然保存在栈内存(无效内存)。比如当 foo 函数再次调用另外一个函数时,这块无效内存会被直接覆盖掉,用来存放另外一个函数的执行上下文。
因此,当一个函数执行结束之后,JavaScript 引擎会通过向下移动 ESP 来销毁该函数保存在栈中的执行上下文。
如何回收堆中的数据?
当上面那段代码的 foo 函数执行结束之后,ESP 应该是指向 全局执行上下文,那意味着,虽然 bar 函数和 foo 函数的执行上下文已经处于无效状态,但保存在堆中的两个对象依然占用着空间,如下图所示:
从上图可知,1001 和 1002 这两块内存依然被占用着,而如果要回收堆中的垃圾数据,那就需要用到 JavaScript 中的 垃圾回收器。
什么是代际假说?
代际假说是垃圾回收领域中一个重要的术语,垃圾回收的策略都是建立在它的基础上。
代际假说(The Generational Hypothesis)有两个特点:
- 大部分对象在内存中存在的时间很短,简单来说,就是很多对象一经分配内存,很快就变得不可访问
- 不死的对象,会活得更久
什么是分代收集?
在 V8 中会把堆分为两个区域:
- 新生代:新生代中存放的是 生存时间短 的对象,通常只支持 1~8M 的容量
- 老生代:老生代中存放的 生存时间久 的对象,支持的容量就比新生代大很多
对于这两块区域,V8 分别使用两个不同的垃圾回收器,以便更高效地实施垃圾回收:
- 副垃圾回收器:主要负责 新生代 的垃圾回收
- 主垃圾回收器:主要负责 老生代 的垃圾回收
垃圾回收器的工作流程
现在我们知道了 V8 中会分为 新生代 和 老生代 两块区域,也知道它们对应着 副垃圾回收器 和 主垃圾回收器,看起来垃圾回收器有 主副 之分,但不论什么类型的垃圾回收器,它们都有一套共同的执行流程:
- 第一步 是 标记 空间中 活动对象 和 非活动对象
活动对象 指还在使用的对象,非活动对象 指可进行垃圾回收的对象
- 第二步 是 回收非活动对象所占据的内存
- 即在完成所有标记后,统一清理内存中所有被标记为可回收的对象
- 第三步 是做 内存整理
- 频繁回收对象后,内存中就会存在大量不连续空间,即 内存碎片
- 内存中出现大量的 内存碎片,可能会导致被后期分配内存时出现 内存不足 的情况
- 注意 副垃圾回收器 不会产生 内存碎片
副垃圾回收器
副垃圾回收器 主要负责 新生区 的垃圾回收。而通常情况下,大多数小的对象都会被分配到 新生区,这个区域虽然不大,但是垃圾回收还是比较频繁的。
新生代中用 Scavenge 算法来处理,也就是把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域
新加入的对象都会存放到 对象区域,当对象区域 快被写满 时,就需要执行一次 垃圾清理 操作,回收过程仍然是上面提到的三步,以及外加一些策略:
- 为 对象区域 中的垃圾做 标记
- 标记完成之后,就进入 垃圾清理阶段,副垃圾回收器 会把这些 存活 的对象 复制 到 空闲区域 中
- 由于上一步的操作,使得不会有 内存碎片 产生,也没必要进行 内存整理
- 完成复制后,对象区域 与 空闲区域 进行角色 翻转,即原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域
为什么新生代的空间会被设置得比较小?
由于新生代中采用的 Scavenge 算法,所以每次执行清理操作时,都需要将存活的对象从对象区域复制到空闲区域。
但 复制操作需要时间成本,如果新生区空间设置得太大,会导致每次清理的时间就会过久,所以为了执行效率,一般新生区的空间会被设置得比较小。
也正是因为新生区的空间不大,所以 很容易 被存活的对象 装满 整个区域。为了解决这个问题,JavaScript 引擎 采用了 对象晋升策略,即经过 两次垃圾回收 依然存活的对象,会被移动到 老生区 中。
主垃圾回收器
主垃圾回收器 主要负责 老生区 中的垃圾回收
老生区中的对象有两个特点:
- 一是对象占用空间大,即一些大的对象会直接被分配到老生区
- 二是对象存活时间长,即新生区中经过两次垃圾回收,然后存活的对象,即 晋升对象
标记 - 清除(Mark-Sweep)算法
由于以上特点,主垃圾回收器不适用于 Scavenge 算法,它采用 标记 - 清除(Mark-Sweep) 算法进行垃圾回收:
- 标记过程阶段
- 从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达 的元素称为 活动对象,没有到达 的元素就为 垃圾数据
- 垃圾的清除过程
- 它和 副垃圾回收器 的垃圾清除过程 完全不同,可以将这个过程具象化为是清除掉红色标记数据的过程
标记 - 整理(Mark-Compact)算法
从上图可知,标记 - 清除算法 会产生 内存碎片,而碎片过多会导致大对象无法分配到足够的连续内存,于是又产生了 标记 - 整理(Mark-Compact)算法。
这个标记过程仍然与 标记 - 清除算法 是一样的,但后续步骤不是直接对可回收对象进行清理,而是让所有 存活对象 都向 一端移动,然后直接清理掉端边界以外的内存,如图:
全停顿
V8 是使用 主副垃圾回收器 来进行垃圾回收,不过由于 JavaScript 是运行在 主线程 之上的,一旦执行 垃圾回收算法,最终都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行,即 全停顿(Stop-The-World)
增量标记(Incremental Marking)算法
为了降低老生代的垃圾回收而造成的卡顿,V8 将标记过程分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑 交替 进行,直到标记阶段完成,即 增量标记(Incremental Marking)算法
增量标记算法,把一个完整的垃圾回收任务 拆分为很多小任务,这些小任务执行时间比较短,可以 穿插 在其他的 JavaScript 任务中间执行,这样当执行上述动画效果时,就不会让用户因为垃圾回收任务占用主线程而感受到页面的卡顿。
总结
关于内存和垃圾回收的内容,大多属于是概念性的,在前端进阶上又是无法避免的部分,概念虽然比较多,但如果我们能将这些概念具象化,那么也就能更容易掌握它们。