JavaScript引擎基本原理: 优化prototypes

原文连接: JavaScript engine fundamentals: optimizing prototypesweb

这篇文章介绍了一些JavaScript引擎经常使用的优化关键点, 并不仅是Benedikt和Mathias开发的v8. 做为一名js开发者, 更深层次的了解引擎的工做原理能够帮助你了解你代码的性能特征.编程

以前, 咱们js使用shapes和inline caches优化对象和数组的访问. 这篇文章介绍了优化管道的权衡利弊(trade-off, 就是前面的使用解析器和优化器的权衡), 以及介绍了引擎如何提高访问原型的性能.数组

优化层(tiers)和权衡利弊(trade-off)的执行

咱们上一篇文章介绍了如今JavaScript引擎都拥有的相同的管道(pipeline):缓存

图片

咱们也指出, 即便在引擎之间的高程度管道如此相似, 可是在优化管道的时候, 仍是会有一些不一样. 那是什么缘由呢? 为何一些引擎能够作到比其余引擎更高程度的优化层呢? 那就致使了权衡利弊这种结果, 是选择更快的产生代码去运行, 仍是花费更多的时间在最后运行优化程度更高的代码.多线程

图片

解析器能够更快的产生机器码, 可是这些机器码一般并不高效. 优化器使用另外一种方式多花费一点时间能够产生更加高效的机器码.并发

事实上这就是V8所使用的模型. V8中的解析器被称为启动器(ignition), 单就原始代码的执行速度而言, v8的解释器(ignition)是最快的. V8的优化器叫作(TurboFan), 最后能够产生优化程度更高的机器码.frontend

图片

启动延迟和执行速度之间的权衡, 致使了一些引擎选择在他们中间添加一个优化层. 举个例子: SpiderMonkey添加了一个 基线 (BaseLine) 在解释器和他的整个IconMonkey优化器之间.ide

图片

解释器能够更快的产生字节码, 可是字节码的执行相对较慢. 基线 可使用多一点时间产生代码, 可是能够提供更好的运行时间优化. 最后, IonMonkey优化器花费更长的时间产生的机器码, 这些代码能够运行的更加高效.函数

让咱们从一个具体的例子看下不一样引擎下的管道(pipeline)如何使用它. 下面是一段在热循环中重复获取的代码.性能

let result = 0;
for (let i = 0; i < 4242424242; i++) {
  result += i;
}
console.log(result);

v8开始使用Ignition解释器运行代码. 引擎经过一些细节肯定这段代码是 热(hot) 的, 开始启动TurboFan frontend, 这是TurboFan的一部分. 用来处理收集的数据, 生成一个基础的机器代码的表示. 这些东西经过不一样的线程发送给TurboFan优化器, 用于之后的性能提高.

图片

当优化器开始执行的时候, v8依旧使用ignition运行字节码. 当一些优化完成的时候, 咱们得到更高执行效率的机器码, 而后接下来就是使用这些机器码进行运行.

SpiderMonkey开始也是使用解释器运行代码. 可是他在中间已经添加了基线层, 这表示热代码(hot code)在第一时间发送给基线层. 基线层优化器在主线程上产生基线代码, 并在准备就绪后执行代码.

图片

当代码运行一会的时候, SpiderMonkey启动Ionmonkey fronted, 和开始优化, 这和V8很是类似. 可以在IconMonkey开始优化的时候, 继续在Baseline上运行. 最后, 当优化结束, 优化后的代码代替基线代码开始执行.

Chakra的体系结构和SpiderMonkey很是类似, 可是Chakra为了不主线程阻塞会尝试运行更多的某些东西. 为了替代编译器运行在主线程的全部部分, Chaka会把全部编译器看起来像是须要的字节码和分析数据复制出来, 并发送他们给优化器, 用来决定优化过程.

图片

一旦代码产生完成, 引擎就开始运行SimpleJIT的代码. 相同的方式启动FullJIT. 这种方式的好处是: 复制所须要的时间远远低于一个完整的优化器(fronted)运行运行. 可是这种方式的缺点是, 启发式的复制(copy heuristic) 会失去一些须要当前优化方式的信息, 因此没能保证代码质量会形成某种程度的延迟.

在JavaScriptCor引擎中, 全部的优化编译器都会和JavaScript主线程一块儿 彻底同步 执行. 那里并无复制的部分. 相反, 主线程仅仅是触发在另外一个线程上的编译工做, 也就是优化工做. 这些优化器会使用复杂的锁定方案访问主线程上的分析数据.

图片

这种方案优势是减小了JavaScript优化器在主线程上的闪避(jank). 负面影响是须要处理复杂的多线程问题, 而且须要为各类操做付出锁的消耗.

咱们讨论了关于在解释器上更快的产生代码和在解释器上更快的产生代码. 可是还有一种权衡: 内存占用! 为了说明这个问题, 下面是求两数加和的简单的JavaScript程序.

function add(x, y) {
  return x + y;
}
add(1, 2)

下面是咱们使用V8引擎中的ignition解释器, 对add函数产生的字节码.

StackCheck
Ldar a1
Add a0, [0]
Return

不要担忧这些真实的字节码, 你并不须要真会认识他们. 须要强调的点是只有四个表示

当代码变热的时候, TurboFan会产生下面这种更高程度优化的机器码.

leaq rcx,[rip+0x0]
movq rcx,[rcx-0x37]
testb [rcx+0xf],0x1
jnz CompileLazyDeoptimizedCode
push rbp
movq rbp,rsp
push rsi
push rdi
cmpq rsp,[r13+0xe88]
jna StackOverflow
movq rax,[rbp+0x18]
test al,0x1
jnz Deoptimize
movq rbx,[rbp+0x10]
testb rbx,0x1
jnz Deoptimize
movq rdx,rbx
shrq rdx, 32
movq rcx,rax
shrq rcx, 32
addl rdx,rcx
jo Deoptimize
shlq rdx, 32
movq rax,rdx
movq rsp,rbp
pop rbp
ret 0x18

这里有 很是 多的代码, 尤为是当咱们和字节码中四个表示符相比的时候. 和机器码相比, 字节码变得更加紧凑, 尤为是和优化事后的机器码相比. 但从另外一个方面来讲, 字节码须要一个解释器进行运行, 可是优化事后的代码能够经过处理器直接执行.

这就是JavaScript引擎不去优化一切的主要缘由之一. 刚刚咱们看到的, 产生优化后的机器码须要更长的时间, 除此以外, 还有咱们刚刚学到的优化后的机器码也须要更高的内存空间

图片

摘要: JavaScript引擎有不一样的优化层的缘由: 对于解释器更快的产生字节码, 和优化器更加快速的产生代码的权衡. 这是一个策略, 你是否愿意付出更大的复杂度和开支, 来添加更多的优化层, 来获取更加精细的决策. 总结, 这是关于产生代码过程当中优化程度, 和内存使用的权衡. 这就是JavaScript引擎只优化hot函数的缘由.

优化原型属性访问

上一篇文章中介绍到: JavaScript引擎如何经过引入Shapes和Inline Caches来优化对象属性. 回顾下: 引擎将对象的Shape和对象的值分开存储.

图片

Shapes 支持一种被称为 Inline Caches 或者 ICs 的优化. 结合起来, Shapes和ICs可以提高你代码中相同地方, 重复访问的属性.

图片

类和基于原型的编程(Classes and prototype-based programming)

如今咱们知道如何更加快速的访问JavaScript对象上的属性, 让咱们来看下最近JavaScript中新增的: classess. 下面是这种JavaScript类语法的表示形式:

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

尽管这是在JavaScript中新出现的一一种概念, 但他也不过是以前JavaScript关于原型编程的语法糖:

function Bar(x) {
  this.x = x;
}
Bar.prototype.getX = function getX() {
  return this.x;
}

在这, 咱们分配了一个getX属性到Bar.prototype对象上. 他的实际的工做方式和其余的对象是同样的, 由于在JavaScript中原型就是一个对象! JavaScript语言更像是一门基于原型编程的语言, 经过原型分享方法的使用, 字段(也就是那些值)实际存储在真实的实例上.

让咱们仔细观察下, 当经过Bar的执行建立一个新的foo实例的时候, 所发生的一切.

const foo = new Bar(true)

运行这段代码能够产生一个实例对象, 这个实例对象有一个模型, 这个模型上面只有一个属性x. Bar.prototype属于类Bar, 上面拥有属性foo

图片

这个Bar.prototype也拥有它本身的模型, 包含了惟一的属性getX, 它的值对应了咱们的函数getX, 就是执行的时候, 返回this.x的函数. Bar.prototype的原型就是Object.prototype, 这是JavaScript语言的一部分. Object.prototype就是原型链的根节点, 而后他的原型指向null.

图片

当咱们使用这个相同的类建立另外一个实例的时候, 两个实例会共享一个对象模型, 就像咱们前面讨论的那样. 两个实例的指针都指向同一个相同的Bar.prototype对象.

Prototype property access

好了, 如今咱们知道了当咱们定义一个类和建立一个实例的时候发生了什么. 可是, 若是咱们运行一个实例上的方法会发生什么呢? 就像下面这样.

class Bar {
  constructor(x) {
    this.x = x;
  }
  getX() {
    return this.x;
  }
}
const foo = new Bar(true);
const x = foo.getX();
//            ^^^^^

你能够理解为任何方法的执行都做为两个步骤.

const x = foo.getX();

// 实际通过了两个步骤
const $getX = foo.getX;
const x = $getX.call(foo)

第一步的时候, 加载这个函数, 那只是原型上的一个属性, 他的值偏偏是一个函数.第二步的时候, 使用实例上做为this运行这个值. 让咱们完成从实例foo上加载函数getX的第一步.

图片

引擎先从foo实例开始寻找, 发如今foo模型上没有getX属性, 而后就会沿着原型链一直寻找. 当咱们找到Bar.prototype, 获取了他的原型的模型, 发现了getX属性, 而且存储了偏移量0. 咱们在Bar.prototype上发现getX是一个JSFunction. 那就是他了.

JavaScript的灵活性, 可能让他们的原型链忽然发生变化. 例如:

const foo = new Bar(true);
foo.getX();
// -> true

Object.setPrototypeOf(foo, null);
foo.getX();
// -> Uncaught TypeError: foo.getX is not a function

在这个例子中, 咱们两次与运行foof.getX(), 可是每一次执行都有彻底不一样意义和结果. 这就是为何, 即便原型在JavaScript仅仅是一个对象, 提升原型上属性的访问速度比仅仅提升在常规对象上面普通属性的访问, 要更有挑战性.

下面这个程序, 可以发现, 加载原型属性是很是频繁的操做: 你每一次执行函数的时候都会发生.

class Bar{
  constructor(x) {
    this.x = x;
  }
  getX() {
    return this.x;
  }
}
const foo = new Bar(true);
const x = foo.getX;
//        ^^^^^^^^

以前, 咱们讨论了若是进行常规属性的加载优化, 即是经过使用Shapes和ICs. 那么咱们应该如如优化具备相关模型的原型属性的的重复访问呢? 咱们看下面的属性访问若是工做.

图片

为了在某些特定的案例中保证对重复属性访问的最快速度, 咱们须要肯定如下三点.

  1. foo的模型肯定不含有getX, 而且模型不会改变. 这表示, 没有任何对于foo对象的添加或者删除属性的操做, 也不会对现有属性描述进行改变.
  2. foo的原型依旧是最初的Bar.prototype. 这表示foo的原型不会经过Object.setPrototypeOf()或者直接访问特殊的__proto__进行改变.
  3. Bar.prototype上的模型, 包含getX而且不会改变. 也就表示, 不会对Bar.prototype进行任何添加或者删除属性, 或缺对属性描述进行修改.

在这个例子中, 咱们须要对原型自己进行一次检查, 而后须要对原型上的每个原型进行两次检查, 一直找到包含咱们须要属性的原(我不明白这里为何须要检查两次).1+2N次查找(N表示表示原型的复杂程度, (是指原型链的长度, 仍是原型上注册的属性个数))在咱们的案例中听起来没这么糟糕, 由于整个原型链相对短一些. 可是引擎常常会处理一些相对较长的原型链, 就想在一个普通的DOM案例中. 下面是例子:

const anchor = document.createElement('a');
// -> HTMLAnchorElement

const title = anchor.getAttribute('title');

咱们有了一个HTMLAnchorElement, 而后咱们执行了元素上的getAttrubute()方法. 这个简单的锚点元素的原型链, 已经存在6层原型长度. 大部分使用的DOM方法不会在在HTMLAnchorelElment原型上直接注册, 可是在更高程度的原型链上.

图片

只有在Element.prototype上面才能够发现方法getAttribute()方法. 那就意味着, 咱们每次调用anchor.getAttribute(), JavaScript引擎都须要作一下工做:

  1. 检查getAttribute是否存在于anthor对象自己.
  2. 检查HTMLAnchorElement.prototype这个原型对象.
  3. 确认getAttribute属性不在上面.
  4. 检查下一个原型:HTMLElement.prototype
  5. 确认getAttribute属性也不在这.
  6. 终于检查到了下一个原型Element.prototype
  7. 发现了getAttribute属性

一共是七次查找. 由于在web中, 这种代码很是常见. 引擎应用一些技巧减小原型属性访问的检查次数, 是很关键的.

退回上一个例子, 当咱们访问foo上面的getX属性的时候, 一共通过了三次查找.

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

const foo = new Bar(true);
const $getX = foo.getX;

对于每一个涉及的对象的模型, 咱们都须要作属性检查, 直接找到携带属性的那个. 若是咱们可以经过折叠属性检查变成缺乏检查, 来检查属性检查, 就太好了. 而且这是引擎应用的很是重要的小技巧: 引擎存储在实例自己上的原型链替换为存储在Shape上.

图片

每个模型都指向了原型. 这也意味着每一次foo原型改变的时候, 引擎都会转变成一个新的模型. 如今咱们只须要去检查一个对象的模型, 便可以判断是否含有目标属性, 又能够保护原型链.

经过这个方法, 咱们加快原型属性的访问, 从1+2N变为1+N. 可是那已经具备很是大的消耗, 由于依旧和原型链的长度线性相关. 引擎应用不一样的技巧减小之后检查的次数, 尤为是后面关于相同属性加载的访问执行.

Validity cells

基于这个目的, V8特地处理的模型的形状. 每个原型都有与其余对象不共享的模型 尤为是其余的原型对象. 而且这些原型模型, 都会有一个特殊的ValidityCell与他关联.

图片

不管是任何关联原型的改变, 或者是原型上属性的改变, 这个ValidityCell都会失效. 让咱们看下他实际如何工做.

为了提升后面属性的加载, V8会在内存中安置一个具备四个属性的在线缓存.

图片

当咱们第一次运行这段代码的时候, 进行预热处理, V8记录了咱们发现原型上属性的偏移量, 含有这个属性的原型(在这个例子中就是Bar.prototype), 实例的模型(这个例子中是foo的模型), 而后也会有一条线指向当前的ValidityCell, 也就是 当即原型(immediate prototype) 那是一条来自实例原型的线(在这个例子中也是发生在Bar.prototype).

下一次执行的时候, 这个内嵌缓存就被打开了, 引擎会去查找实例模型, 和ValidityCell. 当他是有效的时候, 引擎能够理解查找出Prototype上的Offset, 再也不进行额外的查找.

图片

当原型改变的时候, 给原型分配一个小的模型, 上一个ValidityCell失效. 致使在线缓存在下一次执行的时候关掉, 影响性能.

让咱们退回到以前的DOM元素的例子, 他意味着, 在Object.prototype上的任何改变, 都不仅是让他本身的内嵌缓存失效, 可是也让他下面的原型, 包括EventTarget.prototype, Node.prototype, Element.prototype等等, 一直到HTMLAnchorElement.prototype的路都垮掉了.

图片

实际上, 在代码中修改Object.prototype表示再无性能可言. 千万不要这么作.

让咱们经过一个具体的例子探索更多. 看的出, 咱们有一个类Bar, 而且咱们有一个函数loadX, 经过Bar对象执行. 咱们只是在刚刚经过一个来自相同类的实例, 执行了loadX这个函数.

class Bar { /* ... */ }
function loadX(bar) {
  return bar.getX(); // IC for 'getX' on `Bar` instance
}

loadX(new Bar(true));
loadX(new Bar(false));
// IC in 'loadX' now links the `ValidityCell` for `Bar.prototype`

Object.prototype.newMethod = y => y;
// The `ValidityCell` in the `loadX` IC is invalid now, because `Object.prototype` changed

loadX上的内嵌缓存指向了Bar.prototypeValidityCell. 若是你随后作了一些操做, 例如忽然改变了Object.prototype, 这是JavaScript中全部原型的根节点, ValidityCell都会变得无效, 而且全部的在线缓存在下一次更改的时候, 都会关闭, 致使性能变得不好.

Object.prototype忽然改变是很是糟糕的方法, 由于他可让全部在此操做以前引擎放置的, 关于原型加载的内嵌缓存都失效. 下面是两一个不要作的例子:

Object.prototype.foo = function() {
  /* ... */
};

// Run critical code
someObject.foo();
// End of critical code

delete Obejct.prototype.foo;

咱们扩展了Object.prototype, 那会让引擎以前放置的在线缓存失效. 而后咱们使用这个新原型方法运行了一些代码. 整个引擎必须从新开始, 当我欧恩访问原型属性的时候, 设置新的属性缓存. 在最后, 咱们本身"清除了本身", 而后移除了咱们最近添加的原型方法.

清除也许听起来是一个好主意, 真的吗? 哇, 在这个案例中, 他达到了最坏的后果. 修改Object.prototype删除上的属性, 会让全部的IC再次所有失效, 引擎又要从新开始.

提示: 尽管, 原型只是一个对象, 可是他们已经经过JavaScript引擎特殊处理, 优化了再原型上查找方法的性能. 让你的原型保持孤独. 或者, 当你须要去移动原型的时候, 那就再全部的代码以前操做, 保证你最后一次操做, 并不让引擎针对你代码的优化失效.

另外

咱们学习了JavaScript引擎如何存储对象和类, Shpaes, 内嵌缓存, 和ValidityCell若是帮助咱们优化原型操做. 基于这些知识, 咱们发现了一个使用的JavaScript编码优化技巧, 不要乱搞原型, (或者 你真的真的须要这么作的话, 在全部代码运行执行处理他.)

相关文章
相关标签/搜索