拿下JavaScript引擎的基本原理

2020-10-10 18:00:34
作為欄目開發人員,深入瞭解 JavaScript 引擎的工作原理有助於你瞭解自己程式碼的效能特徵。這篇文章對所有 JavaScript 引擎中常見的一些關鍵基礎知識進行了介紹,不僅僅侷限於 V8 引擎。

JavaScript 引擎的工作流程 (pipeline)

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

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

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

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

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

Chakra,在 Edge 中使用的 Microsoft 的 JavaScript 引擎,非常相似的,也有2個優化編譯器。直譯器優化程式碼到 SimpleJIT(JIT 代表 Just-In-Time 編譯器,即時編譯器),SimpleJIT 會生成稍微優化的程式碼。而 FullJIT 結合分析資料,可以生成更為優化的程式碼。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 屬性的字典。

除了 [[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' 下。「length」 屬性是另一個不可列舉且不可設定的屬性。 當一個元素被新增到陣列中時, JavaScript 會自動更新 「length「 屬性的 [[value]] 屬性。

優化屬性存取

知道了物件在 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 的物件,它使用我們前面討論過的字典資料結構:它包含字串形式的鍵,這些鍵指向它們各自的屬性值。

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

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

該物件開始沒有任何屬性,因此它指向一個空的 shape。下一個語句為該物件新增一個值為 5 的屬性 "x",所以 JavaScript 引擎轉向一個包含屬性 "x" 的 shape,並在第一個偏移量為 0 處向 JSObject 新增了一個值 5。 下一行新增了一個屬性 'y',引擎便轉向另一個包含 'x' 和 'y' 的 shape,並將值 6 新增到 JSObject(位於偏移量 1 處)。

我們甚至不需要為每個 shape 儲存完整的屬性表。相反,每個shape 只需要知道它引入的新屬性。例如,在本例中,我們不必將有關 「x」 的資訊儲存在最後一個 shape 中,因為它可以在更早的鏈上找到。要實現這一點,每個 shape 都會連結回其上一個 shape:

如果你在 JavaScript 程式碼中寫 o.x,JavaScript 引擎會沿著轉換鏈去查詢屬性 "x",直到找到引入屬性 "x" 的 Shape。

但是如果沒有辦法建立一個轉換鏈會怎麼樣呢?例如,如果有兩個空物件,並且你為每個物件新增了不同的屬性,該怎麼辦?

const object1 = {};
object1.x = 5;const object2 = {};
object2.y = 6;複製程式碼

在這種情況下,我們必須進行分支操作,最終我們會得到一個轉換樹而不是轉換鏈。

這裡,我們建立了一個空物件 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 屬性的物件,而不是生成一個空物件是有意義的。

包含屬性 ‘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。

當這樣的操作更頻繁時, 速度會變得非常慢,特別是當物件有很多屬性的時候。尋找屬性的時間複雜度為 O(n), 即和物件上的屬性數量線性相關。為了加快屬性的搜尋速度, JavaScript 引擎增加了一種 ShapeTable 的資料結構。這個 ShapeTable 是一個字典,它將屬性鍵對映到描述對應屬性的 shape 上。

現在我們又回到字典查詢了我們新增 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 中執行該函數,它會產生以下位元組碼:

第一條 get_by_id 指令從第一個引數(arg1)載入屬性 ‘x’,並將結果儲存到 loc0 中。第二條指令將儲存的內容返回給 loc0。

JSC 還將一個 Inline Cache 嵌入到 get_by_id 指令中,該指令由兩個未初始化的插槽組成。

現在, 我們假設用一個物件 { x: 'a' },來執行 getX 這個函數。正如我們所知,,這個物件有一個包含屬性 ‘x’ 的 shape, 該 shape儲存了屬性 ‘x’ 的偏移量和特性。當你在第一次執行這個函數的時候,get_by_id 指令會查詢屬性 ‘x’,然後發現其值儲存在偏移量為 0 的位置。

嵌入到 get_by_id 指令中的 IC 儲存了 shape 和該屬性的偏移量:

對於後續執行,IC 只需要比較 shape,如果 shape 與之前相同,只需從儲存的偏移量載入值。具體來說,如果 JavaScript 引擎看到物件的 shape 是 IC 以前記錄過的,那麼它根本不需要接觸屬性資訊,相反,可以完全跳過昂貴的屬性資訊查詢過程。這要比每次都查詢屬性快得多。

高效儲存陣列

對於陣列,儲存陣列索引屬性是很常見的。這些屬性的值稱為陣列元素。為每個陣列中的每個陣列元素儲存屬性特性是非常浪費記憶體的。相反,預設情況下,陣列索引屬性是可寫的、可列舉的和可設定的,JavaScript 引擎基於這一點將陣列元素與其他命名屬性分開儲存。

思考下面的陣列:

const array = [    '#jsconfeu',
];複製程式碼

引擎儲存了陣列長度(1),並指向包含偏移量和 'length' 屬性特性的 shape。

這和我們之前看到的很相似……但是陣列的值存到哪裡了呢?

每個陣列都有一個單獨的元素備份儲存區,包含所有陣列索引的屬性值。JavaScript 引擎不必為陣列元素儲存任何屬性特性,因為它們通常都是可寫的、可列舉的和可設定的。

那麼,在非通常情況下會怎麼樣呢?如果更改了陣列元素的屬性特性,該怎麼辦?

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

上面的程式碼片段定義了名為 「0」 的屬性(恰好是陣列索引),但將其特性設定為非預設值。

在這種邊緣情況下,JavaScript 引擎將整個元素備份儲存區表示成一個字典,該字典將陣列索引對映到屬性特性。

即使只有一個陣列元素具有非預設特性,整個陣列的備份儲存區也會進入這種緩慢而低效的模式。避免對陣列索引使用Object.defineProperty!

建議

我們已經瞭解了 JavaScript 引擎如何儲存物件和陣列,以及 shape 和 ICs 如何優化對它們的常見操作。基於這些知識,我們確定了一些可以幫助提高效能的實用的 JavaScript 編碼技巧:

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

相關免費學習推薦:(視訊)

以上就是拿下JavaScript引擎的基本原理的詳細內容,更多請關注TW511.COM其它相關文章!