Node.js背後的V8引擎優化技術

geek.csdn.net發表於2016-01-21

  Node.js的執行速度遠超Ruby、Python等指令碼語言,這背後都是V8引擎的功勞。本文將介紹如何編寫高效能Node.js程式碼。V8是Chrome背後的JavaScript引擎,因此本文的相關優化經驗也適用於基於Chrome瀏覽器的JavaScript引擎。

 V8優化技術概述

  V8引擎在虛擬機器與語言效能優化上做了很多工作。不過按照Lars Bak的說法,所有這些優化技術都不是他們創造的,只是在前人的基礎上做的改進。

  隱藏類(Hidden Class)

  為了減少JavaScript中訪問屬性所花的時間,V8採用了和動態查詢完全不同的技術實現屬性的訪問:動態地為物件建立隱藏類。這並不是什麼新想法,基於原型的程式語言Self就用map來實現了類似功能。在V8中,當一個新的屬性被新增到物件中時,物件所對應的隱藏類會隨之改變。

  我們用一個簡單的JavaScript函式來加以說明:

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

  當new Point(x, y)執行時,一個新的Point物件會被建立。如果這是Point物件第一次被建立,V8會為它初始化一個隱藏類,不妨稱作C0。因為這個物件還沒有定義任何屬性,所以這個初始類是一個空類。到此時為止,物件Point的隱藏類是C0(如圖1)。

圖1 物件Point的隱藏類C0

  執行函式Point中的第一條語句會為物件Point建立一個新的屬性x。此時,V8會在C0的基礎上建立另一個隱藏類C1,並將屬性x的資訊新增到C1中:這個屬性的值會被儲存在距Point物件偏移量為0的地方(如圖2)。

圖2 物件Point的隱藏類被更新為C1

  在C0中新增適當的類轉移資訊,使得當有另外的以其為隱藏類的物件在新增了屬性x之後能找到C1作為新的隱藏類。此時物件Point的隱藏類更新為C1。

  執行函式Point中的第二條語句會新增一個新的屬性y到物件Point中。同理,此時V8會有以下操作。

  • 在C1的基礎上建立另一個隱藏類C2,並在C2中新增關於屬性y的資訊:這個屬性將被儲存在記憶體中離Point物件的偏移量為1的地方。
  • 在C1中新增適當的類轉移資訊,使得當有另外的以其為隱藏類的物件在新增了屬性y之後能找到C2作為新的隱藏類。此時物件Point的隱藏類被更新為C2(如圖3)。

圖3 物件Point的隱藏類被更新為C2

  乍一看似乎每次新增一個屬性都建立一個新的隱藏類非常低效。實際上,利用類轉移資訊,隱藏類可以被重用。下次建立一個Point物件時,就可以直接共享由最初那個Point物件所建立出來的隱藏類。

  例如,又有一個Point物件被建立出來,一開始Point物件沒有任何屬性,它的隱藏類將會被設定為C0。當屬性x被新增到物件中時,V8通過C0到C1的類轉移資訊將物件的隱藏類更新為C1,並直接將x的屬性值寫入到由C1所指定的位置(偏移量0)。當屬性y被新增到物件中時,V8又通過C1到C2的類轉移資訊將物件的隱藏類更新為C2,並直接將y的屬性值寫入到由C2所指定的位置(偏移量1)。儘管JavaScript比通常的物件導向程式語言都更加動態一些,然而大部分JavaScript程式都會表現出像上文描述的那樣執行時高度結構重用的行為特徵來。使用隱藏類主要有兩個好處:屬性訪問不再需要動態字典查詢;為V8使用經典的基於類的優化和內聯快取技術創造了條件。

  內聯快取(Incline Cache)

  在第一次執行到訪問某個物件的屬性的程式碼時,V8會找出物件當前的隱藏類。同時,假設在相同程式碼段裡的其他所有物件的屬性訪問都由這個隱藏類進行描述,並修改相應的內聯程式碼讓他們直接使用這個隱藏類。當V8預測正確時,屬性值的存取僅需一條指令即可完成。如果預測失敗,則再次修改內聯程式碼並移除剛才加入的內聯優化。
例如,訪問一個Point物件的x屬性的程式碼如下:

point.x

  在V8中,對應生成的機器碼如下:

ebx = the point object
cmp [ebx, <hidden class offset>], <cached hidden class>
jne <inline cache miss>
mov eax, [ebx, <cached x offset>]

  如果物件的隱藏類和快取的隱藏類不一樣,執行會跳轉到V8執行系統中處理內聯快取預測失敗的地方,在那裡原來的內聯程式碼會被修改,以移除相應的內聯快取優化。如果預測成功,屬性x的值會被直接讀出來。

  當有許多物件共享同一個隱藏類時,這樣的實現方式下,屬性的訪問速度可以接近大多數動態語言。使用內聯快取程式碼和隱藏類實現屬性訪問的方式與動態程式碼生成和優化的方式結合起來,讓大部分JavaScript程式碼的執行效率得以大幅提升。

  兩次編譯與反優化(Crankshaft)

  儘管JavaScript是個非常動態的語言,且原本的實現是解釋性的,但現代的JavaScript執行時引擎都會進行編譯。V8(Chrome的JavaScript)有兩個不同的執行時(JIT)編譯器。

  “完全”編譯器(Unoptimized):一開始,所有V8程式碼都執行在Unoptimized狀態。它的好處是編譯速度非常快,使程式碼初次執行速度非常快。

  “優化”編譯器(Optimized):當V8發現某段程式碼執行非常熱時,它會根據通常的執行路徑進行程式碼優化,生成Optimized程式碼。優化程式碼的執行速度非常快。

  編譯器有可能從“優化”退回到“完全”狀態, 這就是Deoptimized。這是很不幸的過程,優化後的程式碼沒法正確執行,不得不退回到Unoptimized版本。當然最不幸的是程式碼不停地被Optimized,然後又被Deoptimized,這會帶來很大效能損耗。圖4是程式碼Optimized與Deoptimized執行流程。

圖4 程式碼Optimized與Deoptimized執行流程

  高效垃圾收集

  最初的V8引擎垃圾收集是不分代的,但目前V8引擎的GC機制幾乎採用了與Java Hotspot完全相同的GC機制。對Java虛擬機器有經驗的開發者直接套用。

  但V8有一個重要的特性卻是Java沒有的,而且是非常重要的特性,因此必須要提一下,這個特性叫Incremental Mark+Lazy Sweep。它的設計思路與Java的CMS垃圾收集類似,就是儘量減少GC系統停頓的時間。不過在V8裡這是預設的GC方式,不象CMS需要非常複雜的配置,而且還可能有Promotion Fail引起的問題。圖5是通常Full GC的Mark Sweep流程。

圖5 通常的Full GC的Mark、Sweep流程

  這個流程裡每次GC都要完成完整的Mark、Sweep流程,因此停頓時間較久。

  引入了Increment Mark之後的流程如圖6所示。

圖6 引入Increment Mark後的流程

  這個流程每次GC可以在Mark一半時停住,在完成業務邏輯後繼續下一輪GC,因此停頓時間較短。

  只要保證Node.js記憶體大小不超過500MB,V8即使發生Full GC也能控制在50毫秒內,這使Node.js在開發高實時應用(如實時遊戲)時比Java更有優勢。

 編寫對V8友好的高效能程式碼

  隱藏類(Hidden Class)的教訓

  在建構函式裡初始化所有物件的成員(因此這些例項之後不會改變其隱藏類)。

  • 總是以相同的次序初始化物件成員。
  • 永遠不要delete物件的某個屬性。

  示例1

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

var p1 = new Point(11, 22);
var p2 = new Point(33, 44);
// At this point, p1 and p2 have a shared hidden class
// 這裡的p1和p2擁有共享的隱藏類
p2.z = 55;
// warning! p1 and p2 now have different hidden classes!
// 注意!這時p1和p2的隱藏類已經不同了!

  在以上例子中,p2.z破壞了上述原則, 將導致p1與p2使用了不同的隱藏類。

  在我們為p2新增“z”這個成員之前,p1和p2一直共享相同的內部隱藏類——因此V8可以生成一段單獨版本的優化彙編碼,這段程式碼可以同時封裝p1和p2的JavaScript程式碼。派生出這個新的隱藏類還將使編譯器無法在Optimized模式執行。我們越避免隱藏類的派生,就會獲得越高的效能。

  示例2

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

for (var i=0; i<1000000; i++) {
  var p1 = new Point(11, 22);
  delete p1.x;
  p1.y++;
}

  由於呼叫了delete,將導致hidden class產生變化,從而使p1.y不能用inline cache直接獲取。

  以上程式在使用了delete之後耗時0.339s,在註釋掉delete後只需0.05s。

  Deoptimized的教訓

  • 單態操作優於多型操作;
  • 謹慎使用try catch與for in。

  示例1

  如果一個操作的輸入總是相同型別,則其為單態操作。否則,操作呼叫時的某個引數可以跨越不同的型別,那就是多型操作。例如add()的第二個呼叫就觸發了多型操作:

function add(x, y) {
  return x + y;
}

add(1, 2);     // add中的+操作是單態操作
add("a", "b"); // add中的+操作變成了多型操作 

  以上示例由於傳入的資料型別不同,使add操作編譯成Optimized程式碼。

  示例2

  該示例來自Google I/O 2013的一個演講:Accele­rating Oz with V8。The oz story的遊戲有頻繁的GC,遊戲的幀率在執行一段時間後不斷下降,圖7是GC曲線。

圖7 遊戲GC曲線

  是什麼導致如此GC呢? 有三個疑犯:

  1. new出來的物件沒有釋放,這通常由閉包或集合類的操作導致;
  2. 物件在初始化後改變屬性,就是hidden class示例1的例子;
  3. 某段特別熱的程式碼執行在Deoptimized模式。

  unit9的開發人員對JavaScript的開發規範瞭然於胸,絕對不會犯前兩個錯誤,於是懷疑定在第3個嫌疑犯。圖8是診斷time後的結果。

圖8 診斷結果

  圖中drawSprites執行在Optimized狀態,但updateSprites一直執行在Deoptimized狀態。

  導致不斷GC的原凶竟然是這幾行程式碼:

圖9 導致不斷GC的程式碼

  因為for in下面的程式碼在V8下暫時無法優化。把for in內部的程式碼提出成單獨的function,V8就可以優化這個function了。這時GC和掉幀率的問題就立刻解決了。GC曲線出現了緩慢平緩的狀態:

圖10 解決問題後的曲線

  以上教訓不僅僅是使用for in或try catch的問題,也許未來V8引擎會解決這兩個問題。我們要理解怎麼發現問題、解決問題,還有Deoptimized竟然會對GC產生影響。

  以上排查過程使用了–trace-opt、–trace-deopt、–prof命令選項,及mac-tick-processor等工具。值得注意的是Node.js裡直接使用mac-tick-processor或linux-tick-processor是解不出JavaScript段執行結果的,可以使用node-tick-processor這個工具。

  記憶體管理與GC的教訓

  《深入淺出Node.js》書中有詳細的V8記憶體管理和使用經驗介紹。這裡只展示兩個簡單的例子。

  閉包

  閉包會使程式邏輯變複雜,有時會看不清楚是否物件記憶體被釋放,因此要注意釋放閉包中的大物件,否則會引起記憶體洩漏。

  例如以下程式碼:

var a = function () {
var largeStr = new Array(1000000).join(‘x’);
return function () {
return largeStr;
};
}();

  例子中的largeStr會被收集嗎?當然不會, 因為通過全域性的a()就可以取到largeStr。
那麼以下程式碼呢?

var a = function () {
    var smallStr = 'x';
    var largeStr = new Array(1000000).join('x');
    return function (n) {
        return smallStr;
    };
}();

  這次a()得到的結果是smallStr,而largeStr則不能通過全域性變數獲得,因此largeStr可被收集。

  timer

  timer的記憶體洩漏很普遍,也較難被發現。例如:

var myObj = {
    callMeMaybe: function () {
        var myRef = this;
        var val = setTimeout(function () { 
            console.log('Time is running out!'); 
            myRef.callMeMaybe();
        }, 1000);
    }
};

  當呼叫如下程式碼:

myObj.callMeMaybe();

  定時器會不停列印“Time is running out”。

  當用如下程式碼釋放掉myObj:

myObj=null;

  定時器仍然會不停列印“Time is running out”。

  myObj物件不會被釋放掉,因為內部的myRef物件也指向了myObj,而內部的setTimeout呼叫會將閉包加到Node.js事件迴圈的佇列裡,因此myRef物件不會釋放。

 其他教訓

  使用數字的教訓

  當型別可以改變時,V8使用標記來高效地標識其值。V8通過其值來推斷你會以什麼型別的數字來對待它。因為這些型別可以動態改變,所以一旦V8完成了推斷,就會通過標記高效完成值的標識。不過有時改變型別標記還是比較消耗效能的,我們最好保持數字的型別始終不變,通常標識為有符號的31位整數是最優的。

  使用Array的教訓

  為了掌控大而稀疏的陣列,V8內部有兩種陣列儲存方式:

  • 快速元素:對於緊湊型關鍵字集合,進行線性儲存;
  • 字典元素:對於其他情況,使用雜湊表。

  最好別導致陣列儲存方式在兩者之間切換。

  因此:

  • 使用從0開始連續的陣列關鍵字;
  • 別預分配大陣列(例如大於64K個元素)到其最大尺寸,令尺寸順其自然發展就好;
  • 別刪除陣列裡的元素,尤其是數字陣列;
  • 別載入未初始化或已刪除的元素。

  示例1

a = new Array();
for (var b = 0; b < 10; b++) {
  a[0] |= b;  // 杯具!
}

a = new Array();
a[0] = 0;
for (var b = 0; b < 10; b++) {
  a[0] |= b;  // 比上面快2倍
}

  以上兩段程式碼,由於第一段程式碼的a[0]未初始化, 儘管執行結果正確,但會導致執行效率的大幅下降。

  示例2

  同樣的,雙精度陣列會更快——陣列的隱藏類會根據元素型別而定,而只包含雙精度的陣列會被拆箱(unbox),這導致隱藏類的變化。對陣列不經意的封裝就可能因為裝箱/拆箱(boxing/unboxing)而導致額外的開銷。例如以下程式碼:

var a = new Array();
a[0] = 77; // 分配
a[1] = 88;
a[2] = 0.5; // 分配,轉換
a[3] = true; // 分配,轉換

  因為第一個例子是一個個分配賦值的,在對a[0] 、a[1]賦值時陣列被判定為整型陣列,但對a[2]的賦值導致陣列被拆箱為了雙精度。但對a[3]的賦值又將陣列重新裝箱回了任意值(數字或物件)。

  下面的寫法效率更高:

var a = [77, 88, 0.5, true];

  第二種寫法時,編譯器一次性知道了所有元素的字面上的型別,隱藏隱藏類可以直接確定。
因此:

  • 初始化小額定長陣列時,用字面量進行初始化;
  • 小陣列(小於64k)在使用之前先預分配正確的尺寸;
  • 請勿在數字陣列中存放非數字的值(物件);
  • 如果通過非字面量進行初始化小陣列時,切勿觸發型別的重新轉換。

 結論

  Google V8使JavaScript語言的執行效率上了一大臺階。但JavaScript是非常靈活的語言,過於靈活的語法將導致不規範的JavaScript語言無法優化。因此,在編寫對V8編譯器友好的JavaScript或者Node.js語言時就要格外注意。

相關文章