首頁 >web前端 >js教程 >拿下JavaScript引擎的基本原理

拿下JavaScript引擎的基本原理

coldplay.xixi
coldplay.xixi轉載
2020-10-10 17:28:592248瀏覽

身為JavaScript欄位開發人員,深入了解 JavaScript 引擎的工作原理有助於你了解自己程式碼的效能特性。這篇文章對所有 JavaScript 引擎中常見的一些關鍵基礎知識進​​行了介紹,不僅限於 V8 引擎。

JavaScript 引擎的工作流程 (pipeline)

這一切都要從你寫的 JavaScript 程式碼開始。 JavaScript 引擎解析原始程式碼並將其轉換為抽象語法樹(AST)。基於 AST,解釋器便可以開始工作並產生字節碼。就在此時,引擎開始真正運行 JavaScript 程式碼。 拿下JavaScript引擎的基本原理為了讓它運作得更快,字節碼能與分析資料一起傳送到最佳化編譯器。最佳化編譯器基於現有的分析資料做出某些特定的假設,然後產生高度最佳化的機器碼。

如果某個時刻某一個假設被證明是不正確的,那麼最佳化編譯器將取消最佳化並回到解釋器階段。

JavaScript 引擎中的解釋器/編譯器工作流程

現在,讓我們來看看實際執行JavaScript 程式碼的這部分流程,即程式碼被解釋和優化的部分,並討論其在主要的JavaScript 引擎之間存在的一些差異。

一般來說,JavaSciript 引擎都有一個包含解釋器和最佳化編譯器的處理流程。其中,解釋器可以快速產生未最佳化的字節碼,而最佳化編譯器會耗費更長的時間,但最終可產生高度最佳化的機器碼。 拿下JavaScript引擎的基本原理這個通用流程和 Chrome 和 Node.js 中使用的 Javascript 引擎, V8 的工作流程幾乎一致:拿下JavaScript引擎的基本原理V8 中的解釋器稱為 Ignition,負責產生和執行字節碼。當它運行字節碼時,它會收集分析數據,這些數據可用於後面加快程式碼的執行速度。當一個函數變成 hot 時,例如當它經常運行時,生成的字節碼和分析資料將傳遞給我們的最佳化編譯器 Turbofan,以根據分析資料產生高度最佳化的機器碼。 拿下JavaScript引擎的基本原理Mozilla 在 Firefox 和 Spidernode 中使用的 JavaScript 引擎 SpiderMonkey ,則不太一樣。它們有兩個最佳化編譯器,而不是一個。解釋器先通過 Baseline 編譯器,產生一些最佳化的程式碼。然後,結合執行程式碼時收集的分析數據,IonMonkey 編譯器可以產生更高程度最佳化的程式碼。如果嘗試最佳化失敗,IonMonkey 將會回到 Baseline 階段的程式碼。

Chakra,在 Edge 中使用的 Microsoft 的 JavaScript 引擎,非常相似的,也有2個最佳化編譯器。解釋器最佳化程式碼到 SimpleJIT(JIT 代表 Just-In-Time 編譯器,即時編譯器),SimpleJIT 會產生稍微最佳化的程式碼。而 FullJIT 結合分析數據,可以產生更優化的程式碼。 拿下JavaScript引擎的基本原理JavaScriptCore(縮寫為 JSC),在 Safari 和 React Native 中使用的 Apple 的 JavaScript 引擎,它透過三種不同的最佳化編譯器將其發揮到極致。低層解釋器LLInt 最佳化程式碼到Baseline 編譯器中,然後最佳化程式碼到DFG(Data Flow Graph)編譯器中,DFG(Data Flow Graph)編譯器又可以將最佳化後的程式碼傳送到FTL(Faster Than Light)編譯器中。

為什麼有些引擎有更多的最佳化編譯器?這是權衡利弊的結果。解釋器可以快速產生字節碼,但字節碼通常效率不高。另一方面,最佳化編譯器需要更長的時間,但最終會產生更有效率的機器碼。在快速讓程式碼運行(解釋器)或花費更多時間,但最終以最佳效能運行程式碼(最佳化編譯器)之間需要權衡。一些引擎選擇添加具有不同時間/效率特性的多個最佳化編譯器,允許在額外的複雜性的代價下對這些權衡進行更細粒度的控制。另一個需要權衡的方面與記憶體使用有關,後續將有專門的文章詳細介紹。

我們剛剛強調了每個 JavaScript 引擎中解釋器和最佳化編譯器流程中的主要差異。除了這些差異之外,在高層上,所有 JavaScript 引擎都有相同的架構:那就是有解析器和某種解釋器/編譯器流程。

JavaScript 的物件模型

讓我們透過放大一些方面的實作來看看 JavaScript 引擎還有什麼共同點。

例如,JavaScript 引擎如何實作 JavaScript 物件模型,以及它們使用哪些技巧來加速存取 JavaScript 物件的屬性?事實證明,所有主要引擎在這一點上的實現都很相似。

ECMAScript 規格基本上將所有物件定義為由字串鍵值對應到 property 屬性的字典。

拿下JavaScript引擎的基本原理除了[[Value]] 本身,規格也定義了這些屬性:

  • [[Writable]] 決定屬性是否能被重新賦值,
  • [[Enumerable]] 決定屬性是否出現在for in 迴圈中,
  • [[Configurable]] 決定屬性是否能被刪除。

[[雙方括號]] 的符號表示看起來有些特別,但這正是規範定義不能直接暴露給 JavaScript 的屬性的表示方法。在 JavaScript 中你仍然可以透過 Object.getOwnPropertyDescriptor API 來取得指定物件的屬性值:

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

這就是 JavaScript 定義物件的方式,那麼陣列呢?

你可以把數組看成是一個特殊的對象,其中的一個區別就是數組會對數組索引進行特殊的處理。這裡的陣列索引是 ECMAScript 規範中的一個特殊術語。在 JavaScript 中限制數組最多有 2³²−1個元素,數組索引是在該範圍內的任何有效索引,即 0 到 2³²−2 的任何整數。

另一個差異是數組還有一個特殊的 length 屬性。

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

在該例中,陣列被建立時 length 為 2。當我們為索引為 2 的位置分配另一個元素時,length 自動更新了。

JavaScript 定義陣列的方式和物件類似。例如,所有的鍵值, 包括數組的索引, 都明確地表示為字串。數組中的第一個元素,就是儲存在鍵值 '0' 下。 拿下JavaScript引擎的基本原理“length” 屬性是另一個不可枚舉且不可設定的屬性。 當一個元素被加入到陣列中, JavaScript 會自動更新 “length“ 屬性的 [[value]] 屬性。 拿下JavaScript引擎的基本原理

優化屬性存取

知道了物件在 JavaScript 中是如何定義的, 那麼就讓我們來深入地了解一下 JavaScript 引擎是如何高效地使用物件的。 整體來說,存取屬性是至今為止 JavaScript 程式中最常見的操作。因此,JavaScript 引擎是否能快速地存取屬性是至關重要的。

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 的對象,它使用我們前面討論過的字典資料結構:它包含字串形式的鍵,這些鍵指向它們各自的屬性值。 拿下JavaScript引擎的基本原理

如果你存取某個屬性,例如 object.y,JavaScript 引擎會在 JSObject 中尋找鍵值 'y',然後載入對應的屬性值,最後回傳 [[Value]]。

但這些屬性值儲存在記憶體中的什麼位置呢?我們是否應該將它們作為 JSObject 的一部分進行儲存?假設我們稍後會遇到更多同 shape 的對象,那麼在 JSObject 自身儲存包含屬性名稱和屬性值的完整字典便是一種浪費,因為對於具有相同 shape 的所有對象,屬性名稱都是重複的。這是大量的重複和不必要的記憶體使用。作為一種優化,引擎將物件的 Shape 分開儲存。 拿下JavaScript引擎的基本原理shape 包含除了 [[Value]] 以外所有屬性名稱和屬性。另外,shape 還包含了 JSObject 內部值的偏移量,以便 JavaScript 引擎知道在哪裡可以找到值。每個具有相同 shape 的 JSObject 都指向該 shape 實例。現在每個 JSObject 只需要儲存對這個物件來說唯一的值。 拿下JavaScript引擎的基本原理當我們有多個物件時,好處就顯而易見了。不管有多少個對象,只要它們有相同的 shape,我們只需要儲存 shape 和屬性資訊一次!

所有的 JavaScript 引擎都使用了 shapes 作為最佳化,但稱呼各有不同:

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

本文中,我们将继续使用术语 shapes.

转换链和树

如果你有一个具有特定 shape 的对象,但你又向它添加了一个属性,此时会发生什么? JavaScript 引擎是如何找到这个新 shape 的?

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

这些 shapes 在 JavaScript 引擎中形成所谓的转换链(transition chains)。下面是一个例子:

拿下JavaScript引擎的基本原理

该对象开始没有任何属性,因此它指向一个空的 shape。下一个语句为该对象添加一个值为 5 的属性 "x",所以 JavaScript 引擎转向一个包含属性 "x" 的 shape,并在第一个偏移量为 0 处向 JSObject 添加了一个值 5。 下一行添加了一个属性 'y',引擎便转向另一个包含 'x' 和 'y' 的 shape,并将值 6 添加到 JSObject(位于偏移量 1 处)。

我们甚至不需要为每个 shape 存储完整的属性表。相反,每个shape 只需要知道它引入的新属性。例如,在本例中,我们不必将有关 “x” 的信息存储在最后一个 shape 中,因为它可以在更早的链上找到。要实现这一点,每个 shape 都会链接回其上一个 shape:拿下JavaScript引擎的基本原理

如果你在 JavaScript 代码中写 o.x,JavaScript 引擎会沿着转换链去查找属性 "x",直到找到引入属性 "x" 的 Shape。

但是如果没有办法创建一个转换链会怎么样呢?例如,如果有两个空对象,并且你为每个对象添加了不同的属性,该怎么办?

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

在这种情况下,我们必须进行分支操作,最终我们会得到一个转换树而不是转换链。拿下JavaScript引擎的基本原理

这里,我们创建了一个空对象 a,然后给它添加了一个属性 ‘x’。最终,我们得到了一个包含唯一值的 JSObject 和两个 Shape :空 shape 以及只包含属性 x 的 shape。

第二个例子也是从一个空对象 b 开始的,但是我们给它添加了一个不同的属性 ‘y’。最终,我们得到了两个 shape 链,总共 3 个 shape。

这是否意味着我们总是需要从空 shape 开始呢? 不一定。引擎对已含有属性的对象字面量会进行一些优化。比方说,我们要么从空对象字面量开始添加 x 属性,要么有一个已经包含属性 x 的对象字面量:

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

在第一个例子中,我们从空 shape 开始,然后转到包含 x 的shape,这正如我们之前所见那样。

在 object2 的例子中,直接在一开始就生成含有 x 属性的对象,而不是生成一个空对象是有意义的。拿下JavaScript引擎的基本原理

包含属性 ‘x’ 的对象字面量从含有 ‘x’ 的 shape 开始,有效地跳过了空 shape。V8 和 SpiderMonkey (至少)正是这么做的。这种优化缩短了转换链并且使从字面量构建对象更加高效。

下面是一个包含属性 ‘x'、'y' 和 'z' 的 3D 点对象的示例。

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

正如我们之前所了解的, 这会在内存中创建一个有3个 shape 的对象(不算空 shape 的话)。 当访问该对象的属性 ‘x’ 的时候,比如, 你在程序里写 point.x,javaScript 引擎需要循着链接列表寻找:它会从底部的 shape 开始,一层层向上寻找,直到找到顶部包含 ‘x’ 的 shape。拿下JavaScript引擎的基本原理

当这样的操作更频繁时, 速度会变得非常慢,特别是当对象有很多属性的时候。寻找属性的时间复杂度为 O(n), 即和对象上的属性数量线性相关。为了加快属性的搜索速度, JavaScript 引擎增加了一种 ShapeTable 的数据结构。这个 ShapeTable 是一个字典,它将属性键映射到描述对应属性的 shape 上。

拿下JavaScript引擎的基本原理

现在我们又回到字典查找了我们添加 shape 就是为了对此进行优化!那我们为什么要去纠结 shape 呢? 原因是 shape 启用了另一种称为 Inline Caches 的优化。

Inline Caches (ICs)

shapes 背后的主要动机是 Inline Caches 或 ICs 的概念。ICs 是让 JavaScript 快速运行的关键要素!JavaScript 引擎使用 ICs 来存储查找到对象属性的位置信息,以减少昂贵的查找次数。

这里有一个函数 getX,该函数接收一个对象并从中加载属性 x:

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

如果我们在 JSC 中运行该函数,它会产生以下字节码:拿下JavaScript引擎的基本原理

第一条 get_by_id 指令从第一个参数(arg1)加载属性 ‘x’,并将结果存储到 loc0 中。第二条指令将存储的内容返回给 loc0。

JSC 还将一个 Inline Cache 嵌入到 get_by_id 指令中,该指令由两个未初始化的插槽组成。

拿下JavaScript引擎的基本原理现在, 我们假设用一个对象 { x: 'a' },来执行 getX 这个函数。正如我们所知,,这个对象有一个包含属性 ‘x’ 的 shape, 该 shape存储了属性 ‘x’ 的偏移量和特性。当你在第一次执行这个函数的时候,get_by_id 指令会查找属性 ‘x’,然后发现其值存储在偏移量为 0 的位置。

拿下JavaScript引擎的基本原理

嵌入到 get_by_id 指令中的 IC 存储了 shape 和该属性的偏移量:

拿下JavaScript引擎的基本原理

对于后续运行,IC 只需要比较 shape,如果 shape 与之前相同,只需从存储的偏移量加载值。具体来说,如果 JavaScript 引擎看到对象的 shape 是 IC 以前记录过的,那么它根本不需要接触属性信息,相反,可以完全跳过昂贵的属性信息查找过程。这要比每次都查找属性快得多。

高效存储数组

对于数组,存储数组索引属性是很常见的。这些属性的值称为数组元素。为每个数组中的每个数组元素存储属性特性是非常浪费内存的。相反,默认情况下,数组索引属性是可写的、可枚举的和可配置的,JavaScript 引擎基于这一点将数组元素与其他命名属性分开存储。

思考下面的数组:

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

引擎存储了数组长度(1),并指向包含偏移量和 'length' 属性特性的 shape。

拿下JavaScript引擎的基本原理

这和我们之前看到的很相似……但是数组的值存到哪里了呢?

拿下JavaScript引擎的基本原理

每个数组都有一个单独的元素备份存储区,包含所有数组索引的属性值。JavaScript 引擎不必为数组元素存储任何属性特性,因为它们通常都是可写的、可枚举的和可配置的。

那么,在非通常情况下会怎么样呢?如果更改了数组元素的属性特性,该怎么办?

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

上面的代码片段定义了名为 “0” 的属性(恰好是数组索引),但将其特性设置为非默认值。

在这种边缘情况下,JavaScript 引擎将整个元素备份存储区表示成一个字典,该字典将数组索引映射到属性特性。

拿下JavaScript引擎的基本原理

即使只有一个数组元素具有非默认特性,整个数组的备份存储区也会进入这种缓慢而低效的模式。避免对数组索引使用Object.defineProperty!

建议

我们已经了解了 JavaScript 引擎如何存储对象和数组,以及 shape 和 ICs 如何优化对它们的常见操作。基于这些知识,我们确定了一些可以帮助提高性能的实用的 JavaScript 编码技巧:

  • 總是會以相同的方式初始化對象,這樣它們就不會有不同的 shape。
  • 不要弄亂陣列元素的屬性特性,這樣可以有效地儲存和操作它們。

相關免費學習推薦:javascript(影片)

#

以上是拿下JavaScript引擎的基本原理的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:juejin.im。如有侵權,請聯絡admin@php.cn刪除