JavaScript引擎基础知识:常规形式和内联缓存。 第一部分

朋友你好 4月底,我们将开设一门新课程“信息系统安全” 。 现在,我们想与您分享文章的翻译,这无疑对本课程非常有用。 原始文章可以在这里找到

本文介绍了关键基础,这些基础对所有JavaScript引擎都是通用的,而不仅仅是V8 (引擎的作者( 本尼迪克特Matias )正在研究的V8)通用。 作为JavaScript开发人员,我可以说,对JavaScript引擎如何工作的更深入的了解将帮助您弄清楚如何编写有效的代码。



注意 :如果您喜欢观看演示文稿而不是阅读文章,请观看此视频 。 如果不是,则跳过它并继续阅读。
管道(管道)JavaScript引擎

这一切都始于您编写JavaScript代码的事实。 之后,JavaScript引擎将处理源代码,并将其呈现为抽象语法树(AST)。 基于构造的AST,解释器最终可以开始工作并开始生成字节码。 太好了! 这是引擎执行JavaScript代码的时刻。



为了使其运行更快,您可以将字节码与分析数据一起发送到优化编译器。 优化编译器基于概要分析数据进行某些假设,然后生成高度优化的机器代码。

如果在某些时候这些假设不正确,则优化编译器将对代码进行非优化,然后返回到解释器阶段。

JavaScript引擎中的解释器管道/编译器

现在,让我们仔细看一下执行JavaScript代码的管道部分,即解释和优化代码的地方,并看一下主要JavaScript引擎之间的一些区别。

一切的核心是包含解释器和优化编译器的管道。 解释器快速生成未优化的字节码,优化的编译器反过来工作时间更长,但是输出具有高度优化的机器代码。



接下来是一个管道,显示了V8的工作原理,这是Chrome和Node.js使用的JavaScript引擎。



V8中的解释器称为Ignition,它负责生成和执行字节码。 它收集性能分析数据,这些数据可在处理字节码时加快下一步的执行速度。 例如,当某个函数变热时 (如果它频繁启动),则将生成的字节码和配置文件数据传输到TurboFan,即传输到优化编译器,以根据配置文件数据生成高度优化的机器代码。



例如,在Firefox和SpiderNode中使用的Mozilla的SpiderMonkey JavaScript引擎的工作方式略有不同。 它没有一个,但是有两个优化的编译器。 解释器被优化为一个基本的编译器(Baseline编译器),该编译器生成一些优化的代码。 IonMonkey编译器与代码执行期间收集的性能分析数据一起可以生成经过高度优化的代码。 如果推测优化失败,则IonMonkey将返回到基准代码。



Chakra-Edge和Node-ChakraCore中使用的Microsoft JavaScript引擎具有非常相似的结构,并使用了两个优化的编译器。 解释器在SimpleJIT中进行了优化(JIT代表“即时编译器”,Just-In-Time编译器会生成经过某种程度优化的代码。FullJIT与配置文件数据一起可以创建更高优化的代码。



Safari和React Native使用的Apple JavaScript引擎JavaScriptCore(缩写为JSC)通常具有三种不同的优化编译器。 LLInt是针对基础编译器进行了优化的低层解释器,而基础编译器又针对DFG(数据流图)编译器进行了优化,并且已经针对FTL(Fast Than Light)编译器进行了优化。

为什么某些引擎比其他引擎具有更多的优化编译器? 这完全是妥协。 解释器可以快速处理字节码,但是仅字节码并不是特别有效。 另一方面,优化的编译器的工作时间会更长一些,但是会产生更有效的机器代码。 这是在快速获取代码(解释器)或以最大性能(优化编译器)等待并运行代码之间的折衷方案。 某些引擎选择添加具有不同时间和效率特性的多个优化编译器,这使您可以对该折衷解决方案提供最佳控制,并了解内部设备额外复杂性的成本。 另一个权衡是内存使用情况;请查看本文以获得更好的理解。

我们刚刚研究了各种JavaScript引擎的解释器和优化器编译器管道之间的主要区别。 尽管存在这些高级差异,但是所有JavaScript引擎都具有相同的体系结构:它们都具有解析器和某种解释器/编译器管道。

JavaScript对象模型

让我们看看JavaScript引擎还有哪些共同点,以及它们使用了哪些技巧来加快对JavaScript对象属性的访问? 事实证明,所有主引擎都以类似的方式执行此操作。

ECMAScript规范将所有对象定义为具有与属性属性匹配的字符串键的字典。



除了[[Value]] ,该规范还定义了以下属性:

  • [[Writable]]确定是否可以重新分配属性;
  • [[Enumerable]]确定属性是否在for-in循环中显示;
  • [[Configurable]]确定是否可以删除属性。

符号[[ ]]看起来很奇怪,但这是规范如何描述JavaScript中的属性。 您仍然可以使用Object.getOwnPropertyDescriptor API获得JavaScript中任何给定对象和属性的以下属性属性:

 const object = { foo: 42 }; Object.getOwnPropertyDescriptor(object, 'foo'); // → { value: 42, writable: true, enumerable: true, configurable: true } 

好的,所以JavaScript定义了对象。 数组呢?

您可以将数组想象为特殊对象。 唯一的区别是数组具有特殊的索引处理。 在这里,数组索引是ECMAScript规范中的一个特殊术语。 JavaScript对数组中元素的数量有限制-最多2³²− 1。 数组索引是该范围内的任何可用索引,即0到2³²− 2之间的任何整数值。

另一个区别是数组具有length的神奇特性。

 const array = ['a', 'b']; array.length; // → 2 array[2] = 'c'; array.length; // → 3 

在此示例中,数组在创建时的长度为2。 然后,我们为索引2分配另一个元素,长度自动增加。

JavaScript定义数组以及对象。 例如,所有键(包括数组索引)都明确表示为字符串。 数组的第一个元素存储在键“ 0”下。



length属性只是另一个无法枚举和不可配置的属性。

将元素添加到数组后,JavaScript会自动更新length属性的[[Value]]属性的属性。



通常,我们可以说数组的行为类似于对象。

优化属性访问

现在我们知道了如何在JavaScript中定义对象,让我们看一下JavaScript引擎如何使您有效地使用对象。

在日常生活中,获得财产是最常见的操作。 对于发动机而言,快速执行此操作非常重要。

 const object = { foo: 'bar', baz: 'qux', }; // Here, we're accessing the property `foo` on `object`: doSomething(object.foo); // ^^^^^^^^^^ 

表格

在JavaScript程序中,很常见的做法是将相同的属性键分配给许多对象。 他们说这些物体具有相同的形状

 const object1 = { x: 1, y: 2 }; const object2 = { x: 3, y: 4 }; // `object1` and `object2` have the same shape. 

同样常见的机制是访问具有相同形式的对象的属性:

 function logX(object) { console.log(object.x); // ^^^^^^^^ } const object1 = { x: 1, y: 2 }; const object2 = { x: 3, y: 4 }; logX(object1); logX(object2); 

知道了这一点,JavaScript引擎可以根据对象的形状优化对对象属性的访问。 看看它是如何工作的。

假设我们有一个具有属性x和y的对象,它使用前面讨论的字典数据结构; 它包含指向它们各自属性的键字符串。



如果您访问诸如object.y,类的属性,则JavaScript引擎将使用键'y'搜索JSObject,然后加载与该查询匹配的属性,最后返回[[Value]]

但是这些属性在哪里存储在内存中? 我们应该将它们存储为JSObject的一部分吗? 如果这样做,以后我们将看到更多这种形式的对象,在这种情况下,由于在相同形式的所有对象中都重复了属性名称,因此在JSObject本身中存储包含属性和属性名称的完整字典是浪费空间。 这将导致大量重复并导致内存分配错误。 为了优化,引擎分别存储对象的形状。



Shape包含[[Value]]之外的所有属性名称和属性。 相反,该表单包含JSObject内部的偏移值,因此JavaScript引擎知道在哪里寻找这些值。 每个具有通用表单的JSObject都指示该表单的特定实例。 现在,每个JSObject只需要存储对象唯一的值。



只要有很多对象,优势就会变得显而易见。 它们的数量无关紧要,因为如果它们只有一种形式,那么我们只保存一次有关该形式和属性的信息。

所有JavaScript引擎都使用表单作为优化手段,但它们并不直接将其命名为shapes

  1. 学术文档将它们称为“隐藏类”(类似于JavaScript类);
  2. V8称为地图;
  3. 查克拉称它们为类型;
  4. JavaScriptCore称它们为Structures;
  5. SpiderMonkey称它们为Shapes。

在本文中,我们继续称它们为shapes

过渡链和树木

如果您有一个特定形状的对象,但是您向其中添加了一个新属性,会发生什么? JavaScript引擎如何定义新表格?

 const object = {}; object.x = 5; object.y = 6; 

表单在JavaScript引擎中创建所谓的过渡链。 这是一个例子:



对象最初没有属性;它对应于一个空表格。 以下表达式将值为5的属性'x'添加到该对象,然后引擎转到包含属性'x'的形式,并将值5添加到JSObject的第一个偏移量0。下一行添加属性'y' ,然后引擎转到下一个一个既包含'x'又包含'y'的表单,并且还向偏移量1的JSObject添加值6。
注意 :添加属性的顺序会影响表单。 例如,{x:4,y:5}的格式将不同于{y:5,x:4}。
我们甚至不需要为每个表单存储整个属性表。 而是,每个表单都只需要知道他们要包括在其中的新属性。 例如,在这种情况下,我们无需以后一种形式存储有关“ x”的信息,因为可以在链中的较早位置找到它。 为此,该表格将与其先前的表格合并。



如果您在JavaScript代码中编写ox ,则JavaScript会在过渡链中查找'x'属性,直到它检测到已经具有'x'属性的表单为止。

但是,如果不可能创建过渡链会怎样? 例如,如果您有两个空对象并向它们添加不同的属性,会发生什么?

 const object1 = {}; object1.x = 5; const object2 = {}; object2.y = 6; 

在这种情况下,会出现一个分支,而不是过渡链,而是获得过渡树:



我们创建一个空对象a并向其中添加属性'x' 。 结果,我们有了一个包含单个值和两种形式的JSObject :空和具有单个'x'属性的形式。

第二个示例从以下事实开始:我们有一个空对象b ,但随后添加了另一个属性'y' 。 结果,这里我们得到两个形式的链,但是最后我们得到了三个链。

这是否意味着我们总是以空表格开头? 不一定。 引擎使用一些对象文字优化,这些文字已经包含属性。 假设我们添加x,从一个空的对象常量开始,或者我们有一个已经包含x的对象常量:

 const object1 = {}; object1.x = 5; const object2 = { x: 6 }; 

在第一个示例中,我们以一个空的形式开始,然后转到一个也包含x的链,就像我们之前看到的那样。

对于object2有意义的是直接创建从一开始就已经具有x的对象,而不是从一个空对象和一个过渡开始。



包含属性'x'的对象的文字从一开始就以包含'x'的形式开头,并且有效地跳过了空形式。 这(至少)是V8和SpiderMonkey所做的。 优化缩短了转换链,使从文字中组装对象更加方便。

本尼迪克特(Benedict)在React上惊人的应用程序多态性博客文章中谈到了这些细微差别如何影响性能。

进一步,您将看到具有属性'x''y''z'的三维对象的点的示例。

 const point = {}; point.x = 4; point.y = 5; point.z = 6; 

正如您之前所了解的,我们创建一个在内存中具有三种形式的对象(不计算空形式)。 为了访问该对象的'x'属性,例如,如果您在程序中编写了point.x ,则JavaScript引擎必须遵循一个链表:从最底部的表单开始,然后逐渐向上移动至具有'x'的表单在最顶端。



结果非常缓慢,尤其是如果您经常这样做并且具有对象的许多属性时。 属性的停留时间为O(n) ,即它是与对象的属性数量相关的线性函数。 为了加快属性搜索的速度,JavaScript引擎添加了ShapeTable数据结构。 ShapeTable是一个字典,其中的键以某种方式与表格进行映射,并产生所需的属性。



稍等片刻,现在我们返回字典搜索...这正是我们将表单放在首位时的开始! 那么,为什么我们还要关心表格呢?
事实是表单有助于另一种称为“ 内联缓存”的优化

我们将文章的第二部分中讨论内联缓存或IC的概念,现在我们想邀请您参加免费的开放式网络研讨会 ,该研讨会将于4月9日由著名的病毒分析师和兼职老师Alexander Kolesnikov举行。

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


All Articles