在弱类型语言 JavaScript 中,变量上能有多少优化窍门?本文从最基础的变量类型说起,带你深入 V8 底层类型变换与优化机制。真正的老司机,一行代码可见一斑。以后你可以说,我写的代码,连变量声明都比你快…
本文参考 V8 开发者博客中关于 React 性能断崖的一篇分析,细节很多,整理一下与大家分享。
JavaScript 作为弱类型语言,我们可以对一个变量赋予任意类型值,但即使如此,对于各类 JavaScript 值,V8 仍需要对不同类型值应用特定的内存表示方式。充分了解底层原理后,我们甚至可以从变量使用方式上入手,写出更加优雅、符合引擎行为的代码。
先从为人熟知的 JavaScript 8大变量类型讲起。
按照当前 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'
在规范中, Null
虽然作为 null
本身的类型,但 typeofnull
却返回 object
。想知道背后的设计原理,首先要了解 JavaScript 中的一个定义,在 JavaScript 中所有类型集合都被分为两个组:
Object
的类型)在定义中, null
意为 noobjectvalue
,而 undefined
意为 novalue
。
按照上图构想,JavaScript 的创始人 Brendan Eich 在设计之初就将属于 objects
和 null
类型集合下的所有类型值统一返回了 '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;
针对 31 位有符号位范围内的整型数字,V8 为其定义了一种特殊的表示法 Smi
,其他任何不属于 Smi
的数据都被定义为 HeapObject
, HeapObject
代表着内存的实体地址。
对于数字而言,非 Smi
范围内的数字被定义为 HeapNumber
, HeapNumber
是一种特殊的 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
与 HeapNumber
是如何运作的呢?假设我们有一个对象:
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.1
、 1.1
、 2.1
、 3.1
、 4.1
、 5.1
,而等到循环结束,其中 5 个实例都会成为垃圾。
为了防止这个问题,V8 提供了一种优化方式去原地更新非 Smi
的值:当一个数字内存区域拥有一个非 Smi
范围内的数值时,V8 会将这块区域标志为 Double
区域,并会为其分配一个用 64 位浮点表示的 MutableHeapNumber
实例。
此后当你再次更新这块区域,V8 就不再需要创建一个新的 HeapNumber
实例,而可以直接在 MutableNumber
实例中进行更新了。
前面说到, HeapNumber
和 MutableNumber
都是使用指针引用的方式指向内存实体,而 MutableNumber
是可变的,如果此时你将属于 MutableNumber
的值 o.x
赋值给其他变量 y
,你一定不希望你下次改变 o.x
时, y
也跟着改变。为了防止这种情况,当 o.x
被共享时, o.x
内的 MutableHeapNumber
需要被重新封装成 HeapNumber
传递给 y
:
不同的内存表示方式对应不同的
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 失去所有引用计数,而只需等待垃圾回收器释放它。
我们深入讨论了以下知识点:
primitives
和 objects
的区分,以及 typeof
的不准确原因。基于这些知识,我们可以得出一些能帮助提高性能的 JavaScript 编码最佳实践:
我们通过了解复杂的底层知识,获得了很简单的编码最佳实践,或许这些点能带来的性能提升很小。但所谓厚积薄发,恰恰是清楚这些有底层理论支撑着的优化点,我们写代码时才能做到心中有数。
另外我很喜欢这类以小见大的技术点,以后当别人问你为什么要这样声明变量时,你往往就能开始表演……
参考文章:The story of a V8 performance cliff in React