事件循环和垃圾回收的工作机制

本文已参与掘金创作者训练营第三期「话题写作」赛道,详情查看:掘力计划|创作者训练营第三期正在进行,「写」出个人影响力

前言

JavaScript 是一门 单线程 语言,即同一时间只能执行一个任务,即代码执行是同步并且阻塞的。

这就像一条单行道,车辆需要一辆接着一辆的排队前行。

只能同步执行肯定是有问题的,所以 JS 有了一个用来实现异步的函数:setTimeout

下面要讲的 Event Loop 就是为了确保 异步代码 可以在 同步代码 执行后继续执行的。
在介绍 Event Loop之前 先了解下 队列 栈 堆

队列(Queue)

队列 是一种 FIFO(First In, First Out) 的数据结构,它的特点就是 先进先出

生活中最常见的例子就是排队啦,排在队伍最前面的人最先被提供服务。

栈(Stack)

 是一种 LIFO(Last In, First Out)的数据结构,特点即 后进先出

就像桶装的羽毛球,在包装的时候只能从顶部放入,而拿的时候也只能从顶部拿出,这就叫后进先出

是临时存储空间,主要存储局部变量和函数调用。

基本类型数据Number, Boolean, String, Null, Undefined, Symbol, BigInt)保存在在栈内存中。
引用类型数据保存在堆内存中,引用数据类型的变量是一个指向堆内存中实际对象的引用,存在栈中。

基本类型赋值,系统会为新的变量在栈内存中分配一个新值,这个很好理解。引用类型赋值,系统会为新的变量在栈内存中分配一个值,这个值仅仅是指向同一个对象的引用,和原对象指向的都是堆内存中的同一个对象。

对于函数,解释器创建了”调用栈“来记录函数的调用过程。每调用一个函数,解释器就可以把该函数添加进调用栈,解释器会为被添加进来的函数创建一个栈帧(用来保存函数的局部变量以及执行语句)并立即执行。如果正在执行的函数还调用了其他函数,新函数会继续被添加进入调用栈。函数执行完成,对应的栈帧立即被销毁。

栈虽然很轻量,在使用时创建,使用结束后销毁,但是不是可以无限增长的,被分配的调用栈空间被占满时,就会引起”栈溢出“的错误。

为什么基本数据类型存储在栈中,引用数据类型存储在堆中?

JavaScript引擎需要用栈来维护程序执行期间的上下文的状态,如果栈空间大了的话,所有数据都存放在栈空间里面,会影响到上下文切换的效率,进而影响整个程序的执行效率。

堆空间存储的数据比较复杂,大致可以划分为下面 5 个区域:代码区(Code Space)、Map 区(Map Space)、大对象区(Large Object Space)、新生代(New Space)、老生代(Old Space)。本篇文章主要讨论新生代和老生代的内存回收算法。

新生代内存是临时分配的内存,存活时间段,老生代内存是常驻内存,存活时间长。

Event Table

Event Table 可以理解成一张 事件->回调函数 对应表

它就是用来存储 JavaScript 中的异步事件 (request, setTimeout, IO等) 及其对应的回调函数的列表

Event Queue

Event Queue 简单理解就是 回调函数 队列,所以它也叫 Callback Queue

消息队列称为任务队列,或者叫事件队列,总之是和异步任务相关的队列,可以确定的是,它是队列这种先入先出的数据结构,和排队是类似的,哪个异步操作完成的早,就排在前面。不论异步操作何时开始执行(这个执行是指注册函数执行),只要异步操作执行完成,就可以到消息队列中排队(这个消息就是指回调函数),这样,主线程在空闲的时候,就可以从消息队列中获取消息并执行(回调函数加入到消息队列,并执行)

当 Event Table 中的事件被触发,事件对应的 回调函数 就会被 push 进这个 Event Queue,然后等待被执行

任务队列分为 macrotask queue (宏任务)和microtask queue(微任务),当 macrotask queue(宏任务) 空了(都处理完了)就开始处理 microtask queue(微任务),并且依次就将所有 microtask queue 都处理完(类似将 microtask queue 的所有任务合成为一个当 macrotask)

宏任务:script(全局任务), setTimeout, setInterval, setImmediate, I/O, UI rendering;

微任务:process.nextTick, Promise, Object.observer, MutationObserver;

Event Loop

事件循环
HTML标准中是这样解释的:为了协调事件(event),用户交互(user interaction),脚本(script),渲染(rendering),网络(networking)等,用户代理(user agent)必须使用事件循环(event loops)。每个代理都有一个关联的事件循环。大体意思就是浏览器运行时有一个叫事件循环的机制。

一个事件循环里有很多个任务队列(task queues),根据任务的来源,将任务分配到不同的任务队列里。主线程循环不断从任务队列中读取事件。
Event Loop 的执行顺序如下所示:

  1. 在执行栈中执行一个宏任务。
  2. 执行过程中遇到微任务,将微任务添加到微任务队列中。
  3. 当前宏任务执行完毕,立即执行微任务队列中的任务。
  4. 当前微任务队列中的任务执行完毕,检查渲染,GUI线程接管渲染。
  5. 渲染完毕后,js线程接管,开启下一次事件循环,执行下一次宏任务(事件队列中取)

垃圾回收

新生代内存回收

新生代中用 Scavenge 算法来处理。所谓 Scavenge 算法,是把新生代空间对半划分为两个区域,一半是对象区域(from),一半是空闲区域 (to)。

新的对象会首先被分配到 from 空间,当进行垃圾回收的时候,会先将 from 空间中的 存活的对象复制到 to 空间进行保存,对未存活的对象的空间进行回收。
复制完成后, from 空间和 to 空间进行调换,to 空间会变成新的 from 空间,原来的 from 空间则变成 to 空间。这种算法称之为 ”Scavenge“。

新生代内存回收频率很高,速度也很快,但是空间利用率很低,因为有一半的内存空间处于”闲置”状态。

老生代内存回收

新生代中多次进行回收仍然存活的对象会被转移到空间较大的老生代内存中,这种现象称为晋升。以下两种情况

  1. 在垃圾回收过程中,发现某个对象之前被清理过,那么将会晋升到老生代的内存空间中
  2. 在 from 空间和 to 空间进行反转的过程中,如果 to 空间中的使用量已经超过了 25% ,那么就讲 from 中的对象直接晋升到老生代内存空间中。

因为老生代空间较大,如果仍然用 Scavenge 算法来频繁复制对象,那么性能开销就太大了。

标记-清除(Mark-Sweep)

老生代采用的是”标记清除“来回收未存活的对象。

分为标记和清除两个阶段。标记阶段会遍历堆中所有的对象,并对存活的对象进行标记,清除阶段则是对未标记的对象进行清除。

标记-整理(Mark-Compact)

标记清除不会对内存一分为二,所以不会浪费空间。但是经过标记清除之后的内存空间会生产很多不连续的碎片空间,这种不连续的碎片空间中,在遇到较大的对象时可能会由于空间不足而导致无法存储。

为了解决内存碎片的问题,需要使用另外一种算法 – 标记-整理(Mark-Compact) 。标记整理对待未存活对象不是立即回收,而是将存活对象移动到一边,然后直接清掉端边界以外的内存。

增量标记

为了避免出现JavaScript应用程序与垃圾回收器看到的不一致的情况,进行垃圾回收的时候,都需要将正在运行的程序停下来,等待垃圾回收执行完成之后再回复程序的执行,这种现象称为“全停顿”。如果需要回收的数据过多,那么全停顿的时候就会比较长,会影响其他程序的正常执行。

为了避免垃圾回收时间过长影响其他程序的执行,V8将标记过程分成一个个小的子标记过程,同时让垃圾回收和JavaScript应用逻辑代码交替执行,直到标记阶段完成。我们称这个过程为增量标记算法。

通俗理解,就是把垃圾回收这个大的任务分成一个个小任务,穿插在 JavaScript任务中间执行,这个过程其实跟 React Fiber 的设计思路类似。


以上就是本篇的全部内容了,非常感谢帅哥美女们能看到这里,如果这个文章写得还不错或者对你有一点点帮助,求点赞,求关注,求分享,当然有任何问题可以在评论讨论,我都会积极回答的,再次感谢?

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享