关于前端:赠你13张图助你20分钟打败了V8垃圾回收机制

35次阅读

共计 5909 个字符,预计需要花费 15 分钟才能阅读完成。

前言

大家好,我是林三心。前两天,无心中看到了 B 站上一个讲 V8 垃圾回收机制 的视频,感兴趣的我看了一下,感觉有点难懂,于是我就在想,大家是不是跟我一样对 V8 垃圾回收机制 这方面的常识都比拟懵,或者说看过这方面的常识,然而看不懂。所以,我思考了三天,想了一下 如何能力用最艰深的话,讲最难的知识点。

一般了解

我置信大部分同学在面试中经常被问到:”说一说 V8 垃圾回收机制吧“

这个时候,大部分同学必定会这么答复:”垃圾回收机制有两种形式,一种是 援用法 ,一种是 标记法

援用法

就是判断一个对象的援用数,援用数 为 0 就回收,援用数 大于 0 就不回收。请看以下代码

let obj1 = {name: '林三心', age: 22}
let obj2 = obj1
let obj3 = obj1

obj1 = null
obj2 = null
obj3 = null

援用法是有毛病的,上面代码执行完后,按理说 obj1 和 obj2 都会被回收,然而因为他们相互援用,各自援用数都是 1,所以不会被回收,从而造成 内存透露

function fn () {const obj1 = {}
  const obj2 = {}
  obj1.a = obj2
  obj2.a = obj1
}
fn()

标记法

标记法就是,将 可达 的对象标记起来,不可达 的对象当成垃圾回收。

那问题来了,可不可达,通过什么来判断呢?(这里的可达,可不是可达鸭)

言归正传,想要判断可不可达,就不得不说 可达性 了,可达性 是什么?就是从初始的 根对象(window 或者 global)的指针开始,向下搜寻子节点,子节点被搜寻到了,阐明该子节点的援用对象可达,并为其进行标记,而后接着递归搜寻,直到所有子节点被遍历完结。那么没有被遍历到节点,也就没有被标记,也就会被当成没有被任何中央援用,就能够证实这是一个须要被开释内存的对象,能够被垃圾回收器回收。

// 可达
var name = '林三心'
var obj = {arr: [1, 2, 3]
}
console.log(window.name) // 林三心
console.log(window.obj) // {arr: [1, 2, 3] }
console.log(window.obj.arr) // [1, 2, 3]
console.log(window.obj.arr[1]) // 2

function fn () {var age = 22}
// 不可达
console.log(window.age) // undefined

一般的了解其实是不够的,因为 垃圾回收机制 (GC) 其实不止这两个算法,想要更深刻地理解V8 垃圾回收机制,就持续往下看吧!!!

JavaScript 内存治理

其实 JavaScript 内存的流程很简略,分为 3 步:

  • 1、调配给 使用者 所需的内存
  • 2、使用者 拿到这些内存,并应用内存
  • 3、使用者 不须要这些内存了,开释并归还给零碎

那么这些 使用者 是谁呢?举个例子:

var num = ''var str =' 林三心 'var obj = {name:' 林三心 '}
obj = {name: '林瘦子'}

下面这些 num,str,obj 就是就是 使用者 ,咱们都晓得,JavaScript 数据类型分为 根底数据类型 援用数据类型:

  • 根底数据类型 :领有固定的大小,值保留在 栈内存 里,能够通过值间接拜访
  • 援用数据类型 :大小不固定(能够加属性), 栈内存 中存着指针,指向 堆内存 中的对象空间,通过援用来拜访

  • 因为栈内存所存的根底数据类型大小是固定的,所以栈内存的内存都是 操作系统主动调配和开释回收的
  • 因为堆内存所存大小不固定,零碎 无奈主动开释回收,所以须要JS 引擎来手动开释这些内存

为啥要垃圾回收

在 Chrome 中,V8 被限度了内存的应用(64 位约 1.4G/1464MB,32 位约 0.7G/732MB),为什么要限度呢?

  • 表层起因:V8 最后为浏览器而设计,不太可能遇到用大量内存的场景
  • 深层起因:V8 的垃圾回收机制的限度(如果清理大量的内存垃圾是很耗时间,这样回引起 JavaScript 线程暂停执行的工夫,那么性能和利用直线降落)

后面说到栈内的内存,操作系统会主动进行内存调配和内存开释,而堆中的内存,由 JS 引擎(如 Chrome 的 V8)手动进行开释,当咱们的代码没有依照正确的写法时,会使得 JS 引擎的垃圾回收机制无奈正确的对内存进行开释(内存泄露),从而使得浏览器占用的内存一直减少,进而导致 JavaScript 和利用、操作系统性能降落。

V8 的垃圾回收算法

1. 分代回收

在 JavaScript 中,对象存活周期分为两种状况

  • 存活周期很短:通过一次垃圾回收后,就被开释回收掉
  • 存活周期很长:通过屡次垃圾回收后,他还存在,赖着不走

那么问题来了,对于存活周期短的,回收掉就算了,但对于存活周期长的,屡次回收都回收不掉,明知回收不掉,却还一直地去做回收无用功,那岂不是很耗费性能?

对于这个问题,V8 做了 分代回收 的优化办法,艰深点说就是:V8 将堆分为两个空间,一个叫新生代,一个叫老生代,新生代是寄存存活周期短对象的中央,老生代是寄存存活周期长对象的中央

新生代通常只有 1-8M 的容量,而老生代的容量就大很多了。对于这两块区域,V8 别离应用了 不同的垃圾回收器和不同的回收算法,以便更高效地施行垃圾回收

  • 副垃圾回收器 + Scavenge 算法:次要负责新生代的垃圾回收
  • 主垃圾回收器 + Mark-Sweep && Mark-Compact 算法:次要负责老生代的垃圾回收

1.1 新生代

在 JavaScript 中,任何对象的申明调配到的内存,将会先被搁置在新生代中,而因为大部分对象在内存中存活的周期很短,所以须要一个效率十分高的算法。在新生代中,次要应用 Scavenge 算法进行垃圾回收,Scavenge算法是一个典型的就义空间换取工夫的复制算法,在占用空间不大的场景上十分实用。

Scavange 算法 将新生代堆分为两局部,别离叫 from-spaceto-space,工作形式也很简略,就是将 from-space 中存活的流动对象复制到 to-space 中,并将这些对象的内存有序的排列起来,而后将 from-space 中的非流动对象的内存进行开释,实现之后,将 from space 和to space 进行调换,这样能够使得新生代中的这两块区域能够反复利用。

具体步骤为以下 4 步:

  • 1、标记流动对象和非流动对象
  • 2、复制 from-space 的流动对象到 to-space 中并进行排序
  • 3、革除 from-space 中的非流动对象
  • 4、将 from-spaceto-space进行角色调换,以便下一次的 Scavenge 算法 垃圾回收

那么,垃圾回收器是怎么晓得哪些对象是流动对象,哪些是非流动对象呢?

这就要不得不提一个货色了——可达性 。什么是可达性呢?就是从初始的 根对象(window 或者 global)的指针开始,向下搜寻子节点,子节点被搜寻到了,阐明该子节点的援用对象可达,并为其进行标记,而后接着递归搜寻,直到所有子节点被遍历完结。那么没有被遍历到节点,也就没有被标记,也就会被当成没有被任何中央援用,就能够证实这是一个须要被开释内存的对象,能够被垃圾回收器回收。

新生代中的对象什么时候变成老生代的对象?

在新生代中,还进一步进行了细分。分为 nursery 子代intermediate 子代 两个区域,一个对象第一次分配内存时会被调配到新生代中的 nursery 子代,如果通过下一次垃圾回收这个对象还存在新生代中,这时候咱们将此对象挪动到intermediate 子代,在通过下一次垃圾回收,如果这个对象还在新生代中, 副垃圾回收器 会将该对象挪动到老生代中,这个挪动的过程被称为 降职

1.2 老生代

新生代空间的对象,南征北战之后,留下来的老对象,胜利降职到了老生代空间里,因为这些对象都是通过屡次回收过程然而没有被回收走的,都是一群生命力倔强,存活率高的对象,所以老生代里,回收算法不宜应用Scavenge 算法,为啥呢,有以下起因:

  • Scavenge 算法 是复制算法,重复复制这些存活率高的对象,没什么意义,效率极低
  • Scavenge 算法 是以空间换工夫的算法,老生代是内存很大的空间,如果应用Scavenge 算法,空间资源十分节约,得失相当啊。。

所以老生代里应用了 Mark-Sweep 算法(标记清理)Mark-Compact 算法(标记整顿)

Mark-Sweep(标记清理)

Mark-Sweep分为两个阶段,标记和清理阶段,之前的 Scavenge 算法 也有标记和清理,然而 Mark-Sweep 算法Scavenge 算法 的区别是,后者须要复制后再清理,前者不须要,Mark-Sweep间接标记流动对象和非流动对象之后,就间接执行清理了。

  • 标记阶段:对老生代对象进行第一次扫描,对流动对象进行标记
  • 清理阶段:对老生代对象进行第二次扫描,革除未标记的对象,即非流动对象

由上图,我想大家也发现了,有一个问题:清除非流动对象之后,留下了很多 零零散散的空位

Mark-Compact(标记整顿)

Mark-Sweep 算法 执行垃圾回收之后,留下了很多 零零散散的空位 ,这有什么害处呢?如果此时进来了一个大对象,须要对此对象调配一个大内存,先从 零零散散的空位 中找地位,找了一圈,发现没有适宜本人大小的空位,只好拼在了最初,这个寻找空位的过程是耗性能的,这也是 Mark-Sweep 算法 的一个 毛病

这个时候 Mark-Compact 算法 呈现了,他是 Mark-Sweep 算法 的加强版,在 Mark-Sweep 算法 的根底上,加上了 整顿阶段,每次清理完非流动对象,就会把剩下的流动对象,整顿到内存的一侧,整顿实现后,间接回收掉边界上的内存

2. 全进展(Stop-The-World)

说完 V8 的分代回收,咱们来聊聊一个问题。JS 代码的运行要用到 JS 引擎,垃圾回收也要用到 JS 引擎,那如果这两者同时进行了,发生冲突了咋办呢?答案是,垃圾回收优先于代码执行 ,会先进行代码的执行,等到垃圾回收结束,再执行 JS 代码。这个过程,称为 全进展

因为新生代空间小,并且存活对象少,再配合 Scavenge 算法,进展工夫较短。然而老生代就不一样了,某些状况流动对象比拟多的时候,进展工夫就会较长,使得页面呈现了 卡顿景象

3. Orinoco 优化

orinoco 为 V8 的垃圾回收器的我的项目代号,为了晋升用户体验,解决 全进展问题 ,它提出了 增量标记、懒性清理、并发、并行 的优化办法。

3.1 增量标记(Incremental marking)

咱们后面一直强调了 先标记,后革除 ,而增量标记就是在 标记 这个阶段进行了优化。我举个活泼的例子:路上有很多 垃圾 ,害得 路人 都走不了路,须要 清洁工 清扫洁净能力走。前几天路上的垃圾都比拟少,所以路人们都等到清洁工全副清理洁净才通过,然而后几天垃圾越来越多,清洁工清理的太久了,路人就等不及了,跟清洁工说:“你清扫一段,我就走一段,这样效率高”。

大家把下面例子里,清洁工清理垃圾的过程——标记过程,路人——JS 代码 ,一一对应就懂了。当垃圾大量时不会做增量标记优化,然而当垃圾达到肯定数量时,增量标记就会开启: 标记一点,JS 代码运行一段,从而提高效率

3.2 惰性清理(Lazy sweeping)

下面说了,增量标记只是针对 标记 阶段,而惰性清理就是针对 革除 阶段了。在增量标记之后,要进行清理非流动对象的时候,垃圾回收器发现了其实就算是不清理,残余的空间也足以让 JS 代码跑起来,所以就 提早了清理 ,让 JS 代码先执行,或者 只清理局部垃圾 ,而不清理全副。这个优化就叫做 惰性清理

整顿标记和惰性清理的呈现,大大改善了 全进展 景象。然而问题也来了:增量标记是 标记一点,JS 运行一段 ,那如果你前脚刚标记一个对象为流动对象,后脚 JS 代码就把此对象设置为非流动对象,或者反过来,前脚没有标记一个对象为流动对象,后脚 JS 代码就把此对象设置为流动对象。总结起来就是:标记和代码执行的交叉,有可能造成 对象援用扭转,标记谬误 景象。这就须要应用 写屏障 技术来记录这些援用关系的变动

3.3 并发(Concurrent)

并发式 GC 容许在在垃圾回收的同时不须要将主线程挂起,两者能够同时进行,只有在个别时候须要短暂停下来让垃圾回收器做一些非凡的操作。然而这种形式也要面对增量回收的问题,就是在垃圾回收过程中,因为 JavaScript 代码在执行,堆中的对象的援用关系随时可能会变动,所以也要进行 写屏障 操作。

3.4 并行

并行式 GC 容许主线程和辅助线程同时执行同样的 GC 工作,这样能够让辅助线程来分担主线程的 GC 工作,使得垃圾回收所消耗的工夫等于总工夫除以参加的线程数量(加上一些同步开销)。

V8 以后的垃圾回收机制

2011 年,V8 利用了 增量标记机制 。直至 2018 年,Chrome64 和 Node.js V10 启动 并发标记(Concurrent),同时在并发的根底上增加 并行(Parallel)技术,使得垃圾回收工夫大幅度缩短。

副垃圾回收器

V8 在新生代垃圾回收中,应用并行(parallel)机制,在整顿排序阶段,也就是将流动对象从 from-to 复制到 space-to 的时候,启用多个辅助线程,并行的进行整顿。因为多个线程竞争一个新生代的堆的内存资源,可能呈现有某个流动对象被多个线程进行复制操作的问题,为了解决这个问题,V8 在第一个线程对流动对象进行复制并且复制实现后,都必须去保护复制这个流动对象后的指针转发地址,以便于其余帮助线程能够找到该流动对象后能够判断该流动对象是否已被复制。

主垃圾回收器

V8 在老生代垃圾回收中,如果堆中的内存大小超过某个阈值之后,会启用并发(Concurrent)标记工作。每个辅助线程都会去追踪每个标记到的对象的指针以及对这个对象的援用,而在 JavaScript 代码执行时候,并发标记也在后盾的辅助过程中进行,当堆中的某个对象指针被 JavaScript 代码批改的时候,写入屏障(write barriers)技术会在辅助线程在进行并发标记的时候进行追踪。

当并发标记实现或者动态分配的内存达到极限的时候,主线程会执行最终的疾速标记步骤,这个时候主线程会挂起,主线程会再一次的扫描根集以确保所有的对象都实现了标记,因为辅助线程曾经标记过流动对象,主线程的本次扫描只是进行 check 操作,确认实现之后,某些辅助线程会进行清理内存操作,某些辅助过程会进行内存整理操作,因为都是并发的,并不会影响主线程 JavaScript 代码的执行。

结语

读懂了这篇文章,下次面试官问你的时候,你就能够不必傻乎乎地说:“援用法和标记法”。而是能够更全面地,更粗疏地驯服面试官了。

后续会出一篇讲 我的项目中内存透露 的文章,敬请期待!!!

我是林三心,一个热心的前端菜鸟程序员。如果你上进,喜爱前端,想学习前端,那咱们能够交朋友,一起摸鱼哈哈,摸鱼群,加我请备注【思否】

正文完
 0