足球资料库数据/孙祥/nba五佳球/足球直播哪个平台好 - cctv5今日现场直播

首頁 > 知識庫 > 正文

Node.js背后的V8引擎優(yōu)化技術
2016-01-30 18:13:13   來源: mengyidan1988   評論:0 點擊:

【文 謝騁超】Node js的執(zhí)行速度遠超Ruby、Python等腳本語言,這背后都是V8引擎的功勞。本文將介紹如何編寫高性能Node js代碼。V8是Chrome背后的JavaScript引擎,因此本文的相關優(yōu)化經(jīng)驗也適用于基于Chrome瀏覽器的JavaScript引擎。 V8優(yōu)化技術概述 V8引擎在虛擬機與語言性能優(yōu)化上做了很多工作。不過按照Lars Bak的說法,所有這些優(yōu)化技術都不是他們
【文/ 謝騁超】Node.js的執(zhí)行速度遠超Ruby、Python等腳本語言,這背后都是V8引擎的功勞。本文將介紹如何編寫高性能Node.js代碼。V8是Chrome背后的JavaScript引擎,因此本文的相關優(yōu)化經(jīng)驗也適用于基于Chrome瀏覽器的JavaScript引擎。

V8優(yōu)化技術概述
V8引擎在虛擬機與語言性能優(yōu)化上做了很多工作。不過按照Lars Bak的說法,所有這些優(yōu)化技術都不是他們創(chuàng)造的,只是在前人的基礎上做的改進。

隱藏類(Hidden Class)
為了減少JavaScript中訪問屬性所花的時間,V8采用了和動態(tài)查找完全不同的技術實現(xiàn)屬性的訪問:動態(tài)地為對象創(chuàng)建隱藏類。這并不是什么新想法,基于原型的編程語言Self就用map來實現(xiàn)了類似功能。在V8中,當一個新的屬性被添加到對象中時,對象所對應的隱藏類會隨之改變。

我們用一個簡單的JavaScript函數(shù)來加以說明:
function Point(x, y) {    this.x = x;    this.y = y;}

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



圖1 對象Point的隱藏類C0

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



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

在C0中添加適當?shù)念愞D(zhuǎn)移信息,使得當有另外的以其為隱藏類的對象在添加了屬性x之后能找到C1作為新的隱藏類。此時對象Point的隱藏類更新為C1。

執(zhí)行函數(shù)Point中的第二條語句會添加一個新的屬性y到對象Point中。同理,此時V8會有以下操作。
  • 在C1的基礎上創(chuàng)建另一個隱藏類C2,并在C2中添加關于屬性y的信息:這個屬性將被存儲在內(nèi)存中離Point對象的偏移量為1的地方。
  • 在C1中添加適當?shù)念愞D(zhuǎn)移信息,使得當有另外的以其為隱藏類的對象在添加了屬性y之后能找到C2作為新的隱藏類。此時對象Point的隱藏類被更新為C2(如圖3)。




圖3 對象Point的隱藏類被更新為C2

乍一看似乎每次添加一個屬性都創(chuàng)建一個新的隱藏類非常低效。實際上,利用類轉(zhuǎn)移信息,隱藏類可以被重用。下次創(chuàng)建一個Point對象時,就可以直接共享由最初那個Point對象所創(chuàng)建出來的隱藏類。

例如,又有一個Point對象被創(chuàng)建出來,一開始Point對象沒有任何屬性,它的隱藏類將會被設置為C0。當屬性x被添加到對象中時,V8通過C0到C1的類轉(zhuǎn)移信息將對象的隱藏類更新為C1,并直接將x的屬性值寫入到由C1所指定的位置(偏移量0)。當屬性y被添加到對象中時,V8又通過C1到C2的類轉(zhuǎn)移信息將對象的隱藏類更新為C2,并直接將y的屬性值寫入到由C2所指定的位置(偏移量1)。盡管JavaScript比通常的面向?qū)ο缶幊陶Z言都更加動態(tài)一些,然而大部分JavaScript程序都會表現(xiàn)出像上文描述的那樣運行時高度結構重用的行為特征來。使用隱藏類主要有兩個好處:屬性訪問不再需要動態(tài)字典查找;為V8使用經(jīng)典的基于類的優(yōu)化和內(nèi)聯(lián)緩存技術創(chuàng)造了條件。

內(nèi)聯(lián)緩存(Incline Cache)

在第一次執(zhí)行到訪問某個對象的屬性的代碼時,V8會找出對象當前的隱藏類。同時,假設在相同代碼段里的其他所有對象的屬性訪問都由這個隱藏類進行描述,并修改相應的內(nèi)聯(lián)代碼讓他們直接使用這個隱藏類。當V8預測正確時,屬性值的存取僅需一條指令即可完成。如果預測失敗,則再次修改內(nèi)聯(lián)代碼并移除剛才加入的內(nèi)聯(lián)優(yōu)化。
例如,訪問一個Point對象的x屬性的代碼如下:
point.x

在V8中,對應生成的機器碼如下:
ebx = the point objectcmp [ebx, <hidden class offset>], <cached hidden class>jne <inline cache miss>mov eax, [ebx, <cached x offset>]

如果對象的隱藏類和緩存的隱藏類不一樣,執(zhí)行會跳轉(zhuǎn)到V8運行系統(tǒng)中處理內(nèi)聯(lián)緩存預測失敗的地方,在那里原來的內(nèi)聯(lián)代碼會被修改,以移除相應的內(nèi)聯(lián)緩存優(yōu)化。如果預測成功,屬性x的值會被直接讀出來。

當有許多對象共享同一個隱藏類時,這樣的實現(xiàn)方式下,屬性的訪問速度可以接近大多數(shù)動態(tài)語言。使用內(nèi)聯(lián)緩存代碼和隱藏類實現(xiàn)屬性訪問的方式與動態(tài)代碼生成和優(yōu)化的方式結合起來,讓大部分JavaScript代碼的運行效率得以大幅提升。

兩次編譯與反優(yōu)化(Crankshaft)

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

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

“優(yōu)化”編譯器(Optimized):當V8發(fā)現(xiàn)某段代碼執(zhí)行非常熱時,它會根據(jù)通常的執(zhí)行路徑進行代碼優(yōu)化,生成Optimized代碼。優(yōu)化代碼的執(zhí)行速度非常快。

編譯器有可能從“優(yōu)化”退回到“完全”狀態(tài), 這就是Deoptimized。這是很不幸的過程,優(yōu)化后的代碼沒法正確執(zhí)行,不得不退回到Unoptimized版本。當然最不幸的是代碼不停地被Optimized,然后又被Deoptimized,這會帶來很大性能損耗。圖4是代碼Optimized與Deoptimized執(zhí)行流程。

圖4 代碼Optimized與Deoptimized執(zhí)行流程

高效垃圾收集

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

但V8有一個重要的特性卻是Java沒有的,而且是非常重要的特性,因此必須要提一下,這個特性叫Incremental Mark+Lazy Sweep。它的設計思路與Java的CMS垃圾收集類似,就是盡量減少GC系統(tǒng)停頓的時間。不過在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一半時停住,在完成業(yè)務邏輯后繼續(xù)下一輪GC,因此停頓時間較短。

只要保證Node.js內(nèi)存大小不超過500MB,V8即使發(fā)生Full GC也能控制在50毫秒內(nèi),這使Node.js在開發(fā)高實時應用(如實時游戲)時比Java更有優(yōu)勢。

編寫對V8友好的高性能代碼
隱藏類(Hidden Class)的教訓

在構造函數(shù)里初始化所有對象的成員(因此這些實例之后不會改變其隱藏類)。
  • 總是以相同的次序初始化對象成員。
  • 永遠不要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的隱藏類已經(jīng)不同了!

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

在我們?yōu)閜2添加“z”這個成員之前,p1和p2一直共享相同的內(nèi)部隱藏類——因此V8可以生成一段單獨版本的優(yōu)化匯編碼,這段代碼可以同時封裝p1和p2的JavaScript代碼。派生出這個新的隱藏類還將使編譯器無法在Optimized模式執(zhí)行。我們越避免隱藏類的派生,就會獲得越高的性能。

示例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++;}

由于調(diào)用了delete,將導致hidden class產(chǎn)生變化,從而使p1.y不能用inline cache直接獲取。
以上程序在使用了delete之后耗時0.339s,在注釋掉delete后只需0.05s。

Deoptimized的教訓
  • 單態(tài)操作優(yōu)于多態(tài)操作;
  • 謹慎使用try catch與for in。

示例1

如果一個操作的輸入總是相同類型,則其為單態(tài)操作。否則,操作調(diào)用時的某個參數(shù)可以跨越不同的類型,那就是多態(tài)操作。例如add()的第二個調(diào)用就觸發(fā)了多態(tài)操作:
function add(x, y) {  return x + y;}add(1, 2);     // add中的+操作是單態(tài)操作add("a", "b"); // add中的+操作變成了多態(tài)操作 

以上示例由于傳入的數(shù)據(jù)類型不同,使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的開發(fā)人員對JavaScript的開發(fā)規(guī)范了然于胸,絕對不會犯前兩個錯誤,于是懷疑定在第3個嫌疑犯。圖8是診斷time后的結果。



圖8 診斷結果
圖中drawSprites運行在Optimized狀態(tài),但updateSprites一直運行在Deoptimized狀態(tài)。

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



圖9 導致不斷GC的代碼

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



圖10 解決問題后的曲線

以上教訓不僅僅是使用for in或try catch的問題,也許未來V8引擎會解決這兩個問題。我們要理解怎么發(fā)現(xiàn)問題、解決問題,還有Deoptimized竟然會對GC產(chǎn)生影響。
以上排查過程使用了–trace-opt、–trace-deopt、–prof命令選項,及mac-tick-processor等工具。值得注意的是Node.js里直接使用mac-tick-processor或linux-tick-processor是解不出JavaScript段執(zhí)行結果的,可以使用node-tick-processor這個工具。

內(nèi)存管理與GC的教訓

《深入淺出Node.js》書中有詳細的V8內(nèi)存管理和使用經(jīng)驗介紹。這里只展示兩個簡單的例子。

閉包

閉包會使程序邏輯變復雜,有時會看不清楚是否對象內(nèi)存被釋放,因此要注意釋放閉包中的大對象,否則會引起內(nèi)存泄漏。

例如以下代碼:
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的內(nèi)存泄漏很普遍,也較難被發(fā)現(xiàn)。例如:
var myObj = {    callMeMaybe: function () {        var myRef = this;        var val = setTimeout(function () {             console.log('Time is running out!');             myRef.callMeMaybe();        }, 1000);    }};

當調(diào)用如下代碼:
myObj.callMeMaybe();

定時器會不停打印“Time is running out”。
當用如下代碼釋放掉myObj:
myObj=null;

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

myObj對象不會被釋放掉,因為內(nèi)部的myRef對象也指向了myObj,而內(nèi)部的setTimeout調(diào)用會將閉包加到Node.js事件循環(huán)的隊列里,因此myRef對象不會釋放。

其他教訓

使用數(shù)字的教訓

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

使用Array的教訓

為了掌控大而稀疏的數(shù)組,V8內(nèi)部有兩種數(shù)組存儲方式:
  • 快速元素:對于緊湊型關鍵字集合,進行線性存儲;
  • 字典元素:對于其他情況,使用哈希表。
  • 最好別導致數(shù)組存儲方式在兩者之間切換。


因此:
  • 使用從0開始連續(xù)的數(shù)組關鍵字;
  • 別預分配大數(shù)組(例如大于64K個元素)到其最大尺寸,令尺寸順其自然發(fā)展就好;
  • 別刪除數(shù)組里的元素,尤其是數(shù)字數(shù)組;
  • 別加載未初始化或已刪除的元素。

示例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]未初始化, 盡管執(zhí)行結果正確,但會導致執(zhí)行效率的大幅下降。

示例2

同樣的,雙精度數(shù)組會更快——數(shù)組的隱藏類會根據(jù)元素類型而定,而只包含雙精度的數(shù)組會被拆箱(unbox),這導致隱藏類的變化。對數(shù)組不經(jīng)意的封裝就可能因為裝箱/拆箱(boxing/unboxing)而導致額外的開銷。例如以下代碼:
var a = new Array();a[0] = 77; // 分配a[1] = 88;a[2] = 0.5; // 分配,轉(zhuǎn)換a[3] = true; // 分配,轉(zhuǎn)換

因為第一個例子是一個個分配賦值的,在對a[0] 、a[1]賦值時數(shù)組被判定為整型數(shù)組,但對a[2]的賦值導致數(shù)組被拆箱為了雙精度。但對a[3]的賦值又將數(shù)組重新裝箱回了任意值(數(shù)字或?qū)ο螅?br />
下面的寫法效率更高:
var a = [77, 88, 0.5, true];

第二種寫法時,編譯器一次性知道了所有元素的字面上的類型,隱藏隱藏類可以直接確定。
因此:
  • 初始化小額定長數(shù)組時,用字面量進行初始化;
  • 小數(shù)組(小于64k)在使用之前先預分配正確的尺寸;
  • 請勿在數(shù)字數(shù)組中存放非數(shù)字的值(對象);
  • 如果通過非字面量進行初始化小數(shù)組時,切勿觸發(fā)類型的重新轉(zhuǎn)換。

結論
Google V8使JavaScript語言的執(zhí)行效率上了一大臺階。但JavaScript是非常靈活的語言,過于靈活的語法將導致不規(guī)范的JavaScript語言無法優(yōu)化。因此,在編寫對V8編譯器友好的JavaScript或者Node.js語言時就要格外注意。

相關熱詞搜索:javascript Node js V8 web Web前端

上一篇:Mark Jeftovic訪談錄:easyDNS的架構與攻擊防御
下一篇:基于Google Drapper的開源分布式應用追蹤分析系統(tǒng)——Sky Walking

分享到: 收藏