How Javascript works (Javascript工作原理) (二) 引擎,運行時,如何在 V8 引擎中書寫最優代碼的 5 條小技巧
個人總結:
一個Javascript引擎由一個標準解釋程序,或者即時編譯器來實現。
解釋器(Interpreter): 解釋一行,執行一行。
編譯器(Compiler): 全部編譯成機器碼,統一執行。(減少了切換和調度的開銷,更快。)
V8引擎是一種即時編譯器。
V8引擎的優化策略:
1.內聯:將函數被調用的內行代碼置換為被調用的函數體。
2.隱藏類:大多數動態語言使用類字典的結構(基於哈希函數)在內存中存儲對象屬性值的內存地址(即對象的內存地址),所以比非動態語言(Java,C#)要慢,
V8通過使用"隱藏類",來增加效率。
3.內聯緩存:對於經常被使用的屬性,V8 忽略隱藏類的查找並且只是簡單地把屬性的位移添加給對象指針自身。
4.垃圾回收:V8使用傳統的標記-清除技術。
如何在 V8 引擎中書寫最優代碼的 5 條小技巧
這是 JavaScript 工作原理的第二章。
本章將會深入谷歌 V8 引擎的內部結構。我們也會為如何書寫更好的 JavaScript 代碼提供幾條小技巧。
概述
一個 JavaScript 引擎就是一個程序或者一個解釋程序,它運行 JavaScript 代碼。一個 JavaScript 引擎可以用標準解釋程序或者即時編譯器來實現,即時編譯器即以某種形式把 JavaScript 解釋為字節碼。
以下是一系列實現 JavaScript 引擎的熱門工程:
- V8-由谷歌開源的以 C++ 語言編寫
- Rhin-由 Mozilla 基金會主導,開源的,完全使用 Java 開發。
- SpiderMonkey-初代 JavaScript 引擎,由在之前由網景瀏覽器提供技術支持,現在由 Firefox 使用。
- JavaScriptCore-開源,以 Nitro 的名稱來推廣,並由蘋果為 Safari 開發。
- KJS-KDE 引擎,起先是由 Harri Porten 為 KDE 工程的 Konqueror 瀏覽器所開發。
- Chakra (JScript9)-IE
- Chakra (JavaScript)-Microsoft Edge
- Nashorn-作為 OpenJDK 的一部分來開源,由 Oracle Java 語言和 Tool Group 編寫。
- JerryScript-一款輕量級的物聯網引擎。
V8 引擎的由來
V8 引擎是由谷歌開源並以 C++ 語言編寫。Google Chrome 內置了這個引擎。而 V8 引擎不同於其它引擎的地方在於,它也被應用於時下流行的 Node.js 運行時中。
起先 V8 是被設計用來優化網頁瀏覽器中的 JavaScript 的運行性能。為了達到更快的執行速度,V8 把 JavaScript 代碼轉化為更加高效的機器碼而不是使用解釋程序。它通過實現一個即時編譯器在運行階段把 JavaScript 代碼編譯為機器碼,就像諸如 SpiderMonkey or Rhino (Mozilla) 等許多現代 JavaScript 引擎所做的那樣。主要的區別在於 V8 不產生字節碼或者任何的中間碼。
V8 曾經擁有兩個編譯器
在 V8 5.9誕生(2017 年初) 之前,引擎擁有兩個編譯器:
- full-codegen-一個簡單且快速的編譯器用來產出簡單且運行相對緩慢的機器碼。
- Crankshaft-一個更復雜(即時)優化的編譯器用來產生高效的代碼。
V8 引擎內部也使用多個線程:
- 主線程做你所期望的事情-抓取你的代碼,編譯後執行
- 有獨立的線程來編譯代碼,所以主線程可以保持執行而前者正在優化代碼
- 一個用於性能檢測的線程會告訴運行時我們在哪個方法上花了太多的時間,以便於讓 Crankshaft 來優化這些代碼
- 有幾個線程用來處理垃圾回收器的清理工作。
當第一次執行 JavaScript 代碼的時候,V8 使用 full-codegen 直接把解析的 JavaScript 代碼解釋為機器碼,中間沒有任何轉換。這使得它一開始非常快速地運行機器碼。註意到 V8 沒有使用中間字節碼來表示,這樣就不需要解釋器了。
當代碼已經執行一段時間後,性能檢測器線程已經收集了足夠多的數據來告訴 Crankshaft 哪個方法可以被優化。
接下來,在另一個線程中開始進行 Crankshaft 代碼優化。它把 JavaScript 語法抽象樹轉化為一個被稱為 Hydrogen 的高級靜態單賦值並且試著優化這個 Hydrogen 圖表。大多數的代碼優化是發生在這一層。
內聯
第一個優化方法即是提前盡可能多地內聯代碼。內聯指的是把調用地址(函數被調用的那行代碼)置換為被調用函數的函數體的過程。這個簡單的步驟使得接下來的代碼優化更有意義。
隱藏類
JavaScript 是基於原型的語言:當進行克隆的時候不會有創建類和對象。JavaScript 也是一門動態編程語言,這意味著在它實例化之後,可以任意地添加或者移除屬性。
大多數的 JavaScript 解釋器使用類字典的結構(基於哈希函數)在內存中存儲對象屬性值的內存地址(即對象的內存地址)。這種結構使得在 JavaScript 中獲取屬性值比諸如 Java 或者 C# 的非動態編程語言要更耗費時間。在 Java 中,所有的對象屬性都在編譯前由一個固定的對象布局所決定並且不能夠在運行時動態添加或者刪除(嗯, C# 擁有動態類型,這是另外一個話題)。因此,屬性值(指向這些屬性的指針)以連續的緩沖區的形式存儲在內存之中,彼此之間有固定的位移。位移的長度可以基於屬性類型被簡單地計算出來,然而在 JavaScript 中這是不可能的,因為運行時可以改變屬性類型。
由於使用字典在內存中尋找對象屬性的內存地址是非常低效的,V8 轉而使用隱藏類。隱藏類工作原理和諸如 Java 語言中使用的固定對象布局(類)相似,除了它們是在運行時創建的以外。現在,讓我們看看他們的樣子:
function Point(x, y) {
this.x = x;
this.y = y;
}
var p1 = new Point(1, 2);
一旦 "new Point(1,2)" 調用發生,V8 他創建一個叫做 "C0" 的隱藏類。
因為還沒有為類 Point 創建屬性,所以 "C0" 是空的。
一旦第一條語句 "this.x = x" 開始執行(在 Point 函數中), V8 將會基於 "C0" 創建第二個隱藏類。"C1" 描述了可以找到 x 屬性的內存地址(相對於對象指針)。本例中,"x" 存儲在位移 0 中,這意味著當以內存中連續的緩沖區來查看點對象的時候,位移起始處即和屬性 "x" 保持一致。V8 將會使用 "類轉換" 來更新 "C0","類轉換" 即表示屬性 "x" 是否被添加進點對象,隱藏類將會從 "C0" 轉為 "C1"。以下的點對象的隱藏類現在是 "C1"。
每當對象添加新的屬性,使用轉換路徑來把舊的隱藏類更新為新的隱藏類。隱藏類轉換是重要的,因為它們使得以同樣方式創建的對象可以共享隱藏類。如果兩個對象共享一個隱藏類並且兩個對象添加了相同的屬性,轉換會保證兩個對象收到相同的新的隱藏類並且所有的優化過的代碼都會包含這些新的隱藏類。
當運行 "this.y = y" 語句的時候,會重復同樣的過程(還是在 Point 函數中,在 "this.x = x" 語句之後)。
一個被稱為 "C2" 的隱藏類被創造出來,一個類轉換被添加進 "C1" 中表示屬性 "y" 是否被添加進點對象(已經擁有屬性 "x")之後隱藏會更改為 "C2",然後點對象的隱藏類會更新為 "C2"。
隱藏類轉換依賴於屬性被添加進對象的順序。看如下的代碼片段:
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",先添加屬性 "b" 然後是 "a"。這樣,因為使用不同的轉換路徑,"p1" 和 "p2" 會使用不同的隱藏類。在這種情況下,更好的方法是以相同的順序初始化動態屬性以便於復用隱藏類。
內聯緩存
V8 利用了另一項優化動態類型語言的技術叫做內聯緩存。內聯緩存依賴於對於同樣類型的對象的同樣方法的重復調用的觀察。這裏有一份深入闡述內聯緩存的文章。
我們將會接觸到內聯緩存的大概概念(萬一你沒有時間去通讀以上的深入理解內聯緩存的文章)。
它是如何工作的呢?V8 會維護一份傳入最近調用方法作為參數的對象類型的緩存,然後使用這份信息假設在未來某個時候這個對象類型將會被傳入這個方法。如果 V8 能夠很好地預判即將傳入方法的對象類型,它就可以繞過尋找如何訪問對象屬性的過程,代之以使用儲存的來自之前查找到的對象隱藏類的信息。
所以隱藏類的概念和內聯緩存是如何聯系在一起的呢?每當在一個指定的對象上調用方法的時候,V8 引擎不得不執行查找對象隱藏類的操作,用來取得訪問指定屬性的位移。在兩次對於相同隱藏類的相同方法的成功調用之後,V8 忽略隱藏類的查找並且只是簡單地把屬性的位移添加給對象指針自身。在之後所有對這個方法的調用,V8 引擎假設隱藏類沒有改變,然後使用之前查找到的位移來直接跳轉到指定屬性的內存地址。這極大地提升了代碼運行速度。
內存緩存也是為什麽同樣類型的對象共享隱藏類是如此重要的原因。當你創建了兩個同樣類型的對象而使用不同的隱藏類(正如之前的例子所做的那樣),V8 將不可能使用內存緩存,因為即使相同類型的兩個對象,他們對應的隱藏類為他們的屬性分派不同的地址位移。
這兩個對象基本上是一樣的但是創建 "a" 和 "b" 的順序是不同的
編譯為機器碼
一旦優化了 Hydrogen 圖表,Crankshaft 會把它降級為低級的展現叫做 Lithium。大多數 Lithium 的實現都是依賴於指定的架構的。寄存器分配發生在這一層。
最後,Lithium 會被編譯為機器碼。之後其它被稱為 OSR 的事情發生了:堆棧替換。在開始編譯和優化一個明顯的耗時的方法之前,過去極有可能去運行它。V8 不會忘記代碼執行緩慢的地方,而再次使用優化過的版本代碼。相反,它會轉換所有的上下文(堆棧,寄存器),這樣就可以在執行過程中切換到優化的版本代碼。這是一個復雜的任務,你只需要記住的是,在其它優化過程中,V8 會初始化內聯代碼。V8 並不是唯一擁有這項能力的引擎。
這裏有被稱為逆優化的安全防護,以防止當引擎所假設的事情沒有發生的時候,可以進行逆向轉換和把代碼反轉為未優化的代碼。
垃圾回收
V8 使用傳統的標記-清除技術來清理老舊的內存以進行垃圾回收。標記階段會中止 JavaScript 的運行。為了控制垃圾回收的成本並且使得代碼執行更加穩定,V8 使用增量標記法:不遍歷整個內存堆,試圖標記每個可能的對象,它只是遍歷一部分堆,然後重啟正常的代碼執行。下一個垃圾回收點將會從上一個堆遍歷中止的地方開始執行。這會在正常的代碼執行過程中有一個非常短暫的間隙。之前提到過,清除階段是由單獨的線程處理的。
Ignition 和 TurboFan
隨著 2017 早些時候 V8 5.9 版本的發布,帶來了一個新的執行管道。新的管道獲得了更大的性能提升和在現實 JavaScript 程序中,顯著地節省了內存。
新的執行管道是建立在新的 V8 解釋器 Ignition 和 V8 最新的優化編譯器 TurboFan 之上的。
你可以查看 V8 小組的博文。
自從 V8 5.9 版本發布以來,full-codegen 和 Crankshaft(V8 從 2010 開始使用至今) 不再被 V8 用來運行JavaScript,因為 V8 小組正努力跟上新的 JavaScript 語言功能以及為這些功能所做的優化。
這意味著接下來整個 V8 將會更加精簡和更具可維護性。
網頁和 Node.js benchmarks 評分的提升
這些提升只是一個開始。新的 Ignition 和 TurboFan 管道為未來的優化作鋪墊,它會在未來幾年內提升 JavaScript 性能和縮減 Chrome 和 Node.js 中的 V8 痕跡。
最後,這裏有一些如何寫出優化良好的,更好的 JavaScript 代碼。你可以很容易地從以上的內容中總結出來,然而,為了方便你,下面有份總結:
如何寫優化的 JavaScript 代碼
- 對象屬性的順序:總是以相同的順序實例化你的對象屬性,這樣你的隱藏類及之後的優化代碼都可以被共享。
- 動態屬性:實例化之後為對象添加屬性會致使為之前隱藏類優化的方法變慢。相反,在對象構造函數中賦值對象的所有屬性。
- 方法:重復執行相同方法的代碼會比每次運行不同的方法的代碼更快(多虧了內聯緩存)。
- 數列:避免使用鍵不是遞增數字的稀疏數列。稀疏數列中沒有包含每個元素的數列稱為一個哈希表。訪問該數列中的元素會更加耗時。同樣地,試著避免預先分配大型數組。最好是隨著你使用而遞增。最後,不要刪除數列中的元素。這會讓鍵稀疏。
- 標記值:V8 用 32 位來表示對象和數字。它使用一位來辨別是對象(flag=1)或者是被稱為 SMI(小整數) 的整數(flag=0),之所以是小整數是因為它是 31 位的。之後,如果一個數值比 31 位還要大,V8 將會裝箱數字,把它轉化為浮點數並且創建一個新的對象來存儲這個數字。盡可能試著使用 31 位有符號數字來避免創建 JS 對象的耗時裝箱操作。
How Javascript works (Javascript工作原理) (二) 引擎,運行時,如何在 V8 引擎中書寫最優代碼的 5 條小技巧