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

今天,我们将发布有关V8内部机制和React性能问题调查的材料的第二部分翻译。



第一部分

对象形式的过时和迁移


如果该字段最初包含Smi ,然后情况发生变化并且需要存储一个Smi表示Smi不适合的值怎么办? 例如,如下面的示例所示,当使用最初存储xSmi的对象的相同形式表示两个对象时:

 const a = { x: 1 }; const b = { x: 2 }; //  `x`       `Smi` bx = 0.2; //  `bx`     `Double` y = ax; 

在示例的开头,我们有两个对象,对于它们的表示,我们使用相同的对象形式,其中Smi格式用于存储x


相同的形式用于表示对象

bx属性更改并且您必须使用Double格式表示它时,V8会为该对象的新形状分配内存空间,其中x分配了Double表示形式,并且表示一个空格式。 V8还创建了一个MutableHeapNumber的实体,该实体用于存储x属性的值0.2。 然后,我们更新对象b ,以使其引用此新形式,并更改对象中的插槽,以使其MutableHeapNumber先前在偏移量0处创建的MutableHeapNumber实体。最后,将对象的旧形式标记为过时并将其与树断开连接过渡。 这是通过为'x'创建一个从空格式到我们刚刚创建的格式的新过渡来完成的。


将新值分配给对象属性的后果

目前,我们无法完全删除旧表格,因为对象a仍在使用它。 此外,在搜索所有引用旧表格的对象时绕过所有内存并立即更新这些对象的状态将非常昂贵。 相反,V8在这里使用了“惰性”方法。 即,首先将有关读取或写入对象a的属性的所有操作转移为使用新形式。 该操作背后的想法是最终使对象的过时形式无法实现。 这将导致垃圾收集器对其进行处理。


异常的内存释放了垃圾回收器

在更改视图的字段不是链中的最后一个字段的情况下,事情变得更加复杂:

 const o = {  x: 1,  y: 2,  z: 3, }; oy = 0.1; 

在这种情况下,V8需要找到所谓的分割形状。 这是链中的最后一个形式,位于相应属性出现的形式之前。 在这里,我们更改y ,即-我们需要找到没有y的最后一种形式。 在我们的示例中,这是x出现的形式。


搜索没有变化值的最后一个表格

在这里,从这种形式开始,我们为y创建一个新的过渡链,该链重现了所有先前的过渡。 只有现在,属性'y'将表示为Double 。 现在,我们将这个新过渡链用于y ,将其标记为过时的旧子树。 在最后一步中,我们现在使用MutableHeapNumber实体存储y MutableHeapNumber ,将o对象的实例迁移到新形式。 通过这种方法,新对象将不会使用旧过渡树的片段,并且在所有对旧形式的引用都消失之后,树的过时部分也将消失。

可扩展性和过渡完整性


使用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); 

对该代码的处理始于我们已经知道的动作。 即,从对象的空形式过渡到包含属性'x' (表示为实体Smi )的新形式。 当我们禁止对象b扩展时,这会导致向新形式的特殊转换,该新形式被标记为不可扩展。 这种特殊的过渡不会导致某些新属性的出现。 实际上,这仅仅是一个标记。


使用Object.preventExtensions()方法处理对象的结果

请注意,我们不能只更改其中带有值x的现有形式,因为另一个对象(即仍可扩展的对象a )需要使用它。

反应性能问题


现在,让我们收集讨论的所有内容,并使用所获得的知识来了解最近的React性能问题的本质。 当React团队分析实际应用程序时,他们注意到对React核心起作用的V8性能出现了奇怪的下降。 这是代码问题部分的简化复制:

 const o = { x: 1, y: 2 }; Object.preventExtensions(o); oy = 0.2; 

我们有一个对象,其中两个字段表示为Smi实体。 我们防止对象进一步扩展,然后执行导致以下事实的操作:第二个字段必须以Double格式表示。

我们已经发现,禁止物体膨胀的情况大致导致以下情况。


禁止对象扩展的后果

为了表示对象的两个属性,将Smi实体,并且需要最后一次转换才能将对象的形状标记为不可扩展。

现在我们需要更改y属性由Double表示的方式。 这意味着我们需要开始寻找一种分离形式。 在这种情况下,这是x属性出现的形式。 但是现在V8感到困惑。 事实是,分隔形式是可扩展的,而当前形式被标记为不可扩展。 V8不知道如何在类似情况下重现过渡过程。 结果,引擎只是拒绝尝试解决所有问题。 相反,它只是创建一个单独的表单,该表单不连接到当前表单树,并且不与其他对象共享。 这有点像对象的孤立形式。


孤儿形式

很容易猜到,如果这发生在许多对象上,那将是非常糟糕的。 事实是,这使整个V8对象形式的系统无用。

当最近的React问题发生时,发生了以下情况。 FiberNode类的每个对象都具有用于在启用概要分析时存储时间戳的字段。

 class FiberNode {  constructor() {    this.actualStartTime = 0;    Object.preventExtensions(this);  } } const node1 = new FiberNode(); const node2 = new FiberNode(); 

这些字段(例如, actualStartTime )被初始化为0或-1。 这导致一个事实,即Smi实体用于Smi表示其含义。 但是后来,他们以performance.now()方法返回的浮点数格式保存了实时时间戳 这导致以下事实:这些值不再可以Smi的形式表示。 为了表示这些字段,现在需要Double实体。 最重要的是,React还阻止了FiberNode类实例的扩展。

最初,我们的简化示例可以以以下形式呈现。


系统的初始状态

类的两个实例共享对象形式的相同过渡树。 严格来说,这就是V8中的对象形状系统设计的目的。 但是,当将实时时间戳存储在对象中时,V8无法理解如何找到分离形式。


V8感到困惑

V8将新的孤立表格分配给node1 。 稍后, node2对象node2发生相同的情况。 结果,我们现在有两种“孤立”形式,每种形式仅由一个对象使用。 在许多实际的React应用程序中,此类对象的数量远远超过两个。 这些可以是FiberNode类的数十个甚至数千个对象。 很容易理解,这种情况不会很好地影响V8的性能。

幸运的是,我们在V8 v7.4中 解决了此问题,并且我们正在探索使更改对象字段表示形式的操作占用较少资源可能性。 这将使我们能够解决在这种情况下仍然存在的性能问题。 由于此修复程序,V8现在可以在上述问题情况下正常运行。


系统的初始状态

这是它的外观。 FiberNode类的两个实例引用了不可扩展的形式。 在这种情况下, 'actualStartTime'表示为Smi字段。 当执行第一个为node1.actualStartTime属性赋值的操作时,将创建一个新的过渡链,并且前一个链被标记为已过时。


向Node1.actualStartTime属性分配新值的结果

请注意,现在已在新链中正确复制到不可扩展形式的过渡。 这是系统在更改node2.actualStartTime的值后所node2.actualStartTime


向node2.actualStartTime属性分配新值的结果

将新值分配给node2.actualStartTime属性后,两个对象都引用新表单,并且垃圾回收器可以销毁过渡树的过时部分。

请注意,将对象形式标记为过时的操作及其迁移可能看起来有些复杂。 实际上-就是这样。 我们怀疑,在真实的网站上,这样做弊大于利(在性能,内存使用,复杂性方面)。 特别是在指针压缩之后 ,我们将无法再使用此方法以嵌入对象中的值的形式存储Double字段。 结果,我们希望完全放弃 V8对象形式的过时机制,并使该机制本身过时。

应该注意的是,React团队自己解决了这个问题,确保FiberNodes类的对象中的FiberNodes最初由Double值表示:

 class FiberNode {  constructor() {    //     `Double`   .    this.actualStartTime = Number.NaN;    //       ,  :    this.actualStartTime = 0;    Object.preventExtensions(this);  } } const node1 = new FiberNode(); const node2 = new FiberNode(); 

在这里,可以使用任何不适合Smi范围的浮点值来代替Number.NaN 。 这些值包括0.000001, Number.MIN_VALUE ,-0和Infinity

值得注意的是,React中描述的问题特定于V8,并且在创建某些代码时,开发人员无需根据特定JavaScript引擎的特定版本来对其进行优化。 但是,在某些错误的原因根源于引擎功能的情况下,能够通过优化代码来修复某些问题很有用。

值得记住的是,在JS引擎的肠道中,有很多各种各样的奇妙的事情。 如果可能的话,JS开发人员可以为所有这些机制提供帮助,而无需分配不同类型的相同变量值。 例如,您不应将数值字段初始化为null ,因为这将否定观察字段表示的所有优点并提高代码的可读性:

 //   ! class Point {  x = null;  y = null; } const p = new Point(); px = 0.1; py = 402; 

换句话说-编写可读的代码,性能将自成体系!

总结


在本文中,我们研究了以下重要问题:

  • JavaScript区分“原始”值和“对象”值,并且不能信任typeof结果。
  • 甚至具有相同JavaScript类型的值也可以在引擎的肠道中以不同的方式表示。
  • V8试图找到最佳方法来表示JS程序中使用的对象的每个属性。
  • 在某些情况下,V8执行将对象的表单标记为过时的操作,并执行表单的迁移。 包括-实现与禁止对象扩展相关的过渡。

基于上述内容,我们可以提供一些实用的JavaScript编程技巧,以帮助提高代码性能:

  • 始终以相同的方式初始化对象。 这有助于有效处理对象形式。
  • 负责任地选择对象字段的初始值。 这将帮助JavaScript引擎选择如何内部表示这些值。

亲爱的读者们! 您是否曾经根据某些JavaScript引擎的内部功能优化了代码?

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


All Articles