面试必备!浏览器v8引擎知识掌握

2,155 阅读8分钟

一、浏览器中的EventLoop

  • 原理
  • 看代码说出执行结果

js是一个单线程语言,所以所有的任务只能排队一个一个去做,这样效率明显很低。 所以event loop就是为解决这个问题而提出的。

在主程序中,分为两个线程,一个运行程序本身,称作主线程,另一个负责主线程和其它线程进行通信(主要是I/O操作),被称作event loop线程

在运行程序的时候,当遇到I/O操作的时候,主线程就让Event loop 线程通知相应的I/O模块去执行,然后主线程接着执行之后的代码,等到I/O结束后,event loop线程再把运行结果交给主线程,然后主线程再执行相应的回调,整个任务结束。

宏任务 微任务

为了让这些任务在主线程上有条不紊的进行,V8采用队列的方式对这些任务进行存储,然后一一取出执行,其中包含了两种任务队列,除了上述提到的任务队列, 还有一个延迟队列,它专门处理诸如setTimeout/setInterval这样的定时器回调任务。 这两种任务队列里的任务都是宏任务

微任务 通常为 应当发生在当前脚本执行完后的事情 做安排,比如对一系列操作做出反应,或者让某些事情异步但是不承担宏任务的代价

微任务的执行有两种方案,一种是等所有宏任务实行完毕然后依次执行微任务,另一种是在执行完一个宏任务之后,检查微任务队列,如果不为空则依次执行完微任务,然后再执行宏任务。

显然后者更满足需求,否则回调迟迟得不到执行,会造成应用卡顿。

nextTick

process.nextTick 是一个独立于 eventLoop 的任务队列。

在每一个 eventLoop 阶段完成后会去检查这个队列,如果里面有任务,会让这部分任务优先于微任务执行。

宏任务 微任务有哪些

常见的宏任务有:setTimeout setTimeInterval 常见的微任务有:"MutationObserver、Promise.then(或.reject) 以及以 Promise 为基础开发的其他技术(比如fetch API), 还包括 V8 的垃圾回收过程"

图源链接

练习1:

console.log('start');
setTimeout(() => {
  console.log('timeout');
});
Promise.resolve().then(() => {
  console.log('resolve');
});
console.log('end');
//start
//end
//resolve
//timeout
  1. 首先整个脚本作为宏任务开始执行,遇到同步代码直接执行
  2. 打印start
  3. 将settimeout放入宏任务队列
  4. 将Promise.resolve放入微任务队列
  5. 打印end
  6. 执行所有微任务,打印resolve
  7. 执行宏任务,打印timeout

练习2

setTimeout( () => console.log(4))

new Promise(resolve => {
  resolve()
  console.log(1)
}).then(_ => {
  console.log(3)
})

console.log(2)

//1 2 3 4

也就是说 new Promise在实例化的过程中所执行的代码都是同步进行的,而then中注册的回调才是异步执行的。

在同步代码执行完成后才会去检查是否有异步任务完成,并执行对应的回调,而微任务又会在宏任务之前执行

二、 node.js中的eventloop

各个阶段的意义:

  • timer 执行setTimeout\setInterval的回调
  • I/O回调 执行除close timer setImmediate之外的所有回调
  • idle,prepare: 空闲时间,供内部使用
  • poll 轮询,获取新的I/O事件,node在适当情况下会阻塞在这里
  • check: 执行setImmediate()设定的回调。
  • close callbacks: 执行比如socket.on('close', ...)的回调。

process.nextTick

nextTick拥有自己的队列,并且独立于eventLoop,在每个阶段结束后都会检查这个队列,并清空。

nodejs 和 浏览器关于eventLoop的主要区别

两者最主要的区别在于浏览器中的微任务是在每个相应的宏任务中执行的,而node.js中的微任务是在不同阶段之间执行的。

例子:

setTimeout(()=>{
    console.log('timer1')
    Promise.resolve().then(function() {
        console.log('promise1')
    })
}, 0)
setTimeout(()=>{
    console.log('timer2')
    Promise.resolve().then(function() {
        console.log('promise2')
    })
}, 0)

浏览器执行顺序

timer1-->promise1--->timer2-->promise2

nodejs执行顺序

timer1-->timer2--->promise1--->promise2

  1. 全局脚本(main())执行,将2个timer依次放入timer队列,main()执行完毕,调用栈空闲,任务队列开始执行;

  2. 首先进入timers阶段,执行timer1的回调函数,打印timer1,并将promise1.then回调放入microtask队列,同样的步骤执行timer2,打印timer2;

  3. 至此,timer阶段执行结束,event loop进入下一个阶段之前,执行microtask队列的所有任务,依次打印promise1、promise2

话术:

浏览器eventloop:

js是一个单线程语言,按顺序执行,但是如果碰到一个执行时间很长的异步任务的时候,后面的代码就会被阻塞,所以,采用队列来存储这些任务,等到他们返回结果的时候,js主线程再回来处理,这些队列中的任务就是宏任务,然后每个宏任务对应一个微任务队列,微任务就是来解决异步回调问题的,每次执行完宏任务之后,就会检查这个微任务队列,如果有则全部取出执行,没有的话执行下一个宏任务。

node eventloop:

node中的eventloop是分阶段执行的,timer主要执行settimeout setinterval的回调,回调阶段就是执行除了timer 、close 、 setImmediate的回调,然后有一个空闲时间供node内部使用,然后poll检查有没有新的io事件,如果有用setImmediate的,就会跳到check阶段执行其回调,然后执行close相关的回调,另外process.nextTick()会在每个阶段切换之间调用

三、 js的垃圾回收机制

了解垃圾回收之前,我们有必要了解一下js中的数据是如何存储的。 js数据分为基本类型和引用类型,其中前者采用这个数据结构来存储,后者采用这个数据结构来存储,所以存储形式的不同导致了它们的回收机制也有所区别。

栈回收

首先系统栈用来存储变量,它还有一个特点就是通过移动栈顶指针可以切换执行上下文,比如看下面这段代码

function f(a) {
  console.log(a);
}

function func(a) {
  f(a);
}

func(1);
  • 在调用func函数后,将func压入栈中,esp为栈顶指针,此时指向func
  • 然后进入内部,执行f(a),然后把f也压入栈中,esp上移
  • 执行完f(a)后指针下移,f被回收
  • 执行完func,然后再下移,func被回收

我们可以知道,栈的回收机制比较简单,就是在切换执行上下文的时候,栈顶内容自动被回收,这就是栈的垃圾回收机制

堆回收

引用类型数据用来存储,你可能会有疑问,为什么不都用栈来存储呢,因为引用类型数据相较而言比较复杂,切换上下文的开销将变得巨大,所以用堆来存储,但与此同时堆的垃圾回收开销也很大。

在堆中,我们把存储空间分为两个部分,分别是新生代和老生代,老生代就是一些常驻内存,存活时间长,新生代就是临时分配的内存,存活时间短,且新生代内存比老生代小得多。

新生代内存回收

新生代内存里也分为两个部分,我们称一个叫from 一个叫 to

from表示当前正在使用的内存,to空闲内存。

在垃圾回收的时候,会遍历from里的内存,将还存活的变量移动到to中,非存活的直接回收。

为什么不直接在from里回收呢?因为直接回收的话,势必会形成一段一段的不连续的空间,学过操作系统的朋友都知道(当然没学过的话也很容易理解),每个变量都是分配给能容纳它容量的一段连续存储空间,有时候虽然剩余容量足以容纳,但都是分散的,那也无法使用,所以这么多不连续的空间非常影响内存的利用率。

所以我们移动到to中,进行规整,统一排列,然后二者角色调换,此时to就是from,from就成了to,如此循环,这种算法就是Scavenge算法

有的时候遍历了好几轮,我们发现总有几个“老赖”一直还存货在内存里,这样这些“老赖”就晋升为老生代内存的成员了。

老生代内存回收

新生代内存晋升老生代的条件:

  • 经历过一次Scavenge回收
  • to内存里空间占用超过25%

老生代内存的空间比新生代要大得多,所以不能够继续使用上面的算法进行处理,v8采用的方法就是标记法,顾名思义就是先遍历一圈给每个变量都打上标记,然后发现变量还存活就将其标记取消,剩下的有标记的对象直接回收即可。

然后不可避免的会出现内存碎片,v8的处理也很直接,直接统一移动到一头。

这些移动的工作量也是回收中最费时的一部分。

增量标记

js是单线程语言,在垃圾回收的时候就会阻塞主线程的进行,所以v8对此也采取了一些措施,采用增量标记的方法,其实说白了就是在标记阶段:标记一段然后歇会让主线程执行,标记阶段结束之后,进入循环进行内存碎片的整理。