V8 最佳实践:从 JavaScript 变量使用姿势说起

时间:2022-07-26
本文章向大家介绍V8 最佳实践:从 JavaScript 变量使用姿势说起,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

在弱类型语言 JavaScript 中,变量上能有多少优化窍门?本文从最基础的变量类型说起,带你深入 V8 底层类型变换与优化机制。真正的老司机,一行代码可见一斑。以后你可以说,我写的代码,连变量声明都比你快…

本文参考 V8 开发者博客中关于 React 性能断崖的一篇分析,细节很多,整理一下与大家分享。

JavaScript 作为弱类型语言,我们可以对一个变量赋予任意类型值,但即使如此,对于各类 JavaScript 值,V8 仍需要对不同类型值应用特定的内存表示方式。充分了解底层原理后,我们甚至可以从变量使用方式上入手,写出更加优雅、符合引擎行为的代码。

先从为人熟知的 JavaScript 8大变量类型讲起。

JavaScript 变量类型

八大变量类型

按照当前 ECMAScript 规范,JavaScript 中值的类型共有以下八种:Number, String, Symbol, BigInt, Boolean, Undefined, Null, Object

这些类型值都可以通过 typeof操作符监测到,除了一个例外:

typeof 42;// → 'number'typeof 'foo';// → 'string'typeof Symbol('bar');// → 'symbol'typeof 42n;// → 'bigint'typeof true;// → 'boolean'typeof undefined;// → 'undefined'typeof null;// → 'object' 注意这里typeof { x: 42 };// → 'object'

为什么 typeof null === 'object'

在规范中, Null虽然作为 null本身的类型,但 typeofnull却返回 object。想知道背后的设计原理,首先要了解 JavaScript 中的一个定义,在 JavaScript 中所有类型集合都被分为两个组:

  • objects(引用类型,比如 Object的类型)
  • primitives(原始类型,所有非引用类型的值)

在定义中, null意为 noobjectvalue,而 undefined意为 novalue

按照上图构想,JavaScript 的创始人 Brendan Eich 在设计之初就将属于 objectsnull类型集合下的所有类型值统一返回了 'object'类型。

事实上,这是当时受到了 Java 的影响。在 Java 中, null从来就不是一个单独的类型,它代表的是所有引用类型的默认值。这就是为什么尽管规范中规定了 null有自己单独的 Null类型,而 typeofnull仍旧返回 'object'的原因。

值的内存表示方式

JavaScript 引擎必须能够在内存中表示任意值,而需要注意的是,同一类型值其实也会存在不同的内存表示方式

比如值 42在 JavaScript 中的类型是 number

typeof 42;// → 'number'

而在内存上有许多种方式可以用来表示 42

representation

bits

8位二进制补码

00101001

32位二进制补码

00000000000000000000000000101010

二进制编码的十进数码

01000010

32位 IEEE-754 单精度浮点

01000010001010000000000000000000

64位 IEEE-754 双精度浮点

0100000001000101000000000000000000000000000000000000000000000000

ECMAScript 标准约定 number数字需要被当成 64 位双精度浮点数处理,但事实上,一直使用 64 位去存储任何数字实际是非常低效的,所以 JavaScript 引擎并不总会使用 64 位去存储数字,引擎在内部可以采用其他内存表示方式(如 32 位),只要保证数字外部所有能被监测到的特性对齐 64 位的表现就行。

例如我们知道,ECMAScript 中的数组合法索引范围在 [0,2³²−2]

array[0]; // Smallest possible array index.array[42];array[2**32-2]; // Greatest possible array index.

通过下标索引访问数组元素时,V8 会使用 32 位的方式去存储这些合法范围的下标数字,这是最佳的内存表示方式。用 64 位去存储数组下标会导致极大浪费,每次访问数组元素时引擎都需要不断将 Float64 转换为二进制补码,此时若使用 32 位去存储下标则能省下一半的转换时间。

32 位二进制补码表示法不仅仅应用在数组读写操作中,所有 [0,2³²−2]内的数字都会优先使用 32 位的方式去存储,而一般来说,处理器处理整型运算会比处理浮点型运算快得多,这就是为什么在下面例子里,第一个循环的执行效率比第二个循环的执行效率快上将近两倍:

for (let i = 0; i < 100000000; ++i) {  // fast → 77ms}
for (let i = 0.1; i < 100000000.1; ++i) {  // slow → 122ms}

对运算符也是一样,下面例子中 mod 操作符的执行性能取决于两个操作数是否为整型:

const remainder = value % divisor;// Fast: 如果`value`和`divisor`都是被当成整型存储// slow: 其他情况

值得一提的是,针对 mod 运算,当 divisor的值是 2 的幂时,V8 为这种情况添加了额外的快捷处理路径。

另外,整型值虽然能用32位去存储,但是整型值之间的运算结果仍有可能产生浮点型值,并且 ECMAScript 标准本身是建立在 64 位的基础上的,因此规定了运算结果也必须符合 64 位浮点的表现。这个情况下,JS 引擎需要特别确保以下例子结果的正确性:

// Float64 的整数安全范围是 53 位,超过这个范围数值会失去精度2**53 === 2**53+1;// → true
// Float64 支持负零,所以 -1 * 0 必须等于 -0,但是在 32 位二进制补码中无法表示出 -0-1*0 === -0;// → true
// Float64 有无穷值,可以通过和 0 相除得出1/0 === Infinity;// → true-1/0 === -Infinity;// → true
// Float64 有 NaN0/0 === NaN;

Smi、HeapNumber

针对 31 位有符号位范围内的整型数字,V8 为其定义了一种特殊的表示法 Smi,其他任何不属于 Smi的数据都被定义为 HeapObjectHeapObject代表着内存的实体地址。

对于数字而言,非 Smi范围内的数字被定义为 HeapNumberHeapNumber是一种特殊的 HeadObject

 -Infinity // HeapNumber-(2**30)-1 // HeapNumber  -(2**30) // Smi       -42 // Smi        -0 // HeapNumber         0 // Smi       4.2 // HeapNumber        42 // Smi   2**30-1 // Smi     2**30 // HeapNumber  Infinity // HeapNumber       NaN // HeapNumber

Smi范围的整型数在 JavaScript 程序中非常常用,因此 V8 针对 Smi启用了一个特殊优化:当使用 Smi内的数字时,引擎不需要为其分配专门的内存实体,并会启用快速整型操作

通过以上讨论我们可以知道,即使值拥有相同的 JavaScript 类型,引擎内部依然可以使用不同的内存表示方式去达到优化的手段。

Smi vs HeapNumber vs MutableHeapNumber

SmiHeapNumber是如何运作的呢?假设我们有一个对象:

const o = {  x: 42,  // Smi  y: 4.2, // HeapNumber};

o.x中的 42会被当成 Smi直接存储在对象本身,而 o.y中的 4.2需要额外开辟一个内存实体存放,并将 o.y的对象指针指向该内存实体。

此时,当我们运行以下代码片段:

o.x += 10;// → o.x is now 52o.y += 1;// → o.y is now 5.2

在这个情况下, o.x的值会被原地更新,因为新的值 52仍在 Smi范围中。HeapNumber是不可变的,当我们改变 o.y的值为 5.2时,V8 需要再开辟一个新的内存实体给到 o.y引用。

借助 HeapNumber不可变的特性,V8 可以启用一些手段,如以下代码,我们将 o.y的值引用赋予 o.x

o.x = o.y;// → o.x is now 5.2

在这样的情况下,V8 不需要再为 o.x新的值 5.2去开辟一块内存实体,而是直接使用同一内存引用。

在具有以上优点的同时, HeapNumber不可变的特性也有一个缺陷,如果我们需要频繁更新 HeapNumber的值,执行效率会比 Smi慢得多:

// 创建一个`HeapNumber`对象const o = { x: 0.1 };
for (let i = 0; i < 5; ++i) {  // 创建一个额外的`HeapNumber`对象  o.x += 1;}

在这个短暂的循环中,引擎不得不创建 6 个 HeapNumber实例, 0.11.12.13.14.15.1,而等到循环结束,其中 5 个实例都会成为垃圾。

为了防止这个问题,V8 提供了一种优化方式去原地更新非 Smi的值:当一个数字内存区域拥有一个非 Smi范围内的数值时,V8 会将这块区域标志为 Double区域,并会为其分配一个用 64 位浮点表示的 MutableHeapNumber实例。

此后当你再次更新这块区域,V8 就不再需要创建一个新的 HeapNumber实例,而可以直接在 MutableNumber实例中进行更新了。

前面说到, HeapNumberMutableNumber都是使用指针引用的方式指向内存实体,而 MutableNumber是可变的,如果此时你将属于 MutableNumber的值 o.x赋值给其他变量 y,你一定不希望你下次改变 o.x时, y也跟着改变。为了防止这种情况,当 o.x被共享时, o.x内的 MutableHeapNumber需要被重新封装成 HeapNumber传递给 y

Shape 的初始化、弃用与迁移

不同的内存表示方式对应不同的 Shape,Shape 可以理解为数据结构类一样的存在。

问题来了,如果我们一开始给一个变量赋值 Smi范围的数字,紧接着再赋值 HeapNumber范围的数字,引擎会怎样处理呢?

下面例子,我们用相同的数据结构创建两个对象,并将对象中的 x值初始化为 Smi

const a = { x: 1 };const b = { x: 2 };// → objects have `x` as `Smi` field now
b.x = 0.2;// → `b.x` is now represented as a `Double`
y = a.x;

这两个对象指向相同的数据结构,其中 x都为 Smi

紧接着当我们修改 b.x数值为 0.2时,V8 需要分配一个新的被标志为 Double的 Shape 给到 b,并将新的 Shape 指针重新指向回空 Shape,除此之外,V8 还需要分配一个 MutableHeapNumber实例去存储这个 0.2。而后 V8 希望尽可能复用 Shape,紧接着会将旧的 Shape 标志为 deprecated

可以注意到此时 a.x其实仍指向着旧 Shape,V8 将旧 Shape 标志为 deprecaed的目的显然是要想移除它,但对于引擎来说,直接遍历内存去找到所有指向旧 Shape 的对象并提前更新引用,其实是非常昂贵的操作。V8 采用了懒处理方案:当下一次 a发生任何属性访问和赋值时再将 a的 Shape 迁移到新的 Shape 上。这个方案最终可以使得旧 Shape 失去所有引用计数,而只需等待垃圾回收器释放它。

小结

我们深入讨论了以下知识点:

  • JavaScript 底层对 primitives和 objects的区分,以及 typeof的不准确原因。
  • 即使变量的值拥有相同的类型,引擎底层也可以使用不同的内存表示方式去存储。
  • V8 会尝试找一个最优的内存表示方式去存储你 JavaScript 程序中的每一个属性。
  • 我们讨论了 V8 针对 Shape 初始化、弃用与迁移的处理方案。

基于这些知识,我们可以得出一些能帮助提高性能的 JavaScript 编码最佳实践:

  • 尽量用相同的数据结构去初始化你的对象,这样对 Shape 的利用是最高效的。
  • 为你的变量选择合理的初始值,让 JavaScript 引擎可以直接使用对应的内存表示方式。
  • write readable code, and performance will follow

我们通过了解复杂的底层知识,获得了很简单的编码最佳实践,或许这些点能带来的性能提升很小。但所谓厚积薄发,恰恰是清楚这些有底层理论支撑着的优化点,我们写代码时才能做到心中有数。

另外我很喜欢这类以小见大的技术点,以后当别人问你为什么要这样声明变量时,你往往就能开始表演……

参考文章:The story of a V8 performance cliff in React