有关V8,React和性能下降的故事。 第一部分

该材料是我们今天发布的翻译的第一部分,将讨论V8 JavaScript引擎如何选择代表内存中各种JS值的最佳方法,以及这如何影响V8的内部机制(所谓的表单)对象 (形状)。 所有这些都将帮助我们理清最近的React性能问题的实质。



JavaScript数据类型


每个JavaScript值只能具有八个现有数据类型之一: NumberStringSymbolBigIntBooleanUndefinedNullObject


JavaScript数据类型

值的类型可以使用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具有自己的类型typeof null ,但typeof null命令返回'object'而不是'null' 。 为了理解这种类型的行为的原因,我们考虑到所有JavaScript类型的集合可以分为两组的事实:

  • 对象(即,键入Object )。
  • 原始值(即任何非客观值)。

根据这种知识,结果表明null表示“无对象值”,而undefined表示“无值”。


原始值,对象,空值和未定义

遵循Java精神的这些思考,Brendan Eich设计了JavaScript,以便typeof运算符将为右图中的那些类型的值返回'object' 。 所有对象值和null都在这里。 这就是为什么表达式typeof null === 'object'为true的原因,尽管语言规范中存在单独的类型Null


v ==='object'的表达式typeof为true

价值表示


JavaScript引擎应该能够表示内存中的所有JavaScript值。 但是,请务必注意,JavaScript中的值类型与JS引擎在内存中表示它们的方式是分开的。

例如,JavaScript中的值42为number类型。

 typeof 42; // 'number' 

有几种方法可以在内存中表示像42这样的整数:
投稿

8位,另外两个
0010 1010
32位,最多增加两个
0000 0000 0000 0000 0000 0000 0000 0010 1010
压缩二进制十进制(BCD)
0100 0010
32位,IEEE-754浮点数
0 100 0010 0010 1000 0000 0000 0000 0000
64位,IEEE-754浮点数
0100 0000 0100 0101 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000

根据ECMAScript标准,数字是64位浮点值,称为双精度浮点数(Float64)。 但是,这并不意味着JavaScript引擎总是将数字存储在Float64视图中。 那将是非常非常低效的! 引擎可以使用数字的其他内部表示形式-只要值的行为与Float64数字的行为完全匹配即可。

事实证明,实际JS应用程序中的大多数数字都是有效的ECMAScript数组索引 。 即-在0到2 32 -2范围内的整数。

 array[0]; //      . array[42]; array[2**32-2]; //      . 

JavaScript引擎可以选择最佳格式来表示内存中的此类值。 这样做是为了优化使用索引使用数组元素的代码。 执行内存访问操作的处理器需要将数组索引作为可存储在视图中的数字加2来使用 。 相反,如果我们以Float64值的形式表示数组的索引,则将浪费系统资源,因为每当有人访问数组元素时,引擎都需要将Float64数字转换为加2的格式,反之亦然。

32位数字加上最多两个数字的表示不仅对优化数组工作很有用。 通常,应该指出,处理器执行整数运算的速度比使用浮点值的运算快得多。 这就是为什么在以下示例中,没有问题的第一个周期是第二个周期的两倍。

 for (let i = 0; i < 1000; ++i) {  //  } for (let i = 0.1; i < 1000.1; ++i) {  //  } 

这同样适用于使用数学运算符的计算。

例如,操作员从下一个代码片段中获取余数除法的性能取决于计算中涉及的数字。

 const remainder = value % divisor; //  -  `value`  `divisor`   , //    . 

如果两个操作数均由整数表示,则处理器可以非常有效地计算结果。 对于divisor操作数由2的幂表示的数,V8中还有其他优化。 对于用浮点数表示的值,计算要复杂得多,并且要花费更长的时间。

由于整数运算通常比对浮点值进行运算要快得多,因此似乎引擎可以始终将所有整数和整数运算的所有结果简单地以加两个的格式存储。 不幸的是,这种方法会违反ECMAScript规范。 如前所述,该标准以Float64格式提供数字表示,并且某些使用整数的运算可能会导致以浮点数形式出现结果。 在这种情况下,JS引擎产生正确的结果很重要。

 //  Float64   53-  . //         . 2**53 === 2**53+1; // true // Float64   ,   -1 * 0   -0,  //           . -1*0 === -0; // true // Float64   Infinity,   , //     . 1/0 === Infinity; // true -1/0 === -Infinity; // true // Float64    NaN. 0/0 === NaN; 

即使在前面的示例中,表达式左侧的所有数字都是整数,表达式右侧的所有数字都是浮点值。 这就是为什么使用32位格式(最多增加两个)无法正确执行以前的操作的原因。 JavaScript引擎必须特别注意以确保在执行整数运算时,您可以获得正确的(虽然看起来像上例一样,但看起来与众不同)Float64结果。

如果小整数落在有符号整数的31位表示形式的范围内,则V8使用称为Smi的特殊表示形式。 不是Smi值的所有内容都表示为HeapObject值,这是内存中某个实体的地址。 对于不属于Smi范围的数字,我们有一种特殊的HeapObject所谓的HeapNumber

 -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 

从上一个示例中可以看到,一些JS数字表示为Smi ,而有些则表示为HeapNumber 。 V8引擎在处理Smi编号方面进行了优化。 事实是,小整数在实际的JS程序中非常常见。 使用Smi值时,不必为单个实体分配内存。 此外,使用它们还可以使您快速执行整数运算。

Smi,HeapNumber和MutableHeapNumber的比较


让我们谈谈这些机制的内部结构是什么样的。 假设我们有以下对象:

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

对象x的属性的值42被编码为Smi 。 这意味着它可以存储在对象本身内部。 另一方面,要存储值4.2,则需要创建一个单独的实体。 在对象中,将有一个指向该实体的链接。


储存各种价值

假设我们正在执行以下JavaScript代码:

 ox += 10; // ox   52 oy += 1; // oy   5.2 

在这种情况下,属性x的值可以在其存储位置进行更新。 事实是x的新值是52,并且这个数字落在Smi的范围内。


属性x的新值存储在先前值存储的位置。

但是,新的y值5.2不适合Smi的范围,此外,它与以前的y-4.2值不同。 结果,V8必须为新的HeapNumber实体分配内存,并已从对象中引用它。


新实体HeapNumber存储新的y值

HeapNumber实体是不可变的。 这使您可以进行一些优化。 假设我们要将对象x的属性设置为属性y x值:

 ox = oy; // ox   5.2 

执行此操作时,我们可以简单地引用相同的HeapNumber实体,而不分配额外的内存来存储相同的值。

HeapNuber实体的抗扰性的缺点之一是,频繁更新具有Smi范围之外的值的字段的速度很慢。 在以下示例中对此进行了演示:

 //   `HeapNumber`. const o = { x: 0.1 }; for (let i = 0; i < 5; ++i) {  //    `HeapNumber`.  ox += 1; } 

处理第一行时,将创建HeapNumber的实例,其初始值为0.1。 在循环的主体中,此值更改为1.1、2.1、3.1、4.1,最后更改为5.1。 结果,在执行此代码的过程中, HeapNumber 6个HeapNumber实例,其中五个实例在循环完成后将进行垃圾回收操作。


堆号实体

为了避免出现此问题,V8进行了优化,这是一种用于在数值已存储的相同位置更新数值不符合Smi范围的数值字段的机制。 如果数字字段存储的Smi实体不适合存储的值,则V8以对象的形式将该字段标记为Double并为MutableHeapNumber实体分配内存,该实体存储以Float64格式表示的实数值。


使用MutableHeapNumber实体

结果,在字段值更改之后,V8不再需要为新的HeapNumber实体分配内存。 相反,只需将新值写入现有的MutableHeapNumber实体。


将新值写入MutableHeapNumber

但是,这种方法有其缺点。 即,由于MutableHeapNumber的值可以更改,因此重要的是要确保系统以这些值按照语言规范提供的方式工作。


MutableHeapNumber的缺点

例如,如果将ox的值分配给其他变量y ,则y的值必须不随ox的后续更改而更改。 那将违反JavaScript规范! 结果,在访问ox ,必须将数字重新打包为通常的HeapNumber值,然后才能将其分配为y

对于浮点数,V8使用其内部机制执行上述打包操作。 但是对于小整数,使用MutableHeapNumber将浪费时间,因为Smi是表示此类数字的更有效方法。

 const object = { x: 1 }; // ""  `x`    object.x += 1; //   `x`   

为了避免系统资源的低效率使用,我们要做的小整数就是将对象形式的对应字段标记为Smi 。 因此,只要这些字段的值与Smi范围相对应,就可以直接在对象内部对其进行更新。


使用其值在Smi范围内的整数

待续...

亲爱的读者们! 您是否遇到过由JS引擎功能引起的JavaScript性能问题?

Source: https://habr.com/ru/post/zh-CN467247/


All Articles