1 Like
浅析 V8 原理

浅析 V8 原理

249 PV1 LikeJavaScriptV8
这篇文章是李兵老师《图解 Google V8》的笔记, 也当作以后读 V8 源码的参考. 至于后面到底读不读, 先 🐎 住, 后面再说嘛...

V8 工作原理概览

V8 是 JavaScript 虚拟机的一种, 它将人类能够理解的编程语言 JavaScript, 编译成成机器能够理解的机器语言. 市面上有很多种 JavaScript 引擎, 诸如 SpiderMonkey, V8, JavaScriptCore 等. 但 V8 绝对是最牛逼的那个, 在 V8 出现之前, 所有的 JavaScript 虚拟机所采用的都是解释执行的方式. 而 V8 率先使用即时编译(JIT) 的双轮驱动的设计, 即使用混合编译执行和解释执行这两种手段. 换句人话, 就是对于 JavaScript 代码需要经过编译执行两个阶段, 其中编译过程是指 V8 将 JavaScript 代码转换为字节码或者将多次使用的热点代码转化成二进制机器代码的阶段, 而执行阶段则是指解释器解释执行字节码, 或者是 CPU 直接执行二进制机器代码的阶段. 此外, V8 也是早于其他虚拟机引入了惰性编译, 隐藏类, 内联缓存等机制(三种机制我们下面都会说到).

V8 工作原理概览
V8 工作原理概览

虚拟机通过模拟实际计算机的各种功能来实现代码的执行, 如模拟实际计算机的 CPU, 堆栈, 寄存器等, 虚拟机还具有它自己的一套指令系统. 对于 JavaScript 代码来说, V8 就是它的整个世界, 当 V8 执行 JavaScript 代码时, 我们并不需要担心现实中不同操作系统的差异, 也不需要担心不同体系结构计算机的差异, 我们只需要按照虚拟机的规范写好代码就可以了.

V8 并没有采用某种单一的编译技术, 而是混合编译执行和解释执行这两种手段, 我们把这种混合使用编译器和解释器的技术称为 JIT(Just In Time). 这是一种权衡策略, 因为这两种方法都各自有各自的优缺点, 解释执行的启动速度快, 但是执行时的速度慢, 而编译执行的启动速度慢, 但是执行时的速度快.

V8 编译流水线
V8 编译流水线

首先看上图左边部分, V8 启动执行 JavaScript 之前, 它还需要准备执行 JavaScript 时所需要的一些基础环境, 这些基础环境包括了堆空间, 栈空间, 全局执行上下文, 全局作用域, 消息循环系统, 内置函数等, 这些内容都是在执行 JavaScript 过程中需要使用到的.

  • JavaScript 全局执行上下文就包含了执行过程中的全局信息, 比如一些内置函数, 全局变量等信息;
  • 全局作用域包含了一些全局变量, 在执行过程中的数据都需要存放在内存中;
  • 而 V8 是采用了经典的堆和栈的内存管理模式, 所以 V8 还需要初始化内存中的堆和栈结构;
  • 另外, 想要我们的 V8 系统活起来, 还需要初始化消息循环系统, 消息循环系统包含了消息驱动器和消息队列, 它如同 V8 的心脏, 不断接受消息并决策如何处理消息.

基础环境准备好之后, 接下来就可以向 V8 提交要执行的 JavaScript 代码了. V8 会进行一系列的结构化操作, 即词法分析, 语法分析等等, 此时就生成了 AST 和作用域.

有了 AST 和作用域, 接下来就可以生成字节码了, 字节码是介于 AST 和机器代码的中间代码. 但是与特定类型的机器代码无关, 解释器可以直接解释执行字节码, 或者通过编译器将其编译为二进制的机器代码再执行.

生成了字节码之后, 解释器就会按照顺序解释执行字节码, 并输出执行结果.

我们在解释器附近画了个监控机器人, 这是一个监控解释器执行状态的模块, 在解释执行字节码的过程中, 如果发现了某一段代码会被重复多次执行, 那么监控机器人就会将这段代码标记为热点代码. 当某段代码被标记为热点代码后, V8 就会将这段字节码丢给优化编译器, 优化编译器会在后台将字节码编译为二进制代码, 然后再对编译后的二进制代码执行优化操作, 优化后的二进制机器代码的执行效率会得到大幅提升. 如果下面再执行到这段代码时, 那么 V8 会优先选择优化之后的二进制代码, 这样代码的执行速度就会大幅提升.

不过由于 JavaScript 是一门动态语言, 对象的结构和属性是可以在运行时任意修改的, 而经过优化编译器优化过的代码只能针对某种固定的结构, 一旦在执行过程中, 对象的结构被动态修改了, 那么优化之后的代码势必会变成无效的代码, 这时候优化编译器就需要执行反优化操作, 经过反优化的代码, 下次执行时就会回退到解释器解释执行.

为什么高级语言需要被编译

这里首先补点儿课, 来聊一聊为什么高级语言需要被编译. 我们知道 CPU 只能跟二进制的指令进行沟通. 比如我们给 CPU 发出 1000100111011000 的二进制指令, 这条指令的意思是将一个寄存器中的数据移动到另外一个寄存器中, 当处理器执行到这条指令的时候, 便会按照指令的意思去实现相关的操作.

为了能够完成复杂的任务, 工程师们为 CPU 提供了一大堆指令, 来实现各种功能, 我们就把这一大堆指令称为指令集(Instructions), 也就是机器语言. 二进制代码难以阅读和记忆, 于是我们又将二进制指令集转换为人类可以识别和记忆的符号, 这就是汇编指令集. 当然汇编语言仍然不能被 CPU 直接使用, 所以如果我们使用汇编编写了一段程序, 我们还需要一个汇编编译器, 来将汇编代码编程成机器代码.

1000100111011000 // 机器指令 mov ax,bx // 汇编指令

即便汇编语言对机器语言做了一层抽象, 仍然难以快速写出一段代码. 除了汇编语言仍然难以记忆, 还有如下两个原因:

  • 不同的 CPU 有着不同的指令集, 市面上的 CPU 有 x86 架构的, arm 架构的等等, 如果要使用机器语言或者汇编语言来实现一个功能, 那么我们需要为每种架构的 CPU 编写特定的汇编代码.
  • 在编写汇编代码时, 我们还需要了解和处理器架构相关的硬件知识, 比如寄存器, 内存, 操作 CPU 等等.

因此我们需要一种屏蔽了计算机架构细节的语言, 能适应多种不同 CPU 架构的语言, 能专心处理业务逻辑的语言, 诸如 C, C++, Java, C#, Python, JavaScript 等, 这些高级语言就应运而生了. 和汇编语言一样, 处理器也不能直接识别由高级语言所编写的代码, 通常会有两种方式来执行这些代码.

第一种是解释执行, 需要先将输入的源代码通过解析器编译成中间代码, 之后直接使用解释器解释执行中间代码, 然后直接输出结果.

解释执行
解释执行

第二种是编译执行. 采用这种方式时, 也需要先将源代码转换为中间代码, 然后我们的编译器再将中间代码编译成机器代码. 通常编译成的机器代码是以二进制文件形式存储的, 需要执行这段程序的时候直接执行二进制文件就可以了. 还可以使用虚拟机将编译后的机器代码保存在内存中, 然后直接执行内存中的二进制代码.

编译执行
编译执行

JavaScript 的设计思想

我们知道 V8 的主要职责是用来执行 JavaScript 代码的, 在深入 V8 之前, 我们首先需要了解 JavaScript 这门语言的基本特性和设计思想.

JavaScript 借鉴了很多语言的特性, 比如 C 语言的基本语法, Java 的类型系统和内存管理, Scheme 的函数作为一等公民, 还有 Self 基于原型(prototype)的继承机制. 毫无疑问, JavaScript 是一门非常优秀的语言, 特别是原型继承机制函数是一等公民这两个设计.

但操蛋的是, 由于历史原因, 很多错误的或者不合理的设计都被延续至今, 比如使用 new 加构造函数来创建对象, 这种方式的背后隐藏了太多的细节, 非常容易增加代码出错概率, 而且也大大增加了新手的学习成本;再比如初期的 JavaScript 没有块级作用域机制, 使得 JavaScript 需要采取变量提升的策略, 而变量提升又是非常反人性的设计.

因此在学习 V8 工作原理时, 我们就要格外关注 JavaScript 这些独特的设计思想和特性背后的实现. 比如, 为了实现函数是一等公民的特性, JavaScript 采取了基于对象的策略; 再比如为了实现原型继承, V8 为每个对象引入了 __proto__ 属性等等.

JavaScript 的设计思想
JavaScript 的设计思想

我们会从函数即对象, 对象存储策略, 函数声明和函数表达式几个方面来讲述 JavaScript 的设计思想.

函数即对象

在 JavaScript 中, 函数是一种特殊的对象, 它和对象一样可以拥有属性和值, 但是函数和普通对象不同的是, 函数可以被调用, 所以一个函数被调用时, 它还需要关联相关的执行上下文. 并且除了使用函数名称来实现函数的调用, 还可以直接调用一个匿名函数. 在 V8 中, 会为函数对象添加了两个隐藏属性, 分别是 name 属性和 code 属性.

函数对象具有隐藏属性
函数对象具有隐藏属性

函数对象的默认的 name 属性值就是 anonymous, 表示该函数对象没有被设置名称, 比如立即执行函数. 其他情况我们可以通过 name 属性来获取函数的名称. 另外一个隐藏属性是 code 属性, 其值表示函数代码, 以字符串的形式存储在内存中. 当执行到一个函数调用语句时, V8 便会从函数对象中取出 code 属性值, 也就是函数代码, 然后再解释执行这段函数代码.

function foo() { console.log("Hello, world!"); } foo.name; // 'foo'

对象存储策略

我们知道对象是由一组组属性和值的集合, 因此内部使用字典存储是再好不过的了. 不过在 V8 实现对象存储时, 并没有完全采用字典的存储方式, 这主要是出于性能的考量. 因为字典是非线性的数据结构, 查询效率会低于线性的数据结构, V8 为了提升存储和查找效率, 采用了一套复杂的存储策略.

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

function Foo() { this[100] = "test-100"; this[1] = "test-1"; this["B"] = "bar-B"; this[50] = "test-50"; this[9] = "test-9"; this[8] = "test-8"; this[3] = "test-3"; this[5] = "test-5"; this["A"] = "bar-A"; this["C"] = "bar-C"; } const bar = new Foo(); for (const key in bar) { console.log(`index:${key} value:${bar[key]}`); }

打印顺序
打印顺序

在上面这段代码中, 我们利用构造函数 Foo 创建了一个 bar 对象, 在构造函数中, 我们给 bar 对象设置了很多属性, 包括了数字属性和字符串属性, 然后我们枚举出来了 bar 对象中所有的属性, 并将其一一打印. 我们看到打印的顺序并没有按照我们定义的顺序, 规律大致如下:

  • 设置的数字属性被最先打印出来了, 并且是按照数字大小的顺序打印的;
  • 设置的字符串属性依然是按照之前的设置顺序打印的, 比如我们是按照 B, A, C 的顺序设置的, 打印出来依然是这个顺序.

之所以出现这样的结果, 是因为在 ECMAScript 规范中定义了数字属性应该按照索引值大小升序排列, 字符串属性根据创建时的顺序升序排列. 在这里我们把对象中的数字属性称为排序属性, 在 V8 中被称为 elements, 字符串属性就被称为常规属性, 在 V8 中被称为 properties.

在 V8 内部, 为了有效地提升存储和访问这两种属性的性能, 分别使用了两个线性数据结构来分别保存排序属性和常规属性, 具体结构如下图所示:

V8 内部的对象构造
V8 内部的对象构造

通过上图我们可以发现, bar 对象包含了两个隐藏属性: elements 属性和 properties 属性, elements 属性指向了 elements 对象, 在 elements 对象中, 会按照顺序存放排序属性, properties 属性则指向了 properties 对象, 在 properties 对象中, 会按照创建时的顺序保存了常规属性.

分解成这两种线性数据结构之后, 如果执行索引操作, 那么 V8 会先从 elements 属性中按照顺序读取所有的元素, 然后再在 properties 属性中读取所有的元素, 这样就完成一次索引操作. 这也就说为什么在遍历的过程中, 会先遍历排序属性, 然后再遍历常规属性.

快属性和慢属性

将不同的属性分别保存到 elements 属性和 properties 属性中, 无疑简化了程序的复杂度, 但是在查找元素时, 却多了一步操作, 比如执行 bar.B 这个语句来查找 B 的属性值, 那么在 V8 会先查找出 properties 属性所指向的对象 properties, 然后再在 properties 对象中查找 B 属性, 这种方式在查找过程中增加了一步操作, 因此会影响到元素的查找效率.

基于这个原因, V8 采取了一个权衡的策略以加快查找属性的效率, 这个策略是将部分常规属性直接存储到对象本身, 我们把这称为对象内属性(in-object properties). 对象在内存中的展现形式我们可以参看下图. 采用对象内属性之后, 常规属性就被保存到 bar 对象本身了, 这样当再次使用 bar.B 来查找 B 的属性值时, V8 就可以直接从 bar 对象本身去获取该值就可以了, 这种方式减少查找属性值的步骤, 增加了查找效率. 不过对象内属性的数量是固定的, 默认是 10 个, 如果添加的属性超出了对象分配的空间, 则它们将被保存在常规属性存储中.

对象内属性
对象内属性

通常, 我们将保存在线性数据结构中的属性称之为快属性, 因为线性数据结构中只需要通过索引即可以访问到属性, 虽然访问线性结构的速度快, 但是如果从线性结构中添加或者删除大量的属性时, 则执行效率会非常低, 这会产生大量时间和内存开销.

因此, 如果一个对象的属性过多时, V8 就会采取另外一种存储策略, 那就是慢属性策略, 但慢属性的对象内部会有独立的非线性数据结构(词典) 作为属性存储容器. 所有的属性元信息不再是线性存储的, 而是直接保存在属性字典中.

慢属性保存在字典中
慢属性保存在字典中

实践: 在 Chrome 中查看对象布局

function Foo(property_num, element_num) { //添加可索引属性 for (let i = 0; i < element_num; i++) { this[i] = `element-${i}`; } //添加常规属性 for (let i = 0; i < property_num; i++) { const str = `property-${i}`; this[str] = str; } } const bar = new Foo(10, 10);

我们首先创建 10 个常规属性和 10 个 element, 在控制台先执行一下, 然后打开 Memory tab, 录制一下, 就得到了数据内存布局. 通过下图, 我们发现:

  • 10 个常规属性作为对象内属性, 存放在 bar 函数内部;
  • 10 个排序属性存放在 elements 中.

new Foo(10, 10)
new Foo(10, 10)

接下来我们调整一下参数, const bar2 = new Foo(20, 10), 使得常规属性变成 20 个. 由于创建的常用属性超过了 10 个, 所以另外 10 个常用属性就被保存到 properties 中了, 但因为 properties 中只有 10 个属性, 所以依然是线性的数据结构, 我们可以看其都是按照创建时的顺序来排列的. 因此:

  • 10 个常规属性直接存放在 bar2 的对象内;
  • 10 个常规属性以线性数据结构的方式存放在 properties 属性里面;
  • 10 个数字属性存放在 elements 属性里面.

new Foo(20, 10)
new Foo(20, 10)

接下来我们调整一下参数, const bar3 = new Foo(100, 10), 使得常规属性变成 100 个. 由于创建的常用属性超过了 10 个, 所以另外 90 个常用属性就被保存到 properties 中了, 但因为 properties 中超过了 10 个属性, 所以就不是线性的数据结构了, 因此:

  • 10 个常规属性直接存放在 bar2 的对象内;
  • 90 个常规属性以非线性数据结构的方式存放在 properties 属性里面;
  • 10 个数字属性存放在 elements 属性里面.

new Foo(100, 10)
new Foo(100, 10)

观察上面的三张图片, 除了 elements 和 properties 属性, V8 还为每个对象实现了 map 属性和 __proto__ 属性. __proto__ 属性就是原型, 用来实现 JavaScript 继承的; 而 map 则是隐藏类, 用于在内存中快速查找对象属性, 我们后面都会介绍到.

函数声明和函数表达式

我们知道函数有函数声明函数表达式两种形式. 如下 foo 为函数声明, bar 为函数表达式.

function foo() {} const bar = function () {};

首先我们谈一谈表达式和语句的区别. 简单来说, 语句是为了进行某种操作, 一般情况下不需要返回值, 而表达式都是为了得到返回值, 一定会返回一个值. 举个例子, if (a === 1) {}, 这个 if 声明的就是一条语句, 而里面的 a === 1 就是一条表达式. 因为 a === 1 一定返回一个 boolean 类型.

函数声明

我们先看看 V8 是怎么处理函数声明的. 以下面的代码为例:

var x = 5; function foo() { console.log("Foo"); }

上面这段代码的作用域
上面这段代码的作用域

函数声明和变量声明类似, V8 在编译阶段, 都会对其执行变量提升的操作, 将它们提升到作用域中, 在执行阶段, 如果使用了某个变量, 就可以直接去作用域中去查找. 不过 V8 对于提升函数和提升变量的策略是不同的, 如果提升了一个变量, 那么 V8 在将变量提升到作用域中时, 还会为其设置默认值 undefined, 如果是函数声明, 那么 V8 会在内存中创建该函数对象, 并提升整个函数对象.

因此对于 var x = 5;, 在 V8 执行 var x = 5 这段代码时, 会认为它是两段代码, 一段是定义变量的语句, 一段是赋值的表达式, 如下所示:

var x = undefined; x = 5;

首先, 在变量提升阶段, V8 并不会执行赋值的表达式, 该阶段只会分析基础的语句, 比如变量的定义, 函数的声明. 而这两行代码是在不同的阶段完成的, var x 是在编译阶段完成的, 也可以说是在变量提升阶段完成的, 而 x = 5 是表达式, 所有的表达式都是在执行阶段完成的. 在变量提升阶段, V8 将这些变量存放在作用域时, 还会给它们赋一个默认的 undefined 值, 所以在定义一个普通的变量之前, 使用该变量, 那么该变量的值就是 undefined.

对于一个普通的函数声明, 执行它并没有输出任何内容, 所以可以肯定, 函数声明并不是一个表达式, 而是一个语句. V8 在变量提升阶段, 如果遇到函数声明, 那么 V8 同样会对该函数声明执行变量提升操作. 函数也是一个对象, 所以在编译阶段, V8 就会将整个函数对象提升到作用域中, 但不是给该函数名称赋一个 undefined, 而是 V8 会在内存中为声明生成函数对象, 并将该对象提升到作用域中.

函数表达式

我们在一个表达式中使用 function 来定义一个函数, 那么就把该函数称为函数表达式. 它与函数声明有三个区别:

  • 函数表达式是在表达式语句中使用 function 的, 最典型的表达式是 a = b 这种形式, 因为函数也是一个对象, 我们把 a = function () {} 这种方式称为函数表达式;
  • 在函数表达式中, 可以省略函数名称, 从而创建匿名函数(anonymous functions);
  • 一个函数表达式可以被用作一个即时调用的函数表达式, IIFE(Immediately Invoked Function Expression).

以下面这段代码为例:

foo(); var foo = function () { console.log("foo"); };

不用多数, 我们知道它肯定报错, 因为变量提升, 可以拆分成如下的形式:

var foo = undefined; foo = function () { console.log("foo"); };

上面的代码中, 第一行是声明语句, 所以 V8 在解析阶段, 就会在作用域中创建该对象, 并将该对象设置为 undefined, 第二行是函数表达式, 在编译阶段, V8 并不会处理函数表达式, 所以也就不会将该函数表达式提升到作用域中了. 那么在函数表达式之前调用该函数 foo, 此时的 foo 只是指向了 undefined, 所以就相当于调用一个 undefined, 而 undefined 只是一个原生对象, 并不是函数, 所以当然会报错了.

IIFE

现在我们知道了, 在编译阶段, V8 并不会处理函数表达式, 而 JavaScript 中的立即函数调用表达式正是使用了这个特性来实现了非常广泛的应用. 看 (a = 3) 这个代码, JavaScript 中有一个圆括号运算符, 圆括号里面可以放一个表达式, 整个语句也是一个表达式, 最终输出 3.

同理, 如果在小括号里面放上一段函数的定义, 因为小括号之间存放的必须是表达式, 所以如果在小括号里面定义一个函数, 那么 V8 就会把这个函数看成是函数表达式, 执行时它会返回一个函数对象. 如果我直接在表达式后面加上调用的括号, 这就称为立即调用函数表达式(IIFE)

(function () { //statements })();

因为函数立即表达式也是一个表达式, 所以 V8 在编译阶段, 并不会为该表达式创建函数对象. 这样的一个好处就是不会污染环境, 函数和函数内部的变量都不会被其他部分的代码访问到. 在 ES6 之前, JavaScript 中没有私有作用域的概念, 如果在多人开发的项目中, 你模块中的变量可能覆盖掉别人的变量, 所以使用函数立即表达式就可以将我们内部变量封装起来, 避免了相互之间的变量污染.

另外, 因为函数立即表达式是立即执行的, 所以将一个函数立即表达式赋给一个变量时, 不是存储 IIFE 本身, 而是存储 IIFE 执行后返回的结果. 如下所示:

var a = (function () { return 1; })(); console.log(a); // 1

V8 编译流水线

聊完了 JavaSript 的设计思想, 我们来聊一聊 V8 编译流水线, 即 V8 执行 JavaScript 代码的这套流程. 它涉及 JIT, 延迟解析, 隐藏类, 内联缓存等等. 这些技术决定着一段 JavaScript 代码能否正常执行, 以及代码的执行效率.

比如 V8 中使用的隐藏类(Hide Class), 这是将 JavaScript 中动态类型转换为静态类型的一种技术, 可以消除动态类型的语言执行速度过慢的问题, 那么我们在编写 JavaScript 时, 就可以充分利用好隐藏类这种强大的优化特性, 写出更加高效的代码. 再比如, V8 实现了 JavaScript 代码的惰性解析, 目的是为了加速代码的启动速度, 通过对惰性解析机制的学习, 我们可以优化代码来更加适应这个机制, 从而提高程序性能.

我们会从运行时环境, 惰性解析, 字节码, 隐藏类, 内联缓存等方面来讲述 V8 编译流水线, 此外, 我们还会使用 D8 来实战生成一段 JavaScript 源码的 AST, 作用域, 字节码.

运行时环境

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

在聊运行 V8 的运行时环境, 我们先聊 V8 的宿主环境. 在浏览器环境, 浏览器为 V8 提供基础的消息循环系统, 全局变量, Web API, 而 V8 的核心是实现了 ECMAScript 标准, 这相当于病毒自己的 DNA 或者 RNA, V8 只提供了 ECMAScript 定义的一些对象和一些核心的函数, 这包括了 Object, Function, String. 除此之外, V8 还提供了垃圾回收器, 协程等基础内容, 不过这些功能依然需要宿主环境的配合才能完整执行. 此外, Node.js 也是 V8 的另外一种宿主环境, 它提供了不同的宿主对象和宿主的 API, 但是整个流程依然是相同的, 比如 Node.js 也会提供一套消息循环系统, 也会提供一个运行时的主线程.

如果 V8 使用不当, 比如不规范的代码触发了频繁的垃圾回收, 或者某个函数执行时间过久, 这些都会占用宿主环境的主线程, 从而影响到程序的执行效率, 甚至导致宿主环境的卡死.

V8 和宿主环境
V8 和宿主环境

构造数据存储空间: 堆空间和栈空间

由于 V8 是寄生在浏览器或者 Node.js 这些宿主中的, 因此, V8 也是被这些宿主启动的. 比如, 在 Chrome 中, 只要打开一个渲染进程, 渲染进程便会初始化 V8, 同时初始化堆空间和栈空间.

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

栈空间的最大的特点是空间连续, 所以在栈中每个元素的地址都是固定的, 因此栈空间的查找效率非常高, 但是通常在内存中, 很难分配到一块很大的连续空间, 因此, V8 对栈空间的大小做了限制, 如果函数调用层过深, 那么 V8 就有可能抛出栈溢出的错误.

如果有一些占用内存比较大的数据, 或者不需要存储在连续空间中的数据, 使用栈空间就显得不是太合适了, 所以 V8 又使用了堆空间. 堆空间是一种树形的存储结构, 用来存储对象类型的离散的数据, 由于堆空间中的数据不是线性存储的, 所以堆空间可以存放很多数据, 但是读取的速度会比较慢. JavaScript 中除了原生类型的数据, 其他的都是对象类型, 诸如函数, 数组, 在浏览器中还有 window 对象, document 对象等, 这些都是存在堆空间的.

下面我们从函数调用角度来分析堆和栈, 以及函数调用是如何影响到内存布局的.

function foo() { foo(); } function foo() { setTimeout(foo, 0); } function foo() { return Promise.resolve().then(foo); }

上面的三段代码, 如果执行 foo 函数, 会有三种不同的结局:

  1. 第一段代码是在同一个任务中重复调用嵌套的 foo 函数, V8 就会报告栈溢出的错误.
  2. 第二段代码是使用 setTimeout 让 foo 函数在不同的任务中执行, 不会栈溢出, 也不会卡死. 这段代码之所以不会导致栈溢出, 是因为 setTimeout 会使得 foo 函数在消息队列后面的任务中执行, 所以不会影响到当前的栈结构
  3. 第三段代码是在同一个任务中执行 foo 函数, 但是却不是嵌套执行, 不会栈溢出, 但页面会卡死. 这是因为当执行 foo 函数时, 由于 foo 函数中调用了 Promise.resolve(), 这会触发一个微任务, 那么此时, V8 会将该微任务添加进微任务队列中, 退出当前 foo 函数的执行. 然后, V8 在准备退出当前的宏任务之前, 会检查微任务队列, 发现微任务队列中有一个微任务, 于是先执行微任务. 由于这个微任务就是调用 foo 函数本身, 所以在执行微任务的过程中, 需要继续调用 foo 函数, 在执行 foo 函数的过程中, 又会触发了同样的微任务. 那么这个循环就会一直持续下去, 当前的宏任务无法退出, 也就意味着消息队列中其他的宏任务是无法被执行的, 比如通过鼠标、键盘所产生的事件. 这些事件会一直保存在消息队列中, 页面无法响应这些事件, 具体的体现就是页面的卡死. 但是由于 V8 每次执行微任务时, 都会退出当前 foo 函数的调用栈, 所以这段代码是不会造成栈溢出的.

这是因为, V8 执行这三种不同代码时, 它们的内存布局是不同的, 而不同的内存布局又会影响到代码的执行逻辑, 因此我们需要了解 JavaScript 执行时的内存布局.

在讲内存布局之前, 我们先说下大部分高级语言为什么采用栈这种结构来管理函数调用. 这与函数的特性有关:

  1. 第一个特点是函数可以被调用, 你可以在一个函数中调用另外一个函数, 当函数调用发生时, 执行代码的控制权将从父函数转移到子函数, 子函数执行结束之后, 又会将代码执行控制权返还给父函数;
  2. 第二个特点是函数具有作用域机制, 所谓作用域机制, 是指函数在执行的时候可以将定义在函数内部的变量和外部环境隔离, 在函数内部定义的变量我们也称为临时变量, 临时变量只能在该函数中被访问, 外部函数通常无权访问, 当函数执行结束之后, 存放在内存中的临时变量也随之被销毁.

比如下面这段 C 代码:

int getZ() { return 4; } int add(int x, int y) { int z = getZ(); return x + y + z; } int main() { int x = 5; int y = 6; int ret = add(x, y); }
  1. 当 main 函数调用 add 函数时, 需要将代码执行控制权交给 add 函数;
  2. 然后 add 函数又调用了 getZ 函数, 于是又将代码控制权转交给 getZ 函数;
  3. 接下来 getZ 函数执行完成, 需要将控制权返回给 add 函数;
  4. 同样当 add 函数执行结束之后, 需要将控制权返还给 main 函数;
  5. 然后 main 函数继续向下执行.

通过上述分析, 我们可以得出, 函数调用者的生命周期总是长于被调用者(后进), 并且被调用者的生命周期总是先于调用者的生命周期结束(先出).

嵌套调用时函数的生命周期
嵌套调用时函数的生命周期

再谈一谈作用域, 作用域机制通常表现在函数执行时, 会在内存中分配函数内部的变量, 上下文等数据, 在函数执行完成之后, 这些内部数据会被销毁掉. 所以站在函数资源分配和回收角度来看, 被调用函数的资源分配总是晚于调用函数(后进), 而函数资源的释放则总是先于调用函数(先出).

函数资源分配流程
函数资源分配流程

所以既然都是后进先出的结构, 那么使用栈是再合适不过的了, 因此我们会使用栈这种数据结构来管理函数的调用过程, 我们也把管理函数调用过程的栈结构称之为调用栈. 我们来用栈的思想分析下面一个例子:

int add(num1,num2){ int x = num1; int y = num2; int ret = x + y; return ret; } int main() { int x = 5; int y = 6; x = 100; int z = add(x,y); return z; }

首先执行 main, 会把 x, y 压入栈中, 当执行到 int z = add(x, y) 时, 会把形参 num1, num2, 再把变量 x, y, ret 的值依次压栈中. 当 add 函数执行完成之后, 需要将执行代码的控制权转交给 main 函数, 这意味着需要将栈的状态恢复到 main 函数上次执行时的状态, 我们把这个过程叫恢复现场.

恢复现场的方法很简单, 就是在寄存器中保存一个永远指向当前栈顶的指针, 栈顶指针的作用就是告诉你应该往哪个位置添加新元素, 这个指针通常存放在 esp 寄存器中. 如果你想往栈中添加一个元素, 那么你需要先根据 esp 寄存器找到当前栈顶的位置, 然后在栈顶上方添加新元素, 新元素添加之后, 还需要将新元素的地址更新到 esp 寄存器中. 下面这个图, 在 add 函数 return ret 的时候, ret 被压入栈中, 同时 esp 寄存器也指向 ret.

add 函数即将执行结束的状态
add 函数即将执行结束的状态

因此在 add 函数执行完毕后, 将 esp 的指针向下移动到之前 main 函数执行时的地方就可以. 当然 CPU 是怎么知道要移动到这个地址呢? CPU 的解决方法是增加了另外一个 ebp 寄存器, 用来保存当前函数的起始位置, 我们把一个函数的起始位置也称为栈帧指针(每个栈帧对应着一个未运行完的函数, 栈帧中保存了该函数的返回地址和局部变量), ebp 寄存器中保存的就是当前函数的栈帧指针. 在 main 函数调用 add 函数的时候, main 函数的栈顶指针就变成了 add 函数的栈帧指针, 所以需要将 main 函数的栈顶指针保存到 ebp 中, 当 add 函数执行结束之后, 我需要销毁 add 函数的栈帧, 并恢复 main 函数的栈帧, 那么只需要取出 main 函数的栈顶指针写到 esp 中即可, 这就相当于将栈顶指针移动到 main 函数的区域.

恢复 main 函数执行现场
恢复 main 函数执行现场

不过现在仍然不能执行 main 函数. 因为 main 函数也有它自己的栈帧指针, 在执行 main 函数之前, 我们还需恢复它的栈帧指针. 通常的方法是在 main 函数中调用 add 函数时, CPU 会将当前 main 函数的栈帧指针保存在栈中. 当函数调用结束之后, 就需要恢复 main 函数的执行现场了, 首先取出 ebp 中的指针, 写入 esp 中, 然后从栈中取出之前保留的 main 的栈帧地址, 将其写入 ebp 中, 到了这里 ebp 和 esp 就都恢复了, 可以继续执行 main 函数了.

当前 main 函数的栈帧指针保存在栈
当前 main 函数的栈帧指针保存在栈

关于 CPU, 寄存器, 内存等知识可以看浅析 CPU 是如何执行二进制机器码的这篇文章.

回到上面的 JavaScript 代码, 在 JavaScript 中, 函数的执行过程也是类似的, 如果调用一个新函数, 那么 V8 会为该函数创建栈帧, 等函数执行结束之后, 销毁该栈帧, 而栈结构的容量是固定的, 所有如果重复嵌套执行一个函数, 那么就会导致栈会栈溢出.

因此, 栈有以下两个优势:

  • 栈的结构和非常适合函数调用过程.
  • 在栈上分配资源和销毁资源的速度非常快, 这主要归结于栈空间是连续的, 分配空间和销毁空间只需要移动下指针就可以了.

不过正是因为栈是连续的, 所以要想在内存中分配一块连续的大空间是非常难的, 因此栈空间是有限的. 因为栈空间是有限的, 这就导致我们在编写程序的时候, 经常一不小心就会导致栈溢出, 比如函数循环嵌套层次太多, 或者在栈上分配的数据过大, 都会导致栈溢出, 基于栈不方便存放大的数据, 因此我们使用了另外一种数据结构用来保存一些大数据, 这就是. 和栈空间不同, 存放在堆空间中的数据是不要求连续存放的, 从堆上分配内存块没有固定模式的, 你可以在任何时候分配和释放它.

全局执行上下文和全局作用域

V8 初始化了基础的存储空间之后, 接下来就需要初始化全局执行上下文和全局作用域了, 当 V8 开始执行一段可执行代码时, 会生成一个执行上下文. V8 用执行上下文来维护执行当前代码所需要的变量声明, this 指向等.

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

而词法环境中, 则包含了使用 let, const 等变量的内容.

全局执行上下文在 V8 的生存周期内是不会被销毁的, 它会一直保存在堆中, 这样当下次在需要使用函数或者全局变量时, 就不需要重新创建了. 另外, 当你执行了一段全局代码时, 如果全局代码中有声明的函数或者定义的变量, 那么函数对象和声明的变量都会被添加到全局执行上下文中. 比如下面这段代码, V8 在执行这段代码的过程中, 会在全局执行上下文中添加变量 x 和函数 show_x.

var x = 1; function show_x() { console.log(x); }

我们需要注意全局作用域和全局执行上下文的关系, 在 ES6 中, 同一个全局执行上下文中, 都能存在多个作用域. 比如下面这段代码在执行时, 就会有两个对应的作用域, 一个是全局作用域, 另外一个是括号内部的作用域, 但是这些内容都会保存到全局执行上下文中.

var x = 5; { let y = 2; const z = 3; }

当 V8 调用了一个函数时, 就会进入函数的执行上下文, 这时候全局执行上下文和当前的函数执行上下文就形成了一个栈结构. 比如执行下面这段代码:

var x = 1; function show_x() { console.log(x); } function bar() { show_x(); } bar();

当执行到 show_x 的时候, 其栈状态如下图所示:

函数调用栈
函数调用栈

构造事件循环系统

有了堆空间和栈空间, 生成了全局执行上下文和全局作用域, JavaScript 代码仍然是不可以被执行的, 这是因为 V8 还需要有一个主线程, 用来执行 JavaScript 和执行垃圾回收等工作. V8 是寄生在宿主环境中的, 它并没有自己的主线程, 而是使用宿主所提供的主线程, V8 所执行的代码都是在宿主的主线程上执行的.

只有一个主线程依然不行, 因为如果你开启一个线程, 在该线程执行一段代码, 那么当该线程执行完这段代码之后, 就会自动退出了, 执行过程中的一些栈上的数据也随之被销毁, 下次再执行另外一个段代码时, 你还需要重新启动一个线程, 重新初始化栈数据, 这会严重影响到程序执行时的性能.

为了在执行完代码之后, 让线程继续运行, 通常的做法是在代码中添加一个循环语句, 在循环语句中监听下个事件, 比如你要执行另外一个语句, 那么激活该循环就可以执行了. 比如下面的模拟代码, 使用了一个循环, 不同地获取新的任务, 一旦有新的任务, 便立即执行该任务.

while(1){ Task task = GetNewTask(); RunTask(task); }

如果主线程正在执行一个任务, 这时候又来了一个新任务, 比如 V8 正在操作 DOM, 这时候浏览器的网络线程完成了一个页面下载的任务, 而且 V8 注册监听下载完成的事件, 那么这种情况下就需要引入一个消息队列, 让下载完成的事件暂存到消息队列中, 等当前的任务执行结束之后, 再从消息队列中取出正在排队的任务. 当执行完一个任务之后, 我们的事件循环系统会重复这个过程, 继续从消息队列中取出并执行下个任务. 这就是事件循环.

需要注意的是, 因为所有的任务都是运行在主线程的, 在浏览器的页面中, V8 会和页面共用主线程, 共用消息队列, 所以如果 V8 执行一个函数过久, 会影响到浏览器页面的交互性能.

V8 的惰性解析

在编译 JavaScript 代码的过程中, V8 并不会一次性将所有的 JavaScript 解析为中间代码, 这主要是基于以下两点:

  • 首先, 如果一次解析和编译所有的 JavaScript 代码, 过多的代码会增加编译时间, 这会严重影响到首次执行 JavaScript 代码的速度, 让用户感觉到卡顿. 因为有时候一个页面的 JavaScript 代码都有 10 多兆, 如果要将所有的代码一次性解析编译完成, 那么会大大增加用户的等待时间;

  • 其次, 解析完成的字节码和编译之后的机器代码都会存放在内存中, 如果一次性解析和编译所有 JavaScript 代码, 那么这些中间代码和机器代码将会一直占用内存, 特别是在手机普及的年代, 内存是非常宝贵的资源.

因此, V8 是实现了惰性解析. 所谓惰性解析是指解析器在解析的过程中, 如果遇到函数声明, 那么会跳过函数内部的代码, 并不会为其生成 AST 和字节码, 而仅仅生成顶层代码的 AST 和字节码.

function foo(a, b) { var d = 100; var f = 10; return d + f + a + b; } var a = 1; var c = 4; foo(1, 5);

当把这段代码交给 V8 处理时, V8 会至上而下解析这段代码, 在解析过程中首先会遇到 foo 函数, 由于这只是一个函数声明语句, V8 在这个阶段只需要将该函数转换为函数对象, 但是并没有解析和编译函数内部的代码, 所以也不会为 foo 函数的内部代码生成抽象语法树. 然后继续往下解析, 由于后续的代码都是顶层代码, 所以 V8 会为它们生成抽象语法树.

代码解析完成之后, V8 便会按照顺序自上而下执行代码, 首先会先执行 a = 1c = 4 这两个赋值表达式, 接下来执行 foo 函数的调用, 过程是从 foo 函数对象中取出函数代码, 然后和编译顶层代码一样, V8 会先编译 foo 函数的代码, 编译时同样需要先将其编译为抽象语法树和字节码, 然后再解释执行.

闭包

但是当惰性解析遇上闭包, 就要有特殊的策略了, 首先我们先复习一下闭包.

因为函数是一种特殊的对象, 所以在 JavaScript 中, 函数可以赋值给一个变量, 也可以作为函数的参数, 还可以作为函数的返回值. 如果某个编程语言的函数, 可以和这个语言的数据类型做一样的事情, 我们就把这个语言中的函数称为一等公民. 但是由于函数的可被调用的特性, 使得实现函数的可赋值, 可传参和可作为返回值等特性变得有一点麻烦.

我们知道, 在执行 JavaScript 函数的过程中, 为了实现变量的查找, V8 会为其维护一个作用域链, 如果函数中使用了某个变量, 但是在函数内部又没有定义该变量, 那么函数就会沿着作用域链去外部的作用域中查找该变量, 具体流程如下图所示:

函数作用域链
函数作用域链

从图中可以看出, 当函数内部引用了外部的变量时, 使用这个函数进行赋值, 传参或作为返回值, 我们还需要保证这些被引用的外部变量是确定存在的, 这就是让函数作为一等公民麻烦的地方, 因为虚拟机还需要处理函数引用的外部变量. 我们来看一段简单的代码:

function foo() { var number = 1; function bar() { number++; console.log(number); } return bar; } var mybar = foo(); mybar();

我们在 foo 函数中定义了一个新的 bar 函数, 并且 bar 函数引用了 foo 函数中的变量 number, 当调用 foo 函数的时候, 它会返回 bar 函数. 那么所谓的函数是一等公民就体现在, 如果要返回函数 bar 给外部, 那么即便 foo 函数执行结束了, 其内部定义的 number 变量也不能被销毁, 因为 bar 函数依然引用了该变量. 我们也把这种将外部变量和和函数绑定起来的技术称为闭包.

当惰性解析遇上闭包

对于普通函数, V8 确实是这么解析的, 但由于存在闭包, 一切都变得复杂起来了. 这是因为闭包有三大特性:

  • 可以在 JavaScript 函数内部定义新的函数;
  • 内部函数中访问父函数中定义的变量;
  • 因为 JavaScript 中的函数是一等公民, 所以函数可以作为另外一个函数的返回值.
function foo() { var d = 20; return function inner(a, b) { const c = a + b + d; return c; }; } const f = foo();

我们考察上面这段代码.

  • 当调用 foo 函数时, foo 函数会将它的内部函数 inner 返回给全局变量 f;
  • 然后 foo 函数执行结束, 执行上下文被 V8 销毁;
  • 虽然 foo 函数的执行上下文被销毁了, 但是依然存活的 inner 函数引用了 foo 函数作用域中的变量 d.

按照通用的做法, d 已经被 v8 销毁了, 但是由于存活的函数 inner 依然引用了 foo 函数中的变量 d, 这样就会带来两个问题:

  • 当 foo 执行结束时, 变量 d 该不该被销毁?如果不应该被销毁, 那么应该采用什么策略?
  • 如果采用了惰性解析, 那么当执行到 foo 函数时, V8 只会解析 foo 函数, 而不会解析内部代码, 也就是并不会解析内部的 inner 函数, 那么这时候 V8 就不知道 inner 函数中是否引用了 foo 函数的变量 d.

我们佐以下面这张图执行堆栈图来分析一下:

堆栈图
堆栈图

从上图可以看出来, 在执行全局代码时, V8 会将全局执行上下文压入到调用栈中, 然后进入执行 foo 函数的调用过程, 这时候 V8 会为 foo 函数创建执行上下文, 执行上下文中包括了变量 d, 然后将 foo 函数的执行上下文压入栈中, foo 函数执行结束之后, foo 函数执行上下文从栈中弹出, 这时候 foo 执行上下文中的变量 d 也随之被销毁.

但是这时候, 由于 inner 函数被保存到全局变量中了, 所以 inner 函数依然存在, 最关键的地方在于 inner 函数使用了 foo 函数中的变量 d, 所以正常的处理方式应该是 foo 函数的执行上下文虽然被销毁了, 但是 inner 函数引用的 foo 函数中的变量却不能被销毁, 那么 V8 就需要为这种情况做特殊处理, 需要保证即便 foo 函数执行结束, 但是 foo 函数中的 d 变量依然保持在内存中, 不能随着 foo 函数的执行上下文被销毁掉.

基于此, V8 在执行 foo 函数的阶段, 虽然采取了惰性解析, 不会解析和执行 foo 函数中的 inner 函数, 但是 V8 还是需要判断 inner 函数是否引用了 foo 函数中的变量, 负责处理这个任务的模块叫做预解析器.

V8 引入预解析器, 比如当解析顶层代码的时候, 遇到了一个函数, 那么预解析器并不会直接跳过该函数, 而是对该函数做一次快速的预解析. 第一, 是判断当前函数是不是存在一些语法上的错误; 第二, 除了检查语法错误之外, 预解析器另外的一个重要的功能就是检查函数内部是否引用了外部变量, 如果引用了外部的变量, 预解析器会将栈中的变量复制到堆中, 在下次执行到该函数的时候, 直接使用堆中的引用, 这样就解决了闭包所带来的问题.

谈一谈字节码

所谓字节码, 是指编译过程中的中间代码, 你可以把字节码看成是机器代码的抽象, 在 V8 中, 字节码有两个作用:

  • 第一个是解释器可以直接解释执行字节码;
  • 第二个是优化编译器可以将字节码编译为二进制代码, 然后再执行二进制机器代码.

早期的 V8

早期 V8 团队认为先生成字节码再执行字节码的方式, 多了个中间环节, 多出来的中间环节会牺牲代码的执行速度. 于是在早期, V8 团队采取了非常激进的策略, 直接将 JavaScript 代码编译成没有优化的二进制的机器代码, 如果某一段二进制代码执行频率过高, 那么 V8 会将其标记为热点代码, 热点代码会被优化编译器优化, 优化后的机器代码执行效率更高.

早期的 V8
早期的 V8

观察上面的执行流程图, 我们可以发现, 早期的 V8 也使用了两个编译器:

  • 第一个是基线编译器, 它负责将 JavaScript 代码编译为没有优化过的机器代码.
  • 第二个是优化编译器, 它负责将一些热点代码(执行频繁的代码)优化为执行效率更高的机器代码.

了解这两个编译器之后, 接下来我们再来看看早期的 V8 是怎么执行一段 JavaScript 代码的.

  1. 首先, V8 会将一段 JavaScript 代码转换为抽象语法树 (AST).
  2. 接下来基线编译器会将抽象语法树编译为未优化过的机器代码, 然后 V8 直接执行这些未优化过的机器代码.
  3. 在执行未优化的二进制代码过程中, 如果 V8 检测到某段代码重复执行的概率过高, 那么 V8 会将该段代码标记为 HOT, 标记为 HOT 的代码会被优化编译器优化成执行效率高的二进制代码, 然后就执行该段优化过的二进制代码.
  4. 不过如果优化过的二进制代码并不能满足当前代码的执行, 这也就意味着优化失败, V8 则会执行反优化操作.

机器代码缓存

当 JavaScript 代码在浏览器中被执行的时候, 需要先被 V8 编译, 早期的 V8 会将 JavaScript 编译成未经优化的二进制机器代码, 然后再执行这些未优化的二进制代码, 通常情况下, 编译占用了很大一部分时间. 为了能够复用编译好的二进制代码, Chrome 浏览器引入二进制代码缓存. 过把二进制代码保存在内存中来消除冗余的编译, 重用它们完成后续的调用, 这样就省去了再次编译的时间. V8 使用两种代码缓存策略来缓存生成的代码:

  • 首先, 是 V8 第一次执行一段代码时, 会编译源 JavaScript 代码, 并将编译后的二进制代码缓存在内存中, 我们把这种方式称为内存缓存(in-memory cache). 然后通过 JavaScript 源文件的字符串在内存中查找对应的编译后的二进制机器码. 这样当再次执行到这段代码时, V8 就可以直接去内存中查找是否编译过这段代码. 如果内存缓存中存在这段代码所对应的二进制代码, 那么就直接执行编译好的二进制代码.
  • 其次, V8 除了采用将代码缓存在内存中策略之外, 还会将代码缓存到硬盘上, 这样即便关闭了浏览器, 下次重新打开浏览器再次执行相同代码时, 也可以直接重复使用编译好的二进制代码.

从数据上看, 在浏览器中采用了二进制代码缓存的方式, 初始加载时分析和编译的时间缩短了 20%-40%.

为什么又采用了字节码

上面说到旧时代的 V8 将二进制机器码保存起来, 这显然是一种空间换时间的策略. 我们知道 Chrome 的多进程架构已经非常吃内存了, 而二进制机器码数千倍于相应的 JavaScript 代码, 在 PC 端尚可, 而在早期智能手机上 RAM 是吃不消的.

此外, 我们上面说到了惰性编译, 它除了能提升 JavaScript 启动速度, 还可以解决部分内存占用的问题(毕竟函数体内的东西在未执行前没有解析, 因此早期的 Chrome 并没有缓存函数内部的二进制代码, 只是缓存了顶层次的二进制代码). 比如下面的图片,当 V8 首次执行这段代码的过程中, 开始只是编译最外层的代码, 那些函数内部的代码, 如下图中的黄色的部分, 会推迟到第一次调用时再编译. 为了解决缓存的二进制机器代码占用过多内存的问题, 早期的 Chrome 并没有缓存函数内部的二进制代码, 只是缓存了顶层次的二进制代码, 比如上图中红色的区域.

旧时代 V8 的惰性解析与缓存策略
旧时代 V8 的惰性解析与缓存策略

但是, 如果惰性编译遇到 IIFE 就没辙了, 因为按照惰性编译的策略, 如果只缓存了 IIFE 顶层, 没缓存 IIFE 内部的闭包函数, 比如看下面的代码, init, add 这些都没法被缓存.

var test_module = (function () { var count_; function init_() { count_ = 0; } function add_() { count_ = count_ + 1; } function show_() { console.log(count_); } return { init: init_, add: add_, show: show_, }; })(); test_module.init(); test_module.add(); test_module.show(); test_module.add(); test_module.show();

所以采取只缓存顶层代码的方式是不完美的, 没办法适应多种不同的情况, 因此, V8 团队对早期的 V8 架构进行了非常大的重构, 具体地讲, 抛弃之前的基线编译器和优化编译器, 引入了字节码, 解释器和新的优化编译器. 而通过引入字节码, 就能降低 V8 在执行时的内存占用. 从下图中可以看出, 字节码虽然占用的空间比原始的 JavaScript 多, 但是相较于机器代码, 字节码还是小了太多. 有了字节码, 无论是解释器的解释执行, 还是优化编译器的编译执行, 都可以直接针对字节来进行操作. 由于字节码占用的空间远小于二进制代码, 所以浏览器就可以实现缓存所有的字节码, 而不是仅仅缓存顶层的字节码.

源码, 字节码, 机器码大小对比
源码, 字节码, 机器码大小对比

虽然采用字节码在执行速度上稍慢于机器代码, 但是整体上权衡利弊, 采用字节码也许是最优解. 之所以说是最优解, 是因为采用字节码除了降低内存之外, 还提升了代码的启动速度, 解决空间问题, 并降低了代码的复杂度, 而牺牲的仅仅是一点执行效率:

  • 解决启动问题: 源码生成字节码的时间很短, 生成二进制码时间消耗较长;
  • 解决空间问题: 字节码占用内存不多, 缓存字节码会大大降低内存的使用;
  • 代码架构清晰: 字节码, 一般是在虚拟机上执行的代码, 可以简化程序的复杂度, 使得 V8 移植到不同的 CPU 架构平台更加容易; 而二进制代码代码在不同 CPU 上是不同的.

字节码如何提升代码启动速度

字节码与机器码对比
字节码与机器码对比

图中可以看出, 生成机器代码比生成字节码需要花费更久的时间, 但是直接执行机器代码却比解释执行字节码要更高效, 所以在快速启动 JavaScript 代码与花费更多时间获得最优运行性能的代码之间, 我们需要找到一个平衡点.

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

字节码如何降低代码的复杂度

期的 V8 代码, 无论是基线编译器还是优化编译器, 它们都是基于 AST 抽象语法树来将代码转换为机器码的, 我们知道, 不同架构的机器码是不一样的, 而市面上存在不同架构的处理器又是非常之多. 这意味着基线编译器和优化编译器要针对不同的体系的 CPU 编写不同的代码, 这会大大增加代码量.

引入了字节码之前
引入了字节码之前

引入了字节码, 就可以统一将字节码转换为不同平台的二进制代码. 因为字节码的执行过程和 CPU 执行二进制代码的过程类似, 相似的执行流程, 那么将字节码转换为不同架构的二进制代码的工作量也会大大降低, 这就降低了转换底层代码的工作量.

引入了字节码之后
引入了字节码之后

实战: 解释器如何执行字节码的

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

要查看 V8 中间生成的一些结构, 可以使用 V8 提供的调试工具 D8 来查看. 注意不要使用 brew install v8 安装, 因为这个是生产环境的 V8, 而我们需要安装 debug 版本的 V8, 才可以进行一些调试. 我们可以使用 jsvu, 它是一个 JavaScript 引擎版本更新器, 我们可以通过这个工具安装 debug 版本的 V8.

V8 debug
V8 debug

然后我们在 .zshrc 或者 .bashrc 中保存如下 shell 脚本, 重启下终端就可以了.

# D8 export PATH="${HOME}/.jsvu:${PATH}"

我们考察下面这段代码:

function add(x, y) { var z = x + y; return z; } console.log(add(1, 2));

AST

执行 v8-debug --print-ast index.js, 就可以获取到抽象语法树的结构.

[generating bytecode for function: add] --- AST --- FUNC at 12 . KIND 0 . LITERAL ID 1 . SUSPEND COUNT 0 . NAME "add" . PARAMS . . VAR (0x7fa45f81ce70) (mode = VAR, assigned = false) "x" . . VAR (0x7fa45f81cef0) (mode = VAR, assigned = false) "y" . DECLS . . VARIABLE (0x7fa45f81ce70) (mode = VAR, assigned = false) "x" . . VARIABLE (0x7fa45f81cef0) (mode = VAR, assigned = false) "y" . . VARIABLE (0x7fa45f81cf70) (mode = VAR, assigned = false) "z" . BLOCK NOCOMPLETIONS at -1 . . EXPRESSION STATEMENT at 31 . . . INIT at 31 . . . . VAR PROXY local[0] (0x7fa45f81cf70) (mode = VAR, assigned = false) "z" . . . . ADD at 33 . . . . . VAR PROXY parameter[0] (0x7fa45f81ce70) (mode = VAR, assigned = false) "x" . . . . . VAR PROXY parameter[1] (0x7fa45f81cef0) (mode = VAR, assigned = false) "y" . RETURN at 40 . . VAR PROXY local[0] (0x7fa45f81cf70) (mode = VAR, assigned = false) "z"
  • 第一部分为参数的声明(PARAMS), 参数声明中包括了所有的参数, 在这里主要是参数 x 和参数 y, 你可以在函数体中使用 arguments 来使用对应的参数.
  • 第二部分是变量声明节点(DECLS), 参数部分你可以使用 arguments 来调用, 同样, 你也可以将这些参数作为变量来直接使用, 这体现在 DECLS 节点下面也出现了变量 x 和变量 y, 除了可以直接使用 x 和 y 之外, 我们还有一个 z 变量也在 DECLS 节点下. 你可以注意一下, 在上面生成的 AST 数据中, 参数声明节点中的 x 和变量声明节点中的 x 的地址是相同的, 都是 0x7fa45f81ce70 y 也是相同的, 都是 0x7fa45f81cef0, 这说明它们指向的是同一块数据.
  • 第三部分是 x + y 的表达式节点, 我们可以看到, 节点 add 下面使用了 var proxy xvar proxy x 的语法, 它们指向了实际 x 和 y 的值.
  • 第四部分是 RETURN 节点, 它指向了 z 的值, 在这里是 local[0].

作用域

我们执行 v8-debug --print-scopes index.js, 就可以获取到作用域, 作用域中的变量都是未使用的, 默认值都是 undefined, 在执行阶段, 作用域中的变量会指向堆和栈中相应的数据.

在解析期间, 所有函数体中声明的变量和函数参数, 都被放进作用域中, 如果是普通变量, 那么默认值是 undefined, 如果是函数声明, 那么将指向实际的函数对象.

Global scope: function add (x, y) { // (0x7fda8d80da20) (12, 51) // will be compiled // NormalFunction // 1 stack slots // local vars: VAR x; // (0x7fda8d80dc70) parameter[0], never assigned VAR z; // (0x7fda8d80dd70) local[0], never assigned VAR y; // (0x7fda8d80dcf0) parameter[1], never assigned }

字节码

我们可以通过 v8-debug --print-bytecode index.js 来获取字节码.

[generated bytecode for function: add (0x0ad900253961 <SharedFunctionInfo add>)] Bytecode length: 7 Parameter count 3 Register count 1 Frame size 8 Bytecode age: 0 0xad900253b0a @ 0 : 0b 04 Ldar a1 0xad900253b0c @ 2 : 39 03 00 Add a0, [0] 0xad900253b0f @ 5 : c4 Star0 0xad900253b10 @ 6 : a9 Return Constant pool (size = 0) Handler Table (size = 0) Source Position Table (size = 0)

我们可以看到, 生成的字节码第一行提示了 Parameter count 3, 这是告诉我们这里有三个参数, 包括了显式地传入了 x 和 y, 还有一个隐式地传入了 this. 接下来我们先谈一谈解释器的架构设计, 然后再来逐行来了解字节码指令集的作用.

解释器的架构设计

字节码似乎和汇编代码有点像, 每一行表示一个特定的功能, 把这些功能拼凑在一起就构成完整的程序, 我们把每一行称为字节码的指令集. 如下是截取的部分指令集, 完整的你可以去 v8 源码里去看.

部分字节码指令集
部分字节码指令集

解释器就是模拟物理机器来执行字节码的, 比如可以实现如取指令, 解析指令, 执行指令, 存储数据等, 所以解释器的执行架构和 CPU 处理机器代码的架构类似. 关于这些底层知识, 你可以参考我写的浅析 CPU 是如何执行二进制机器码的这篇文章.

通常有两种类型的解释器, 基于栈(Stack-based)和基于寄存器(Register-based), 基于栈的解释器使用栈来保存函数参数, 中间运算结果, 变量等, 基于寄存器的虚拟机则支持寄存器的指令操作, 使用寄存器来保存参数, 中间计算结果. 基于栈的虚拟机也定义了少量的寄存器, 基于寄存器的虚拟机也有堆栈, 其区别体现在它们提供的指令集体系.

大多数解释器都是基于栈的, 比如 Java 虚拟机, .Net 虚拟机, 还有早期的 V8 虚拟机. 基于堆栈的虚拟机在处理函数调用, 解决递归问题和切换上下文时简单明快. 而现在的 V8 虚拟机则采用了基于寄存器的设计, 它将一些中间数据保存到寄存器中.

基于寄存器的解释器架构
基于寄存器的解释器架构

基于寄存器的解释器执行时主要有四个模块, 内存中的字节码, 寄存器, 栈, 堆. 这和我们在浅析 CPU 是如何执行二进制机器码的这篇文章中讲到 CPU 执行二进制机器代码的模式是类似的.

  • 使用内存中的一块区域来存放字节码;
  • 使用了通用寄存器 r0, r1, r2 这些寄存器用来存放一些中间数据;
  • PC 寄存器用来指向下一条要执行的字节码;
  • 栈顶寄存器用来指向当前的栈顶的位置.

我们先看 Ldar a1, Ldar(LoaD Accumulator from Register), 是累加器的意思, 表示将寄存器中的值加载到累加器中, Ldar a1 的意思就是把 a1 寄存器中的值, 加载到累加器中.

Ldar
Ldar

再看 Star r0, Star(Store Accumulator Register), 就是把累加器中的值保存到某个寄存器中, Star r0 的意思就是将累加器中的数值保存到 r0 寄存器中.

Star
Star

接下来是 Add a0, [0], Add a0 从 a0 寄存器加载值并将其与累加器中的值相加, 然后将结果再次放入累加器, 最终操作如下图所示. 后面的 [0] 称为反馈向量槽(feedback vector slot), 它是一个可变数组, 解释器将解释执行过程中的一些数据类型的分析信息都保存在这个反馈向量槽中了, 目的是为了给 TurboFan 优化编译器提供优化信息, 很多字节码都会为反馈向量槽提供运行时信息.

Add
Add

当然最后的 Return 指的是结束当前函数的执行, 并将控制权传回给调用方. 返回的值是累加器中的值.

最后让我们捋一下上面这个例子的执行过程:

  • 参数对象 parameter 保存在栈中, 包含了 a0 和 a1 两个值, 在上面的代码中, 这两个值分别是 1 和 2;
  • 执行字节码, Ldar a1, 这是将 a1 寄存器中的参数值加载到累加器中;
  • 接下来执行加法操作, Add a0, [0], 因为 a0 是第一个寄存器, 存放了第一个参数, Add a0 就是将第一个寄存器中的值和累加器中的值相加, 也就是将累加器中的 2 和通用寄存器中 a0 中的 1 进行相加, 同时将相加后的结果 3 保存到累加器中.
  • 现在累加器中就保存了相加后的结果, 然后执行第四段字节码, Star r0, 这是将累加器中的值, 也就是 1 + 2 的结果 3 保存到寄存器 r0 中, 那么现在寄存器 r0 中的值就是 3 了.
  • 接下来 V8 将寄存器 r0 中的值加载到累加器中, 然后执行最后一句 Return 指令, Return 指令会中断当前函数的执行, 并将累加器中的值作为返回值.

优化和反优化

生成字节码之后, 解释器会解释执行这段字节码, 如果重复执行了某段代码, 监控器就会将其标记为热点代码, 并提交给编译器优化执行, 如果我们想要查看那些代码被优化了, 可以使用下面的命令. 当然我们的代码太简单了, 没有被标记成热点代码的情况.

v8-debug --trace-opt index.js

如果要查看那些代码被反优化了, 可以使用如下命令行来查看.

pt --trace-deopt index.js

谈一谈隐藏类

我们知道 JavaScript 是一门动态语言, 其执行效率要低于静态语言. 这是因为 JavaScript 在运行时, 对象的属性是可以被修改的, 所以当 V8 访问一个对象 point.x 时, 它并不知道该对象中是否有 x, 也不知道 x 相对于对象的偏移量是多少, 也可以说 V8 并不知道该对象的具体的形状. 我们在上面讲快属性和慢属性时候, 假设 x 被分配到了慢属性, 这个查找过程非常的慢且耗时.

而静态语言不同, 比如 Rust, 在声明一个对象之前需要定义该对象的结构, 我们也可以称为形状, 比如 Point 结构体就是一种形状, 我们可以使用这个形状来定义具体的对象.

struct Point { x: i32, y: i32 }

Rust 代码在执行之前需要先被编译, 编译的时候, 每个对象的形状都是固定的, 也就是说, 在代码的执行过程中, Point 的形状是无法被改变的.

因此那么在 Rust 中访问一个对象的属性时, 自然就知道该属性相对于该对象地址的偏移值了, 比如在 Rust 中使用 point.x 的时候, 编译器会直接将 x 相对于 point 的地址写进汇编指令中, 那么当使用了对象 point 中的 x 属性时, CPU 就可以直接去内存地址中取出该内容即可, 没有任何中间的查找环节. 因此静态语言可以直接通过偏移量查询来查询对象的属性值, 这也就是静态语言的执行效率高的一个原因.

而 V8 也参考了这种静态的特性, 目前所采用的一个思路就是将 JavaScript 中的对象静态化, 也就是 V8 在运行 JavaScript 的过程中, 会假设 JavaScript 中的对象是静态的, 具体地讲, V8 对每个对象做如下两点假设:

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

V8 基于这两点假设, 为每个对象创建了一个叫做隐藏类的属性, 对象的隐藏类中记录了该对象一些基础的布局信息, 包括以下两点:

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

有了隐藏类之后, 那么当 V8 访问某个对象中的某个属性时, 就会先去隐藏类中查找该属性相对于它的对象的偏移量, 有了偏移量和属性类型, V8 就可以直接去内存中取出对于的属性值, 而不需要经历一系列的查找过程, 那么这就大大提升了 V8 查找对象的效率.

let point = { x: 100, y: 200 };

以上面这段代码为例, 当 V8 执行到这段代码时, 会先为 point 对象创建一个隐藏类, 在 V8 中, 把隐藏类又称为 map, 每个对象都有一个 map 属性, 其值指向内存中的隐藏类. 隐藏类描述了对象的属性布局, 它主要包括了属性名称和每个属性所对应的偏移量, 比如 point 对象的隐藏类就包括了 x 和 y 属性, x 的偏移量是 4, y 的偏移量是 8.

注意这是 point 对象的 map, 它不是 point 对象本身. 关于 point 对象和 map 之间的关系, 你可以参看下图, 在这张图中, 左边的是 point 对象在内存中的布局, 右边是 point 对象的 map, 我们可以看到, point 对象的第一个属性就指向了它的 map, 关于如何通过浏览器查看对象的 map, 我们在上面讲快属性和慢属性是时候已经讲了怎么在 Chrome Dev Tools 中查看隐藏类.

map
map

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

这就是将动态语言静态化的一个操作, V8 通过引入隐藏类, 模拟 Rust 这种静态语言的机制, 从而达到静态语言的执行效率.

我曾经翻译过一篇文章从 React 源码谈 v8 引擎对数组的内部处理, 这篇文章讲 V8 对数组的优化, 有兴趣也可以看看.

实战: 在 D8 查看隐藏类

我们编写如下代码, DebugPrint 是 D8 内置的 API, 用来打印 debug. (操, 这玩意儿让我想起在百度写 Smarty 的恐惧感来了).

let point = { x: 100, y: 200 }; %DebugPrint(point);

然后执行以下脚本:

d8 --allow-natives-syntax test.js

下面就打印出 point 对象的基础结构了, 打印出来的结果如下所示:

DebugPrint: 0x31f1000ca021: [JS_OBJECT_TYPE] - map: 0x31f100287d51 <Map(HOLEY_ELEMENTS)> [FastProperties] - prototype: 0x31f100244401 <Object map = 0x31f1002821e9> - elements: 0x31f100002261 <FixedArray[0]> [HOLEY_ELEMENTS] - properties: 0x31f100002261 <FixedArray[0]> - All own properties (excluding elements): { 0x31f1002538dd: [String] in OldSpace: #x: 100 (const data field 0), location: in-object 0x31f1002538ed: [String] in OldSpace: #y: 200 (const data field 1), location: in-object } 0x31f100287d51: [Map] - type: JS_OBJECT_TYPE - instance size: 20 - inobject properties: 2 - elements kind: HOLEY_ELEMENTS - unused property fields: 0 - enum length: invalid - stable_map - back pointer: 0x31f100287d29 <Map(HOLEY_ELEMENTS)> - prototype_validity cell: 0x31f1001c45b5 <Cell value= 1> - instance descriptors (own) #2: 0x31f1000ca051 <DescriptorArray[2]> - prototype: 0x31f100244401 <Object map = 0x31f1002821e9> - constructor: 0x31f100244015 <JSFunction Object (sfi = 0x31f1001db471)> - dependent code: 0x31f1000021e9 <Other heap object (WEAK_ARRAY_LIST_TYPE)> - construction counter: 0

首先 HOLEY_ELEMENTS 是不是很熟? 如果你看过我写的从 React 源码谈 v8 引擎对数组的内部处理这篇文章, 一定知道是什么意思.

从这段 point 的内存结构中我们可以看到, point 对象的第一个属性就是 map, 它指向了 0x31f100287d51 这个地址, 这个地址就是 V8 为 point 对象创建的隐藏类, 除了 map 属性之外, 还有我们之前讲快属性慢属性时介绍过的 prototype 属性, elements 属性和 properties 属性.

多个对象共用一个隐藏类

我们上面分析 Rust 的 struct 时大抵就能猜出, 不是每个对象都有自己独一无二的隐藏类, 只要两个对象的形状相同, 他们就会使用同一个隐藏类. 我们仍可以用 D8 做个实现, 我们使用 Point 类新建两个实例, 分别 DebugPrint 出来.

class Point { constructor(x, y) { this.x = x; this.y = y; } } let point = new Point(1, 2); let point1 = new Point(3, 4); %DebugPrint(point); %DebugPrint(point1);

如下是打印出来的 point 和 point1 两个对象的基础结构, 我们可以看到两个对象的隐藏类地址是同一个.

DebugPrint: 0x2e54000ca199: [JS_OBJECT_TYPE] - map: 0x2e5400287e41 <Map(HOLEY_ELEMENTS)> [FastProperties] // point 对象的隐藏类地址 - prototype: 0x2e54000ca0cd <Point map = 0x2e5400287df1> - elements: 0x2e5400002261 <FixedArray[0]> [HOLEY_ELEMENTS] - properties: 0x2e5400002261 <FixedArray[0]> - All own properties (excluding elements): { 0x2e54002538dd: [String] in OldSpace: #x: 1 (const data field 0), location: in-object 0x2e54002538ed: [String] in OldSpace: #y: 2 (const data field 1), location: in-object } 0x2e5400287e41: [Map] - type: JS_OBJECT_TYPE - instance size: 52 - inobject properties: 10 - elements kind: HOLEY_ELEMENTS - unused property fields: 8 - enum length: invalid - stable_map - back pointer: 0x2e5400287e19 <Map(HOLEY_ELEMENTS)> - prototype_validity cell: 0x2e5400253c35 <Cell value= 0> - instance descriptors (own) #2: 0x2e54000ca239 <DescriptorArray[2]> - prototype: 0x2e54000ca0cd <Point map = 0x2e5400287df1> - constructor: 0x2e54000ca0ad <JSFunction Point (sfi = 0x2e54002539c9)> - dependent code: 0x2e54000021e9 <Other heap object (WEAK_ARRAY_LIST_TYPE)> - construction counter: 6 DebugPrint: 0x2e54000ca261: [JS_OBJECT_TYPE] - map: 0x2e5400287e41 <Map(HOLEY_ELEMENTS)> [FastProperties] // point1 对象的隐藏类地址 - prototype: 0x2e54000ca0cd <Point map = 0x2e5400287df1> - elements: 0x2e5400002261 <FixedArray[0]> [HOLEY_ELEMENTS] - properties: 0x2e5400002261 <FixedArray[0]> - All own properties (excluding elements): { 0x2e54002538dd: [String] in OldSpace: #x: 3 (const data field 0), location: in-object 0x2e54002538ed: [String] in OldSpace: #y: 4 (const data field 1), location: in-object } 0x2e5400287e41: [Map] - type: JS_OBJECT_TYPE - instance size: 52 - inobject properties: 10 - elements kind: HOLEY_ELEMENTS - unused property fields: 8 - enum length: invalid - stable_map - back pointer: 0x2e5400287e19 <Map(HOLEY_ELEMENTS)> - prototype_validity cell: 0x2e5400253c35 <Cell value= 0> - instance descriptors (own) #2: 0x2e54000ca239 <DescriptorArray[2]> - prototype: 0x2e54000ca0cd <Point map = 0x2e5400287df1> - constructor: 0x2e54000ca0ad <JSFunction Point (sfi = 0x2e54002539c9)> - dependent code: 0x2e54000021e9 <Other heap object (WEAK_ARRAY_LIST_TYPE)> - construction counter: 6

重新构建隐藏类

我们上面说到 V8 为了实现隐藏类是依据两条假设的, 但在运行时, 对象的形状是可以被改变的, 如果某个对象的形状改变了, 隐藏类也会随着改变, 这意味着 V8 要为新改变的对象重新构建新的隐藏类, 这对于 V8 的执行效率来说, 是一笔大的开销. 通俗地理解, 给一个对象添加新的属性, 删除新的属性, 或者改变某个属性的数据类型都会改变这个对象的形状, 那么势必也就会触发 V8 为改变形状后的对象重建新的隐藏类. 我们看下面的代码:

let point = { x: 100, y:100 }; %DebugPrint(point); point.z = 100; %DebugPrint(point); delete point.x; %DebugPrint(point);

我们看到三次 map 指向的地址都不同.

DebugPrint: 0x4dd000ca069: [JS_OBJECT_TYPE] - map: 0x04dd00287d51 <Map(HOLEY_ELEMENTS)> [FastProperties] // 第一次 - prototype: 0x04dd00244401 <Object map = 0x4dd002821e9> - elements: 0x04dd00002261 <FixedArray[0]> [HOLEY_ELEMENTS] - properties: 0x04dd00002261 <FixedArray[0]> - All own properties (excluding elements): { 0x4dd002538dd: [String] in OldSpace: #x: 100 (const data field 0), location: in-object 0x4dd002538ed: [String] in OldSpace: #y: 100 (const data field 1), location: in-object } 0x4dd00287d51: [Map] - type: JS_OBJECT_TYPE - instance size: 20 - inobject properties: 2 - elements kind: HOLEY_ELEMENTS - unused property fields: 0 - enum length: invalid - stable_map - back pointer: 0x04dd00287d29 <Map(HOLEY_ELEMENTS)> - prototype_validity cell: 0x04dd001c45b5 <Cell value= 1> - instance descriptors (own) #2: 0x04dd000ca099 <DescriptorArray[2]> - prototype: 0x04dd00244401 <Object map = 0x4dd002821e9> - constructor: 0x04dd00244015 <JSFunction Object (sfi = 0x4dd001db471)> - dependent code: 0x04dd000021e9 <Other heap object (WEAK_ARRAY_LIST_TYPE)> - construction counter: 0 DebugPrint: 0x4dd000ca069: [JS_OBJECT_TYPE] - map: 0x04dd00287d79 <Map(HOLEY_ELEMENTS)> [FastProperties] // 第二次 - prototype: 0x04dd00244401 <Object map = 0x4dd002821e9> - elements: 0x04dd00002261 <FixedArray[0]> [HOLEY_ELEMENTS] - properties: 0x04dd000ca0f5 <PropertyArray[3]> - All own properties (excluding elements): { 0x4dd002538dd: [String] in OldSpace: #x: 100 (const data field 0), location: in-object 0x4dd002538ed: [String] in OldSpace: #y: 100 (const data field 1), location: in-object 0x4dd00253915: [String] in OldSpace: #z: 100 (const data field 2), location: properties[0] } 0x4dd00287d79: [Map] - type: JS_OBJECT_TYPE - instance size: 20 - inobject properties: 2 - elements kind: HOLEY_ELEMENTS - unused property fields: 2 - enum length: invalid - stable_map - back pointer: 0x04dd00287d51 <Map(HOLEY_ELEMENTS)> - prototype_validity cell: 0x04dd00253a75 <Cell value= 0> - instance descriptors (own) #3: 0x04dd000ca0c1 <DescriptorArray[3]> - prototype: 0x04dd00244401 <Object map = 0x4dd002821e9> - constructor: 0x04dd00244015 <JSFunction Object (sfi = 0x4dd001db471)> - dependent code: 0x04dd000021e9 <Other heap object (WEAK_ARRAY_LIST_TYPE)> - construction counter: 0 DebugPrint: 0x4dd000ca069: [JS_OBJECT_TYPE] - map: 0x04dd00285709 <Map(HOLEY_ELEMENTS)> [DictionaryProperties] // 第三次 - prototype: 0x04dd00244401 <Object map = 0x4dd002821e9> - elements: 0x04dd00002261 <FixedArray[0]> [HOLEY_ELEMENTS] - properties: 0x04dd000ca109 <NameDictionary[29]> - All own properties (excluding elements): { y: 100 (data, dict_index: 2, attrs: [WEC]) z: 100 (data, dict_index: 3, attrs: [WEC]) } 0x4dd00285709: [Map] - type: JS_OBJECT_TYPE - instance size: 12 - inobject properties: 0 - elements kind: HOLEY_ELEMENTS - unused property fields: 0 - enum length: invalid - dictionary_map - may_have_interesting_symbols - back pointer: 0x04dd000023e9 <undefined> - prototype_validity cell: 0x04dd001c45b5 <Cell value= 1> - instance descriptors (own) #0: 0x04dd000021f5 <Other heap object (STRONG_DESCRIPTOR_ARRAY_TYPE)> - prototype: 0x04dd00244401 <Object map = 0x4dd002821e9> - constructor: 0x04dd00244015 <JSFunction Object (sfi = 0x4dd001db471)> - dependent code: 0x04dd000021e9 <Other heap object (WEAK_ARRAY_LIST_TYPE)> - construction counter: 0

隐藏类的最佳实践

一: 使用字面量初始化对象时, 要保证属性的顺序是一致的. 比如先通过字面量 x, y 的顺序创建了一个 point 对象, 然后通过字面量 y, x 的顺序创建一个对象 point2. 虽然创建时的对象属性一样, 但是它们初始化的顺序不一样, 这也会导致形状不同, 所以它们会有不同的隐藏类.

二: 尽量使用字面量一次性初始化完整对象属性. 因为每次为对象添加一个属性时, V8 都会为该对象重新设置隐藏类.

三: 尽量避免使用 delete 方法. delete 方法会破坏对象的形状, 同样会导致 V8 为该对象重新生成新的隐藏类.

谈一谈内联缓存

我们考察如下代码:

function loadX(o) { return o.x; } var o = { x: 1, y: 3 }; var o1 = { x: 3, y: 6 }; for (var i = 0; i < 90000; i++) { loadX(o); loadX(o1); }

我们知道 V8 会给 o 和 o1 增加了隐藏类, 在获取 o.x 时, V8 会查找对象 o 的隐藏类, 再通过隐藏类查找 x 属性偏移量, 然后根据偏移量获取属性值. 但是如果放在一个大循环里, 意味着每次都要做这个操作. 我们有没有办法再度简化这个查找过程, 最好能一步到位查找到 x 的属性值呢?

答案是有的, V8 采用内联缓存 (Inline Cache, 简称为 IC), 来加速函数执行. 我们来看一下 IC 的工作原理: V8 执行函数的过程中, 会观察函数中一些调用点(CallSite) 上的关键的中间数据, 然后将这些数据缓存起来, 当下次再次执行该函数的时候, V8 就可以直接利用这些中间数据, 节省了再次获取这些数据的过程, 因此 V8 利用 IC, 可以有效提升一些重复代码的执行效率.

IC 会为每个函数维护一个反馈向量(FeedBack Vector), 反馈向量记录了函数在执行过程中的一些关键的中间数据. 反馈向量其实就是一个表结构, 它由很多项组成的, 每一项称为一个插槽(Slot), V8 会依次将执行 loadX 函数的中间数据写入到反馈向量的插槽中.

function loadX(o) { o.y = 4; return o.x; }

比如上面这个代码, 当 V8 执行这段函数的时候, 它会判断 o.y = 4return o.x 这两段是调用点(CallSite), 因为它们使用了对象和属性, 那么 V8 会在 loadX 函数的反馈向量中为每个调用点分配一个插槽.

每个插槽中包括了插槽的索引 (slot index), 插槽的类型 (type), 插槽的状态 (state), 隐藏类 (map) 的地址, 还有属性的偏移量, 比如上面这个函数中的两个调用点都使用了对象 o, 那么反馈向量两个插槽中的 map 属性也都是指向同一个隐藏类的, 因此这两个插槽的 map 地址是一样的.

插槽
插槽

加载类型

了解插槽的数据结构后, 我们看下面一段代码.

function loadX(o) { return o.x; } loadX({ x: 1 });

将它转换成字节码. Return 我们上面介绍过, 就是返回累加器中的属性值.

... Bytecode age: 0 0x163b00253b12 @ 0 : 2d 03 00 00 LdaNamedProperty a0, [0], [0] 0x163b00253b16 @ 4 : a9 Return ...

我们重点关注 LdaNamedProperty 这句字节码, 我们看到它有三个参数. a0 就是 loadX 的第一个参数; 第二个参数 [0] 表示取出对象 a0 的第一个属性值, 这两个参数很好理解. 第三个参数就和反馈向量有关了, 它表示将 LdaNamedProperty 操作的中间数据写入到反馈向量中, 方括号中间的 0 表示写入反馈向量的第一个插槽中. 下面是一个示例.

使用 LdaNamedProperty 缓存了数据
使用 LdaNamedProperty 缓存了数据

  • 在 map 栏, 缓存了 o 的隐藏类的地址;
  • 在 offset 一栏, 缓存了属性 x 的偏移量;
  • 在 type 一栏, 缓存了操作类型, 这里是 LOAD 类型. 在反馈向量中, 我们把这种通过 o.x 来访问对象属性值的操作称为 LOAD 类型.

存储类型和函数调用类型

V8 除了缓存 o.x 这种 LOAD 类型的操作以外, 还会缓存存储(STORE)类型函数调用(CALL)类型的中间数据. 考察下面的代码:

function foo() {} function loadX(o) { o.y = 4; foo(); return o.x; } loadX({ x: 1, y: 4 });

字节码如下:

... Bytecode age: 0 0x1bfc00253bea @ 0 : 0d 04 LdaSmi [4] 0x1bfc00253bec @ 2 : 32 03 00 00 StaNamedProperty a0, [0], [0] 0x1bfc00253bf0 @ 6 : 21 01 02 LdaGlobal [1], [2] 0x1bfc00253bf3 @ 9 : c4 Star r0 0x1bfc00253bf4 @ 10 : 61 fa 04 CallUndefinedReceiver0 r0, [4] 0x1bfc00253bf7 @ 13 : 2d 03 02 06 LdaNamedProperty a0, [2], [6] 0x1bfc00253bfb @ 17 : a9 Return ...

这段代码的流程图大致如下:

执行流程
执行流程

o.y = 4 对应的字节码如下, 这段代码是先使用 LdaSmi [4], 将常数 4 加载到累加器中, 然后通过 StaNamedProperty 的字节码指令, 将累加器中的 4 赋给 o.y, 这是一个存储 (STORE) 类型的操作, V8 会将操作的中间结果存放到反馈向量中的第一个插槽中.

LdaSmi [4] StaNamedProperty a0, [0], [0]

foo() 对应的字节码如下, 解释器首先加载 foo 函数对象的地址到累加器中, 这是通过 LdaGlobal 来完成的, 然后 V8 会将加载的中间结果存放到反馈向量的第 3 个插槽中(因为索引是 2), 这是一个存储类型的操作. 接下来执行 CallUndefinedReceiver0, 来实现 foo 函数的调用, 并将执行的中间结果放到反馈向量的第 5 个插槽中(因为索引是 4), 这是一个调用 (CALL) 类型的操作.

LdaGlobal [1], [2] Star r0 CallUndefinedReceiver0 r0, [4]

最后两句上面已经讲到了, return o.x 仅仅是加载对象中的 x 属性, 所以这是一个加载(LOAD)类型的操作. 下表是这段代码内联缓存的表格.

内联缓存
内联缓存

现在有了反馈向量缓存的数据, 当 V8 再次调用 loadX 函数时, 比如执行到 loadX 函数中的 return o.x 语句时, 它就会在对应的插槽中查找 x 属性的偏移量, 之后 V8 就能直接去内存中获取 o.x 的属性值了. 这样就大大提升了 V8 的执行效率.

多态和超态

function loadX(o) { return o.x; } var o = { x: 1, y: 3 }; var o1 = { x: 3, y: 6, z: 4 }; for (var i = 0; i < 90000; i++) { loadX(o); loadX(o1); }

我们考察上述代码, 由于 o 和 o1 形状不同, 这导致 V8 给这两个对象创建的隐藏类也不相同.

第一次执行时 loadX 时, V8 会将 o 的隐藏类记录在反馈向量中, 并记录属性 x 的偏移量. 那么当再次调用 loadX 函数时, V8 会取出反馈向量中记录的隐藏类, 并和新的 o1 的隐藏类进行比较, 发现不是一个隐藏类, 那么此时 V8 就无法使用反馈向量中记录的偏移量信息了.

面对这种情况, V8 会选择将新的隐藏类也记录在反馈向量中, 同时记录属性值的偏移量, 这时, 反馈向量中的第一个槽里就包含了两个隐藏类和偏移量. 具体可以参看下图:

多态
多态

当 V8 再次执行 loadX 函数中的 o.x 语句时, 同样会查找反馈向量表, 发现第一个槽中记录了两个隐藏类. 这时, V8 需要额外做一件事, 那就是拿这个新的隐藏类和第一个插槽中的两个隐藏类来一一比较, 如果新的隐藏类和第一个插槽中某个隐藏类相同, 那么就使用该命中的隐藏类的偏移量. 如果没有相同的, 同样将新的信息添加到反馈向量的第一个插槽中.

现在我们知道了, 一个反馈向量的一个插槽中可以包含多个隐藏类的信息, 那么:

  • 如果一个插槽中只包含 1 个隐藏类, 那么我们称这种状态为单态(monomorphic);
  • 如果一个插槽中包含了 2-4 个隐藏类, 那我们称这种状态为多态(polymorphic);
  • 如果一个插槽中超过 4 个隐藏类, 那我们称这种状态为超态(magamorphic).

可以预见的是, 从单态到多态到超态, 执行效率是依次降低的, 因为查找的次数变多了. 比如多态的情况 V8 使用线性结构来存储; 如果是超态, 那么 V8 会采取 hash 表的结构来存储, 这无疑会拖慢执行效率. 因此, 如果你的函数的参数是个对象, 那么就要避免多态和超态, 也就意味着尽量默认所有的对象属性是不变的, 比如你写了一个 loadX(o) 的函数, 那么当传递参数时, 尽量不要使用多个不同形状的 o 对象.

当然, 虽然我们分析的隐藏类和 IC 能提升代码的执行速度, 但是在实际的项目中, 影响执行性能的因素非常多, 找出那些影响性能瓶颈才是至关重要的, 你不需要过度关注微优化, 你也不需要过度担忧你的代码是否破坏了隐藏类或者 IC 的机制, 因为相对于其他的性能瓶颈, 它们对效率的影响可能是微不足道的.

就像我在从 React 源码谈 v8 引擎对数组的内部处理这篇文章最后写道的: 这就是一篇爽文, 旨在涨涨见识. 基本百分之九十以上, 后端返回给我们的数组就已经是 PACKED_ELEMENTS 的类型了. 我想我们需要关注的, 就是对于那些可以保证是单态的代码, 我们保证它是单态的就够了, 对于其他情况, 算逑.

结语

关于 V8 在 Event Loop, 垃圾回收, 生成器与 async / await 相关的原理, 这篇文章不再赘述, 有兴趣可以参考我的其他三篇文章:

下面是一段讲解 V8 的视频, 讲得不错, Google 开源的 JavaScript 引擎——V8, 视频转载自某逼乎.

总结
总结

谈 Generator 与 async / await

PREVIOUS POST

谈 Generator 与 async / await

    Search by