JavaScript 如何工做: 深刻 V8 引擎 + 編寫優質代碼的 5 個技巧

譯者: 波比小金剛javascript

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

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

ps: 最近開始整理全部的優質文章翻譯集,固然若是你有好的文章請提 issue,我會找時間翻譯出來。java


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

概述

JavaScrip 引擎是執行 JavaScript 代碼的程序或解釋器。JavaScript 引擎能夠由標準的解釋器實現,或者經過 JIT 編譯器(以某種形式將 JavaScript 代碼編譯成字節碼)。github

以下列表展現了流行的 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

相關文章
相關標籤/搜索