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

jopen 8年前發布 | 38K 次閱讀 V8 Node.js

【文/ 謝騁超】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)。

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

圖1 對象Point的隱藏類C0

執行函數Point中的第一條語句會為對象Point創建一個新的屬性x。此時,V8會在C0的基礎上創建另一個隱藏類C1,并將屬性x的信息添加到C1中:這個屬性的值會被存儲在距Point對象偏移量為0的地方(如圖2)。

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

圖2 對象Point的隱藏類被更新為C1

在C0中添加適當的類轉移信息,使得當有另外的以其為隱藏類的對象在添加了屬性x之后能找到C1作為新的隱藏類。此時對象Point的隱藏類更新為C1。

執行函數Point中的第二條語句會添加一個新的屬性y到對象Point中。同理,此時V8會有以下操作。

  • 在C1的基礎上創建另一個隱藏類C2,并在C2中添加關于屬性y的信息:這個屬性將被存儲在內存中離Point對象的偏移量為1的地方。
  • 在C1中添加適當的類轉移信息,使得當有另外的以其為隱藏類的對象在添加了屬性y之后能找到C2作為新的隱藏類。此時對象Point的隱藏類被更新為C2(如圖3)。

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

圖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執行流程。

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

圖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流程。

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

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

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

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

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

圖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曲線。

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

圖7 游戲GC曲線

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

1.new出來的對象沒有釋放,這通常由閉包或集合類的操作導致;

2.對象在初始化后改變屬性,就是hidden class示例1的例子;

3.某段特別熱的代碼運行在Deoptimized模式。

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

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

圖8 診斷結果

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

導致不斷GC的原兇竟然是這幾行代碼:

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

圖9 導致不斷GC的代碼

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

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

圖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語言時就要格外注意。

來自: http://www.iteye.com/news/31307

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!