1. 程式人生 > >JavaScript 如何工作: 深入 V8 引擎 + 編寫優質程式碼的 5 個技巧

JavaScript 如何工作: 深入 V8 引擎 + 編寫優質程式碼的 5 個技巧

譯者: 波比小金剛

翻譯水平有限,如有錯誤請指出。

原文: blog.sessionstack.com/how-javascr…

ps: 最近開始整理所有的優質文章翻譯集,當然如果你有好的文章請提 issue,我會找時間翻譯出來。


第二篇文章的重點將會深入 V8 引擎內部,並且分享一些編寫優質 JavaScript 程式碼的最佳實踐。

概述

JavaScrip 引擎是執行 JavaScript 程式碼的程式或直譯器。JavaScript 引擎可以由標準的直譯器實現,或者通過 JIT 編譯器(以某種形式將 JavaScript 程式碼編譯成位元組碼)。

如下列表展示了流行的 JavaScript 引擎:

V8 的誕生

V8 是 Google 的開源專案,由 C++ 編寫,除了 Chrome 使用了 V8 之外,還有大名鼎鼎的 Nodejs!

v8

V8 設計之初的目的是為了提升瀏覽器執行 JavaScript 程式碼的效能。為了獲取速度,V8 並沒有採用標準的直譯器,而是通過把 JavaScript 程式碼編譯成效率更高的機器碼。 V8 和很多現代 JavaScript 引擎(比如:SpiderMonkey、Rhino)一樣,通過 JIT 編譯器把 JavaScript 程式碼編譯成機器碼。這裡的主要區別就是 V8 不會產生任何的位元組碼或者中間程式碼。

V8 有兩個編譯器

截止最近的 5.9 版本,V8 使用了兩個編譯器:

  • full-codegen -- 一個簡單而快速的編譯器,可以生成簡單但相對較慢的機器程式碼。
  • Crankshaft -- 更復雜(JIT)的優化編譯器,可以生成高度優化的程式碼。

V8 引擎內部使用多個執行緒:

  • 主執行緒如你所想:拉取你的程式碼、編譯、然後執行。
  • 還有一個單獨的執行緒用於編譯,因此主執行緒可以繼續執行,而前者正在優化程式碼。
  • 一個 Profiler 執行緒將告訴 runtime 哪些方法耗時太長,以便 Crankshaft 對其進行優化。
  • 一些執行緒用於 GC

首次執行 JavaScript 程式碼的時候,full-codegen 登場,直接將解析後的 JavaScript 翻譯為機器碼而不需要任何的轉換。這使得 V8 可以非常快速的開始執行機器程式碼。

注意!V8 不使用中間位元組碼,意味著它不需要直譯器。

當代碼執行一段時間後,profiler 執行緒也已經收集到了足夠的資料以表示哪些方法需要被優化。

接下來,Crankshaft 從另一個執行緒開始進行優化,它翻譯 JavaScript AST,然後用更高階的 SSA來表示(V8 中叫做 Hydrogen)。 並且嘗試優化 Hydrogen 圖,大多數優化都是在這個級別完成的。

下面是譯者的註釋。

整個過程分別在兩個執行緒執行,不阻塞主執行緒,一方面通過 FC 直接編譯出機器碼,一方面通過 Crankshaft 對熱點函式進行優化。

不產生中間程式碼或者位元組碼的原因據說可能是 Google 覺得通過編譯前端把 AST 翻譯為中間程式碼還不如直接讓編譯後端將其翻譯成機器碼,一步到位。

內聯

第一個優化點就是提前內聯儘可能多的程式碼。內聯的過程其實就是用呼叫函式的函式主體替換呼叫函式點(call site) (呼叫函式所在的程式碼行)。

正是這個簡單的步驟使得如下圖的優化更有意義:

step

下面是譯者的註釋。

簡明扼要的說函式呼叫點(call site)其實就是一行程式碼的呼叫。

// 未優化前 2 個 call site
a = sqr(b)
c = sqr(b)

// 同一個 call site 呼叫 3 次,因為是動態語言,呼叫函式在執行時選擇,所以這裡函式呼叫進行了3次選擇
for (i in 1..3) {  
    a.call(i)  
}
複製程式碼

這段 Groovy 程式碼在高版本引入 Call Site 優化之後會就同一個 Call Site 的方法選擇結果快取起來,如果下一次呼叫時的引數型別一樣,則呼叫該快取起來的方法,否則重新選擇。

異曲同工,V8 中的內聯快取(下邊會說)也是與 Call Site 密切相關的。

V8 的內聯快取實際上就是針對具有相同屬性的 JavaScript 物件的通用屬性訪問優化,目的是跳過昂貴的屬性資訊查詢(過程)。這比每次查詢屬性要快得多。

請務必閱讀這篇文章

現在你大概可以理解 V8 在背後對上圖所示過程進行的優化了。

隱藏類(Hidden Class)

JavaScript 是基於原型的語言:所以和物件不是用克隆的過程建立的,JavaScript 也是一門動態語言,意味著物件在例項化之後可以輕鬆的增加或者移除屬性。

大多數 JavaScript 直譯器用類似於字典的資料結構(基於雜湊函式)來儲存物件的屬性值在記憶體的位置資訊。 這種結構使得在 JavaScript 中檢索屬性的值比起在非動態型別語言(比如 Java、C#),需要更高的計算成本! 在 Java 中,所有的物件屬性都是在編譯之前由固定的物件佈局決定的,並且無法在執行時新增或者刪除(C#具有動態型別)。 結果就是,屬性值(或者指向這些屬性的指標)可以作為連續的緩衝(buffer)儲存在記憶體中,並且每個緩衝區之間有固定的偏移量(fixed-offset)。 可以根據屬性的型別輕鬆的確定該偏移的長度,而在屬性型別也可以在執行時改變的 JavaScript 中,這是不可能的。

譯者注:這裡的連續緩衝的方式,我個人覺得就是指一段連續的記憶體空間,通過 offset 的值對應不同的屬性,那麼對屬性的檢索就變成了類似陣列中的查詢(O(1)),效率就很快了。

由於使用字典(結構)在記憶體中尋找物件屬性的位置十分低效,V8 使用了不同的方式代替:隱藏類(Hidden Class)。 Hidden Class 的工作方式類似於上邊提到的 Java 中的固定物件佈局(classes),除非它們是執行時建立的,我們來看看它們實際上是什麼樣的:

function Point(x, y) {
    this.x = x;
    this.y = y;
}

var p1 = new Point(1, 2);
複製程式碼

一旦 "new Point(1, 2)" 被呼叫,V8 就會建立一個叫做 "C0" 的隱藏類(Hidden Class)

point

尚未為 Point 定義任何屬性,所以 C0 為空。

一旦第一個語句 "this.x = x" 執行(在 Point 函式內)。V8 將會基於 "C0" 建立第二個隱藏類,叫做 "C1"。 "C1" 描述了在記憶體中的哪個位置(相對於物件指標)可以找到屬性 "x",在這種情況下,"x" 被存在偏移 0 的位置(offset 0),這意味著如果把記憶體中的一個 point 物件視為連續緩衝(buffer), 在偏移為 0 的位置就對應著屬性 "x"。V8 也會通過 "class transition" 來更新 "C0",這裡 "class transition" 的作用其實就是宣告如果屬性 "x" 加到了 point 物件上,那麼隱藏類(Hidden Class)就應該切換到 "C1",所以如下圖所示,隱藏類現在是 "C1":

point-x

每次將新屬性新增到物件,舊的隱藏類就會通過轉換路徑更新為新的隱藏類。隱藏類轉換非常重要,因為它們允許同樣方式建立的物件之間共享隱藏類。如果兩個物件共享一個隱藏類,並且相同的屬性被加到它們中,那麼轉換(transition) 將要確保兩個物件都要接收到新的、相同的隱藏類及附帶的優化程式碼。

在執行 "this.y = y" 的時候,上述過程將會被重複(同樣,在 Point 函式內,this.x = x 之後)

一個新的隱藏類 "C2" 被建立,一個 "class transition" 被新增到 "C1" 來宣告如果屬性 "y" 被新增到 Point 物件(此時已包含屬性 "x"),那麼隱藏類應該切換到 "C2"。 並且point 物件的隱藏類被更新到 "C2":

point-y

隱藏類轉換(就是上邊的 class transition)取決於屬性新增到物件的順序,我們可以看看下面的程式碼片段:

function Point(x, y) {
    this.x = x;
    this.y = y;
}
var p1 = new Point(1, 2);
p1.a = 5;
p1.b = 6;
var p2 = new Point(3, 4);
p2.b = 7;
p2.a = 8;
複製程式碼

現在,假設對於 p1 和 p2 都是用的相同的隱藏類和轉換。那麼,對於 p1 首先新增的是屬性 "a", 然後是 "b",對於 p2 則是相反的順序。 最終會在不同的轉換路徑作用下會產生不同的隱藏類。那麼這種情況下,以相同的順序初始化物件屬性就會優化很多,因為可以重用隱藏類。

內聯快取(Inline caching)

V8 中另一種優化動態型別語言的技術叫做,內聯快取(Inline caching)。

行內函數關注的是對相同方法的呼叫趨向於發生在相同型別的物件上,如果想要深入瞭解的話請細細品味下邊的拓展閱讀部分。

V8 的內聯快取實際上就是針對具有相同屬性的 JavaScript 物件的通用屬性訪問優化,目的是跳過昂貴的屬性資訊查詢(過程)。這比每次查詢屬性要快得多。

我們這裡只會討論內聯快取的一般概念。

所以,它是怎麼工作的?V8 維護著一個關於當前函式呼叫時作為引數傳入的物件的型別的快取,並且使用該快取資訊來預測未來可能被作為引數傳入的物件的型別。 如果 V8 能夠做出很好的預測,那麼我們就可以繞過昂貴的屬性查詢過程,而使用之前查詢物件隱藏類儲存的資訊。

所以,隱藏類和內聯快取的概念有何關聯?每當對一個特定的物件呼叫方法時,V8 引擎會執行一次對物件隱藏類的查詢以確定訪問特定屬性的偏移量(offset)。 當同一方法成功呼叫兩次後兩者擁有相同的隱藏類,V8 會忽略掉隱藏類的查詢,並且只是將屬性的偏移量新增到物件指標自身。 對於該方法未來所有的呼叫,V8 引擎會假定其隱藏類未發生改變,並使用先前查詢儲存的屬性偏移量直接跳到記憶體中該特定屬性的儲存地址。這大大提高了執行速度。

內聯快取也是為什麼同類型物件要共享隱藏類是如此重要的原因。如果你建立兩個同類型物件但是擁有不同的隱藏類(如我們之前的例子),V8 將無法使用內聯快取進行優化,因為即使是同一型別的物件, 但是不同的隱藏類意味著會為其物件屬性分配不同的偏移量。

inline-cache

這兩個物件基本相同,但“a”和“b”屬性是按不同順序建立的。

編譯到機器碼

一旦 Hydrogen 圖被優化,Crankshaft 將會降低其級別,稱之為 Lithium。大多數 Lithium 實現都是特定於體系結構的。暫存器分配發生在此級別。

最後,Lithium 被編譯成機器碼。然後,觸發 OSR:堆疊替換。當我們開始編譯並且優化一個明顯的耗時方法,我們可能正在執行它,V8 會慢慢的執行它來重啟一個優化的版本,V8 會切換我們擁有的所有上下文(堆疊、暫存器),以便我們在執行過程中切換的優化版本。這是一項非常複雜的任務,請記住,在其它優化中,V8 已經在初始階段內聯了程式碼。V8 不是唯一能做到這一點的引擎。

當然,這裡還有一種叫做去優化的保護機制。當 V8 不能準確預測的情況下恢復到非優化程式碼(優雅回退)。

GC

對於 GC,V8 使用傳統的標記清除演算法清理老生代記憶體。在標記階段會阻塞 JavaScript 執行。 為了控制 GC 的成本並使執行更加穩定,V8 使用了增量標記的方式:不是遍歷整個堆記憶體,只是標記部分堆記憶體中的可能的物件,然後恢復主執行緒的執行。下一次的遍歷接著從上一次停止的地方繼續,所謂增量即是如此。這樣就可以最大限度的降低因為 GC 任務執行帶來的阻塞開銷。而且清理階段也是在單獨的執行緒執行。

Ignition and TurboFan

2017 年釋出的 V8 5.9 中,引入了 pipeline,pipeline 的引入帶來了對 JavaScript 應用更大的效能提升和顯著的記憶體節省。

新引入的 pipeline 建立在 Ignition、V8 的直譯器、TurboFan 之上。

你可以點選這裡檢視 V8 團隊關於這個主題的介紹部落格。

自從 V8 的 5.9 版本問世以來,full-codegen 和 Crankshaft (自2010年以來,V8採用的技術)已經廢掉了。因為 V8 需要與時俱進,隨著 JavaScript 語言的演進而不斷的優化。

這也意味著 V8 目前擁有更簡單、更易於維護的架構。

Ignition and TurboFan

這些改進只是一個開始,新的 Ignition 和 TurboFan pipeline 為進一步的優化鋪平了道路,這些優化在未來幾年會提升 JavaScript 效能並縮小其在 Chrome 和 Node 中所佔的空間。

好,接下來是一些總結的最佳實踐:

最佳實踐部分

  1. 物件屬性排序:始終以相同的順序例項化物件屬性,以共享隱藏類和隨後的優化程式碼。

  2. 動態屬性:在例項化之後為一個物件新增屬性會強制改變隱藏類,並且減慢為之前隱藏類優化的程式碼執行速度,最好的方式還是在建構函式中分配好所有的屬性。

  3. 方法:相同的方法重複執行比執行一次多個不同方法更快(因為內聯快取)

  4. 陣列:避免使用 key 不是遞增數字的稀疏陣列。稀疏陣列是 hash table 結構,這種結構中的元素訪問代價更高。此外,不要提前設定大的陣列,應該根據具體場景,惰性增加。也不要隨意刪除陣列中的元素,這樣容易造成稀疏。

  5. 標記值:V8 用 32bits 表示物件或者數字。它使用一個 bit 來表示它是一個物件(flag = 1)還是一個稱為 SMI(SMall Integer)的整數(flag = 0),對於剩下的 31 位。 如果數值大於 31 位,V8 將對該數字進行處理,將其變為雙精度並建立一個新物件以將數字放入其中。 所以嘗試儘可能使用 31 位帶符號的數字,以避免對 JS 物件進行昂貴的裝箱操作。

拓展閱讀

1. 深入淺出 JIT 編譯器

2. JavaScript Just-in-time (JIT) 工作原理

3. a closer look at crankshaft, v8's optimizing compiler

4. v8 full-codegen

5. 內聯快取

6. justjavac 的專欄

7. JavaScript 引擎基礎:Shapes 和 Inline Caches

8. Optimizing dynamic JavaScript with inline caches