【JavaScript Weekly #399】JavaScript 引擎基础 (上):形态和内联缓存
🥳 欢迎有兴趣的小伙伴,一起做点有意义的事!本文译者:道道里
我发起了一个周刊翻译计划,仓库地址:github.com,访问地址:fedarling.github.io
现在还很缺志同道合的小伙伴,纯属个人兴趣,当然对于提升英语和前端技能也会有帮助,要求:英语不要差的离谱、github熟练使用、有恒心、谦虚、对自己做的事负责。
想参与的小伙伴,可以 wx 联系我,也可以给仓库发issue留言,我博客也有具体的个人联系方式:daodaolee.cn
本文描述 JavaScript 引擎中通用的一些关键的基础知识——不仅仅是 V8。作为一名 JavaScript 开发人员,对 JavaScript 引擎的工作原理深入了解一下有助于你更好的编写代码。
JavaScript 引擎管道
JavaScript 代码在运行时候, JavaScript 引擎会解析源代码并将其转换为抽象语法树 (AST)。基于该 AST,解释器会执行自己的任务并生成字节码。WOW!引擎这时候实际上正在执行着你写的 JavaScript 代码。
为了使其运行得更快,可以将字节码与数据分析一起发送到优化编译器。优化编译器根据这些数据做出判断,然后生成高度优化的机器代码。
如果在执行过程期间某个执行错误了,优化编译器会取消优化并返回到解释器。
JavaScript 引擎中的解释器/编译器管道
现在,让我们看下这个管道中实际运行 JavaScript 代码的部分,也就是代码被解释和优化的地方。
一般来说,会有一个包含解释器和优化编译器的管道。解释器快速生成未优化的字节码,然后优化编译器通过耗时稍长的逻辑来生成最终高度优化的机器码。
这个管道基本上就是 V8(Chrome 和 Node.js 中使用的 JavaScript 引擎)的工作原理:
V8 中的解释器的名字叫 引燃(Ignition),负责生成和执行字节码。在运行字节码时,它会收集分析数据,这些数据可用于稍后加快执行速度。当一个函数运行时,生成的字节码和分析数据被传递给我们的优化编译器 涡轮风扇(TurboFan),从而根据分析数据生成高度优化的机器代码。
SpiderMonkey 是 Mozilla 在 Firefox 和 SpiderNode 中使用的 JavaScript 引擎,它的做法略有不同。他们不是一个,而是两个优化编译器:基准编译器(Baseline compiler) 和 离子猴编译器(IonMonkey compiler)。解释器优化到基准编译器时,会输出一些优化的代码。结合运行代码时收集的分析数据,离子猴编译器可以生成高度优化的代码。如果中途优化失败,离子猴编译器会退回到基准代码。
Chakra 是 Microsoft 在 Edge 和 Node-ChakraCore 中使用的 JavaScript 引擎,也带有两个优化编译器:简易编译器(SimpleJIT) 和 完全体编译器(FullJIT)。解释器优化为 简易编译器 — 其中 JIT 代表 Just-In-Time 编译器,该编译器会产生一些优化的代码。然后结合分析数据,完全体编译器可以生成更加优化的代码。
JavaScriptCore(JSC) 是 Apple 在 Safari 和 React Native 中使用的 JavaScript 引擎,它通过三种不同的优化编译器将其发挥到了极致:低级解释器( LLInt,low-level-Interprete),优化到 基准编译器(Baseline compiler),然后优化到 数据流图形编译器(DFG,Data Flow Graph),而 DFG 编译器又可以优化到 超光速编译器(FTL,Faster Than Light)。
那为什么有些厂商的引擎比其他的优化编译器的个数要多呢?当然这些都是权衡之后的。解释器可以快速生成字节码,但字节码通常效率不高。另一方面,优化编译器的执行需要更长的时间,但最终会产生更高效的机器代码。一些引擎会选择添加具有不同时间/效率的多个优化编译器,以牺牲更多的复杂性为代价来增加更细粒度的控制权。除此之外,内存也是一个权衡引擎个数和工作流程要考虑的原因,相关文章请见 另一篇。
到现在为止,我们介绍了每个 JavaScript 引擎的解释器和优化编译器管道的区别。除了这些差异之外,在高层次上,所有 JavaScript 引擎都具有相同的架构:有一个解析器和某种解释器/编译器管道。
JavaScript 对象模型
通过放大代码的内部结构来看看 JavaScript 引擎还有哪些共同点。比如这个问题,JavaScript 引擎是如何实现 JavaScript 对象模型的,它们使用哪些技巧来加速访问 JavaScript 对象的属性?
ECMAScript 规范本质上将所有对象定义为字典:字符串key 映射到 property 属性。
除了 [[Value]]
本身之外,规范还定义了以下属性:
[[Writable]]
属性表示是否可以重写[[Enumerable]]
属性表示是否可以枚举[[Configurable]]
表示是否可以删除该属性
[[xx]]
这个双括号写法看起来很时髦,但这确实是规范,表示不直接暴露给 JavaScript 的属性的方式。你可以使用 Object.getOwnPropertyDescriptor
这个 API 来获取 JavaScript 中任何给定对象和属性的这些值:
const object = { foo: 42 };
Object.getOwnPropertyDescriptor(object, 'foo');
// → { value: 42, writable: true, enumerable: true, configurable: true }
OK!这就是 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 引擎是如何有效地处理对象的。
纵观 JavaScript 程序,访问属性是最常见的操作,所以让 JavaScript 引擎快速访问属性至关重要。
const object = {
foo: 'bar',
baz: 'qux',
};
// Here, we’re accessing the property `foo` on `object`:
doSomething(object.foo);
// ^^^^^^^^^^
形态(Shape)
在 JavaScript 程序中,多个对象具有相同的属性键是很常见的,这些对象具有相同的形态:
const object1 = { x: 1, y: 2 };
const object2 = { x: 3, y: 4 };
// `object1` 和 `object2` 长的一样.
在具有相同形态的对象上访问相同的属性也很常见:
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
上是很浪费的,因为属性名称对于具有相同形态的所有对象都是重复的!这样会导致很多重复和不必要的内存使用。此时有一个优化方案:引擎单独存储对象的形态。
该引擎包含了除了它们的 [[Value]]
之外所有属性名称和属性值。形态(Shape) 里包含 JSObject
内部值的offset,以便 JavaScript 引擎知道在哪里可以找到这些值。每个具有相同形状的 JSObject
都指向这个 Shape 实例。现在每个 JSObject
只需要存储该对象唯一的值即可。
当我们有多个对象时,这种方法就显得很好。不管有多少对象,只要它们的形态相同,我们只需要存储一次Shape和属性信息!
所有 JavaScript 引擎都使用 Shape 作为优化,但它们并不都称它们为形态:
- 学术论文称它们为隐藏类(混淆 w.r.t. JavaScript 类)
- V8 称它们为 Maps(混淆 w.r.t. JavaScript Maps)
- Chakra 称它们为类型(混淆 w.r.t. JavaScript 的动态类型和 typeof)
- JavaScriptCore 称它们为结构
- SpiderMonkey 称它们为形态
在该篇文章中,我们将继续使用术语 — 形态。
转换链和树(Transition chains and trees)
如果你有一个具有特定形态的对象,随后向它添加了一个属性,会发生什么? JavaScript 引擎如何找到新的形态?
const object = {};
object.x = 5;
object.y = 6;
这些形态在 JavaScript 引擎中形成所谓的转换链,看下面这个例子:
该对象最开始没有任何属性,因此它指向了空 Shape。下面一条语句将值为 5
的属性 “x”
添加到此对象,因此 JavaScript 引擎转换为包含属性 “x”
的 Shape,并且在第一个offset 0
处将值 5
添加到 JSObject
。接着,下一行添加了一个属性 “y”
,引擎会转换到另一个包含 “x”
和 “y”
的 Shape,并将值 6
附加到 JSObject
(offset 1)上。
注意:添加属性的顺序会影响形态。例如,{ x: 4, y: 5 } 会产生与 { y: 5, x: 4 } 不同的形态。
我们不需要为每个 Shape
存储完整的属性表。相反,每个 Shape 只需要知道它引入的新属性即可。例如,在这种情况下,我们不必将关于 “x”
的信息存储在最后一个 Shape 中,因为它可以在链的头部位置找到。为了正确解析,每个 Shape 都链接回其先前的形状:
如果你在 JavaScript 代码中编写o.x
,则 JavaScript 引擎会通过遍历转换链来查找属性 “x”
,直到找到引入属性 “x”
的 Shape
。
那,如果无法创建转换链会怎样?例如,如果你有两个空对象,并且为每个对象添加不同的属性怎么办?
const object1 = {};
object1.x = 5;
const object2 = {};
object2.y = 6;
在这种情况下,我们得利用分支,而不是链,最终可以得到一个转换树:
如上图,我们创建一个空对象 a
,然后为其添加一个属性 “x”
。我们最终得到一个包含 单个值 和 两个 Shape 的 JSObject
:空Shape 和 只有属性 x
的 Shape。
第二个示例也以空对象 b
开头,随后添加了不同的属性 “y”
。我们最终得到两个 Shape 链,总共有三个Shape。
那是不是说我们一定要从空的 Shape 开始执行呢?不是的。引擎对已经包含属性的对象应用了一些优化。看下面这个例子,从空对象字面量开始添加 x
和 拥有一个已经包含 x
的对象字面量:
const object1 = {};
object1.x = 5;
const object2 = { x: 6 };
在 object1 中,我们从空 Shape 开始并执行转换到包含 x
的Shape,在 object2 中,直接生成了一个从一开始就已经有 x
的对象。
包含属性 “x”
的对象字面量从包含 “x”
的 Shape 开始执行,直接跳过了 空Shape!这就是 V8 和 SpiderMonkey 所做的。这种优化缩短了转换链,并提高了构造对象的效率。
下面是一个具有 “x”
、“y”
和 “z”
属性的 3D 点位对象的示例:
const point = {};
point.x = 4;
point.y = 5;
point.z = 6;
正如我们之前所了解的,这会在内存中创建一个具有 3 个 Shape 的对象(除空Shape之外)。如果你在程序中编写 point.x
,JavaScript 引擎就会遵循链表:从底部的 Shape 开始,然后一直到顶部引入了 “x”
的 Shape 结束。
如果我们更频繁地这样做,效率就会非常慢,尤其是当对象有很多属性时。找到属性的时间是 O(n),即对象的属性数量是线性的。为了加快属性搜索速度,JavaScript 引擎添加了一个 ShapeTable 数据结构。这个 ShapeTable 是一个字典,将属性键映射到引入给定属性的各个 Shapes。
现在,我们回到字典查找,也就是我们开始添加 Shape 之前的位置!显然要查找很多很多次!这时候怎么办?
Shape 启用了另一种称为 内联缓存 的优化。
内联缓存(Inline Caches,ICs)
Shape 背后的主要逻辑是 内联缓存(IC) 。 IC 是让 JavaScript 快速运行的关键因素! JavaScript 引擎使用 IC 来记忆有关在何处查找对象属性的信息,以减少昂贵的查找次数。
这是一个 getX
函数,它接受一个对象参数并从中加载属性 x:
function getX(o) {
return o.x;
}
如果我们在 JSC 中运行这个函数,它会生成以下字节码:
第一条 get_by_id
指令从第一个参数 (arg1) 加载属性 “x”
并将结果存储到 loc0
。第二条指令将我们存储的内容返回到 loc0
。
JSC 还在 get_by_id
指令中嵌入了一个内联缓存,该指令由两个未初始化的插槽组成:
现在假设我们传入一个对象 { x: 'a' }
给 getX
。正如我们所了解的,这个对象有一个带有属性 “x”
的 Shape,并且该 Shape 存储了该属性 x
的offset和属性。第一次执行该函数时,get_by_id
指令查找属性 'x'
并发现该值存储在offset 0
处:
嵌入到 get_by_id
指令中的 IC
会记住找到属性的 Shape 和offset:
对于后续的运行,IC只需比较 Shape 即可,如果与之前相同,则从内存的offset中加载值。具体来说,如果 JavaScript 引擎看到具有 IC 之前记录的 Shape 的对象,它就不再需要访问属性信息——相反,可以完全跳过代价极大的属性信息查找。这比每次查找属性要快得多。
高效存储数组
对于数组,通常会存储作为数组索引的属性,此类属性的值称为数组元素。在每个数组中存储每个数组元素的属性都会浪费内存。相反,JavaScript 引擎会使用数组索引属性操作(是可写、可枚举和可配置的时候),并将数组元素与其他命名属性分开存储。
来看这个例子:
const array = [
'#jsconfeu',
];
引擎存储数组长度为 1,并指向包含offset 和 “length”属性 的 Shape。
这与我们之前看到的结构很像,但是数组值存储在哪里?
每个数组都有一个单独的元素后备存储,其中包含所有数组索引的属性值。 JavaScript 引擎不必为数组元素存储任何属性,因为通常它们都是可写、可枚举和可配置的。
但是,在不寻常的情况下会发生什么?如果更改数组元素的属性属性怎么办?
// 千万不要这样做!
const array = Object.defineProperty(
[],
'0',
{
value: 'Oh noes!!1',
writable: false,
enumerable: false,
configurable: false,
}
);
上面的代码片段定义了一个名为 '0'
的属性(它恰好是一个数组索引),但将其属性设置为非默认值。
在这种边缘情况下,JavaScript 引擎将整个后备存储元素表示为将数组索引映射到属性属的字典。
即使只有一个数组元素具有非默认属性,整个数组的后备存储也会进入这种缓慢且低效的模式。一定要避免在数组索引上使用 Object.defineProperty
!
最后
我们已经了解了 JavaScript 引擎如何存储对象和数组,以及 Shapes 和 IC 如何帮助优化它们的常见操作。基于这些知识,我们确定了一些有助于提高性能的实用 JavaScript 编码技巧:
- 始终以相同的方式初始化你的对象,它们最终不会具有不同的 Shape。
- 不要乱用数组元素的属性。
相关资料
-
前端
-
JavaScript
下篇文章已出:地址