v8引擎相关知识

作用域

作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。

全局作用域 函数作用域 块级作用域

块级作用域通过 词法环境(Lexical Environment) 实现,通过 const let 声明的变量具有块级作用域,只能在包含他们的代码块中访问。如果在顶级使用 let const,他么们不会被添加到 window 对象上,但是会在全局作用域中

词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。词法作用域是代码编译阶段就决定好的,和函数是怎么调用的没有关系。

作用域链

其实在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为 outer。

JavaScript 引擎首先会在当前的执行上下文中查找该变量,如果在当前的变量环境中没有查找到,那么 JavaScript 引擎会继续在 outer 所指向的执行上下文中查找。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function foo() {
var a = 1;
let b = 2;
{
let b = 3;
var c = 4;
let d = 5;
console.log(a);
console.log(b);
}
console.log(b);
console.log(c);
console.log(d);
}
foo();
  • 进行词法分析,a,c 加入到变量环境 Variable Environment = {a:undefined,c:undefined}
    b 会加入到词法环境,解析块级作用域中的代码,需要开辟新的词法环境 Lexical Environment = {b:不可达}=> {b:不可达,d:不可达}

    let 只会在执行的时候赋值,在词法分析阶段会被识别,但是不能访问,也就是暂时性死区(TDZ,Temporal Dead Zone)

  • 函数执行,a,b 被赋值,Variable Environment = {a:1,c:undefined}Lexical Environment = {b:2} => {b:3,d:5}
    会先查找词法环境,在查找变量环境

闭包

在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。

如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量。

执行上下文

执行上下文负责运行时代码的管理,包括作用域,变量,对象,函数生命周期。

  • 创建阶段,确定 this,创建变量环境,创建词法环境。
  • 执行阶段,函数内部代码开始执行,变量赋值,函数引用和执行。
  • 回收阶段,当函数执行完毕后,从相应的栈中弹出,资源被释放。

内存

JavaScript 是一种弱类型的、动态的语言.

  • 弱类型,意味着你不需要告诉 JavaScript 引擎这个或那个变量是什么数据类型,JavaScript 引擎在运行代码的时候自己会计算出来。
  • 动态,意味着你可以使用同一个变量保存不同类型的数据。

数据科技分为原始类型引用类型

原始类型的数据保存在环境变量或词法环境中,包含这两个区域的执行上下文又被压入调用栈中,所以可以说原始类型是保存在栈空间中的.

字符串,symbol,bigint 虽然是原始类型,实际还是存放在堆空间的。

如果是一个引用类型,会单独存放到堆空间中,在分配了引用类型数据之后会拿到一个堆中的地址,在把这个地址保存到环境变量中.

JavaScript 引擎需要用栈来维护程序执行期间上下文的状态,如果栈空间大了话,所有的数据都存放在栈空间里面,那么会影响到上下文切换的效率,进而又影响到整个程序的执行效率。JavaScript 引擎需要离开当前的执行上下文,只需要将指针下移到上个执行上下文的地址就可以了.

所以通常情况下,栈空间都不会设置太大,主要用来存放一些原始类型的小数据。而引用类型的数据占用的空间都比较大,所以这一类数据会被存放到堆中,堆空间很大,能存放很多大的数据,不过缺点是分配内存和回收内存都会占用一定的时间。

原始类型的赋值会完整复制变量值,而引用类型的赋值是复制引用地址。

从内存的角度来理解闭包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function foo() {
let name = "one";
const obj = {
getName() {
return name;
},
setName(_name) {
name = _name;
},
};
return obj;
}

var bar = foo();
bar.setName("two");
bar.getName();

当 foo 函数执行的时候,首先是编译的过程,当遇到对象的两个方法时还需要对两个方法进行词法分析, 发现引用了 name 变量.

JavaScript 引擎判断这是一个闭包,于是在堆空间创建换一个“closure(foo)”的对象(这是一个内部对象,JavaScript 是无法访问的),用来保存 name 变量。并把堆空间的地址保存在 foo 执行上下文中的环境变量中.

垃圾回收

分为栈内存回收,堆内存回收

栈内存回收需要用到一个记录当前执行状态的指针(称为 ESP),指向的就是当前的执行上下文,JavaScript 引擎会通过向下移动 ESP 来销毁该函数保存在栈中的执行上下文。当下移之后如果有新的函数调用,原来的内存位置就会写入新的执行上下文.

回收堆中的垃圾数据,就需要用到 JavaScript 中的垃圾回收器,垃圾回收的策略都是建立代际假说的基础之上的

代际假说有以下两个特点:

  • 第一个是大部分对象在内存中存在的时间很短,简单来说,就是很多对象一经分配内存,很快就变得不可访问;
  • 第二个是不死的对象,会活得更久。

需要根据对象变量的不同生命周期长短使用不同的策略:

在 V8 中会把堆分为新生代老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放的生存时间久的对象。新生区通常只支持 1 ~ 8M 的容量.副垃圾回收器,主要负责新生代的垃圾回收。主垃圾回收器,主要负责老生代的垃圾回收。

垃圾回收大致都分为一下几个步骤:

  • 标记活动对象和非活动对象
  • 清楚非活动对象
  • 内存整理,并不是所有的回收策略都需要这一步

副垃圾回收器主要负责新生区的垃圾回收. 大多数小的对象都会被分配到新生区,这个区域虽然不大,但是垃圾回收还是比较频繁的。用 Scavenge[ˈskævɪndʒ] 算法来处理.原理是:

把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区.

新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作。在垃圾回收过程中,首先要对对象区域中的垃圾做标记;标记完成之后,就进入垃圾清理阶段,副垃圾回收器会把这些存活的对象复制到空闲区域中,同时它还会把这些对象有序地排列起来,所以这个复制过程,也就相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了。

完成复制后,对象区域与空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。这样就完成了垃圾对象的回收操作,同时这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去。

由于新生代中采用的 Scavenge 算法,所以每次执行清理操作时,都需要将存活的对象从对象区域复制到空闲区域。但复制操作需要时间成本,如果新生区空间设置得太大了,那么每次清理的时间就会过久,所以为了执行效率,一般新生区的空间会被设置得比较小。

也正是因为新生区的空间不大,所以很容易被存活的对象装满整个区域。为了解决这个问题,JavaScript 引擎采用了对象晋升策略,也就是经过两次垃圾回收依然还存活的对象,会被移动到老生区中。

主垃圾回收器 主要负责老生区中的垃圾回收。

除了新生区中晋升的对象,一些大的对象会直接被分配到老生区。因此老生区中的对象有两个特点,一个是对象占用空间大,另一个是对象存活时间长。

,若要在老生区中使用 Scavenge 算法进行垃圾回收,复制这些大的对象将会花费比较多的时间,从而导致回收执行效率不高,同时还会浪费一半的空间。因而,主垃圾回收器是采用标记 - 清除(Mark-Sweep)的算法进行垃圾回收的。

标记阶段就是从一组根元素(调用栈)开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。

因为清除部分内存会产生碎片,而碎片过多会导致大对象无法分配到足够的连续内存,于是又产生了另外一种算法——标记 - 整理(Mark-Compact),这个标记过程仍然与标记 - 清除算法里的是一样的,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

全停顿,内存回收过程过长,又因为 JS 为单线程而阻塞 JS 执行,这个问题叫全停顿.

对于新生代的内存回收影响不大,因为内存比较小,可以全停顿等待执行完成.对于老生代会将标记过程分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成,我们把这个算法称为增量标记(Incremental Marking)算法

使用增量标记算法,可以把一个完整的垃圾回收任务拆分为很多小的任务,这些小的任务执行时间比较短,可以穿插在其他的 JavaScript 任务中间执行,这样当执行上述动画效果时,就不会让用户因为垃圾回收任务而感受到页面的卡顿了。

JS 如何执行

由解释型语言编写的程序,在每次运行时都需要通过解释器对程序进行动态解释和执行。

编译型语言在程序执行之前,需要经过编译器的编译过程,并且编译之后会直接保留机器能读懂的二进制文件,这样每次运行程序时,都可以直接运行该二进制文件,而不需要再次重新编译了

字节码就是介于 AST 和机器码之间的一种代码。但是与特定类型的机器码无关,字节码需要通过解释器将其转换为机器码后才能执行。

如果有一段第一次执行的字节码,解释器 Ignition 会逐条解释执行。解释器 Ignition 除了负责生成字节码之外,它还有另外一个作用,就是解释执行字节码。在 Ignition 执行字节码的过程中,如果发现有热点代码(HotSpot),比如一段代码被重复执行多次,这种就称为热点代码,那么后台的编译器 TurboFan 就会把该段热点的字节码编译为高效的机器码,然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,这样就大大提升了代码的执行效率。

字节码配合解释器和编译器是最近一段时间很火的技术,比如 Java 和 Python 的虚拟机也都是基于这种技术实现的,我们把这种技术称为即时编译(JIT)。

处理新任务

当一个线程在执行任务的时候,如何可以处理新任务,最简单的办法就是通过一个循环,不断检测是否有新的任务产生。

这样可以解决同一个线程中产生的新任务,但是无法解决其他线程中产生的新任务。因为没有办法直接检测其他线程是否有新任务的产生。

通用的模型就是消息队列,息队列是一种数据结构,可以存放要执行的任务。它符合队列“先进先出”的特点,也就是说要添加任务的话,添加到队列的尾部;要取出任务的话,从队列头部去取。

渲染进程专门有一个 IO 线程用来接收其他进程传进来的消息,接收到消息之后,会将这些消息组装成任务发送给渲染主线程

任务类型

任务类型包括, 输入事件(鼠标滚动、点击、移动)、微任务、文件读写、WebSocket、JavaScript 定时器等等。

除此之外,消息队列中还包含了很多与页面相关的事件,如 JavaScript 执行、解析 DOM、样式计算、布局计算、CSS 动画等。

以上这些事件都是在主线程中执行的,所以在编写 Web 应用时,你还需要衡量这些事件所占用的时长,并想办法解决单个任务占用主线程过久的问题。

高优先级任务

一个典型的场景是监听 DOM 的改变做一些逻辑处理,如果不加入消息队列选择同步处理,在 DOM 频繁改变的时候,当前任务会被延长,导致后面的任务不能及时处理。

如果加入到消息队尾部,又可能影响效率,因为可能已经有很多任务在排队了。

针对这种情况微任务就产生了,通常我们把消息队列中的任务称为宏任务,每个宏任务中都包含了一个微任务队列,在执行宏任务的过程中,如果 DOM 有变化,那么就会将该变化添加到微任务列表中,等宏任务中的主要功能都直接完成之后,这时候,渲染引擎并不着急去执行下一个宏任务,而是执行当前宏任务中的微任务,因为 DOM 变化的事件都保存在这些微任务队列中,这样也就解决了实时性问题。

事件循环

浏览器处理消息队列用到了事件循环系统,但这个事件循环与 nodejs 事件循环没有关系。

  • V8: V8 引擎自己实现了一个事件循环,但是 nodejs 和 浏览器都没有采用
  • 浏览器: 不同的厂商实现可能不同,chrome 浏览器使用 libevent 实现事件循环。
  • nodejs: 使用 libuv 实现事件循环。

setTimeout 如何实现

从使用方式上能感觉到,setTimeout 需要等待指定时间才能执行,而消息队列中的任务是立即执行的,所以 setTimeout 中的回调函数不能立即加入到消息队列中。

所以浏览器还维护着一个延时任务列表,包括定时器和内部一些延时任务,创建一个定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中。这个回调任务包括,定义的回调函数,发起时间,延时时间。

在处理消息队列的时候,会调用出延时任务的方法 ProcessTimerTask 。这个方法的调用时机是当前事件循环中一个任务处理结束后开始执行。

ProcessDelayTask 函数会根据发起时间和延迟时间计算出到期的任务,然后依次执行这些到期的任务。等到期的任务执行完成之后,再继续下一个循环过程。

在 Chrome 中,定时器被嵌套调用 5 次以上,系统会判断该函数方法被阻塞了,在 5 次调用之后,如果定时器的调用时间间隔小于 4 毫秒,那么浏览器会将每次调用的时间间隔设置为 4 毫秒。

未被激活的页面中定时器最小值大于 1000 毫秒,也就是说,如果标签不是当前的激活标签,那么定时器最小的时间间隔是 1000 毫秒,目的是为了优化后台页面的加载损耗以及降低耗电量。

Chrome、Safari、Firefox 都是以 32 个 bit 来存储延时值的,32bit 最大只能存放的数字是 2147483647 毫秒,这就意味着,如果 setTimeout 设置的延迟值大于 2147483647 毫秒(大约 24.8 天)时就会溢出,那么相当于延时值被设置为 0 了,这导致定时器会被立即执行。

XMLHttpRequest 实现流程

渲染进程会将请求发送给网络进程,然后网络进程负责资源的下载,等网络进程接收到数据之后,就会利用 IPC 来通知渲染进程;渲染进程接收到消息之后,会将 xhr 的回调函数封装成任务并添加到消息队列中,等主线程循环系统执行到该任务的时候,就会根据相关的状态来调用对应的回调函数。

宏任务和微任务

前面已经介绍过微任务的由来。

宏任务:

  • 渲染事件(如解析 DOM、计算布局、绘制);
  • 用户交互事件(如鼠标点击、滚动页面、放大缩小等);
  • JavaScript 脚本执行事件;
  • 网络请求完成
  • 文件读写完成事件

WHATWG 规范中定义事件循环机制:

  • 先从多个消息队列中选出一个最老的任务,这个任务称为 oldestTask;
  • 然后循环系统记录任务开始执行的时间,并把这个 oldestTask 设置为当前正在执行的任务;
  • 当任务执行完成之后,删除当前正在执行的任务,并从对应的消息队列中删除掉这个 oldestTask;
  • 最后统计执行完成的时长等信息。

由于宏任务不能精细的控制执行的时机,因为两个红任务之间可能被插入了很多系统级的任务。

微任务:

微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。

  • MutationObserver 监控某个 DOM 节点,然后再通过 JavaScript 来修改这个节点,或者为这个节点添加、删除部分子节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务。
  • Promise 当调用 Promise.resolve() 或者 Promise.reject() 的时候,也会产生微任务。

在当前宏任务中的 JavaScript 快执行完成时,也就在 JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。WHATWG 把执行微任务的时间点称为检查点。

如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行。

  • 微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列。
  • 微任务的执行时长会影响到当前宏任务的时长。
  • 在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行。

通过异步操作解决了同步操作的性能问题;通过微任务解决了实时性的问题。

W3C 最新解释:

  • 每一个任务都有一个任务类型,同一个类型的任务必须在一个队列,不同类型的任务可以分属于不同的队列,在一次事件循环中,浏览器可以根据实际情况从不同的队列中取出任务执行。

  • 浏览器必须准备好一个微队列,微队列中的任务优先所有其他任务执行。

因此现在 chrome 中至少有 3 个队列:

  • 延时队列: 用于存放定时器到达后的回调任务,优先级中
  • 交互任务: 用于存放用户交互产生的任务,优先级高
  • 微队列: 用于存放最快需要执行的任务,优先级最高

Promise.then 返回 Promise 的行为

当一个 promise.then 方法返回另一个 Promise 时,thenable 的状态会吸收返回的 promise 的状态,也就是说 thenable 的状态与返回的 promise 状态保持一致

但是这种状态并不是立即吸收的,返回的 promise 会被使用 then 方法,包装成新的 promise 对象,并添加到微队列中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
Promise.resolve()
.then(() => {
//1 添加到微队列
console.log(0);

//3 打印0
//4 包装成 (Promise.resolve(4).then(res=>res)).then(res=>res) => p4.then(res=>res)添加到微队列
//7 res=>res 添加到微队列
return Promise.resolve(4).then((res) => res);
//10 完成状态
})
.then((res) => {
//11 添加到微队列
console.log(res);
//14 打印4
});

Promise.resolve()
.then(() => {
//2 添加到微队列
console.log(1);
//5 打印1
})
.then(() => {
//6 添加到微队列
console.log(2);
//8 打印2
})
.then(() => {
//9 添加到微队列
console.log(3);
//12 打印3
})
.then(() => {
//13 添加到微队列
console.log(5);
//15 打印3
});
打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2015-2025 SunZhiqi

此时无声胜有声!

支付宝
微信