JavaScript 引擎基础:优化原型链
本文描述了所有 JavaScript 引擎共有的一些关键基础知识——而不仅仅是 V8,作者(Benedikt 和 Mathias)使用的引擎。 作为一名 JavaScript 开发人员,深入了解 JavaScript 引擎的工作原理有助于我们推断代码的性能特征。
在这篇文章中我们讨论了 JavaScript 引擎如何通过使用形状和内联缓存来优化对象和数组访问。 本文解释了优化管道的权衡,并描述了引擎如何加速对原型属性的访问。
我们之前的文章讨论了现代 JavaScript 引擎如何拥有相同的整体管道:
我们还指出,尽管引擎之间的高层流水线相似,但优化流水线往往存在差异。 这是为什么? 为什么有些引擎比其他引擎有更多的优化层? 事实证明,在快速运行代码和花费更多时间但最终以最佳性能运行代码之间存在权衡。
解释器可以快速生成字节码,但字节码通常效率不高。 另一方面,优化编译器需要更长的时间,但最终会生成更高效的机器代码。
这正是 V8 使用的模型。 V8 的解释器称为 Ignition,它是所有引擎中最快的解释器(就原始字节码执行速度而言)。 V8 的优化编译器名为 TurboFan,它最终会生成高度优化的机器码。
启动延迟和执行速度之间的这种权衡是一些 JavaScript 引擎选择在两者之间添加优化层的原因。 例如,SpiderMonkey 在解释器和完整的 IonMonkey 优化编译器之间添加了一个基线层:
解释器生成字节码很快,但是字节码执行起来比较慢。 Baseline
生成代码的时间稍长,但它提供了更好的运行时性能。 最后,IonMonkey
优化编译器生成机器代码的时间最长,但该代码可以非常高效地运行。
让我们看一个具体的例子,看看不同引擎中的管道是如何处理的。 这是一些在热循环中经常重复的代码。
let result = 0;
for (let i = 0; i < 4242424242; ++i) {
result += i;
}
console.log(result);
V8 开始在 Ignition
解释器中运行字节码。 在某个时候,引擎确定代码是热的并启动 TurboFan
前端,这是 TurboFan
的一部分,用于处理集成分析数据和构建代码的基本机器表示。 然后将其发送到不同线程上的 TurboFan
优化器以进行进一步改进。
当优化器运行时,V8 继续在 Ignition
中执行字节码。 在某个时候优化器完成并且我们有可执行的机器代码,并且可以继续执行。
更新
:从 Chrome 91(2021 年发布)开始,V8 在Ignition
解释器和TurboFan
优化编译器之间增加了一个名为Sparkplug
的编译器。
SpiderMonkey
引擎也开始在解释器中运行字节码。 但它有额外的基线层,这意味着热代码首先发送到基线。 基线编译器在主线程上生成基线代码,并在准备就绪后继续执行。
如果 Baseline 代码运行了一段时间,SpiderMonkey
最终会启动 IonMonkey
前端,并启动优化器——与 V8 非常相似。 在 IonMonkey
进行优化时,它一直在 Baseline
中运行。 最后,当优化器完成时,将执行优化代码而不是基线代码。
Chakra 的架构与 SpiderMonkey
的非常相似,但 Chakra 试图并发运行更多的东西以避免阻塞主线程。 Chakra 不是在主线程上运行编译器的任何部分,而是复制编译器可能需要的字节码和分析数据,并将其发送到专用的编译器进程。
当生成的代码准备就绪时,引擎开始运行此 SimpleJIT
代码而不是字节码。 FullJIT
也是如此。 这种方法的好处是,与运行完整编译器(前端)相比,发生复制的暂停时间通常要短得多。 但这种方法的缺点是复制启发式可能会遗漏某些优化所需的信息,因此它在某种程度上以代码质量换取延迟。
在 JavaScriptCore
中,所有优化编译器都与主 JavaScript 执行完全并发运行; 没有复制阶段! 相反,主线程只是在另一个线程上触发编译作业。 然后,编译器使用复杂的锁定方案从主线程访问分析数据。
这种方法的优点是它减少了主线程上 JavaScript 优化引起的卡顿。 缺点是需要处理复杂的多线程问题,需要为各种操作付出一定的锁开销。
我们已经讨论了像使用解释器一样快速生成代码或使用优化编译器快速生成代码之间的权衡。 但是还有另一个权衡:内存使用! 为了说明这一点,这里有一个简单的 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 引擎试图只优化热门函数。
优化原型属性访问
我们之前的文章解释了 JavaScript 引擎如何使用形状和内联缓存优化对象属性加载。 回顾一下,引擎将对象的形状与对象的值分开存储。
形状启用称为内联缓存或简称 IC 的优化。 Shapes 和 IC 相结合可以加快代码中同一位置的重复属性访问。
类和基于原型的编程
现在我们知道了如何快速访问 JavaScript 对象的属性,让我们看看最近添加到 JavaScript 中的一项:类。 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;
};
在这里,我们在 Bar.prototype
对象上分配了一个 getX
属性。 这与任何其他对象的工作方式完全相同,因为原型只是 JavaScript 中的对象! 在基于原型的编程语言(如 JavaScript)中,方法通过原型共享,而字段存储在实际实例中。
当我们创建一个名为 foo 的新 Bar 实例时,让我们放大幕后发生的事情。
const foo = new Bar(true);
通过运行此代码创建的实例具有一个具有单个属性“x”的形状。 foo
的原型是属于 Bar 类的 Bar.prototype
。
这个 Bar.prototype
有自己的形状,包含一个属性“getX”,其值为函数 getX
,调用时只返回 this.x
。 Bar.prototype
的原型是 Object.prototype
,它是 JavaScript 语言的一部分。 Object.prototype
是原型树的根,因此它的原型为空。
如果我们创建同一个类的另一个实例,这两个实例共享对象形状,正如我们之前讨论的那样。 两个实例都指向同一个 Bar.prototype
对象。
原型属性访问
好的,现在我们知道当我们定义一个类并创建一个新实例时会发生什么。 但是如果我们在实例上调用方法会发生什么,就像我们在这里所做的那样?
class Bar {
constructor(x) { this.x = x; }
getX() { return this.x; }
}
const foo = new Bar(true);
const x = foo.getX();
// ^^^^^^^^^^
我们可以将任何方法调用视为两个单独的步骤:
const x = foo.getX();
// is actually two steps:
const $getX = foo.getX;
const x = $getX.call(foo);
第 1 步是加载方法,它只是原型上的一个属性(其值恰好是一个函数)。 第 2 步是以实例为 this
值调用函数。 让我们来看看第一步,即从实例 foo 加载方法 getX
。
引擎从 foo 实例开始,并意识到 foo shape 上没有“getX”属性,因此它必须为它沿着原型链向上走。 我们到达 Bar.prototype
,查看其原型形状,并看到它在偏移量 0 处具有“getX”属性。我们在 Bar.prototype
中查找此偏移量处的值并找到我们正在寻找的 JSFunction getX。 就是这样!
JavaScript 的灵活性使得改变原型链链接成为可能,例如:
const foo = new Bar(true);
foo.getX();
// → true
Object.setPrototypeOf(foo, null);
foo.getX();
// → Uncaught TypeError: foo.getX is not a function
在这个例子中,我们调用了两次 foo.getX()
,但每次都有完全不同的含义和结果。 这就是为什么尽管原型只是 JavaScript 中的对象,但加速原型属性访问对于 JavaScript 引擎来说比加速对常规对象的自身属性访问更具挑战性。
在外面看程序,加载原型属性是一个非常频繁的操作:每次调用方法时都会发生!
class Bar {
constructor(x) { this.x = x; }
getX() { return this.x; }
}
const foo = new Bar(true);
const x = foo.getX();
// ^^^^^^^^^^
在这篇文章中我们讨论了引擎如何通过使用形状和内联缓存来优化加载常规的自有属性。 我们如何优化具有相似形状的对象的原型属性的重复加载? 我们在上面看到了属性加载是如何发生的。
为了在这种特殊情况下快速重复加载,我们需要知道这三件事:
- foo 的形状不包含 'getX' 并且没有改变。 这意味着没有人通过添加或删除属性或更改属性属性之一来更改对象 foo。
-
foo 的原型还是最初的
Bar.prototype
。 这意味着没有人通过使用Object.setPrototypeOf()
或分配给特殊的__proto__
属性来更改 foos 原型。 -
Bar.prototype
的形状包含 'getX' 并且没有改变。 这意味着没有人通过添加或删除属性或更改属性属性之一来更改 Bar.prototype。
在一般情况下,这意味着我们必须对实例本身执行 1 次检查,并对每个原型执行 2 次检查,直到包含我们要查找的属性的原型为止。 1+2N
检查(其中 N 是所涉及的原型的数量)对于这种情况来说可能听起来还不错,因为原型链相对较浅——但引擎通常必须处理更长的原型链,就像在普通 DOM 的情况下一样 类。 这是一个例子:
const anchor = document.createElement('a');
// → HTMLAnchorElement
const title = anchor.getAttribute('title');
我们有一个 HTMLAnchorElement
,我们调用它的 getAttribute()
方法。 这个简单的锚元素的原型链已经涉及了6个原型! 大多数有趣的 DOM 方法不在直接的 HTMLAnchorElement
原型上,而是在链的上层。
getAttribute()
方法位于 Element.prototype
中。 这意味着每次我们调用 anchor.getAttribute()
时,JavaScript 引擎都需要……
-
检查“
getAttribute
”不在锚对象本身上, -
检查直接原型是否为
HTMLAnchorElement.prototype
, -
断言那里没有'
getAttribute
', -
检查下一个原型是
HTMLElement.prototype
, -
断言那里也没有'
getAttribute
', -
最终检查下一个原型是
Element.prototype
, -
并且那里存在“
getAttribute
”。
总共有7次检查! 由于这种代码在网络上很常见,因此引擎会应用技巧来减少原型上属性加载所需的检查次数。
回到前面的例子,我们在访问 foo 上的 'getX' 时总共执行了 3 次检查:
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
,以便更快地访问原型属性。 但这仍然非常昂贵,因为它在原型链的长度上仍然是线性的。 引擎实施不同的技巧以进一步将此减少到恒定数量的检查,特别是对于相同属性加载的后续执行。
有效单元
V8 为此专门处理原型形状。 每个原型都有一个独特的形状,不与任何其他对象共享(特别是不与其他原型共享),并且每个原型形状都有一个与之关联的特殊 ValidityCell
。
只要有人更改关联的原型或它上面的任何原型,此 ValidityCell
就会失效。 让我们来看看它是如何工作的。
为了加快原型的后续加载速度,V8 放置了一个内联缓存,其中包含四个字段:
在此代码的第一次运行期间预热内联缓存时,V8 会记住在原型中找到该属性的偏移量、找到该属性的原型(本例中为 Bar.prototype
)、实例的形状 (在这种情况下是 foo 的形状),以及从实例形状(在这种情况下也恰好是 Bar.prototype
)链接到的直接原型的当前 ValidityCell
的链接。
下次命中 Inline Cache 时,引擎必须检查实例的形状和 ValidityCell
。 如果它仍然有效,引擎可以直接访问原型上的偏移量,跳过额外的查找。
当原型改变时,分配一个新的形状,并且使之前的 ValidityCell
失效。 因此,内联缓存在下次执行时会丢失,从而导致性能下降。
回到之前的 DOM 元素示例,这意味着对例如 Object.prototype
不仅会使 Object.prototype
本身的内联缓存失效,还会使下面的任何原型失效,包括 EventTarget.prototype
、Node.prototype
、Element.prototype
等等,一直到 HTMLAnchorElement.prototype
。
实际上,在运行代码时修改 Object.prototype
意味着将性能抛到窗外。 不要这样做!
让我们用一个具体的例子来进一步探讨这个问题。 假设我们有 Bar 类,我们有一个调用 Bar 对象方法的函数 loadX
。 我们使用同一类的实例多次调用此 loadX
函数。
class Bar { /* … */ }
function loadX(bar) {
return bar.getX(); // IC for 'getX' on `Bar` instances.
}
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.prototype
的 ValidityCell
。 如果您随后执行诸如改变 Object.prototype
之类的操作——它是 JavaScript 中所有原型的根——ValidityCell
将变得无效,并且现有的内联缓存在下次被命中时会丢失,从而导致性能下降。
改变 Object.prototype
总是一个坏主意,因为它会使引擎在此之前放置的原型加载的任何内联缓存无效。 这是不该做的另一个例子:
Object.prototype.foo = function() { /* … */ };
// Run critical code:
someObject.foo();
// End of critical code.
delete Object.prototype.foo;
我们扩展了 Object.prototype
,它使引擎在此之前放置的任何原型内联缓存无效。 然后我们运行一些使用新原型方法的代码。 引擎必须从头开始并为任何原型属性访问设置新的内联缓存。 最后,我们“自行清理”并删除我们之前添加的原型方法。
清理听起来是个好主意,对吧‽好吧,在这种情况下,它会使糟糕的情况变得更糟! 删除该属性会修改 Object.prototype
,因此所有 Inline Caches 都将再次失效,引擎必须再次从头开始。
总结
:虽然原型只是对象,但它们被 JavaScript 引擎特殊对待,以优化原型上方法查找的性能。 别管你的原型了! 或者,如果你真的需要接触原型,那么在其他代码运行之前进行,这样你至少不会在代码运行时使引擎中的所有优化无效。
最后
我们了解了 JavaScript 引擎如何存储对象和类,以及形状、内联缓存和 ValidityCells
如何帮助优化原型操作。 基于这些知识,我们确定了一个有助于提高性能的实用 JavaScript 编码技巧:不要弄乱原型(或者如果你真的、真的需要,那么至少在其他代码运行之前这样做)。
相关文章
使用 CSS 和 JavaScript 制作文本闪烁
发布时间:2023/04/28 浏览次数:146 分类:CSS
-
本文提供了使用 CSS、JavaScript 和 jQuery 使文本闪烁的详细说明。
在 PHP 变量中存储 Div Id 并将其传递给 JavaScript
发布时间:2023/03/29 浏览次数:69 分类:PHP
-
本文教导将 div id 存储在 PHP 变量中并将其传递给 JavaScript 代码。
在 JavaScript 中从字符串中获取第一个字符
发布时间:2023/03/24 浏览次数:93 分类:JavaScript
-
在本文中,我们将看到如何使用 JavaScript 中的内置方法获取字符串的第一个字符。
在 JavaScript 中获取字符串的最后一个字符
发布时间:2023/03/24 浏览次数:141 分类:JavaScript
-
本教程展示了在 javascript 中获取字符串最后一个字符的方法