本篇文章主要讲述 V8 如何选择 JavaScript 值在内存中表现形式的优化方式,以及解释 React core 在 V8 中出现的性能断崖。
在这以前,咱们讨论过 JavaScript 引擎如何经过使用内联缓存 (Inline Caches) 和形状 (Shapes) 优化 object 和数组的访问, 而后咱们还特别展开讲解了引擎是如何加快原型属性的访问速度。这篇文章主要讲述 V8 如何选择 JavaScript 值在内存中的表现形式的优化方式, 和这些优化是如何影响 Shape 机制的——这有助于解释近期发生的一个 React core 在 V8 中出现的性能断崖 (performance cliff) 。node
JavaScript 类型数组
每一个 JavaScript 值的类型都必定是 8 个不一样类型中的一个: 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 他本身就是一个类型。为了理解其中的原因,咱们能够先考虑把 Javascript 中的类型分红两组:ide
就此来讲,null 意味着 " 不存在的对象 " 的值, 而 undefined 表明着 " 不存在 " 的值。性能
跟着这条思路,Brendan Eich 按照 Java 的精神将 JavaScript 中 typeof 运算设计为任何值都返回’object’,好比全部的对象和 null。这就是为什么尽管规范中有个单独的 Null 类型,可是 typeof null === 'object’依然成立。测试
类型表达优化
JavaScript 引擎必须能在内存中表达任意的 JavaScript 值。然而,有一点值得注意的地方,那就是 JavaScript 值的类型和值自己在 JavaScript 引擎中是分开表达的。this
好比 42 这个值,在 JavaScript 中是一个 number 类型。编码
typeof 42; // → 'number'
咱们有不少种方法在内存中表达 42 这个整形数值:
ECMAScript 将 number 数据标准化位 64 位浮点数,一般叫 双精度浮点数 和 Float64。然而这并不表明 JavaScript 引擎将 number 类型的数据一直都按照 Float64 的形式存储 – 这样作的话会很是的低效!引擎能够选择其余的内部表达形式,直到肯定须要 Float64 特性的状况出现。
现实中 JavaScript 应用的大部分 number 类型都是有效的 ECMAScript 数组下标,好比说在 0 到 2³²−2 之间的整数。
array[0]; // Smallest possible array index. array[42]; array[2**32-2]; // Greatest possible array index.
JavaScript 引擎能够为这类 number 选择一个在内存中最佳的表达方式来优化根据下标访问数组元素操做的性能。对于处理器的访问内存操做来讲,数组下标必须是一个能用补码形式表达的数字。用 Float64 的方式来表达数组下标是很是浪费的,由于引擎在每次访问数组元素时不得不在 Float64 和补码之间反复转换。
32 位补码表达形式不仅在数组操做中很实用。通常来讲,处理器执行整型操做要比浮点型操做快很是多。这就是下面这个例子中,第一个循环要比第二个循环快 2 倍的缘由。
for (let i = 0; i < 1000; ++i) { // fast 🚀 } for (let i = 0.1; i < 1000.1; ++i) { // slow 🐌 }
这种状况在运算操做中也同样。在下面这个例子中,取模运算的性能取决于你的操做数是否为一个整型数据。
const remainder = value % divisor; // Fast 🚀 if `value` and `divisor` are represented as integers, // slow 🐌 otherwise.
若是全部的操做数都是整型,CPU 能够很是高效地计算出结果。当除数为 2 的指数时,V8 还有个额外的优化。若是操做数是浮点类型,这个计算将会复杂不少而且花费更长时间。
由于整型操做通常执行速度比浮点型要快很是多,看起来引擎应该一直使用补码形式来表达全部的整型数据和整型数据的运算结果。不幸的是,这样是违反 ECMAScript 规范的!ECMAScript 是用 Float64 来标准化的,因此 某些整型操做的结果其实是浮点型。在下面的例子中,这点对 JS 引擎能产出正确结果很重要。
// Float64 的安全整型范围为 53 位, // 超过这个范围你将丢失精度。 2**53 === 2**53+1; // → true // Float64 支持表达 -0,因此 -1 * 0 必须等于 -0 // 但在补码形式中 -0 是没办法表达的。 -1*0 === -0; // → true // Float64 能够表达由于除 0 而产生的 Infinity。 1/0 === Infinity; // → true -1/0 === -Infinity; // → true // Float64 还能表达 NaN。 0/0 === NaN;
虽然等号左边的值都是整数,但等号右边的全是浮点数。这就是使用 32 位二进制补码没法正确执行上述操做的缘由。JavaScript 引擎不得不特殊处理以确保整型计算能适当地回落到复杂的浮点结果。
对于小于 31 位的有符号整型,V8 有个被称为 Smi 的特别的表达方式。任何非 Smi 的数据将会被表达为 HeapObject,即一些在内存中的实体的地址。对于 number 来讲,咱们使用一个特殊的 HeapObject,或者叫 HeapNumber,来表达不在 Smi 范围内的 number 数据。
-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
正如上面例子所展现,一些 JavaScriptnumber 被表达为 Smi,而其余的表达为 HeapNumber。V8 对 Smi 作了特殊的优化,由于在现实的 JavaScript 程序中小整型数据实在是太经常使用了。Smi 不须要在内存中为其分配专门的实体,并且一般可使用快速的整型运算。
这里最重要的一点是,做为一个优化点,即使是同样的 JavaScript 类型可是在内存中表达形式能够彻底不同。
Smi vs. HeapNumber vs. MutableHeapNumbe
接下来讲下这具体是如何执行的。首先你有以下的一个对象:
const o = { x: 42, // Smi y: 4.2, // HeapNumber };
x 的值 42 能够被编码为 Smi,因此它能够被存储在对象自身中。而 y 的 4.2 须要一个分开的实体来保存这个值,而后这个对象指向那个实体。
如今,咱们执行下接下来的 JavaScript 片断:
o.x += 10; // → o.x is now 52 o.y += 1; // → o.y is now 5.2
在这个例子中,因为新值 52 也是 Smi,因此 x 的值能够直接被替换。
另外一方面,y=5.2 的新值不属于 Smi,并且和以前的 4.2 也不一样,因此 V8 分配了一个新的 HeapNumber 实体并将地址赋值给 y。
HeapNumber 是没法被修改的,由于这样能够进行某些优化。举个例子,若是咱们把 y 赋值给 x:
o.x = o.y; // → o.x is now 5.2
那么咱们如今只须要指向相同的 HeapNumber 而没必要为相同的值分配一个新的对象。
HeapNumber 不可变机制很差的一面是频繁修改非 Smi 范围内的属性将会变得缓慢。就像下面这个例子:
// Create a `HeapNumber` instance. const o = { x: 0.1 }; for (let i = 0; i < 5; ++i) { // Create an additional `HeapNumber` instance. o.x += 1; }
第一行代码将会建立一个 HeapNumber 实例并初始化其值为 0.1。循环体将其改成 1.1,2.1,3.1,4.1 直到 5.1,总共建立了 6 个 HeapNumber 实例,其中 5 将会在循环结束后成为内存垃圾。
为了不这个问题,V8 提供了一个优化更新非 Smi 的 number 字段的方法。当一个 number 字段保存了一个再也不 Smi 范围内的值时,V8 在该对象的 shape 中将其标记为 Double 字段,而且分配一个被称为 MutableHeapNumber 的对象以 Float64 编码形式保存其值。
当该字段变化时,V8 再也不须要去从新分配一个新的 HeapNumber,而是只须要更新 MutableHeapNumber 中的值便可。
可是,这种方法也有个问题。由于 MutableHeapNumber 的值能够修改,因此它们不该该被传递出去。
举个例子,若是你将 o.x 赋值给另一个变量 y,你不会但愿 y 值的改变也带来 x.o 的改变 – 这是违反 JavaScript 规范的!因此当 o.x 被访问时,这个数字必须得从新装箱成一个正常的 HeapNumber,而后再赋值给 y。
对于浮点数来讲,V8 在幕后完成了上面提到的全部“装箱”操做。可是由于小整型数据也使用 MutableHeapNumber 机制是很是浪费的,所以 Smi 是一个更加有效的表达方式。
const object = { x: 1 }; // → no “boxing” for `x` in object object.x += 1; // → update the value of `x` inside object
为了不低效,咱们为了小整型数字所要作的事情就是将 shape 上的字段标记为 Smi 表达,而后只要知足小整型范围的更新就只执行数值替换。
Shape 的弃用和整合
那么若是一个字段一开始存的是 Smi 数据,可是后面又被更新成了一个小整数范围以外的数据该怎么办?好比下面这个例子,2 个结构相同的对象,其中 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;
那么一开始这两个对象都指向同一个 shape,其中 x 被标记为 Smi 表达。
当 b.x 修改成 Double 表达时,V8 分配了一个新的 shape 并且其中的 x 被指定为 Double 表达,并指向空 shape。V8 也会为属性 x 分配一个 MutableHeapNumber 来保存这个新的值 0.2。而后当再更新对象 b 指向这个新的 shape,并更改对象中的槽以指向偏移 0 处的先前分配的 MutableHeapNumber。最后,咱们将旧的 shape 标记为废弃的而且将其从转变树 (transition tree) 中摘除。这是经过’x’从空 shape 到新建立的 shape 的转变 (transition) 来完成的。
此时咱们还不能彻底移除旧的 shape,由于它还在被 a 所使用,并且遍历内存去寻找全部指向了旧 shape 的对线并马上更新他们的将是很是昂贵的。相反,V8 使用了一个偷懒的办法:任何对 a 的属性访问或者赋值都会先将其迁移到新的 shape 上。这个思路最终将使得废弃的 shape 变得不可抵达而后被垃圾回收器删除。
若是更改表示的字段不是链中的最后一个字段,则会出现更棘手的状况:
const o = { x: 1, y: 2, z: 3, }; o.y = 0.1;
在这个例子中,V8 须要去寻找一个被称为 分离 shape(split shape) 的 shape,即指相关属性引入以前链中的最后一个 shape。在这里咱们修改了 y,因此咱们须要找到最后一个没有包含 y 的 shape,在咱们这个例子中就是引入了 x 的那个 shape。
从分离 shape 开始,咱们为 y 建立了一个能够重放全部以前的转变的新转变链 (transition chain),可是其中’y’被标记成 Double 表达。而后咱们使用这个新的转变链并将旧的子树标记为废弃的。在最后一步咱们把实例 o 迁移到了新的 shape,并使用了 MutableHeapNumber 来保存 y 的值。这样,新的对象就不会使用老的路径,并且一旦旧 shape 的引用小时,树中废弃的 shape 的那部分就会消失。
扩展性和完整性级别的转换
Object.preventExtensions() 能够阻止将新属性添加到对象上。若是你尝试去这么作,它将会抛出一个异常。(若是你不在严格模式下,异常不会抛出但也不会发生任何修改)
const object = { x: 1 }; Object.preventExtensions(object); object.y = 2; // TypeError: Cannot add property y; // object is not extensible
Object.seal 和 Object.preventExtensions 做用相同,可是它还会将全部属性标记为不可配置,意味着你不能删除它们,或者改变它们的可枚举性,能够配置性或者可写性。
const object = { x: 1 }; Object.seal(object); object.y = 2; // TypeError: Cannot add property y; // object is not extensible delete object.x; // TypeError: Cannot delete property x
Object.freeze 也和 Object.seal 做用相同,可是它还会经过将属性标记为不可写来阻止现有属性被修改。
const object = { x: 1 }; Object.freeze(object); object.y = 2; // TypeError: Cannot add property y; // object is not extensible delete object.x; // TypeError: Cannot delete property x object.x = 3; // TypeError: Cannot assign to read-only property x
让咱们考虑下这个具体的例子,两个对象都有一个属性 x,而后咱们阻止任何对第二个对象进一步的扩展。
const a = { x: 1 }; const b = { x: 2 }; Object.preventExtensions(b);
如咱们以前所知,一切从空 shape 转变到一个包含属性’x’(以 Smi 形式表达) 的新 shape 开始。当咱们阻止了对 b 的扩展,咱们对新的 shape 进行了一个特殊的转变 – 将其标记为不可扩展。这个特殊的转变没有引入任何新的属性 – 它实际上只是个标记。
注意咱们为什么不能直接更新包含 x 的 shape,由于它被另一个对象 a 所引用,并且依然是可扩展的。
React 的性能问题
让咱们把所前面提到的东西放到一块儿,用咱们所学的东西去理解这个 issue 。当 React 团队对一个真实的应用进行性能测试的时候,他们发现了一个影响 React 核心的奇怪的 V8 性能悬崖。这里有个简单的 bug 重现:
const o = { x: 1, y: 2 }; Object.preventExtensions(o); o.y = 0.2;
咱们有个包含了 2 个 Smi 表达的字段。咱们阻止了全部其余对这个对象的扩展,而后最终强制第二个字段变成 Double 表达。
如咱们以前所学,它大体创造了如下配置:
全部属性都被表达为 Smi 形式,并且最终的转变是将这个属性标记为不可扩展的扩展性转变。
如今咱们须要将 y 修改成 Double 表达,意味着咱们须要从新开始找到分离 shape。在本例中,这是引入了 x 的那个 shape。可是如今 V8 有点困惑,由于分离 shape 是可扩展的但当前 shape 是被标记成了不可扩展的,并且 V8 不能确切地知道如何正确地重放转变。因此 V8 实际上直接放弃理解这件事,与此相反地建立了一个和现有的 shape 树没有任何关联的独立 shape,也不会共享给任何其余对象。把它想象成孤立的 shape:
你能够想象到若是有大量的这样的对象出现这种状况将是很是糟糕的,由于这会使整个 shape 系统变得无用。
这 React 的例子中,实际上发生的是:每一个 FiberNode 有几个字段,用来在统计性能时保存一些时间戳。
class FiberNode { constructor() { this.actualStartTime = 0; Object.preventExtensions(this); } } const node1 = new FiberNode(); const node2 = new FiberNode();
这些字段(好比说 actualStartTime) 被初始化为 0 或者 -1,所以一开始按照 Smi 表达。可是后面实际上存进来的是从 performance.now() 返回的浮点型时间戳,致使这些字段变成 Double 表达,由于这些数据不知足 Smi 表达的要求。最重要的是,React 还阻止了对 FiberNode 实例的扩展。
将上面的例子简化以下:
这里有 2 个实例共享一个 shape 树,一切运转如咱们所想。可是接下来,当你储存这个真实的时间戳,V8 开始困惑于寻找分离 shape:
V8 指派了一个新的孤立 shape 给 node1,而后稍后 node2 也发生了一样的状况,致使了两个孤岛,每一个孤岛都有着本身不相交的 shape。不少真实的 React 应用不止有 2 个,而是有超过成千上万个 FiberNodes。如你所想,这种状况对 V8 的性能来讲不是什么好事。
幸运的是,咱们已经在 V8 v7.4 中修复了这个性能悬崖,并且咱们正在想办法让字段表达的改变动加高效来消除任何潜在的性能悬崖。在这个 fix 后,V8 如今作了正确的事:
这两个 FiberNode 实例指向了不可扩展且 actualStartTime 为 Smi 表达的 shape。当第一个对 node1.actualStartTime 的赋值发生时,一个新的转变链被建立而且以前的转变链被标记为废弃的:
注意为什么扩展性转变如今会正确的在新链中重放。
在对 node2.actualStartTime 赋值后,全部的节点引用了新的 shape,并且转变树中废弃的部分能够被垃圾回收器清理。
注意:也许你会认为 shape 的废弃 / 迁移很复杂,那你是对的。实际上,咱们怀疑这个机制致使的问题(在性能,内存占用和复杂度上)比它带来的帮助要多,尤为是由于使用指针压缩,咱们将没法再使用它来把 double-valued(双精度?) 字段内联到对象中。因此,咱们但愿彻底移除掉 V8 的 shape 废弃机制。You could say it’s puts on sunglasses being deprecated. YEEEAAAHHH…(不知道该怎么翻译了 - -)
React 团队在他们那边也经过确保 FiberNode 的全部的时间和持续时间字段都被初始化为 Double 表达来规避这个问题。
class FiberNode { constructor() { // 从一开始就强制 w 诶 `Double` 表达 this.actualStartTime = Number.NaN; // 而后你依然 k 恶意将这个值初始化为任何你想要的值 this.actualStartTime = 0; Object.preventExtensions(this); } } const node1 = new FiberNode(); const node2 = new FiberNode();
不仅是 Number.NaN,任何不在 Smi 范围的浮点值均可以使用。好比说 0.000001,Number.MIN_VALUE,-0,Infinity。
值得指出的的是这个 React 的 Bug 是 V8 规范致使的,开发者不该该为一个特定的 JavaScript 引擎作优化。尽管如此,当事情运转不正常时有个解决方案仍是挺不错的。
记住 JavaScript 引擎会在幕后作一些 magic 的优化,而你能够经过尽量避免类型混用来有效的帮助它执行这些优化。举个例子,不要用 null 来初始化 number 类型的字段,这不只能避免使得全部字段表达跟踪带来收益所有失效,还能让你的代码变得更可读:
// Don’t do this! class Point { x = null; y = null; } const p = new Point(); p.x = 0.1; p.y = 402;
换句话说,写可读的代码,而后性能天然就会提高。
最后总结
咱们在此次深刻探讨中涵盖了如下内容:
基于这些知识,咱们能够得出一些能帮助提高性能的 JavaScript 编码实用提示: