图解 Google V8

2022/1/13

笔记

# V8 知识图谱

# V8 编译流水线

  • V8编译流水线

# 01 | V8 是如何执行一段 js 代码的

# 代码是怎么执行的:

  1. 高级语言 ==输入==> 解析器 ==解析==> 中间代码 ==解释==> 解释器 ==执行==> 结果
  2. 高级语言 ==输入==> 解析器 ==解析==> 中间代码 ==输入==> 编译器 ==编译==> 机器代码/CPU 指令集 ==执行==> 结果

    通常编译成的机器代码是以二进制文件形式存储的,需要执行这段程序的时候直接执行二进制文件就可以了。还可以使用虚拟机将编译后的机器代码保存在内存中,然后直接执行内存中的二进制代码。

# JavaScript 虚拟机/引擎

  • 苹果公司在 Safari 中就是用 JavaScriptCore 虚拟机,Firefox 使用了 TraceMonkey 虚拟机,而 Chrome 则使用了 V8 虚拟机。
  • 想知道如何安装 v8 的同学可以参考这个链接 (opens new window):https://gist.github.com/kevincennis/0cd2138c78a07412ef21

# V8

  • V8 并没有采用某种单一的技术,而是混合编译执行和解释执行这两种手段,我们把这种混合使用编译器和解释器的技术称为 JIT(Just In Time)技术。
  • V8编译流水线
  • 如上图,在解释器附近的监控机器人是一个监控解释器执行状态的模块,在解释执行字节码的过程中,如果发现了某一段代码会被重复多次执行,那么监控机器人就会将这段代码标记为热点代码。当某段代码被标记为热点代码后,V8 就会将这段字节码丢给优化编译器,优化编译器会在后台将字节码编译为二进制代码,然后再对编译后的二进制代码执行优化操作,优化后的二进制机器代码的执行效率会得到大幅提升。
  • 如果下面再执行到这段代码时,那么 V8 会优先选择优化之后的二进制代码,这样代码的执行速度就会大幅提升。
  • 不过,和静态语言不同的是,JavaScript 是一种非常灵活的动态语言,对象的结构和属性是可以在运行时任意修改的,而经过优化编译器优化过的代码只能针对某种固定的结构,一旦在执行过程中,对象的结构被动态修改了,那么优化之后的代码势必会变成无效的代码,这时候优化编译器就需要执行反优化操作,经过反优化的代码,下次执行时就会回退到解释器解释执行。

主要流程如下:

  1. 初始化基础环境;
  2. 解析源码生成 AST 和作用域;
  3. 依据 AST 和作用域生成字节码;
  4. 解释执行字节码;
  5. 监听热点代码;
  6. 优化热点代码为二进制的机器代码;
  7. 反优化生成的二进制机器代码。

# 作用域和上下文

  • 执行上下文是运行代码时的基础环境,包括了变量环境,词法环境,this 值,外部环境等内容。
  • 全局执行上下文就是指全局代码执行时的运行环境。
  • 而作用域是一个抽象概念,它主要引用了执行上下文中的变量,以方便查找。

# 02 | 函数即对象

  • 在 js 中,函数是一种特殊的对象,是可以被赋值、作为参数,还可以作为返回值的,那么如果一个函数返回了另外一个函数,那么就应该返回该函数所有相关的内容。
  • 函数除了可以拥有常用类型的属性值之外,还拥有两个隐藏属性,分别是 name 属性和 code 属性。
  • 隐藏 name 属性的值就是函数名称,如果某个函数没有设置函数名,该函数对象的默认的 name 属性值就是 anonymous,表示该函数对象没有被设置名称。
  • 另外一个隐藏属性是 code 属性,其值表示函数代码,以字符串的形式存储在内存中。当执行到一个函数调用语句时,V8 便会从函数对象中取出 code 属性值,也就是函数代码,然后再解释执行这段函数代码。
  • 函数还有另外一个隐藏属性,那就是 prototype。
  • 一个函数到底关联了哪些内容:
    • 函数作为一个对象,它有自己的属性和值,所以函数关联了基础的属性和值;
    • 函数之所以成为特殊的对象,这个特殊的地方是函数可以“被调用”,所以一个函数被调用时,它还需要关联相关的执行上下文。

# 03 | 快属性和慢属性

# 常规属性 (properties) 和排序属性 (element)

  • 在 ECMAScript 规范中定义了数字属性应该按照索引值大小升序排列,字符串属性根据创建时的顺序升序排列。
  • 我们把对象中的数字属性称为排序属性,在 V8 中被称为 elements,字符串属性就被称为常规属性,在 V8 中被称为 properties。
  • 在 V8 内部,为了有效地提升存储和访问这两种属性的性能,分别使用了两个线性数据结构来分别保存排序属性和常规属性。
  • 也就是说,在 V8 内部,一个对象会自动包含两个隐藏属性:elements 属性和 properties 属性,elements 属性指向了 elements 对象,在 elements 对象中,会按照顺序存放排序属性,properties 属性则指向了 properties 对象,在 properties 对象中,会按照创建时的顺序保存了常规属性。
  • 分解成这两种线性数据结构之后,如果执行索引操作,那么 V8 会先从 elements 属性中按照顺序读取所有的元素,然后再在 properties 属性中读取所有的元素,这样就完成一次索引操作。

# 快属性和慢属性

  1. 线性结构:数组、栈、队列等;非线性结构:树、字典-hash 表等。
  2. 将不同的属性分别保存到 elements 属性和 properties 属性中,无疑简化了程序的复杂度,但是在查找元素时,却多了一步操作,因此会影响到元素的查找效率。
  3. 基于这个原因,V8 采取了一个权衡的策略以加快查找属性的效率,这个策略是将部分常规属性直接存储到对象本身,我们把这称为对象内属性 (in-object properties)
  4. 对象内属性的数量是固定的,默认是 10 个,超过了则会被保存在常规属性存储中。
  5. 将保存在线性数据结构中的属性称之为快属性,因为线性数据结构中只需要通过索引即可以访问到属性,虽然访问线性结构的速度快,但是如果从线性结构中添加或者删除大量的属性时,则执行效率会非常低,这主要因为会产生大量时间和内存开销。
  6. 一个对象的属性过多时,V8 就会采取另外一种存储策略,那就是慢属性策略,但慢属性的对象内部会有独立的非线性数据结构 (词典) 作为属性存储容器。所有的属性元信息不再是线性存储的,而是直接保存在属性字典中。
  7. 除了 elements 和 properties 属性,V8 还为每个对象实现了 map 属性和 __proto__ 属性,以及隐藏类(Hidden Class)。其中,隐藏类用于描述对象的结构。
  8. 几个问题:
  • element 没有内置。
    • element 默认应该采用连续的存储结构,通过浪费空间换取时间,直接下标访问,提升访问速度。
    • 但当 element 的序号十分不连续时,会优化成为 hash 表,因为要浪费的空间太大了,不合算。
  • property 默认采用链表结构
    • 当数据量很小时,查找也会很快,但数据量上升到某个数值后,会优化成为 hash 表。
    • 因为超过某个数值,顺序查找就不够快了,需要通过 hash 表结构查找,提升速度。
  • hash 表不是应该查找一次吗?为何是慢查询?
    • hash 表要解决 key 冲突问题,一般会用 list 存储多个冲突的 key,所以计算 hash 后,还是要做顺序访问,所以要多次访问。
    • 此外,还涉及到 hash 扩容的问题,那就更慢了。
    • 所以,整体上来说,hash 慢于按地址访问的;
    • 在数据量小的时候,也慢于链表的顺序访问。
  • hash 表如何存储 property 顺序?
    • 再用一个链表记录插入属性就好了,类似于 Java 中的 LinkedHashMap ,就可以解决问题

# 04 | 函数表达式

# 函数声明

  • 在编译阶段,将所有的变量提升到作用域的过程称为变量提升。
  • 如果是一个普通变量,变量提升之后的值都是 undefined,如果是声明的函数,那么变量提升之后的值则是函数对象。
  • var x 是在编译阶段完成的,也可以说是在变量提升阶段完成的,而 x = 5 是表达式,所有的表达式都是在执行阶段完成的。
  • 所以表达式是不会在编译阶段执行的。
  • 在变量提升阶段,V8 将这些变量存放在作用域时,还会给它们赋一个默认的 undefined 值
  • 函数声明并不是一个表达式,而是一个语句。V8 在变量提升阶段,如果遇到函数声明,那么 V8 同样会对该函数声明执行变量提升操作。
  • 函数也是一个对象,所以在编译阶段,V8 就会将整个函数对象提升到作用域中,而不是给该函数名称赋一个 undefined。

# 总结:

  • 在 V8 解析 JavaScript 源码的过程中,如果遇到普通的变量声明,那么便会将其提升到作用域中,并给该变量赋值为 undefined,如果遇到的是函数声明,那么 V8 会在内存中为声明生成函数对象,并将该对象提升到作用域中。

# 函数表达式

  • 因为函数立即表达式也是一个表达式,所以 V8 在编译阶段,并不会为该表达式创建函数对象。这样的一个好处就是不会污染环境,函数和函数内部的变量都不会被其他部分的代码访问到。

# 05 | 原型链

# 继承

  • 继承就是一个对象可以访问另外一个对象中的属性和方法,在 JavaScript 中,我们通过原型和原型链的方式来实现了继承特性。
  • 最典型的两种方式是基于类的设计和基于原型继承的设计。
  • 不要将原型链接和作用域链搞混淆了,作用域链是沿着函数的作用域一级一级来查找变量的,而原型链是沿着对象的原型一级一级来查找属性的,虽然它们的实现方式是类似的,但是它们的用途是不同的。
  • 在实际项目中,我们不应该直接通过 __proto__ 来访问或者修改该属性,其主要原因有两个:首先,这是隐藏属性,并不是标准定义的;其次,使用该属性会造成严重的性能问题。
  • 每个函数对象中都有一个公开的 prototype 属性,当你将这个函数作为构造函数来创建一个新的对象时,新创建对象的原型对象就指向了该函数的 prototype 属性。
  • 参考文章 (opens new window)

# 06 | 作用域链

  • 作用域就是存放变量和函数的地方,全局环境有全局作用域,全局作用域中存放了全局变量和全局函数。每个函数也有自己的作用域,函数作用域中存放了函数中定义的变量。
  • 作用域链就是将一个个作用域串起来,实现变量查找的路径。
  • 作用域链是基于调用栈的,而不是基于函数定义的位置的。
  • 因为 JavaScript 是基于词法作用域的,词法作用域就是指,查找作用域的顺序是按照函数定义时的位置来决定的。也就是说函数的作用域在函数声明时就已经确定了,除非使用 call/apply/bind 等改变它的 this 指向。
  • 因为词法作用域是根据函数在代码中的位置来确定的,作用域是在声明函数时就确定好的了,所以我们也将词法作用域称为静态作用域。

# 07 | 类型转换

# a+b

V8 会提供了一个 ToPrimitive 方法,其作用是将 a 和 b 转换为原生数据类型,其转换流程如下:

  1. 先检测该对象中是否存在 valueOf 方法,如果有并返回了原始类型,那么就使用该值进行强制类型转换;
  2. 如果 valueOf 没有返回原始类型,那么就使用 toString 方法的返回值;
  3. 如果 vauleOf 和 toString 两个方法都不返回基本类型值,便会触发一个 TypeError 的错误;
  4. toPrimitive 的第二个参数期望值,如果没填默认值是 Number,但是 date 类型的默认值是 String。Number 就是 valueOf 先调用,String 就是 toString 先调用。

综上:可以通过重写对象的 valueOf 和 toString 方法来实现一些骚操作

# 08 | 如何构建和使用 V8 的调试工具 d8

过...

# 09 | 运行时环境

在执行 JavaScript 代码之前,V8 就已经准备好了代码的运行时环境,这个环境包括了堆空间和栈空间、全局执行上下文、全局作用域、内置的内建函数、宿主环境提供的扩展函数和对象,还有消息循环系统。准备好运行时环境之后,V8 才可以执行 JavaScript 代码,这包括解析源码、生成字节码、解释执行或者编译执行这一系列操作。V8 自身会提供 JavaScript 的核心功能和垃圾回收系统。

除了浏览器可以作为 V8 的宿主环境,Node.js 也是 V8 的另外一种宿主环境,它提供了不同的宿主对象和宿主的 API,但是整个流程依然是相同的。

在 Chrome 中,只要打开一个渲染进程,渲染进程便会初始化 V8,同时初始化堆空间和栈空间,因为是一个进程内部的,所以宿主和 v8 共用一套内存空间。

栈空间主要是用来管理 JavaScript 函数调用的,栈是内存中连续的一块空间,同时栈结构是“先进后出”的策略。在函数调用过程中,涉及到上下文相关的内容都会存放在栈上,比如原生类型、引用到的对象的地址、函数的执行状态、this 值等都会存在在栈上,读取速度快。当一个函数执行结束,那么该函数的执行上下文便会被销毁掉。

堆空间是一种树形的存储结构,用来存储对象类型的离散的数据:一些占用内存比较大的数据,或者不需要存储在连续空间中的数据,读取速度慢。

执行上下文中主要包含三部分,变量环境、词法环境和 this 关键字。比如在浏览器的环境中,全局执行上下文中就包括了 window 对象,还有默认指向 window 的 this 关键字,另外还有一些 Web API 函数,诸如 setTimeout、XMLHttpRequest 等内容。而词法环境中,则包含了使用 let、const 等变量的内容。

全局执行上下文在 V8 的生存周期内是不会被销毁的,它会一直保存在堆中,这样当下次在需要使用函数或者全局变量时,就不需要重新创建了。

V8 还需要有一个主线程,用来执行 JavaScript 和执行垃圾回收等工作。V8 是寄生在宿主环境中的,它并没有自己的主线程,而是使用宿主所提供的主线程,V8 所执行的代码都是在宿主的主线程上执行的。

为了在执行完代码之后,让线程继续运行,通常的做法是在代码中添加一个循环语句,在循环语句中监听下个事件,即事件循环。如果没有任务,那么该线程将被挂起,一旦有新的任务到达了消息队列,那么系统会将这个挂起的线程激活,激活之后线程继续向下执行,并不会导致卡死。

# 10 | 机器代码

在执行代码时,V8 需要先将 JavaScript 编译成字节码,然后再解释执行字节码,或者将需要优化的字节码编译成二进制,并直接执行二进制代码。

代码经过编译之后生成二进制代码/机器码,实质就是一堆指令按照顺序集合在一起组成程序,所以程序的执行,本质上就是 CPU 按顺序执行这一大堆指令的过程。

  • 计算机系统的硬件组织结构

我们把取出指令、分析指令、执行指令这三个过程称为一个 CPU 时钟周期。

在执行指令的过程中,CPU 需要对数据执行读写操作,如果直接读写内存,那么会严重影响程序的执行性能,因此 CPU 就引入了寄存器,将一些中间数据存放在寄存器中,这样就能加速 CPU 的执行速度。

通用寄存器容量小,读写速度快,内存容量大,读写速度慢。

rbp 寄存器通常是用来存放栈帧指针的,rsp 寄存器用来存放栈顶指针的,PC 寄存器用来存放下一条要执行的指令等。

几种常用的指令类型:

  • 加载的指令,其作用是从内存中复制指定长度的内容到通用寄存器中,并覆盖寄存器中原来的内容。
  • 存储的指令,和加载类型的指令相反,其作用是将寄存器中的内容复制内存某个位置,并覆盖掉内存中的这个位置上原来的内容。
  • 更新指令,其作用是复制两个寄存器中的内容到 ALU 中,也可以是一块寄存器和一块内存中的内容到 ALU 中,ALU 将两个字相加,并将结果存放在其中的一个寄存器中,并覆盖该寄存器中的内容。
  • 跳转指令,从指令本身抽取出一个值,这个值是下一条要执行的指令的地址,并将该字复制到 PC 寄存器中,并覆盖掉 PC 寄存器中原来的值。那么当执行下一条指令时,便会跳转到对应的指令了。
  • IO 读 / 写指令,这些指令可以从一个 IO 设备中复制指定长度的数据到寄存器中,也可以将一个寄存器中的数据复制到指定的 IO 设备。

CPU 执行程序的过程:

  1. 首先,在程序执行之前,我们的程序需要被装进内存;
  2. CPU 从 PC 寄存器中拿到要执行的指令地址,并从内存中取出指令;
  3. 此时,系统会做两件事:1.是将下一条指令的地址更新到 PC 寄存器中;2.是分析取出的该指令,并识别出不同的类型的指令,以及各种获取操作数的方法;
  4. 然后执行该指令——通过加载、更新、存储、跳过等指令执行。这期间需要寄存器和内存之间传输数据,或者寄存器和寄存器之间传输数据。

# 11 | 堆和栈

# 为什么使用栈结构来管理函数调用?

  1. 第一个特点是函数可以被调用
  2. 第二个特点是函数具有作用域机制
  3. 函数调用者的生命周期总是长于被调用者(后进),并且被调用者的生命周期总是先于调用者的生命周期结束 (先出)。
  4. 站在函数资源分配和回收角度来看,被调用函数的资源分配总是晚于调用函数 (后进),而函数资源的释放则总是先于调用函数 (先出)。
  5. 函数的生命周期和函数的资源分配情况符合后进先出 (LIFO) 的策略,而栈结构正好满足这种后进先出 (LIFO) 的需求,所以我们选择栈来管理函数调用关系是一种很自然的选择。

# 恢复现场

函数 B 执行完成之后,需要将执行代码的控制权转交给 A 函数,这意味着需要将栈的状态恢复到 A 函数上次执行时的状态,我们把这个过程叫恢复现场。

在 esp 寄存器中存放一个永远指向当前栈顶的指针,栈顶指针的作用就是告诉你应该往哪个位置添加新元素。增减元素之后要将新元素的地址更新到 esp 寄存器中。

CPU 的解决方法是增加了另外一个 ebp 寄存器,用来保存当前函数的起始位置,我们把一个函数的起始位置也称为栈帧指针,ebp 寄存器中保存的就是当前函数的栈帧指针。

栈帧的概念:每个栈帧对应着一个未运行完的函数,栈帧中保存了该函数的返回地址和局部变量。

# 12 | 延迟解析:V8 是如何实现闭包的?

惰性解析是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成 AST 和字节码,而仅仅生成顶层代码的 AST 和字节码。利用惰性解析可以加速 JavaScript 代码的启动速度,如果要将所有的代码一次性解析编译完成,那么会大大增加用户的等待时间。

和 JavaScript 闭包相关的三个重要特性:

  1. 可以在 JavaScript 函数内部定义新的函数;
  2. 内部函数中访问父函数中定义的变量;
  3. 因为 JavaScript 中的函数是一等公民,所以函数可以作为另外一个函数的返回值。

V8 在执行 foo 父函数时需要判断 inner 子函数是否引用了 foo 函数中的变量,负责处理这个任务的模块叫做预解析器。

遇到一个函数,预解析器并不会直接跳过该函数,而是对该函数做一次快速的预解析:一是判断当前函数是不是存在一些语法上的错误;二是检查函数内部是否引用了外部变量,如果引用了外部的变量,预解析器会将栈中的变量复制到堆中,在下次执行到该函数的时候,直接使用堆中的引用,这样就解决了闭包所带来的问题。

# 13 | 字节码 1

# 早期 Chrome

  1. 早期的 V8 为了提升代码的执行速度,直接将 JavaScript 源代码编译成了没有优化的二进制的机器代码,如果某一段二进制代码执行频率过高,那么 V8 会将其标记为热点代码,热点代码会被优化编译器优化,优化后的机器代码执行效率更高。
  2. 将 JavaScript 源码直接编译成二进制代码存在两个致命的问题:
  • 时间问题:编译时间过久,影响代码启动速度;
  • 空间问题:缓存编译后的二进制代码占用更多的内存。

# 现在的 Chrome

  1. 引入了中间的字节码。字节码的优势有如下三点:
  • 解决启动问题:生成字节码的时间很短;
  • 解决空间问题:字节码占用内存不多,缓存字节码会大大降低内存的使用;
  • 代码架构清晰:采用字节码,可以简化程序的复杂度,使得 V8 移植到不同的 CPU 架构平台更加容易。

# 区别

  1. 生成机器代码比生成字节码需要花费更久的时间,但是直接执行机器代码却比解释执行字节码要更高效;
  2. 解释器可以快速生成字节码,但字节码通常效率不高。 相比之下,优化编译器虽然需要更长的时间进行处理,但最终会产生更高效的机器码,这正是 V8 在使用的模型。它的解释器叫 Ignition,(就原始字节码执行速度而言)是所有引擎中最快的解释器。V8 的优化编译器名为 TurboFan,最终由它生成高度优化的机器码。

# 14 | 字节码 2

# 解释器

  1. 通常有两种类型的解释器,基于栈 (Stack-based)和基于寄存器 (Register-based),基于栈的解释器使用栈来保存函数参数、中间运算结果、变量等,基于寄存器的虚拟机则支持寄存器的指令操作,使用寄存器来保存参数、中间计算结果。
  2. 现在的 V8 虚拟机则采用了基于寄存器的设计,它将一些中间数据保存到寄存器中。
  3. 解释器执行时主要有四个模块,内存中的字节码、寄存器、栈、堆。

# 生成字节码

  1. 当 V8 执行一段 JavaScript 代码时,会先对 JavaScript 代码进行解析 (Parser),并生成为 AST 和作用域信息,之后 AST 和作用域信息被输入到一个称为 Ignition 的解释器中,并将其转化为字节码,之后字节码再由 Ignition 解释器来解释执行。

# 15 | 隐藏类

V8 为了提升 JavaScript 的执行速度,借鉴了很多静态语言的特性,比如实现了 JIT 机制,为了提升对象的属性访问速度而引入了隐藏类,为了加速运算而引入了内联缓存。静态语言中,可以直接通过偏移量查询来查询对象的属性值,这也就是静态语言的执行效率高的一个原因。

# 隐藏类

将 JavaScript 中的对象静态化,也就是 V8 在运行 JavaScript 的过程中,会假设 JavaScript 中的对象是静态的,具体地讲,V8 对每个对象做如下两点假设:

  • 对象创建好了之后就不会添加新的属性;
  • 对象创建好了之后也不会删除属性。

根据以上假设来进行优化:V8 会为每个对象创建一个隐藏类,对象的隐藏类中记录了该对象一些基础的布局信息,包括以下两点:

  • 对象中所包含的所有的属性;
  • 每个属性相对于对象的偏移量。

在 V8 中,把隐藏类又称为 map,每个对象都有一个 map 属性,其值指向内存中的隐藏类。有了 map 之后,当你再次使用 point.x 访问 x 属性时,V8 会查询 point 的 map 中 x 属性相对 point 对象的偏移量,然后将 point 对象的起始位置加上偏移量,就得到了 x 属性的值在内存中的位置,有了这个位置也就拿到了 x 的值,这样我们就省去了一个比较复杂的查找过程。

# 多个对象共用一个隐藏类

如果两个对象的形状是相同的,V8 就会为其复用同一个隐藏类,这样有两个好处:

  • 减少隐藏类的创建次数,也间接加速了代码的执行速度;
  • 减少了隐藏类的存储空间。

两个对象的形状是相同的,要满足以下两点:

  • 相同的属性名称;
  • 相等的属性个数。

# 重新构建隐藏类

给一个对象添加新的属性,删除新的属性,或者改变某个属性的数据类型都会改变这个对象的形状,那么势必也就会触发 V8 为改变形状后的对象重建新的隐藏类。

# 最佳实践

  1. 使用字面量初始化对象时,要保证属性的顺序是一致的。
  2. 尽量使用字面量一次性初始化完整对象属性。
  3. 尽量避免使用 delete 方法。

关于隐藏类,我们记住以下几点:

  • 在 V8 中,每个对象都有一个隐藏类,隐藏类在 V8 中又被称为 map。
  • 在 V8 中,每个对象的第一个属性的指针都指向其 map 地址。
  • map 描述了其对象的内存布局,比如对象都包括了哪些属性,这些数据对应于对象的偏移量是多少?
  • 如果添加新的属性,那么需要重新构建隐藏类。
  • 如果删除了对象中的某个属性,同样也需要构建隐藏类。

# 16 | V8 是怎么通过内联缓存来提升函数执行效率的?

# 内联缓存(Inline Cache)

IC 的原理很简单,直观地理解,就是在 V8 执行函数的过程中,会观察函数中一些调用点 (CallSite) 上的关键的中间数据,然后将这些数据缓存起来,当下次再次执行该函数的时候,V8 就可以直接利用这些中间数据,节省了再次获取这些数据的过程,因此 V8 利用 IC,可以有效提升一些重复代码的执行效率。

# 多态和超态

一个反馈向量的一个插槽中可以包含多个隐藏类的信息,那么:

  • 如果一个插槽中只包含 1 个隐藏类,那么我们称这种状态为单态 (monomorphic);执行速度最快,
  • 如果一个插槽中包含了 2 ~ 4 个隐藏类,那我们称这种状态为多态 (polymorphic);执行速度中等,使用线性结构来存储
  • 如果一个插槽中超过 4 个隐藏类,那我们称这种状态为超态 (magamorphic)。执行速度最慢,使用 hash 表的结构来存储

单态的性能优于多态和超态。

要避免多态和超态,那么就尽量默认所有的对象属性是不变的,比如你写了一个 loadX(o) 的函数,那么当传递参数时,尽量不要使用多个不同形状的 o 对象。

# 总结

V8 引入了 IC,IC 会监听每个函数的执行过程,并在一些关键的地方埋下监听点,这些包括了加载对象属性 (Load)、给对象属性赋值 (Store)、还有函数调用 (Call),V8 会将监听到的数据写入一个称为反馈向量 (FeedBack Vector) 的结构中,同时 V8 会为每个执行的函数维护一个反馈向量。有了反馈向量缓存的临时数据,V8 就可以缩短对象属性的查找路径,从而提升执行效率。

# 17 | 消息队列

首先要知道浏览器早期的线程机制: 早期其实只有一个 ui 线程(即主线程),js 的执行也是在 ui 线程中,那么,鼠标的各种事件,例如鼠标移动事件,每移动一像素就会触发一次事件,很显然,ui 线程是无法及时响应和处理这些事件的,所以 “消息队列”的出现就是为了解决这个问题的。

消息队列:即把没执行的事件全部放到了一个队列中,然后 ui 线程不断轮训这个队列,然后取出新的事件执行,直到队列为空,当前 ui 线程也会被挂起。

异步回调,这里也有两种不同的类型,其典型代表是 setTimeout 和 XMLHttpRequest。

  1. settimeout: 遇到定时器,浏览器会将定时器的回调函数封装成一个事件,进入消息队列,然后在合适的时间点从消息队列中取出该事件,并且执行回调函数。

  2. xmlHttpRequest: 遇到 ajax 请求时,即执行 xmlHttpRequest.send()时, ui 线程会将该请求任务转发给网络线程,然后 send 函数推出,ui 线程继续执行,网络线程执行该请求任务,然后将返回的数据和回调函数封装成一个新的事件,并添加到消息队列中。然后 ui 线程从消息队列中取出事件,并且执行回调函数。

# 18 | 微任务

宏任务很简单,就是指消息队列中的等待被主线程执行的事件。

微任务稍微复杂一点,其实你可以把微任务看成是一个需要异步执行的函数,执行时机是在主函数(同步函数)执行结束之后、当前宏任务结束之前。

微任务是基于消息队列、事件循环、UI 主线程还有堆栈而来的,然后基于微任务,又可以延伸出协程、Promise、Generator、await/async 等现代前端经常使用的一些技术。

调用栈是一种数据结构,用来管理在主线程上执行的函数的调用关系。

微任务解决了宏任务执行时机不可控的问题。

理解微任务的执行时机,你只需要记住以下两点:

  • 首先,如果当前的任务中产生了一个微任务,通过 Promise.resolve() 或者 Promise.reject() 都会触发微任务,触发的微任务不会在当前的函数中被执行,所以执行微任务时,不会导致栈的无限扩张;
  • 其次,和异步调用不同,微任务依然会在当前任务执行结束之前被执行,这也就意味着在当前微任务执行结束之前,消息队列中的其他任务是不可能被执行的。

因此在函数内部触发的微任务,一定比在函数内部触发的宏任务要优先执行。

如果在微任务中又触发了新的微任务,那么 V8 会将该微任务添加进当前微任务队列中并执行,如果发生了微任务循环调用,那么这个循环就会一直持续下去,当前的宏任务无法退出,也就意味着消息队列中其他的宏任务是无法被执行的,比如通过鼠标、键盘所产生的事件。这些事件会一直保存在消息队列中,页面无法响应这些事件,具体的体现就是页面的卡死。

不过,由于 V8 每次执行微任务时,都会退出当前同步函数的调用栈,所以循环调用微任务是不会造成栈溢出的。

MutationObserver 和 IntersectionObserver 两个性质应该差不多。我这里简称 ob。ob 是一个微任务,通过浏览器的 requestIdleCallback,在浏览器每一帧的空闲时间执行 ob 监听的回调,该监听是不影响主线程的,但是回调会阻塞主线程。当然有一个限制,如果 100ms 内主线程一直处于未空闲状态,那会强制触发 ob。

# 19 | async/await

Callback 模式的异步编程模型需要实现大量的回调函数,大量的回调函数会打乱代码的正常逻辑,使得代码变得不线性、不易阅读,这就是我们所说的回调地狱问题。

使用 Promise 能很好地解决回调地狱的问题,我们可以按照线性的思路来编写代码,这个过程是线性的,非常符合人的直觉。

但是这种方式充满了 Promise 的 then() 方法,如果处理流程比较复杂的话,那么整段代码将充斥着大量的 then,语义化不明显,代码不能很好地表示执行流程。

我们想要通过线性的方式来编写异步代码,要实现这个理想,最关键的是要能实现函数暂停和恢复执行的功能。而生成器就可以实现函数暂停和恢复,我们可以在生成器中使用同步代码的逻辑来异步代码 (实现该逻辑的核心是协程),但是在生成器之外,我们还需要一个触发器来驱动生成器的执行,因此这依然不是我们最终想要的方案。

我们的最终方案就是 async/await,async 是一个可以暂停和恢复执行的函数,我们会在 async 函数内部使用 await 来暂停 async 函数的执行,await 等待的是一个 Promise 对象,如果 Promise 的状态变成 resolve 或者 reject,那么 async 函数会恢复执行。因此,使用 async/await 可以实现以同步的方式编写异步代码这一目标。

异步编程的 4 个阶段:

  • Callback + setTimeout
  • Promise + then
  • Generator + yield + next + 协程 + co 执行器
  • async + await

async 是一个通过异步执行并隐式返回 Promise 作为结果的函数。

await 可以等待两种类型的表达式:

  • 可以是任何普通表达式 ;
  • 也可以是一个 Promise 对象的表达式。

如果 await 等待的是一个 Promise 对象,它就会暂停执行生成器函数,直到 Promise 对象的状态变成 resolve,才会恢复执行,然后得到 resolve 的值,作为 await 表达式的运算结果。

和生成器函数一样,使用了 async 声明的函数在执行时,也是一个单独的协程,我们可以使用 await 来暂停该协程,由于 await 等待的是一个 Promise 对象,我们可以 resolve 来恢复该协程。

如果 await 等待的是一个非 Promise 对象,比如 await 100,那么 V8 会隐式地将 await 后面的 100 包装成一个已经 resolve 的对象,其效果等价于下面这段代码:Promise.resolve(100)

co 源码实现原理:其实就是通过不断的调用 generator 函数的 next()函数,来达到自动执行 generator 函数的效果(类似 async、await 函数的自动执行)。

# 20 | 垃圾回收器

什么是垃圾数据:从“GC Roots”对象出发,遍历 GC Root 中的所有对象,如果通过 GC Roots 没有遍历到,则这些对象便是垃圾数据 -- 可访问性(reachability)算法。V8 会有专门的垃圾回收器来回收这些垃圾数据。垃圾回收步骤:

  1. 通过 GC Root 标记空间中活动对象和非活动对象。
  2. 回收非活动对象所占据的内存。其实就是在所有的标记完成之后,统一清理内存中所有被标记为可回收的对象。
  3. 做内存整理。整理内存碎片(可选,有的垃圾回收器不产生内存碎片)

在浏览器环境中,GC Root 有很多,通常包括了以下几种 (但是不止于这几种):

  • 全局的 window 对象(位于每个 iframe 中);
  • 文档 DOM 树,由可以通过遍历文档到达的所有原生 DOM 节点组成;
  • 存放栈上变量。

V8 依据代际假说,将堆内存划分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放生存时间久、占用空间大的对象。为了提升垃圾回收的效率,V8 设置了两个垃圾回收器,主垃圾回收器和副垃圾回收器。主垃圾回收器负责收集老生代中的垃圾数据,副垃圾回收器负责收集新生代中的垃圾数据。

主垃圾回收器是采用标记 - 清除(Mark-Sweep)的算法进行垃圾回收的。标记 - 清除算法是如何工作的:

  1. 首先是标记过程阶段。标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。
  2. 接下来就是垃圾的清除过程。它和副垃圾回收器的垃圾清除过程完全不同,主垃圾回收器会直接将标记为垃圾的数据清理掉。

引入另外一种算法——标记 - 整理(Mark-Compact):这个算法的标记过程仍然与标记 - 清除算法里的是一样的,先标记可回收对象,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉这一端之外的内存。目的是避免对一块内存多次执行标记 - 清除算法后,产生大量不连续的内存碎片,而碎片过多会导致大对象无法分配到足够的连续内存。

副垃圾回收器采用了 Scavenge 算法,是把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域。新的数据都分配在对象区域,等待对象区域快分配满的时候,垃圾回收器便执行垃圾回收操作,之后将存活的对象从对象区域拷贝到空闲区域,并将两个区域互换,这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去。主垃圾回收器回收器主要负责老生代中的垃圾数据的回收操作,会经历标记、清除和整理过程。为了执行效率,一般新生区的空间会被设置得比较小。

引用计数存在无法回收循环引用的问题,以前 ie 的 js 引擎就采用了循环引用的方式。

栈里的数据滑动下栈指针就清除了,速度非常快。

# 21 | 垃圾回收器优化

V8 最开始的垃圾回收器有两个特点,第一个是垃圾回收在主线程上执行,第二个特点是一次执行一个完整的垃圾回收流程。

由于 JavaScript 是运行在主线程之上的,因此,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿(Stop-The-World)。

一次完整的垃圾回收分为标记和清理两个阶段,垃圾数据标记之后,V8 会继续执行清理和整理操作,虽然主垃圾回收器和副垃圾回收器的处理方式稍微有些不同,但它们都是主线程上执行的,执行垃圾回收过程中,会暂停主线程上的其他任务,如果垃圾回收器占用主线程时间过久,就会造成页面的卡顿 (Jank),用户体验不佳。

为了解决全停顿而造成的用户体验的问题,V8 团队经过了很多年的努力,向现有的垃圾回收器添加并行、并发和增量等垃圾回收技术,并且也已经取得了一些成效。这些技术主要是从两方面来解决垃圾回收效率问题的:

  • 第一,将一个完整的垃圾回收的任务拆分成多个小的任务,这样就消灭了单个长的垃圾回收任务;
  • 第二,将标记对象、移动对象等任务转移到后台线程进行,这会大大减少主线程暂停的时间,改善页面卡顿的问题,让动画、滚动和用户交互更加流畅。

# 并行回收

所谓并行回收,是指垃圾回收器在主线程上执行的过程中,还会开启多个协助线程,同时执行同样的回收工作。

采用并行回收时,垃圾回收所消耗的时间,等于总体辅助线程所消耗的时间(辅助线程数量乘以单个线程所消耗的时间),再加上一些同步开销的时间。

V8 的副垃圾回收器所采用的就是并行策略,它在执行垃圾回收的过程中,启动了多个线程来负责新生代中的垃圾清理操作,这些线程同时将对象空间中的数据移动到空闲区域。由于数据的地址发生了改变,所以还需要同步更新引用这些对象的指针。

仍然是一种全停顿的垃圾回收方式,在主线程执行回收工作的时候才会开启辅助线程,这依然还会存在效率问题。

# 增量回收

所谓增量式垃圾回收,是指垃圾收集器将标记工作分解为更小的块,并且穿插在主线程不同的任务之间执行。采用增量垃圾回收时,垃圾回收器没有必要一次执行完整的垃圾回收过程,每次执行的只是整个垃圾回收过程中的一小部分工作。

增量回收是并发的(concurrent),要实现增量执行,需要满足两点要求:

  • 垃圾回收可以被随时暂停和重启,暂停时需要保存当时的扫描结果,等下一波垃圾回收来了之后,才能继续启动。
  • 在暂停期间,被标记好的垃圾数据如果被 JavaScript 代码修改了,那么垃圾回收器需要能够正确地处理。

V8 采用了三色标记法,除了黑色和白色,还额外引入了灰色:

  • 黑色表示这个节点被 GC Root 引用到了,而且该节点的子节点都已经标记完成了 ;
  • 灰色表示这个节点被 GC Root 引用到,但子节点还没被垃圾回收器标记处理,也表明目前正在处理这个节点;
  • 白色表示这个节点没有被访问到,如果在本轮遍历结束时还是白色,那么这块数据就会被收回。

引入灰色标记之后,垃圾回收器就可以依据当前内存中有没有灰色节点,来判断整个标记是否完成,如果没有灰色节点了,就可以进行清理工作了。如果还有灰色标记,当下次恢复垃圾回收器时,便从灰色的节点开始继续执行。

为了解决垃圾回收器恢复执行增量标记时,已经标记过的节点发生了变化的问题,增量垃圾回收器添加了一个约束条件:不能让黑色节点指向白色节点。

通常我们使用写屏障 (Write-barrier) 机制实现这个约束条件,也就是说,当发生了黑色的节点引用了白色的节点,写屏障机制会强制将被引用的白色节点变成灰色的,这样就保证了黑色节点不能指向白色节点的约束条件。这个方法也被称为强三色不变性,它保证了垃圾回收器能够正确地回收数据,因为在标记结束时的所有白色对象,对于垃圾回收器来说,都是不可到达的,可以安全释放。

# 并发回收

虽然通过三色标记法和写屏障机制可以很好地实现增量垃圾回收,但是由于这些操作都是在主线程上执行的,如果主线程繁忙的时候,增量垃圾回收操作依然会增加主线程处理任务的吞吐量 (throughput)。

所谓并发回收,是指主线程在执行 JavaScript 的过程中,辅助线程能够在后台完成执行垃圾回收的操作。

并发回收的优势非常明显,主线程不会被挂起,JavaScript 可以自由地执行 ,在执行的同时,辅助线程可以执行垃圾回收操作。

难点:

  • 第一,当主线程执行 JavaScript 时,堆中的内容随时都有可能发生变化,从而使得辅助线程之前做的工作完全无效;
  • 第二,主线程和辅助线程极有可能在同一时间去更改同一个对象,这就需要额外实现读写锁的一些功能了。

# 总结

主垃圾回收器同时采用了这三种策略:

  1. 首先主垃圾回收器主要使用并发标记,我们可以看到,在主线程执行 JavaScript,辅助线程就开始执行标记操作了,所以说标记是在辅助线程中完成的。
  2. 标记完成之后,再执行并行清理操作。主线程在执行清理操作时,多个辅助线程也在执行清理操作。
  3. 另外,主垃圾回收器还采用了增量标记的方式,清理的任务会穿插在各种 JavaScript 任务之间执行。

# 22 | 常见内存问题的解决策略

# node

Node 是 V8 的宿主,它会给 V8 提供事件循环和消息队列。在 Node 中,事件循环是由 libuv 提供的,libuv 工作在主线程中,它会从消息队列中取出事件,并在主线程上执行事件。

  • Node 的体系架构

# 内存问题

可以定义为下面这三类:

  1. 内存泄漏 (Memory leak),它会导致页面的性能越来越差;主要原因是不再需要 (没有作用) 的内存数据依然被其他对象引用着。如果某个节点已从 DOM 树移除,但 JavaScript 仍然引用它,我们称此节点为“detached”。“detached”节点是 DOM 内存泄漏的常见原因。
  2. 内存膨胀 (Memory bloat),它会导致页面的性能会一直很差;主要表现在程序员对内存管理的不科学。
  3. 频繁垃圾回收,它会导致页面出现延迟或者经常暂停。频繁使用大的临时变量,导致了新生代空间很快被装满,从而频繁触发垃圾回收。

# QA

内存泄漏问题的定位,一般是通过 chrome 的 devtool 中 memory report 来观察的,nodejs 环境中的 mem leak case 我们研究的比较多,一般通过结合 memwatch 等 c++扩展包把 report 文件 dump 在线上机磁盘上,然后 download 下来在本地的 chrome 浏览器 devtool 中进行复盘。比较常见的 case 是一些 js 工程师对 scope 的理解不够深,复杂的闭包里出现了隐式的引用持有却没释放。此类问题一般隐蔽性比较强,而且如果不是大厂的业务线(业务高峰产生高并发环境),往往可能压根发现不了,因为就算有 leak 内存逐渐增长到 v8 的 heap limit 后 node 进程死掉就会被 pm2/forever 等守护进程复活,这个重启只要不是非常频繁往往是业务无感的~。

# 场景记录

  1. 介绍一个场景:Node.js v4.x ,BFF 层服务端在 js 代码中写了一个 lib 模块 做 lfu、lru 的缓存,用于针对后端返回的数据进行缓存。把内存当缓存用的时候,由于线上 qps 较大的时候,缓存模块被频繁调用,造成了明显的 gc stw 现象,外部表现就是 node 对上游 http 返回逐渐变慢。由于当时上游是 nginx,且 nginx 设置了 timeout retry,因此这个内存 gc 问题当 node 返回时间超出 nginx timeout 阈值时 进而引起了 nginx 大量 retry,迅速形成雪崩效应。后来不再使用这样的当时,改为使用 node 服务器端本地文件+redis/memcache 的缓存方案,node 做 bff 层时 确实不适合做内存当缓存这种事。

  2. 运行场景:K 线行情列表 技术方案,websocket 推送二进制数据(2 次/秒) -> 转换为 utf-8 格式 -> 检查数据是否相同 -> 渲染到 dom 中 出现问题:页面长时间运行后出现卡顿的现象 问题分析:将二进制数据转换为 utf-8 时,频繁触发了垃圾回收机制 解决方案:后端推送采取增量推送形式

  3. 介绍一下最近遇到的内存问题,非常粗暴就是 webview 页面内存占用了 400 多 M,加上 app 本身、系统的内存占用,1G 内存的移动设备直接白屏。其中部分原因是用 webaudio 加载了十多个音乐文件,用 canvas 加载了几十张小图片。图片直接改成 url 用到的时候再加载到 webgl 中,声音文件按需加载,有了很大的缓解。

  4. 还有一些常见的比如,addEventListener 之后没有 remove,setTimeout、setInterval 之后没有 clear 等

上次更新: 10/20/2022