【译】JavaScript engine fundamentals: Shapes and Inline Caches

JavaScript 引擎原理:外形与内联缓存

前言

本文是根据本身的理解翻译而来,若有疑惑可查看原文 JavaScript engine fundamentals: Shapes and Inline Cachesreact

本次暂定翻译三篇文章:编程

  1. JavaScript engine fundamentals: Shapes and Inline Caches(Published 14th June 2018)
  2. JavaScript engine fundamentals: optimizing prototypes(Published 16th August 2018)
  3. The story of a V8 performance cliff in React(Published 28 August 2019)

JavaScript 引擎工做流

一切从你写的 JavaScript 代码开始。JavaScript 引擎会解析源码并将其转换成抽象语法树(AST)。基于 AST,解释器(interpreter)会进一步地生成字节码。数组

js-engine-pipeline
js-engine-pipeline

为了可以运行得更快,字节码可能会和分析数据(profiling data)一同发给优化编译器(the optimizing compiler)。优化编译器会根据这些分析数据做出某些假设以今生成高度优化的机器码。浏览器

若是某个时刻,某种假设被证实是错误的,优化编译器将去优化并回滚到解释器部分。缓存

JavaScript 引擎中的解释器/编译器流程

如今来关注下 JavaScript 代码被解释和优化的地方,并重温下主流 JavaScript 引擎之间的不一样之处。数据结构

通常来讲,在运行 JavaScript 代码过程当中,会有解释器和优化编译器的参与。解释器会快速地生成还没有优化的字节码,而优化编译器会耗费一些时间来生成高度优化的机器码。架构

interpreter-optimizing-compiler
interpreter-optimizing-compiler

上面的流程和 V8 在浏览器和 Node 环境下的工做流程及其类似:app

interpreter-optimizing-compiler-v8
interpreter-optimizing-compiler-v8

V8 引擎的解释器被称做 Ignition,主要负责生成和执行字节码。当字节码运行时,解释器会收集分析数据,这些数据以后可能会被用来提高执行速度。若是一个函数常常被调用,即 hot,那么,通过解释器转换来的字节码和收集到的分析数据会传给 TurboFan(V8 的优化编译器),进一步被加工成高度优化的机器码。ide

interpreter-optimizing-compiler-spidermonkey
interpreter-optimizing-compiler-spidermonkey

SpiderMonkey,Mozilla 的 JavaScript 引擎,拥有两个优化编译器,Baseline 和 IonMonkey。解释器将转换后的代码传给 Baseline 编译器,Baseline 编译器会将其加工成部分优化的代码。再加上收集到的分析数据,IonMonkey 编译器就能够生成高度优化的代码。若是基于假设的优化不成立,IonMonkey 会将代码会滚到 Baseline 部分。函数

interpreter-optimizing-compiler-chakra
interpreter-optimizing-compiler-chakra

Chakra,Microsoft 的 JavaScript 引擎,也有着相似的两个优化编译器,SimpleJIT 和 FullJIT。解释器将转换后的代码传给 SimpleJIT(JIT,Just-In-Time),SimpleJIT 会将其加工成部分优化的代码。再加上收集到的分析数据,FullJIT 就能够生成高度优化的代码。

interpreter-optimizing-compiler-jsc
interpreter-optimizing-compiler-jsc

JavaScriptCore(JSC),Apple 的 JavaScript 引擎,更是发挥到了极致,使用了三个不一样的优化编译器,Baseline、DFG 和 FTL。低级解释器(LLInt)将转换后的代码传给 Baseline 编译器,经其加工后传给 DFG(Data Flow Graph) 编译器,进一步加工后,传给 FTL(Faster Than Light) 编译器。

为何有些引擎的优化编译器会比其余引擎的多?这彻底是取舍问题。解释器能够很快地生成字节码,可是字节码的效率不高。优化编译器虽然会花更长的时间,可是生成的机器码更为高效。是更快地去执行代码,仍是花些时间去执行更优的代码,这都是须要考虑的问题。有些引擎添加多种不一样特色(省时或高效)的优化编译器,虽然这会变得更加复杂,但却能够对以上的取舍有着更细粒度地控制。还有一点须要考虑的是,内存的使用。

以上只是强调了不一样 JavaScript 引擎的解析器/编译器的区别。抛开这些不谈,从更高的层面来看,全部的 JavaScript 引擎有着相同的架构:一个解析器和一些解释器/编译器。

JavaScript 对象模型

再来看看,在某些具体实现上,JavaScript 引擎之间还有哪些相同之处。

例如,JavaScript 引擎是如何实现 JavaScript 对象模型的?它们又是如何提高对象属性访问速度的?事实证实,全部主流的引擎在这点实现上都很是得类似。

ECMAScript 规范把全部的对象定义为词典,将字符串键映射到属性特性(property attributes)。

object-model
object-model

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

  • [[Writable]] 定义是否可写。
  • [[Enumerable]] 定义是否可枚举。
  • [[Configurable]] 定义是否可配置。

[[双中括号]] 是用来描述不能直接暴露给 JavaScript 的属性。不过你依然能够经过 Object.getOwnPropertyDescriptor 获取某个对象上的以上属性。

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

ok,这是 JavaScript 如何定义对象的。那么,数组呢?

你能够认为数组是一个特殊的对象。一个不一样点是,数组会对数组索引特殊处理。数组索引是 JavaScript 规范中的一个特殊术语。数组索引是某个范围内的任何有效索引,即在 0 ~ 2³²−2 范围内的任何一个整数。

另外一个不一样点是,数组还有一个 length 属性。

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

在这个例子里,数组建立好后,'length' 值为 2。当咱们给数组索引为 2 的位置赋值时,数组的 'length' 会自动更新。

在 JavaScript 中,数组的定义和对象很类似。例如,数组的全部的键(包括数组索引)都是字符串表示。数组的第一个元素存在键值为 '0' 的地方。

array-1
array-1

另外一个属性是 'length' 属性,该属性不可枚举不可配置。

一旦数组添加一个元素,JavaScript 会自动更新 'length'属性上的 [[Value]] 值。

array-2
array-2

通常来讲,数组的行为也是和对象很是类似。

优化属性的访问

既然咱们知道在 JavaScript 中如何定义对象的。接下来让咱们深刻了解 JavaScript 引擎是如何高效地处理对象的。

属性访问是最多见的一个操做,对 JavaScript 引擎来讲,提高属性访问速度事件颇有意义的事。

const object = {
	foo: 'bar',
	baz: 'qux',
};

// Here, we’re accessing the property `foo` on `object`:
doSomething(object.foo);
// ^^^^^^^^^^
复制代码

外形(Shapes)

在 JavaScript 程序中,有相同键的对象不少,它们都有着相同的 Shape。

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

有着相同 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 引擎能够基于对象的 Shape 来优化对象属性的访问速度。

咱们假设一个对象有 x、y 属性,且用着字典这种数据结构:它包含字符串表示的键,而且键指向各自的属性特性(property attributes)。

object-model
object-model

若是要访问一个属性,例如 object.y,JavaScript 引擎会在 JSObject 中查找 y,而后加载对应的属性特性,最后返回 [[Value]]

可是在内存中,这些属性特性要存储在哪呢?咱们应该把它们看成 JSObject 的一部分存储吗?假设以后会有更多的拥有相同 Shape 的对象,若是咱们在 JSObject 上存储一个包含属性名称和属性特性的完整字典的话,那显然会是一种浪费。由于拥有相同 Shape 的对象,它们的属性名称会重复。这会形成大量重复和没必要要的内存使用。做为优化,引擎将对象的 Shape 单独地存储。

shape-1
shape-1

Shape 包含全部的属性名称和属性特性,除了 [[Value]]。不过,Shape 包含了 [[Value]]JSObject 上的偏移量,所以 JavaScript 引擎知道去哪里找到相应的值。 每一个拥有相同 ShapeJSObject 都指向同一个 Shape 实例。如今,每一个 JSObject 只需存储对象的值便可。

shape-2
shape-2

当咱们有不少个对象时,好处也是显而易见的。无论有多少个对象,只要有相同的 ShapeShape 和属性信息只须要存储一次。

全部的 JavaScript 引擎都用 Shapes 来优化,但叫法却不一样:

  • 学术论文称之为 Hidden Classes(容易和 JavaScript 中的 Class 混淆)
  • V8 称之为 Maps(容易和 JavaScript 中的 Map 混淆)
  • Chakra 称之为 Types(容易和 JavaScript 中的动态类型与 typeof 混淆)
  • JavaScriptCore 称之为 Structures
  • SpiderMonkey 称之为 Shapes

在这篇文章中,咱们继续称之为 Shapes。

过渡链与树(Transition chains and trees)

若是一个对象有了一个肯定的 Shape,而后又添加了一个属性,这会发生什么呢?JavaScript 引擎如何找到改变后的新 Shape

const object = {};
object.x = 5;
object.y = 6;
复制代码

在 JavaScript 引擎中,这种 Shapes 结构称之为过渡链(transition chains)。以下:

shape-chain-1
shape-chain-1

对象开始时没有任何属性,所以它会指向一个空的 Shape。下一条语句对象添加了一个属性 'x',属性值为 5,所以对象指向包含属性 'x'Shape,且在 JSObject 中偏移量为 0 的位置添加 5。下一条语句对象添加了一个属性 'y',属性值为 5,所以对象指向包含属性 'x''y'Shape,且在 JSObject 中偏移量为 1 的位置添加 6。

注意: 属性的添加顺序会影响 Shape。例如,{x: 4, y: 5}{y: 5, x: 4} 有不一样的 Shape

咱们没有必要让每一个 Shape 都存储完整的属性表。相反,每一个 Shape 只须要知道新引入的属性便可。例如,在这种状况下,咱们没有必要在最后一个 Shape 中存储属性 'x' 的信息,由于它能够在链的上游中被查找到。要达此目的,每一个 Shape 都会和先前的 Shape 连接。

shape-chain-2
shape-chain-2

若是你在 JavaScript 代码中写了 o.x,JavaScript 引擎会沿着过渡链查找属性 'x',直到发现引入 'x'Shape

可是,若是无法建立过渡链呢?例如,给两个空对象添加不一样的属性。

const object1 = {};
object1.x = 5;
const object2 = {};
object2.y = 6;
复制代码

这种状况下,不得不进行分支处理,用过渡树(transition tree)取代过渡链。

shape-tree
shape-tree

在这里,咱们建立了一个空对象 a 并给它添加了属性 'x'。最终获得以一个包含单个值的 JSObject和两种 Shape(空的 Shape 和仅有属性 'x'Shape)。

第二个例子也是以一个空对象 b 开始,可是添加的是属性 'y'。最终获得两条 Shape 链和三个 Shape

这是否意味着老是以空 Shape 开头呢?不必定。

引擎对已经存在属性的对象字面量作了优化。来看两个例子,一个是从空的对象开始添加属性 'x',一个是已经存在属性 'x' 的对象字面量。

const object1 = {};
object1.x = 5;
const object2 = { x: 6 };
复制代码

第一个例子中,咱们从空的 Shape 过渡到包含属性 'x'Shape,就如以前所看到的那样。

对于 object2,它直接生成包含属性 'x' 的对象而不是从空对象开始过渡。

empty-shape-bypass
empty-shape-bypass

这个包含属性 'x' 的对象,以包含 'x'Shape 开头,省去了空 Shape 这个步骤。至少 V8 和 SpiderMonkey 是这么作的。这种优化缩短了过渡链,使得建立对象更加高效。

Benedikt 的文章 surprising polymorphism in React applications 讨论了这些微妙之处是如何影响到实际性能的。

这有一个拥有属性 'x'、'y''z'` 的三维点对象的例子。

const point = {};
point.x = 4;
point.y = 5;
point.z = 6;
复制代码

就如以前所学到的,在内存上,这会建立有三个 Shape 的对象(空 Shape 不计入)。访问对象的属性 'x',假如,你在程序中写下了 point.x,JavaScript 引擎会顺着链表:它会从底部的 Shape 开始,一直向上查找,直到发现引入 'x' 的那个 Shape

shapetable-1
shapetable-1

若是这种操做很频繁,就会显得很慢,尤为是一个对象有不少属性时。检索到须要的属性所花时间是 O(n),即线性的。为了提升检索速度,JavaScript 引擎加入了 ShapeTable 数据结构。ShapeTable是个字典,它将属性和引入该属性的 Shape 关联起来。

shapetable-2
shapetable-2

且慢,咱们又回到了字典查找……这不就是咱们在引入 Shapes 以前的方式吗?为何咱们非要整出个 Shapes?

缘由是 Shapes 能够实现另外一种称之为内联缓存的优化。

内联缓存(Inline Caches (ICs))

ICs 是 JavaScript 快速运行的关键因素。JavaScript 引擎能够利用 ICs 缓存对象的属性信息,从而减小属性查找的开销。

有个函数 getX ,接受一个对象并加载该对象上的属性 x

function getX(o) {
	return o.x;
}
复制代码

若是咱们在 JSC(JavaScriptCore) 中运行这个函数,它会生成如下的字节码:

js-engines/ic-1
js-engines/ic-1

第一条指令(get_by_id)是从参数 arg1 中加载属性 x,并将其值存储到 loc0 中。第二条指令是返回 loc0 中存储的值。

JSC 还在 get_by_id 指令中嵌入了内联缓存,它是由两个未初始化的插槽组成。

ic-2
ic-2

如今给函数 getX 传入对象 { x: 'a' }。如咱们所知,这个对象有一个包含属性 xShape,这个 Shape 存储了属性 x 的偏移量和特性。当咱们第一次执行函数时,get_by_id 指令会查找属性 x并检索到值被存储在偏移量为 0 位置。

ic-3
ic-3

嵌在 get_by_id 指令中的内联缓存会记住 Shape 和属性的偏移量。

ic-4
ic-4

在下次函数执行时,内联缓存会对比 Shape,若是与以前的 Shape 相同,就只须要经过缓存的偏移量加载值。具体来讲,若是 JavaScript 引擎发现对象的 Shape 和以前记录的 Shape 同样,那么它就不再须要去查找属性信息了 —— 属性信息的查找就能够彻底跳过。相比每次都去查找属性信息,这样的操做会显著地提高速度。

高效存储数组(Storing arrays efficiently)

对于数组,使用数组索引做为数组的属性是很常见的,属性对应的值称之为数组元素。为每一个数组的每一个数组元素存储属性特性是一种铺张浪费的行为。在 JavaScript 引擎中,数组的索引属性默认是可读、可枚举和可配置的,且数组元素是与命名属性分开存储的。

思考如下这个数组:

const array = [
	'#jsconfeu',
];
复制代码

引擎存储了一个数组长度为 1 的数组,它指向一个包含偏移量和 length 特性的 Shape

array-shape
array-shape

这个以前见过的很类似…… 可是数组元素的值存在哪呢?

array-elements
array-elements

每一个数组都有一个独立的元素备份存储(elements backing store),包含着全部索引属性对应的值。JavaScript 引擎没必要为数组元素存储属性特性,由于他们一般是可写、可枚举和可配置的,且数组索引能够替代偏移量的做用。

若是是不寻常的状况会怎样呢?好比,改变数组元素的属性特性(property attributes)。

// Please don’t ever do this!
const array = Object.defineProperty(
	[],
	'0',
	{
		value: 'Oh noes!!1',
		writable: false,
		enumerable: false,
		configurable: false,
	}
);
复制代码

上面的这个代码片断是给对象属性 '0' 的特性设置成非默认值。

像这种状况,JavaScript 引擎会将整个元素备份存储表示为一个字典,把数组索引和属性特性关联起来。

array-dictionary-elements
array-dictionary-elements

即便数组中只有一个元素的属性特性是非默认值,元素备份存储也会进入缓慢低效的模式(从 Elements 模式 到 Dictionary Elements 模式)。避免用 Object.defineProperty 改变数组索引!

看点(Take-aways)

基于以上的知识,咱们可使用一些 JavaScript 编程技巧来提高性能:

  1. 始终以相同的方式初始化对象,这样就能够复用 Shape
  2. 不要没事瞎折腾数组元素的属性特性,它们本能够高效地工做。
相关文章
相关标签/搜索