JavaScript引擎基础知识:原型优化。 第二部分

朋友们下午好! 启动“信息系统的安全性 ”课程,与此相关的是,我们与您共享文章“ JavaScript引擎基础知识:原型的优化”的最后一部分,该文章的第一部分可以在此处阅读。

我们还提醒您,当前出版物是这两篇文章的继续: “ JavaScript引擎的基础:常规形式和内联缓存。 第1部分“ ”,JavaScript引擎的基础:常规形式和内联缓存。 第二部分“



类和原型编程

现在我们知道了如何快速访问JavaScript对象的属性,现在我们来看一下JavaScript类的更复杂的结构。 这是JavaScript中类语法的样子:

class Bar { constructor(x) { this.x = x; } getX() { return this.x; } } 

尽管这对于JavaScript来说似乎是一个相对较新的概念,但对于一直在JavaScript中使用的原型编程来说,这只是“语法糖”:

 function Bar(x) { this.x = x; } Bar.prototype.getX = function getX() { return this.x; }; 

在这里,我们将getX属性分配给getX对象。 这将与其他任何对象一样工作,因为JavaScript中的原型是相同的对象。 在JavaScript之类的原型编程语言中,方法是通过原型访问的,而字段则存储在特定的实例中。

让我们仔细看看创建新的Bar实例(称为foo时会发生什么。

 const foo = new Bar(true); 

使用此代码创建的实例的表单具有单个'x'属性。 foo原型是Bar.prototype ,它属于Bar类。



这个Bar.prototype具有自身的形式,包含唯一的属性'getX' ,其值由函数'getX'确定,调用该函数时将返回this.x Bar.prototype的原型是Object.prototype ,它是JavaScript语言的一部分。 Object.prototype是原型树的根,而其原型为null



正如我们已经了解的那样,当您创建相同类的新实例时,两个实例都具有相同的形式。 两个实例都指向同一个Bar.prototype对象。

访问原型属性

好了,现在我们知道了定义一个类并创建一个新实例时会发生什么。 但是,如果像下面的示例中那样在实例上调用方法,会发生什么?

 class Bar { constructor(x) { this.x = x; } getX() { return this.x; } } const foo = new Bar(true); const x = foo.getX(); // ^^^^^^^^^^ 

您可以将任何方法调用视为两个单独的步骤:

 const x = foo.getX(); // is actually two steps: const $getX = foo.getX; const x = $getX.call(foo); 

第一步是加载方法,它实际上是原型的属性(其值是一个函数)。 第二步是使用实例调用函数,例如this的值。 让我们仔细看一下从foo实例getX方法的第一步。



引擎启动foo的实例,并意识到foo形式没有'getX' ,因此它必须通过原型链才能找到它。 我们进入Bar.prototype ,查看原型表单,看到它具有零偏移量的'getX'属性。 我们在Bar.prototype中的此偏移量处查找值,并找到我们要查找的JSFunction getX

JavaScript的灵活性允许更改原型链链接,例如:

 const foo = new Bar(true); foo.getX(); // → true Object.setPrototypeOf(foo, null); foo.getX(); // → Uncaught TypeError: foo.getX is not a function 

在这个例子中,我们称
 foo.getX() 
两次,但每次都有完全不同的含义和结果。 因此,尽管原型只是JavaScript中的对象,但对于JavaScript引擎而言,加速对原型属性的访问比加速对常规对象属性的访问更为重要。

在日常实践中,加载原型属性是一个相当常见的操作:每次调用方法时都会发生这种情况!

 class Bar { constructor(x) { this.x = x; } getX() { return this.x; } } const foo = new Bar(true); const x = foo.getX(); // ^^^^^^^^^^ 

之前,我们讨论了引擎如何通过使用表单和内联缓存来优化常规属性的加载。 如何优化相同形状的对象的原型属性的加载? 从上面我们看到了如何加载属性。



为了在这种特殊情况下通过重复下载来快速完成此任务,您需要了解以下三件事:

  • 格式foo不包含'getX'并且它没有更改。 这意味着没有人通过添加或删除属性或更改属性之一来更改foo对象。
  • foo原型仍然是原始的Bar.prototype 。 因此,没有人使用Object.setPrototypeOf()或将其分配给特殊的_proto_属性来更改原型foo
  • Bar.prototype表单包含'getX' ,并且未更改。 这意味着没有人通过添加或删除属性或更改属性之一来更改Bar.prototype

在一般情况下,这意味着您需要对实例本身进行一次检查,并且需要对每个原型进行两次以上检查,直到包含所需属性的原型为止。 1 + 2N个检查(其中N是所用原型的数量)在这种情况下听起来并不那么糟糕,因为原型链相对较浅。 但是,引擎通常必须处理更长的原型链,就像常规DOM类一样。 例如:

 const anchor = document.createElement('a'); // → HTMLAnchorElement const title = anchor.getAttribute('title'); 

我们有一个HTMLAnchorElement ,我们调用了getAttribute()方法。 这个简单元素的链已包含6个原型! 我们感兴趣的大多数DOM方法都不在HTMLAnchorElement原型HTMLAnchorElement ,而是在整个链中。



getAttribute()方法位于Element.prototype 。 这意味着,每次我们调用anchor.getAttribute() ,JavaScript引擎都需要:

  1. 检查'getAttribute'不是anchor对象;
  2. 验证最终的原型是HTMLAnchorElement.prototype ;
  3. 确认那里没有'getAttribute'
  4. 验证下一个原型是HTMLElement.prototype
  5. 'getAttribute'确认不存在'getAttribute'
  6. 验证下一个原型是Element.prototype
  7. 检查其中'getAttribute'存在'getAttribute'

总共7张支票。 由于这种类型的代码在网络上非常普遍,因此引擎使用各种技巧来减少加载原型属性所需的检查次数。

回到前面的示例,在该示例中,当为foo请求'getX'时,我们仅执行了三个检查:

 class Bar { constructor(x) { this.x = x; } getX() { return this.x; } } const foo = new Bar(true); const $getX = foo.getX; 

对于在包含所需属性的原型之前发生的每个对象,必须检查表单是否缺少此属性。 如果我们可以通过将原型检查作为不存在属性的检查来减少检查的数量,那将是很好的。 从本质上讲,这正是引擎使用的一个简单技巧:引擎将其以形式存储,而不是存储到实例本身的原型链接。



每种形式都表示一个原型。 这意味着每当原型foo更改时,引擎就会移动到新的形式。 现在,我们只需要检查对象的形状即可确认缺少某些属性,并保护原型链接(保护原型链接)。

使用这种方法,我们可以将所需检查的数量从2N +1减少到1 + N,以加快访问速度。 这仍然是一个相当昂贵的操作,因为它仍然是链中原型数量的线性函数。 引擎使用各种技巧来进一步将检查次数减少到某个恒定值,尤其是在顺序加载相同属性的情况下。

有效性单元格

V8处理专用于此目的的原型表格。 每个原型都有一个不与其他对象(特别是与其他原型)共享的唯一形式,并且这些原型形式中的每一个都具有与之关联的特殊ValidityCell



每当有人更改与其关联的原型或该原型之上的任何其他原型时, ValidityCell禁用此ValidityCell 。 让我们看看它是如何工作的。
为了加快后续原型的下载速度,V8将Inline缓存放置在四个字段中:



当内联缓存第一次运行代码时被加热时,V8会记住在原型,该原型(例如Bar.prototype ),实例表单(在我们的示例中为foo )中找到属性的偏移量,并将当前ValidityCell绑定到收到的原型从表单的实例中获取(在本例中为Bar.prototype )。

下次使用内联缓存时,引擎需要检查实例表单和ValidityCell 。 如果仍然有效,则引擎将直接使用原型上的偏移量,而跳过额外的搜索步骤。



更改原型时,将突出显示新表单,并禁用先前的ValidityCell单元。 因此,Inline缓存在下次启动时将被跳过,从而导致性能下降。

让我们回到带有DOM元素的示例。 Object.prototype每次更改不仅会使Object.prototype的内联缓存无效,而且会使它下面的链中的任何原型无效,包括EventTarget.prototypeNode.prototypeElement.prototype等,直至HTMLAnchorElement.prototype本身。



实际上,在执行代码时修改Object.prototype会造成严重的性能损失。 不要这样做!

让我们看一个具体的例子,以更好地理解它是如何工作的。 假设我们有一个Bar类和一个loadX函数,该函数对Bar类型的对象调用一个方法。 我们使用相同类的实例多次调用loadX函数。

 class Bar { /* … */ } function loadX(bar) { return bar.getX(); // IC for 'getX' on `Bar` instances. } loadX(new Bar(true)); loadX(new Bar(false)); // IC in `loadX` now links the `ValidityCell` for // `Bar.prototype`. Object.prototype.newMethod = y => y; // The `ValidityCell` in the `loadX` IC is invalid // now, because `Object.prototype` changed. 

现在, loadX的嵌入式缓存指向Bar.prototype 。 如果然后修改(mutate) Object.prototype ,它是JavaScript中所有原型的根,则ValidityCell无效,并且下次将不使用现有的Inline缓存,从而导致性能下降。

更改Object.prototype始终是一个坏主意,因为它会使更改时加载的原型的所有Inline缓存无效。 这是不执行操作的示例:

 Object.prototype.foo = function() { /* … */ }; // Run critical code: someObject.foo(); // End of critical code. delete Object.prototype.foo; 

我们正在扩展Object.prototype ,这将使引擎此时加载的所有内联原型缓存无效。 然后,我们将运行一些使用我们描述的方法的代码。 引擎必须从头开始,并配置内联缓存以访问原型属性。 然后,最后,“清理”并删除我们之前添加的原型方法。

您认为清洁是个好主意,对吗? 好吧,在这种情况下,情况将进一步恶化! 删除属性会更改Object.prototype ,因此将再次禁用所有内联缓存,并且引擎必须从头开始。

总结一下 。 尽管原型只是对象,但JavaScript引擎对原型进行了特殊处理,以优化原型搜索方法的性能。 留下原型吧! 或者,如果您确实需要处理它们,请在执行代码之前进行处理,这样至少在执行过程中不会使所有优化代码的尝试都无效!

总结一下

我们了解了JavaScript如何存储对象和类,以及表单,内联缓存和有效性单元如何帮助优化原型操作。 基于这些知识,我们从实际的角度理解了如何提高性能:请勿触摸原型! (或者,如果您确实需要它,请在执行代码之前执行此操作)。

第一部分

这一系列出版物对您有帮助吗? 在评论中写。

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


All Articles