JavaScript 如何工作的: 深入 V8 引擎內部
幾周之前,我開始了一個深入 JavaScript 的系列,它到底是如何工作的:通過了解 JavaScript 的構建塊以及它們是如何協作的,然後寫出更好的程式碼和應用?
這個系列的第一篇文章關注引擎的概述,執行時和呼叫棧。第二篇將會深入到Google V8 JavaScript 應用的部分內部。我們也提供了一些關於如何寫出 JavaScript 程式碼的小技巧 。
概述
JavaScript 引擎 是一個程式或者執行 JavaScript 程式碼的直譯器。一個JavaScript 引擎可以作為一個單獨的直譯器實現或者通過某種方式將 JavaScript 編譯為位元組碼的即時編譯器。
下面是一系列 JavaScript 引擎實現的一些專案:
- V8 - Google 開發的,開源,用 C++ 實現的
- Rhino - 被 Mozilla 基金會管理的,開源,完全通過 java 開發的
- SpiderMonkey - 第一個 JavaScript 引擎,支援 Netscape Navigator,和今天的 Firfox
- JavaScriptCore - 開源,Apple 開發用於 Safari
- KJS - KDE 的引擎,Harri porten 開發的,用於 KDE 專案的 Konqueror 的web 瀏覽器
- Chakra(JScript9) - internet explorer
- Chakra(JavaScript)- Miscrosoft Edge
- Nashorn,開源,作為 OpenJDK 的一部分,Oracle 的 Java 語言和工具組開發的
- JerryScript - 輕量級的物聯網引擎
為什麼創造 V8 引擎?
被 Google 開發的 V8 引擎,是通過 C++ 實現的。這個引擎被用於 Google 的 Chrome 瀏覽器。不像其餘的引擎,然而 V8 也被用於流行的 Node.js 執行時。
設計 V8 是用於提升 JavaScript 在 web 瀏覽器內的執行效能的。為了獲得速度 ,V8 將 JavaScript 程式碼轉換成更高效的機器碼而不是使用直譯器。它通過被一個 JIT 編譯器執行將 JavaScript 程式碼編譯成機器碼,想大多數現代的 JavaScript 引擎(SpiderMonkey 或者 Rhino)一樣。唯一不同的地方是V8 不產生位元組碼或者任意中間碼。
V8 使用兩個編譯器
在 V8 5.9 版本之前,引擎使用兩個編譯器:
- full-codegen - 一個簡單,快速的編譯器,生產簡單地和相對較慢的機器碼。
- CrankShaft - 一個更加複雜(Just-In-Time)優化編譯器,生產更高優化的程式碼。
V8 引擎在內部也是使用幾個執行緒:
- 主執行緒做你所期望的:拉取程式碼,編譯,執行
- 還有一個獨立的用於編譯的執行緒,讓主執行緒可以繼續執行,然而這個執行緒編譯的是優化過的程式碼。
- 一個分析執行緒將會告訴執行時那些方法使用了多少時間,以便讓 Crankshaft 可以優化它們。
- 一些用於處理垃圾收集回收的執行緒。
當第一次執行 JavaScript 程式碼的時候,V8 利用 full-codegen ,直接將 JavaScript 解析為機器碼沒有任何中間碼的轉換。這允許它非常快速地執行機器碼。注意,V8 不使用中間的位元組碼,因而不用使用一個翻譯器。
當你的程式碼執行一段時間,分析執行緒已經獲取到足夠的資料告訴優化編譯器應該優化哪個方法。
下一步,Crankshaft 開始另一個執行緒。它將 JavaScript 抽象語法樹轉換成更高階的被稱為 Hydrogen 的靜態單賦值(SSA- static single-assignment),並且試著優化 Hydrogen 圖。大多數的優化都在這一級別。
內聯
第一個優化是內聯儘可能多的程式碼。內聯是用呼叫的函式體替代函式呼叫。這個簡單的步驟可以使優化後更有意義。

隱藏類
JavaScript 是一個基於原型的語言:沒有類和物件的建立通過克隆的方式。JavaScript 也是一個動態的程式語言意味著物件的屬性可以可以在物件初始化後很容易的新增和刪除。
大多數的 JavaScript 的直譯器使用類字典的資料結構(基於雜湊函式)來儲存物件屬性的地址。這種結構使得 在JavaScript 中檢索屬性的值需要更大量的計算,相對於非動態程式語言來說(如,java,c#)。在 java 中,所有的屬性在編譯之前有一個固定的物件佈局,在執行時不能動態的新增或者刪除(好吧,c# 有動態型別,這又是另一個話題了)。因此屬性的值(或者指向屬性的指標)可以儲存為一個連續的記憶體中,相互之間是固定的偏移量。具體的偏移量可以很容易地根據屬性型別來決定,而這在 JavaScript 中是不可能的,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”是空的。
一旦(Point 函式內部)第一個語句“this.x = x”執行了,V8 將會建立第二個基於“C0”稱作“C1”的隱藏類,。“C1”描述了相對於物件的指標屬性 x 在記憶體中的位置。在這個例子中,“x”儲存的偏移量是 0,意味著當瀏覽一個 point 物件在記憶體中是一個連續的緩衝區,第一個偏移量正是屬性“x”。V8 將會依據“class transition”更新“C0”,“class transition”描述瞭如果一個屬性“x”被新增到一個 point 物件,隱藏類應該從“C0”變為“C1”。下面 point 物件的隱藏類目前是“C1”。

當執行語句“this.y=y”時,再次重複這個過程。
建立了一個新的隱藏類“C2”,“C1”上面新增上類變換,如果屬性“y”新增到 Point 物件然後,隱藏類應該變為“C2”,並且point 物件的隱藏類更新到“C2”。

隱藏類的變換依賴於往一個物件中新增屬性的順序。看下下面的程式碼片段:
function Point(x,y){ this.x = x; thix.y = y; } var p1 = new Point(1, 2); p1.a = 5; p1.b = 6; var p2 = new Point(3, 4); p2.a = 7; p2.b = 8;
現在,你可能假設 p1 和 p2 有使用類變換一樣的隱藏類。好吧,不完全是,對於“p1”,第一個屬性“a”被添加了然後是屬性“b”。對於“p2”來說,然而,第一個“b”被添加了,接下來是“a”。因此,“p1”和“p2”因為有不同的類變換路徑而得到不同的隱藏類。在這種情況下,以相同的順序初始化動態屬性,便於重用隱藏類。
內聯快取
V8 用來優化動態型別語言的另一種方法稱為“內聯快取”。內聯快取依賴於觀察,反覆呼叫相同的方法往往發生在相同型別的物件上面。
更深入關於內聯快取的解釋可以在 這裡 找到。
我們會觸及內聯快取的一般概念(如果你沒有時間通過上面的連結深入理解)。
那麼,它是如何工作的?V8 維護了一個在最近的方法呼叫中作為引數傳遞的物件的型別的快取,使用這些資訊來做一個假設,物件的型別在未來會作為引數傳遞。如果 V8 能夠對將會被傳遞給方法的物件的型別做一個假設,它可以繞過如何訪問物件的屬性這一過程,取代的是,使用之前儲存的資訊查詢物件的隱藏類。
那麼隱藏類和內聯快取有哪些關係呢?無論什麼時候呼叫一個特定物件上面的方法,V8 引擎不得不執行查詢物件的隱藏類為了決定訪問特定屬性的偏移量。經過兩次同一個隱藏類上的相同的方法的成功呼叫,V8 省略了隱藏類的查詢並且簡單地新增屬性的偏移量到一個物件指標上面。今後所有這個方法的呼叫,V8 引擎假設隱藏類沒有發生改變,並且使用查詢的偏移量直接跳躍到記憶體地中查詢特定的屬性。這大大提高了執行速度。
內聯快取也是為什麼相同型別的獨享共享隱藏類如此重要的原因。如果你建立了兩個一樣型別的物件並且不同的隱藏類(正如我們前面的例子),V8 不會使用內聯快取即使兩個物件是一樣的型別,它們相對應的隱藏類屬性分配不同的偏移量。

編譯到機器碼
一旦 Hydrogen 圖優化了,Crankshaft 降低到一個被稱為“Lithium”較低級別的表示。大多數的 Lithium 的實現是架構特定的。暫存器分配發生在這個級別。
最後,Lithium 編譯為機器碼。然後發生了些叫做 OSR 的事情:on-stack replacement。在我們開始編譯和優化一個明顯長執行時間的方法之前,有可能執行它。V8 不會忘記它就慢慢開始執行優化的版本。相反,它將改變所有的上下文(棧和暫存器),這樣我們能夠在執行期間切換到優化過的版本。這是一個複雜的任務,記住,除了別的優化,V8 在內部內聯化了程式碼。V8 不是唯一一個有這種能力的引擎。
這裡有一些被稱為"去優化"做相反的轉換,並且所作的假設不再適用了的情況下回到沒有優化的程式碼。
垃圾回收
對於垃圾回收,V8 使用了傳統的生代標記清除法來清除老生代。標記階段應該停止 Javascript 的執行。為了控制GC 的消耗並且使執行更加穩定,V8 使用了增量式的標記:而不是遍歷整個堆,試著標記每個可能的物件,它僅僅遍歷堆的一部分,然後返回正常的執行。下一次的 GC 將會從上一次遍歷堆停止的地方開始。這允許在正常的執行期間一些小的停頓。像之前提到,擦除階段是在獨立的執行緒中執行的。
Ignition 和 TurboFan
隨著在 2017 年 V8 5.9 版本的釋出,一個新的執行管道(pipeline)被引入了。這個新的執行管道達到更大的效能提升和在實際的 Javascript 中更節省記憶體。
這個新的執行管道是建立在 Ignition 上的,V8 的直譯器,和 V8 最新的優化編譯器。
從 V8 的 5.9 版本之後,full-codegen 和 Crankshaft(這兩項技術從2010 開始使用)不再被V8 用於Javascript 的執行,V8 團隊 一直在努力跟上新的 Javascript 的功能並且這些特新所需的優化。
這意味著整個 V8 有更簡單和更以維護的架構。

這些提升僅僅是開始。新的 Ignition 和 Turbofan 管道為將來的優化鋪設了道路,未來幾年在 Chrome 和 Node.js 中將大幅提升 Javascript 的效能和減少 V8 的記憶體消耗。
最後,這裡有一些關於如何書寫便於優化的更好的 Javascript 的小技巧和訣竅。從上面的內容可以很容易地得到這些,然而,為了你的方便,這裡有一個總結:
如何書寫優化的 Javascript
- 物件屬性的順序:要始終用同樣的順序初始化你的物件的屬性,這樣可以讓隱藏類和後面的程式碼可以被共享
- 動態屬性:向一個初始化後的物件新增屬性將會強制隱藏類的變化和減慢之前隱藏類優化的方法,相反在建構函式中分配一個物件所有的屬性。
- 方法:多次執行相同的方法的程式碼將會比執行很多隻執行一次的方法要快(由於內聯快取)。
- 陣列:避免稀疏陣列。沒有元素在內部的稀疏陣列是一個雜湊表。在這樣的陣列中訪問更加昂貴。同時,儘量避免預分配大陣列。最後,不要刪除陣列中的元素,這樣會導致稀疏陣列。
- 標記的值:V8 用 32 位來表示物件和數字。它需要知道是一個物件(flag=1)還者是一個被稱為 Small integer (因為是 31 位)的整數(flag = 0)。如果一個數值比31 位的大,V8 就會封裝這個數字,將它變為double 型別的,並且建立一個新的物件將數字放裡面。儘量使用31 位 signed 數字避免昂貴的封箱操作為一個 Js 物件。