JavaScript引擎基础知识
JavaScript引擎管道
从编写的JavaScript代码开始。JavaScript引擎解析源代码,并将其转换为抽象语法树(AST)。基于该AST,解释器可以开始做它的事情并产生字节码。大!那时引擎实际上正在运行JavaScript代码。
为了使其运行更快,可以将字节码与分析数据一起发送到优化编译器。优化编译器根据其具有的性能分析数据进行某些假设,然后生成高度优化的机器代码。
如果某个假设在某个时候被证明是不正确的,则优化编译器将进行反优化,然后返回到解释器。
JavaScript引擎中的解释器/编译器管道
现在,让我们放大该管道中实际运行JavaScript代码的部分,即解释和优化代码的位置,并介绍一些主要JavaScript引擎之间的差异。
一般来说,有一个包含解释器和优化编译器的管道。解释器会快速生成未优化的字节码,优化编译器会花费更长的时间,但最终会生成高度优化的机器代码。
这个通用管道几乎与V8(Chrome和Node.js中使用的JavaScript引擎)的工作方式完全相同:
V8中的解释器称为Ignition,负责生成和执行字节码。在运行字节码时,它会收集性能分析数据,这些数据可用于稍后加快执行速度。当某个函数变热时(例如,当它经常运行时),所生成的字节码和分析数据将传递给我们的优化编译器TurboFan,以基于分析数据生成高度优化的机器代码。
SpiderMonkey是Firefox和SpiderNode中使用的Mozilla的JavaScript引擎,其功能有所不同。他们没有一个,只有两个优化编译器。解释器将对Baseline编译器进行优化,该编译器会生成经过某种程度优化的代码。IonMonkey编译器与运行代码时收集的性能分析数据相结合,可以生成经过高度优化的代码。如果推测优化失败,则IonMonkey会退回到基准代码。
Chakra是Edge和Node-ChakraCore中使用的Microsoft JavaScript引擎,具有非常相似的设置,带有两个优化的编译器。解释器将优化为SimpleJIT(其中JIT表示即时编译器),该生成器会生成一些经过优化的代码。结合分析数据,FullJIT可以生成更加优化的代码。
Safari和React Native中使用的Apple JavaScript引擎JavaScriptCore(缩写为JSC)通过三种不同的优化编译器将其发挥到了极致。LLInt(低级解释器)优化到Baseline编译器,然后可以优化到DFG(Data Flow Graph)编译器,后者又可以优化到FTL(Fast Than Light)编译器。
为什么某些引擎比其他引擎具有更多的优化编译器?这都是关于权衡的。解释器可以快速生成字节码,但是字节码通常效率不高。另一方面,优化的编译器会花费更长的时间,但最终会产生效率更高的机器代码。在快速运行代码(解释器)或花费更多时间之间存在权衡,但是最终要以最佳性能(优化编译器)运行代码。一些引擎选择添加具有不同时间/效率特性的多个优化编译器,从而以额外的复杂性为代价,对这些折衷方案进行更细粒度的控制。另一个折衷与内存使用有关。
我们刚刚强调了每个JavaScript引擎在解释器和优化编译器管道方面的主要区别。但是除了这些差异之外,在所有方面,所有JavaScript引擎都具有相同的体系结构:有一个解析器和某种解释器/编译器管道。
JavaScript的对象模型
通过放大某些方面的实现方式,让我们看看JavaScript引擎还有哪些共同点。
例如,JavaScript引擎如何实现JavaScript对象模型,它们使用哪些技巧来加快对JavaScript对象的属性的访问?事实证明,所有主要引擎都非常类似地实现了这一点。
ECMAScript规范实质上将所有对象定义为字典,并且字符串键映射到属性attribute。
除了[[Value]]
本身,规范还定义了以下属性:
[[Writable]]
确定是否可以将属性重新分配给[[Enumerable]]
确定该属性是否显示在for
-in
循环中,- 并
[[Configurable]]
确定是否可以删除该属性。
该[[double square brackets]]
符号看起来很时髦,但这只是规范表示不直接向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
在这个例子中,阵中拥有length
的2
在创建时它。然后,我们将另一个元素分配给index 2
,然后length
自动更新。
JavaScript定义与对象相似的数组。例如,所有包含数组索引的键都明确表示为字符串。数组中的第一个元素存储在key下'0'
。
该'length'
属性只是碰巧不可枚举和不可配置的另一个属性。
将元素添加到数组后,JavaScript会自动更新[[Value]]
属性的'length'
property属性。
一般来说,数组的行为与对象非常相似。
优化属性访问
现在我们知道如何在JavaScript中定义对象,让我们深入研究JavaScript引擎如何有效地使用对象。
随意浏览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引擎在中JSObject
查找键'y'
,然后加载相应的属性属性,最后返回[[Value]]
。
但是这些属性在哪里存储在内存中?我们应该将它们存储为的一部分JSObject
吗?如果我们假设以后会看到更多具有这种形状的对象,那么将包含属性名称和属性的完整字典JSObject
本身存储起来是浪费的,因为对具有相同形状的所有对象重复使用属性名称。那是很多重复,不必要地占用了内存。作为优化,引擎分别存储Shape
对象的。
其中Shape
包含所有属性名称和属性,但不包含[[Value]]
。而是在中Shape
包含值的偏移量JSObject
,以便JavaScript引擎知道在何处查找值。JSObject
具有相同形状的每个对象都精确指向此Shape
实例。现在,每个人JSObject
只需存储该对象唯一的值。
当我们有多个对象时,好处显而易见。不管有多少个对象,只要它们具有相同的形状,我们只需要存储一次形状和属性信息即可!
所有JavaScript引擎都使用形状作为优化,但它们并不全都称为形状:
- 学术论文称它们为“ 隐藏类”(使wrt JavaScript类混乱)
- V8称它们为Maps(混淆了wrt JavaScript
Map
) - Chakra称它们为Types(混淆了JavaScript的动态类型和
typeof
) - JavaScriptCore称它们为结构
- SpiderMonkey称它们为Shapes
在整个本文中,我们将继续使用shapes一词。
过渡链和树木
如果您有一个具有特定形状的对象,但是随后向其添加了属性,会发生什么情况?JavaScript引擎如何找到新形状?
const object = {};
object.x = 5;
object.y = 6;
这些形状在JavaScript引擎中形成了所谓的过渡链。这是一个例子:
该对象开始时没有任何属性,因此它指向空的形状。next语句向该对象添加了一个'x'
带有值的属性5
,因此JavaScript引擎转换为包含该属性的形状,'x'
并且在第一个offset处将一个值5
添加到。下一行添加了一个属性,因此引擎转换为包含和的另一个形状,并将该值附加到(偏移处)。JSObject
0
'y'
'x'
'y'
6
JSObject
1
注意:添加属性的顺序会影响形状。例如,{ x: 4, y: 5 }
结果与的形状不同{ y: 5, x: 4 }
。
我们甚至不需要为每个存储完整的属性表Shape
。相反,每个人Shape
都只需要了解它引入的新属性。例如,在这种情况下,我们不必以'x'
最后一个形状存储有关的信息,因为可以在链中的较早位置找到它。为了使这项工作有效,每个Shape
链接都返回其先前的形状:
如果o.x
您使用JavaScript代码编写代码,则JavaScript引擎将'x'
通过遍历过渡链来查找该Shape
属性,直到找到该引入的property 为止'x'
。
但是,如果没有办法创建过渡链会怎样?例如,如果您有两个空对象,并为每个对象添加一个不同的属性,该怎么办?
const object1 = {};
object1.x = 5;
const object2 = {};
object2.y = 6;
在这种情况下,我们必须分支,而不是使用链,而最终需要一个过渡树:
在这里,我们创建一个空对象a
,然后向其添加属性'x'
。我们最终得到一个,JSObject
其中包含一个值和两个值Shapes
:空形状和仅具有属性的形状x
。
第二个示例也从一个空对象开始b
,然后添加一个不同的property 'y'
。我们最终得到两个形状链,总共三个形状。
这是否意味着我们总是从空的形状开始?不必要。引擎会对已经包含属性的对象文字进行一些优化。假设我们要么x
从空对象常量开始添加,要么拥有已经包含的对象常量x
:
const object1 = {};
object1.x = 5;
const object2 = { x: 6 };
在第一个示例中,我们从空的形状开始过渡到也包含的形状x
,就像我们之前看到的那样。
在的情况下object2
,直接生成x
从头开始已经具有的对象而不是从空对象开始并进行过渡是有意义的。
包含属性的对象文字'x'
从一个'x'
从头开始包含的形状开始,有效地跳过了空形状。这(至少)是V8和SpiderMonkey所做的。这种优化缩短了转换链,使从文字构造对象的效率更高。
Benedikt关于React应用程序中令人惊讶的多态性的博客文章讨论了这些微妙之处如何影响现实世界的性能。
以下是包含性的3D点对象的一个例子'x'
,'y'
和'z'
。
const point = {};
point.x = 4;
point.y = 5;
point.z = 6;
如前所述,这将创建一个内存中具有3种形状的对象(不计算空的形状)。要访问属性'x'
该对象上,例如,如果你写point.x
在你的程序中,JavaScript引擎需要遵循链表:它开始在Shape
底部,然后它的工作方式到Shape
介绍了'x'
顶部。
如果我们更频繁地执行此操作,那将真的很慢,尤其是当对象具有许多属性时。查找属性的时间为O(n)
,即对象上属性的数量呈线性关系。为了加快搜索属性,JavaScript引擎添加了ShapeTable
数据结构。这ShapeTable
是一个字典,将属性键映射到Shape
引入给定属性的各个。
等等,现在我们回到字典查找…那是我们开始添加Shape
s 之前的样子!那么,为什么我们完全不关心形状呢?
原因是形状可以实现另一种称为“ 内联缓存”的优化。
内联缓存(IC)
形状背后的主要动机是嵌入式高速缓存或集成电路的概念。IC是使JavaScript快速运行的关键因素!JavaScript引擎使用IC来存储有关在何处查找对象属性的信息,以减少昂贵的查找次数。
这是一个getX
接受对象并x
从中加载属性的函数:
function getX(o) {
return o.x;
}
如果我们在JSC中运行此函数,它将生成以下字节码:
第一get_by_id
条指令'x'
从第一个参数(arg1
)加载属性,并将结果存储到中loc0
。第二条指令返回我们存储到的内容loc0
。
JSC还将嵌入式高速缓存嵌入到get_by_id
指令中,该指令由两个未初始化的插槽组成。
现在,假设我们getX
使用object进行调用{ x: 'a' }
。如我们所知,该对象具有带属性的形状,'x'
并Shape
存储该属性的偏移量和属性x
。首次执行该函数时,get_by_id
指令将查找该属性,'x'
并发现该值存储在offset处0
。
嵌入到get_by_id
指令中的IC会存储找到属性的形状和偏移量:
对于后续运行,IC仅需要比较形状,如果与以前相同,则只需从存储的偏移中加载值即可。具体来说,如果JavaScript引擎看到的对象具有IC之前记录的形状,则它根本不需要接触属性信息,而是可以完全跳过昂贵的属性信息查找。这比每次查找属性都要快得多。
有效地存储阵列
对于数组,通常存储作为数组索引的属性。这些属性的值称为数组元素。在每个单个数组中存储每个数组元素的属性属性会浪费内存。相反,JavaScript引擎使用以下事实:默认情况下,数组索引属性是可写,可枚举和可配置的,并将数组元素与其他命名属性分开存储。
考虑以下数组:
const array = [
'#jsconfeu',
];
引擎存储数组长度(1
),并指向,Shape
其中包含偏移量和属性的'length'
属性。
这类似于我们之前看到的内容……但是数组值存储在哪里?
每个数组都有一个单独的元素后备存储,其中包含所有数组索引的属性值。JavaScript引擎不必为数组元素存储任何属性属性,因为通常它们都是可写的,可枚举的和可配置的。
但是,在异常情况下会发生什么?如果更改数组元素的属性,该怎么办?
// Please don’t ever do this!
const array = Object.defineProperty(
[],
'0',
{
value: 'Oh noes!!1',
writable: false,
enumerable: false,
configurable: false,
}
);
上面的代码段定义了一个名为'0'
(恰好是数组索引)的属性,但将其属性设置为非默认值。
在这种情况下,JavaScript引擎将整个元素后备存储表示为字典,该字典将数组索引映射到属性属性。
即使只有一个数组元素具有非默认属性,整个数组的后备存储也会进入这种缓慢而低效的模式。避免Object.defineProperty
在数组索引上!(我不确定您为什么还要这样做。这似乎很奇怪,没有用。)
顺带一说
我们已经了解了JavaScript引擎如何存储对象和数组,以及Shapes和IC如何帮助优化它们的通用操作。基于这些知识,我们确定了一些实用的JavaScript编码技巧,可以帮助提高性能:
- 始终以相同的方式初始化对象,以使它们最终不会具有不同的形状。
- 不要弄乱数组元素的属性,这样它们就可以有效地存储和操作。