关于JS引擎优化的理解

以前在网上断续地了解过JS引擎对JS源代码的优化过程,但都不是特别明白,最近阅读Mathias Bynens(V8做者之一)的关于JS引擎的优化原理的博文后以为相对来讲是讲得最明白易懂的,让我用最简单的方式对这个问题有了本身的理解。javascript

这篇笔记是我对这个问题的我的理解的简单总结。原文已经写得足够明白足够好了,我是但愿用本身的方式来描述一下,帮助理解和记忆。也许深刻的原理我还有一些不能准确描述,建议你们如有时间阅读原文来进一步学习。java

注:文中大部分图片源自做者原文node

JS引擎的工做方式

首先是一些背景知识,例如JS引擎都有哪些, 以及它们如何工做。数组

目前的主流JS引擎:

  1. V8(Chrome和NodeJS)
  2. SpiderMonkey(FireFox)
  3. Chakra(IE和Eage)
  4. JavaScriptCore(Safari/ReactNative)

JS引擎执行代码的流程

不一样JS引擎对执行和优化的一些细节上有差异,可是它们有如下通用的流程。浏览器

  1. JS源码会先被解析器parser解析生成抽象语法树(AST, Abstract Syntax Tree);
  2. 解释器能够在AST基础上产生字节码并执行;
  3. 对于部分"hot"(例如被频繁调用)的代码,解释器会连同一些分析信息(profiling data)发送到编译器中进行优化;
  4. 优化是在现有代码及分析信息的基础上做出必定的推测,而后生成优化后的机器码;优化完成后, 该部分的代码就由优化后的机器码代替, 优化后产生机器码,能够直接在系统处理器中执行;
  5. 在某个节点发现优化时的特定推测是错误的,编译器也会进行“去优化”而将代码还原给解释器。
代码 生成者 执行者 生成效率 执行效率 空间效率
字节码 解释器(interpreter) 解释器
机器码 编译器(optimizer) CPU*

*注:此处CPU是我本身的理解,原文为bytecode needs an interpreter to run, whereas the optimized code can be executed directly by the processor缓存

简单说就是解释器能够从抽象语法树很快地拿到第一手字节码并执行,可是代码是未通过优化的,假如某个频繁调用的方法须要从一个对象中访问某个特定的属性,那么每一次调用都会执行完整的查询过程,效率就会显得比较低;安全

优化代码须要时间,也须要更多的空间去存储优化相关的信息和体积变大的优化代码,但却可让诸如以上状况的代码执行效率更高。架构

因此这里就是启动时间-占用空间-执行效率多方面的权衡。以前的V8是采用将源码所有编译为机器码的策略,跳过字节码的步骤,牺牲了部分启动时间,可使执行效率很是高,但是机器码占用内存也会很是大,这样给代码的缓存也带来了很大的问题。某种程度上是有一点“过分优化”了。并非优化越多越好,而是“好钢用在刀刃上”,只对“优化代码能够显著提升运行效率”的那部分代码进行优化。也就是做者口中的“Hot Code”。ide

不一样浏览器引擎的实现

JS引擎 interpreter optimizer
V8 ignition TurboFan
SpiderMonkey interpreter Baseline + IonMonkey
Chakra interpreter SimpleJIT + FullJIT
JavaScriptCore LLInt Baseline + DFG + FTL

虽然它们的解释器和优化编译器看起来有不一样的名字,可是全部JS引擎都具备相同的架构:parser(用于生成AST)和解析器 + 优化编译器的管道结构。说是管道结构是由于解析器执行字节码和优化编译器能够并行执行,当解释器把待优化的代码发送给另外一个线程的编译器执行优化时,依然能够继续执行当前未优化的字节码;而优化过程完成后优化后的代码将会合流至主线程然后执行通过优化的代码。学习

而采用多个优化层,也是在“未优化”和“高度优化”之间设立了更多的中间节点,至关于“分级”--根据“Hot”的程度相应增长优化的程度,从而能够更细粒度地对时间/空间/执行效率之间的权衡决策进行控制。

对象和数组

在EcmaScript中,全部object实质上能够认为是字典,也就说字符串类型的键与属性值构成的键值对集合。对象的属性也有“属性”,就是定义属性自身的特性而不直接暴露给JavaScript的描述符:[[Value]], [[Writable]], [[Enumerable]], [[Configurable]]。每一个属性都有对应的描述符,对于咱们给对象添加的自定义属性,[[Value]]即咱们赋给该属性的值,而其余描述符都会被默认为true。

至于数组,实际上也能够看做对象,不过数组对数值索引会有特别处理,有效字符串整数i的范围缩小到+0 <= i < 232-1, 而普通对象中的整数索引只需是安全整数(+0 <= i <= 253-1)的范围。数组包含length属性,它不可枚举也不可配置,修改数组元素后会自动更新;数组以数值索引的元素与自定义对象属性的描述符默认处理是类似的。

JS引擎的优化方式

Shapes和Inline Caches

想象一个书架(对象)有不少格子(连续的存储位置),每一个格子能够放一本书(属性),咱们每次买来新书都直接放在下一个空格子中。当咱们想要去查看一本书的信息,须要从头开始一本一本检查书名,找一次就算了,若是每次都这样找,效率会很低。因此咱们能够想办法把以前找到的位置序号记住,避免下次重复劳动。

可是这样有个问题,若是书架上的书有增减,位置发生变更了怎么办?那原来保存的信息就不可靠了。可又怎么知道有没有发生过变更呢?

咱们创建一个图书名单,上面写了书名和它对应的位置,若是有变更就更新而且作必定标记,那就能够经过对比这个名单确认是否有过变动。采用这种方式对于须要常常来找某一本书的人来讲就很是方便,他只须要记住是哪个书名单和本身要找的书的位置,下次来只要书名单没有发生过变更,连查找书名那一步都省了,直接能够从对应位置取到他要的书。

若是比喻对象的属性值都是书而属性名是书名,Shape就是相似于上面所说“图书名单”的东西。Shape是一个统称,在不一样的JS引擎中叫法不一,但含义类似。Shape只和属性信息(包括属性所在的内存位置和描述信息)有关,和实际对象的值之间是解耦的,因此只要两个对象的属性名称/描述信息和属性顺序都同样,那就能够共用一个Shape。

Inline Caches(ICs)是加速执行JS的关键所在,能够理解为为了减小对Hot代码执行重复检索而缓存下来的重要信息。之因此叫这个名字(内联缓存),大概是由于这种缓存信息是嵌入Hot Code所在命令的结构中保存的,在每次执行这段代码时进行即时校验和取用。

对象的存储和访问

实际上在JS引擎中对象的属性名和属性值是分别存储的,属性值自己被按顺序保存在对象中,而属性名则创建一个列表(Shape),存储每一个属性名的“偏移量(offset)”和其余描述符属性

若是一个对象在运行时增长了新的属性,那么这个属性名单会过渡到一个新的Shape(只包含了新添加的属性)并连接回原Shape(原文中称为“过渡链”,transition chains),这样访问属性时若是最新的属性列表中没有找到,能够回溯到上一个列表去检索。

由于存在不一样的对象有相同的属性名称列表而重用Shape,当它们发生不一样改变会分别过渡到各自的新Shape,造成分叉结构(原文中称为“过渡树”,transition tree)。

可是若是频繁扩展对象使得Shape链很是长怎么办呢?引擎内部会针对这样的状况再整理一张表(ShapeTable),把全部属性名都列出来而后分别连接至它们所属的Shape...这看起来仍是比较繁琐,但都是为了避免要浪费“已经作过的工做”,使保留有用的检索信息——Inline Caches更加方便。

引用文中的例子:

function getX(o) {
    return o.x;
}
// 第一次执行,检索并缓存Shape连接和offset
getX({x: "a"});
// 以后执行,检查Shape是否相同,决定是否使用缓存
getX({x: "b"});
复制代码

第一次执行时检索Shape,获得offset后取出对象中的值;同时,Shape的连接和此次检索的结果也被内联缓存在代码结构中。

以后再访问时,若是对比Shape仍是和以前同样(对象重用Shape的好处),就直接用缓存的offset。

数组的存储和访问

数组自己就是一种特殊的对象。数组的length属性与对象的属性存储方式相同。而对于数组的元素,本质上也是以字符串(数值)做为key的属性值,且默认状况下与对象自定义属性的描述信息相同(除[[value]]外,均可写,可枚举,可配置)。

JS引擎会把全部数值索引的元素单独存储在该数组的elements backing store中,能够理解为它的物品摆放整齐的后备仓库。若是没有人为修改任何索引的属性描述信息,不须要再存储“offset",由于经过数值索引访问时索引自己就是“offset”,而属性描述符只需存储一份给每个索引属性共用。

可是以上是通常的状况,若是不幸遇到了数组索引的描述符被从新定义的状况,即便只是改变了一个,JS引擎也不得不放弃上面的优化策略,它的仓库也不得不变成“字典”同样的结构,为每一个元素开辟更大的地方,为其索引属性保存完整的描述信息。这样数组操做相对来讲会变得低效。

这里很容易让人想起特别常见的一个关于“手动缓存属性”的例子:

const arr = new Array(100000);
// arr.length内联在每次循环的检查条件中
for (let i = 0; i < arr.length; i++) {
    // ...
}
复制代码

这里的for循环中,每次循环的检查条件是i < arr.length,这样至关于每次都要对arr进行检索取出length属性值,循环的次数越多这种操做就越浪费。因此通常的建议是将arr.length提早用变量缓存,而后循环过程当中直接使用变量,这样对数组length属性读取只需执行一次。

以前在某些文章见到过说这种最佳实践在最新JS引擎的优化功能下已经不那么重要,若是我没有理解错应该就是指即便没有手动缓存,JS引擎中也能够发现这段Hot代码并使用Inline Cache进行结果的缓存。可是这里并不是直接缓存length的结果,而只是缓存可直接用于读取length的内存位置,因此仍是没有把基本值缓存在变量里快。

粗略在console里经过循环测试了下,1000000次循环,结果是缓存变量执行<20ms能够完成的状况下,每次读取length属性须要~150ms。如下是测试代码:

// 每次循环读取arr.length
const arr = new Array(1000000);
let count = 0;
console.time("inline")
// arr.length内联在每次循环的检查条件中
for (let i = 0; i < arr.length; i++) {
    count++;
}
console.timeEnd("inline")
console.log(count);
// inline: 148.780029296875ms
// 1000000

// 将 length 缓存变量
const arr1 = new Array(1000000);
const len = arr1.length;
let count1 = 0;
console.time("len")

for (let i = 0; i < len; i++) {
    count1++;
}
console.timeEnd("len")
console.log(count1);
// len: 13.648193359375ms
// 1000000
复制代码

原型链优化

原型自己也是对象,当经过一个对象访问属性,若是在当前对象没有找到,会沿着原型链向上一级一级查找直到找到或原型为null时中止而返回undefined。

若是把原型和对象同样处理,当访问一个对象的属性,须要先在它自己的Shape中查找是否存在,若是没有,再访问该对象的原型,而后检查原型的Shape,以此类推——每次访问一个原型,至关于要完成在当前Shape中查找属性经过对象访问原型两次检索。而实际上,在JS引擎中,原型的引用被保存在了对象的Shape上而非对象自己,这样能够在检查当前Shape中没有目标属性的时候直接连接至下一个原型对象,使每跳转一次原型只需完成一次检索。

可是这样作仍是须要沿着原型链检索属性,对于重复访问特定属性的操做优化十分有限。沿着原型链查找属性是比较昂贵的操做,尤为是有不少状况下对象的原型链可能会很长而经常使用的重要操做都在原型上,好比做者举的HTML中a元素的例子,咱们能够用下面代码在console中打印出它的原型链:

function protoChain(node) {
    const p = Object.getPrototypeOf(node); // 或node.__proto__
    console.dir(p);
    return p == null || protoChain(p);
};
const a = document.createElement("a");
protoChain(a);
复制代码

打印出的结果是:

若是目标属性在比较深的原型上,每次检索都是一串昂贵操做。按照对象中缓存属性offset的思路,咱们能够把原型上的属性位置也缓存一下,显然同时还必须把这个原型对象也保存一份引用,这样若是下次访问时原型链和原型对象自己没有发生过变化,就能够直接用上次缓存的结果,跳过查找操做。须要注意的是,任何对象的原型能够动态修改,如何肯定原型链是否变化了呢?

JS引擎的作法是,每个原型对象都有一个惟一的Shape(不和任何其余对象重用),Shape上会连接一个校验位(ValidityCell),标记“这个原型及其上游的原型链是否发生过变化”。当一个原型对象的属性发生变更,那这个原型和原型链中在它下游的全部原型的ValidityCell都会被置为false。因此为了保证缓存有效,只要确认实例对象的直接原型的这个校验位是否依然为true。

因此,除了缓存实例对象自己的Shape连接、offset和目标属性所在的原型对象,还须要保存该实例对象的直接原型的ValidityCell的连接。

好比如下这段代码:

class Bar {
    constructor(x) { this.x = x; }
    getX() { return this.x; }
}

const foo = new Bar(true);
const $getX = foo.getX;
复制代码

当执行$getX = foo.getX,其实是先加载出foo.getX对应的值,而后将其赋值给$getX,第一步就是访问对象属性的过程,很明显它须要从原型中获取到,那么这段代码的Inline Cache在一次检索后会保存如下信息:

  • offset结果---目标属性的内存位置
  • 实例对象自己的Shape连接---对象的属性列表和直接原型是否发生过改变
  • 目标属性所在的原型对象连接---获取属性值
  • 实例对象的直接原型的ValidityCell的连接---确认原型链是否发生过改变

下次调用这段代码时,除了须要对比实例对象的Shape,还要对比原型链上是否有变化,若是都没有改变,那么再也不须要检索,直接用缓存的offset取出对应原型对象的属性值便可。这将大大节省查找原型属性所耗费的时间。

而假如此期间修改了原型链的任何一环,原先保存的ValidityCell连接指向的valid值会被置为false,这时缓存就失效了,下次就须要把标准的检索重来一遍。

特别须要注意的一点是,当原型链上的原型对象发生改变时,其下游的任何原型对象原先的Shape对应的ValidityCell都会被标记为“无效”。能够想象,在代码执行过程当中当Object.prototype这样的顶级原型被修改时,多少基于原型属性的Inline Cache会失效。

如上面提到过的HTML中a元素的例子,做者有很是形象的示意图:

当执行Object.prototype.x = 42,使顶级原型发生改变:

优化代码的建议

综合以上信息,做者站在引擎的角度给JS开发者如下几方面的建议:

  1. 始终以相同的方式初始化对象。

一方面提升Shape的重用性,另外一方面尽可能下降过渡链或过渡树的长度/深度,缩短沿Shape链检索属性的时间;

  1. 不要对数组的元素(数值索引属性)修改属性描述.

这样能够保留引擎对数组的优化处理,使数组的存储和访问更高效;

  1. 不要修改原型,尤为是层级较深的原型如Object.prototype等,即便确实有必要修改,也应该在全部代码执行以前修改而不要在代码执行过程当中修改。

不然引擎为了保证取到正确的值而不得不放弃以前的内联缓存,从新以最笨的方法从新去查找和获取属性。

原文连接:

中文译版:

参考:

相关文章
相关标签/搜索