JavaScript引擎基本原理:Shapes和Inline Caches

原文链接: JavaScript engine fundamentals:Shapes and line Cahes

这篇文章描述了一些在js引擎中通用的关键点, 并不只是V8, 这个引擎的作者(Benedikt和Mathias)开发的. 作为一名JavaScript的开发者, 需要较深的理解JavaScript引擎是如何工作的, 那可以帮助你更改的冲原理层面提高你代码的性能.

JavaScript 引擎管道

这是你写出所有JS代码的开始. JS引擎会格式化你的代码, 并他们转成抽象语法树(AST).基于这个AST,解析器能够开始做他的事情, 开始产生字节码. 完美, 就在那一刻, 引擎开始真正的运行JS代码.

JavaScript引擎基本原理:Shapes和Inline Caches

为了能让他跑的更快,这些字节码能够和压缩后的数据一起发送给优化编译器, 这些优化编译器能够根据基于压缩后的代码, 做出某些确认的假设. 然后产生优化程度更高的代码.

如果某些假设是不正确的, 那么优化编译器会自动去优化, 并返回解释器. (TODO: 不太理解退回到解释器, 是退回成初识的代码吗?)

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

现在让我们放到到在管道中的一个部分, 那是你真正运行JavaScript的地方. 就是代码被解析和优化的地方. 然后去对比一些在流行的JavaSript引擎中某些不同的地方.

总的来说, 一个管道包含一个解析器和一个优化编译器 . 解释器会快速并源源不断的产生没有被优化过的字节码. 然后优化编译器会多花点时间. 但是最后产生一些优化程度更高的机器码.

JavaScript引擎基本原理:Shapes和Inline Caches

这种常见的管道, 几乎和V8中存在的一样. JavaScript引擎在Chrome和Node中是使用方式如下:

JavaScript引擎基本原理:Shapes和Inline Caches

解释器在V8中被称为启动装置(Ignition), 负责生成和执行字节码. 当运行这些字节码的时候, 他会收集分析数据, 这些数据用来加速后面的执行. 当一个函数hot的时候. 举个例子, 就是他经常执行的时候, 生成字节码和分析的数据会被通过到TurboFan, 我们的优化编译器, 基于分析的数据会生成更高优化程度的机器码.

JavaScript引擎基本原理:Shapes和Inline Caches

SpiderMonkey, Mozilla的JavaScript引擎被用在火狐和SpriderNode上面, 他有一点点的不同. 他有两个优化编译器. 解释器首先使用基础的优化器优化, 产生一些优化后的代码. 当代码开始运行的时候, 会产生一些分析的数据, IonMonkey能够基于这些分析的数据产生更高程度的优化代码, 如果推测的优化项是错误的, 那么IconMonkey就会退回到基础优化器产生的代码.

JavaScript引擎基本原理:Shapes和Inline Caches

Chakra, 被用在Edge和Node-ChakraCore中的JavaScript引擎,设置了两个非常小的优化编译器. 解析器使用SimpleJIT开始优化, (JIT表示Just-In-Time compiler实时编译器), 哪里会产生一个优化后的代码. 产生一些分析后的数据, 这个FullJIT能够产生优化程度更高的代码.

JavaScript引擎基本原理:Shapes和Inline Caches

JavaScriptCore(简称JSC), 苹果用在Sarari上的和React Native上的JavaScript引擎. 使用三种不同的优化引擎, 使他变得极致. LLInt(Low-Level Interpreter), 最底层的的解析器, 使用基层优化器优化, 然后使用DFG(Data Flow Graph)优化器, 然后再使用FTL(Faster Than Light)优化器.

为什么一些引擎比其他的引擎使用的更多的优化编译器. 这是权衡利弊的结果. 一个解析器能够分成快速的产生字节码, 但是字节码通常不够高效. 另一个方面来说, 一个优化编译器需要花费更长的事件, 但是最终可以产生一些更加高效的机器码. 这就是在更加快速的运行代码或者牺牲一些时间, 最后运行一些性能更高的代码. 一些引擎选择增加多个使用不同时间/高性能的优化编译器, 允许他们对于权衡利弊这事进行更高程度的控制, 但是增加了复杂性. 另一个方面, 权衡利弊也和内存的使用有关系. 后面的文章会有介绍.

我们只是重点讲了在每一个浏览器中, 关于管道中的解析器和优化器的不同. 但基于这些不同, 在更高的层面上, 所有的JavaScript引擎都有相同的特性: 那就是格式化和一些在管道中解析器和优化器的特性.

JavaScript的对象模型

让我们通过放到一些方面的实现来看看JavaScript引擎相同的部分.

举个例子: JavaScript引擎如何实现的JavaScript的对象模型? 又使用来的哪些技巧来提升访问JavaScript对象的性能. 事实证明: 所有主要引擎的实现都非常相似.

ECMAScript规范在本质上定义了所有的对象都作为一个字典, 用一些

key, 去对应一些属性的描述.

JavaScript引擎基本原理:Shapes和Inline Caches

除了表示本身的[[value]], 这个规范定义了一些其他的属性.

  • [[writable]] 确定这个属性是否可以重新分配,

  • [[Enumerable]] 确定了这个属性能否在for-in循环中展示,

  • [[Configurable]] 确定了这个属性能否被删除.

    这两个中括号(double square brackets)的表示, 看起来非常有趣, 这是规范表示不能直接保留的JavaScript属性. 通过使用JavaScript中的Object.getOwnPropertyDesriptorAPI, 你仍然可以任何给定的对象上面属性的描述.

    const obj = {a:1}
    Object.getOwnPropertyDescriptor(obj, 'a')
    // {value: 1, writable: true, enumerable: true, configurable: true}

    好了, JavaScript就是这么定义对象的. 但是对于数组又是如何定义的呢?

    你一定能够想到, 数组作为一个特殊的类型的对象. 其中一个区别就是数组对于数组的索引, 有特殊的处理. ESMA规范规定 数组 索引 是一个特殊的术语. 在JS中数组的最大限制为2^23-1个元素. 数组的索引是任何在限制内的有效值, 就是从0到2^23-2的任何整数.

    另一个不同就是数组会有一个特殊的length属性.

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

    在这个例子中, 数组被创建的时候length2. 当我们分配另一个元素到索引2的位置上的时候, length属性自动被修改了.

    JavaScript定义数组的方式和对象类似. 例如: 所有的属性, 包括数组的索引, 都使用明确的使用字符串表示. 在数组中的第一个元素, 就是存储在属性'0'下面.

    JavaScript引擎基本原理:Shapes和Inline Caches

    'length'属性这是一个不能枚举和删除的属性.

    当一个元素被添加到数组中的时候, JavaScript会自动的更新length属性的[[value]]属性.

    通常来说, 数组和对象非常相似.

优化属性访问

现在我们知道在JavaScript中对象是如何定义的, 让我们深入到JavaScript引擎如何高效的使用对象工作.

让我们看下最简单的JavaScript程序, 访问属性是最常见的简单操作. 所以, JavaScript引擎能否快爽的属性是至关重要的.

const object = {
foo: 'bar',
baz: 'qux
}
// 在这, 我们访问object上的foo属性
doSomething(objet.foo)
// ^^^^^^^^^

Shapes

在JavaScript程序中, 经常出现多个对象具有相同的属性. 这就表明, 很多对象具有相同的 模型(shape).

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能够基于对象的模型, 优化对象的属性访问. 下面是他的工作方式.

我们假设我们有一个对象, 上面有xy两个属性, 并且他使用的了我们之前讨论的字典数据结构: 使用字符串作为key, 这些key各自指向了他们对于对于属性的描述.

JavaScript引擎基本原理:Shapes和Inline Caches

当我们访问其中一个属性, 例如object.y, 然后引擎会去寻找在JSObject上的keyy, 然后找到在一致的属性描述, 最后返回[[Value]]

但是, 这些属性的藐视存储在缓存中的什么位置呢? 我们又是如何将这些描述作为JSObject一个部分进行存储的呢? 假设我们会看到后面更多的对象,使用这个模型. 这个时候,存储这个对象的整个字典,包括属性名称和描述,就变成了一种浪费. 因为所有具有相同模型的对象都是重复的. 这会造成非常多重复和不必要的内存使用. 作为一种优化方式, 引擎会存储个别对象的模型Shape.

JavaScript引擎基本原理:Shapes和Inline Caches

模型包含了除了[[Value]]以外所有的属性名称和描述. 相反Shape包含了JSObject内部值的偏移量(offset), 所以引擎可以获取到正确的values值. 每一个JSObject都有都用这个相同的模型指针表示精确的模型接口. 现在每一个JSObject只需要存储属于他的独一无二的值.

JavaScript引擎基本原理:Shapes和Inline Caches

好处就是当我们有多个对象的时候, 好处就变得明显. 无论我们有多少个对象, 只要他们有相同的墨香, 我们只需要存储一此模型和属性的信息.

所有的JavaScript引擎都用到了模型的优化手段, 但是他们不一定成为模型:

  • Academic papers: Hidden Classes (让人和js中的class搞混)

  • V8: Maps (容易和Map混淆)

  • Chakra: Types (容易和js中的动态类型, 还有typeof 运算符搞混)

  • JavaScriptCore: Structures

  • SpiderMonkey: Shapes

    在本文中, 我们将继续使用shapes这个单词.

Transition chains and tress

当你有一个对象, 这个对象有一个确定的模型, 但是当你添加一个属性的时候, 会发生什么? 引擎发现一个新的模型的时候, 如何如何?

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

这种模型在JavaScript引擎中被称为 转换链(transition chains). 举个例子:

JavaScript引擎基本原理:Shapes和Inline Caches

这个对象起初时没有任何属性, 所以他指向一个空的模型. 当下一个操作添加了一个属性x, 并且赋值为5, 这时引擎就会转换为包含一个属性x为5, 并且第一个的偏移量为0的模型. 当再次添加属性y的时候, 引擎再次转换为另一个包含x和y的模型, 并且y的偏移量为1对应到JSObject上.

提示: 模型收到添加属性顺序的影响. 例如: { x: 4, y: 5}{ y: 5, x: 4 } 使用的是不同的模型

我们甚至不需要存储每一个模型的完整属性表. 相反, 每一个模型只需要知道新的属性的信息. 举个例子: 在下面的案例中, 我们并没有在最后一个模型中存储属性x的信息. 因为可以在之前的链(chain)找到他. 为了实现这个功能, 每一个模型, 都链接了他的上一个模型.

JavaScript引擎基本原理:Shapes和Inline Caches

如果你在代码中写下o.x, 引擎就会沿着转换链一层层的向上寻找, 一直找到一个模型有关于属性'x'的信息.

但当我们不能创建一个原型链的时候呢?举个例子:首先我们有两个空对象, 然后我们对于他们分别添加不同的属性.

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

在这个例子中, 我们有一个分支来代替链, 结束的时候, 我们有一个 转换树(transition tree)

JavaScript引擎基本原理:Shapes和Inline Caches

在这, 我们创建了一个空对象a, 然后给他添加了属性x. 结束后, JSObject包含一个唯一值和两个模型: 一个空模型, 和一个只包含属性x的模型.

第二个例子开始的时候, 我们有一个控模型b, 然后添加了一个属性y. 最后, 我们有了两个模型连, 一共是有三个模型.

那是不是意味着我们总是在开始时使用一个空模型?未必,引擎会针对那些含有确定属性的对象进行优化. 让我们给另外一个空对象添加属性x, 然后有一个对象已经拥有了确定的属性x

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

在这个例子中, 首先我们是有一个空模型, 然后转换到一个包含属性x的模型. 就像我们之前看到的那样.

object2的例子中, 直接产生包含x属性的一个对象是意义的, 而不是先产生空对象, 再去转换.

JavaScript引擎基本原理:Shapes和Inline Caches

这个对象的描述, 一开始就从一个, 包含一个属性x的模型开始的. 有效的跳过了空模型. 这是V8和SpiderMonkey所使用的. 这种优化模式缩短了转换链, 并让对象的构造更加高效.

Benedikt's 的博客surprising polymorphism in React applications讨论了这些微小的细节如何影响所展示的真实性能.

下面是一个拥有'x', 'y', 和'z'的属性的3D的例子.

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

通过我们前面学到的, 会用在内存中使用三个模型来创建这个对象(并没有计算空模型). 当访问属性x的时候, 比如, 你在程序里写到point.x在你的程序里. 引擎需要沿着转换链线性寻找. 他会先寻找最下面的模型. 然后一层层向上寻找, 一直在最上面的模型中找到属性x的描述.

JavaScript引擎基本原理:Shapes和Inline Caches

当我们做的操作越来越多的时候, 那一定会变得非常慢. 尤其是当一个对象具有非常多的属性的时候. 寻找属性的时间复杂度为O(n), 即和对象上的属性数量线性相关. 为了加快搜索属性, JavaScript引擎加入了一个ShapeTable的数据结构. 这个ShapeTable是一个字典, 其中属性key映射不同模型上的属性描述.

JavaScript引擎基本原理:Shapes和Inline Caches

稍等, 现在让我们往前想一想... 这就是我们之前添加模型的地方. 这就是关于模型的全部.

模型的处理方式是非常有效的. 另一种优化方式称之为 内嵌缓存(Inline Caches)

Inline Caches(ICs)

模型背后的主要动机是内嵌缓存(Inline Caches/ ICs)的概念. ICs是JavasCript快速运行的重要因素. 引擎使用ICs来记录找到对象属性的地方, 减少昂贵的查找次数.

这是函数getX, 他接受一个对象, 并加载属性x

function getX(o) {
return o.x;
}

如果我们在JSC中运行这个环节, 他会产出下面字节码.

JavaScript引擎基本原理:Shapes和Inline Caches

首先, get_by_id 从第一个参数中加载属性x, 并将结果存储到loc0中. 第二条命令然后我们存储在loc0中存储的结果.

JAC也嵌入了内嵌缓存到get_by_id指令中, 有两个未初始化的插槽构成.

JavaScript引擎基本原理:Shapes和Inline Caches

现在, 我们假设传入一个对象{ x: 'a' }, 来执行getX这个函数. 前面学到的, 这个对象有一个模型, 这个模型上有属性x, 然后这个Shape存储了偏移量, 和关于属性x的描述. 当你在第一时间执行这个函数的时候, get_by_id指令会去向上查找属性x, 然后发现值是存储在偏移量为0的位置.

JavaScript引擎基本原理:Shapes和Inline Caches

这个内嵌了的IC, 进入到get_by_id指令中, 缓存了模型, 和需要寻找属性的偏移量.

JavaScript引擎基本原理:Shapes和Inline Caches

后面这个函数再次执行的时候, IC只需要对比模型, 发现和上一个模型一样, 那么只需要加载从存取的偏移量取值. 明确一点, 如果引擎发现IC之前记录了这个对象使用的模型, 那么他不再需要去查询出这个属性的全部信息. 相反的, 能够直接跳过昂贵的属性信息查找. 这对于每一次都要查找属性的速度提升是显而易见的.

高效的数组存储

对于数组来说, 经常遇到把数组的索引作为属性存储起来. 每一个属性对应的数值, 称之为数组元素. 把每一个相同的数组中的元素的信息都存储起来, 是非常浪费空间的. 相反的, 引擎利用数组元素的属性是可修改, 可枚举, 可删除这个一个默认配置, 将数组元素和其他的命名元素分成存储.

思考下面的数组:

const array = [
'#jsconfeu'
];

引擎存储了数组的长读(1), 并且指向了Shape, 这里包含了偏移量, 和关于属性length的属性描述.

JavaScript引擎基本原理:Shapes和Inline Caches

这和我们之前看到的非常相似, 那么数组的值存储在哪里呢?

JavaScript引擎基本原理:Shapes和Inline Caches

每一个数组都有一个单独的 元素单元(element backing) 进行存储包含在索引对应的所有的属性的值. JavaScript引擎不会存储任何元素的属性描述, 因为他们总是可编辑, 可枚举, 可删除的.

可是, 在不正常的例子下会发生什么呢? 如果你感觉数组元素的属性描述呢?

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

上面的代码中定义了一个属性0, (但是这又刚好是数组的索引), 但设置他的属性描述为非默认值.. (说真的, 没看懂...)

在这种极限情况下, 引擎支持使用一个字典来映射整个数组元素的存储空间.

JavaScript引擎基本原理:Shapes和Inline Caches

即使我们只有一个数组元素使用了非默认配置的属性, 那整个数组的存储空间都会变慢, 变成一种毫无效果的模式. 避免Object.defineProperty在数组上的使用 (我不知道你为什么要这么做, 他看起来毫无用处.)

另外几点

我们学习了引擎如何存储对象和数组, 已经Shapes和ICs如果优化那些常见的操作. 基于这些只是, 我们可以总结出来几点在实际写代码的时候, 能够帮助促进性能的建议:

  • 经常使用同一种方式初始化你的对象, 这样他们在结束的时候, 就不会产生不同的模型
  • 不要搞错数组元素的属性描述, 让他们更加高效的存储和操作

Note: 这是我的第一篇原文翻译文章

上一篇:21.SQL to MongoDB Mapping Chart-官方文档摘录


下一篇:JavaScript引擎基本原理: 优化prototypes