轉載請注明出處:http://blog.csdn.net/horkychen
Google研發的V8 JavaScript引擎性能優異。我們請熟悉內部程序實現的作者依源代碼來看看V8是如何加速的。
作者:Community Engine公司研發部研發工程師Hajime Morita
Google的Chrome中的V8 JavaScript引擎,由於性能良好吸引了相當的注目。它是Google特別為了Chrome可以高速運行網頁應用(WebApp)而開發的。Chrome利用Apple領導的WebKit研發計划作為渲染引擎(Rendering engine)。 WebKit也被用在Safari瀏覽器中。WebKit的標准配備有稱為JavaScriptCore的JavaScript引擎,但Chrome則以V8取代之(圖1)。
V8開發小組是一群程序語言專家。核心工程師Lars Bak之前研發了HotSpot,這是用在Sun Microsystems公司開發的Java虛擬機器(VM)之加速技術。他也在美國的Animorphic Systems公司(於1997年被Sun Microsystems所並購)研發了稱為Strongtalk的實驗Smalltalk系統。V8充分發揮了研發HotSpot和Strongtalk時所獲得的知識。
圖1 開發自己的JavaScript引擎 Apple的Safari和Google的Chrome使用相同的渲染引擎。配有JavaScriptCore的WebKit渲染引擎在JavaScript引擎中是標准配備,但在Chrome卻被V8取代了.
高速引擎的需求
Google研發小組在2006年開始研發V8,部分的原因是Google對既有JavaScript引擎的執行速度不滿意。我認為當時JavaScript引擎很慢是有兩個原因的:開發的歷史背景,以及JavaScript語言的復雜性。
JavaScript存在至少10年了。在1995年,它出現在網景(Netscape Communications)公司所研發的網頁瀏覽器Netscape Navigator 2.0中。然而有段時間人們對於性能的要求不高,因為它只用在網頁上少數的動畫、交互操作或其它類似的動作上。(最明確的是為了減少網絡傳輸,以提高效率和改善交互性!)瀏覽器的顯示速度視網絡傳輸速度以及渲染引擎(rendering engine)解析HTML、CSS(cascading style sheets, CSS)及其他代碼的速度而定。瀏覽器的開發工作優先提升渲染引擎的速度,而JavaScript的處理速度不是太重要。同時出現的Java有相當大的進步,它被做得愈來愈快,以便和C++競爭。
然而,在過去幾年,JavaScript突然受到廣泛使用。原因是之前被當成桌面應用的軟件(其中包括Office套件等),現已成為可以在瀏覽器中執行的軟件。
Google本身就推出了好幾款JavaScript網絡應用,其中包括它的Gmail電子郵件服務、Google Maps地圖數據服務、以及Google Docs office套件。
這些應用表現出的速度不僅受到服務器、網絡、渲染引擎以及其他諸多因素的影響,同時也受到JavaScript本身執行速度的影響。然而既有的JavaScript引擎無法滿足新的需求,而性能不佳一直是網絡應用開發者最關心的。
語言本身的問題
JavaScript語言的規范現在性能壓力巨大。例如,這在當它判定變量類型時就相當顯而易見。如C++和Java等主流語言采用靜態類型(static typing)。當代碼編譯時,就可宣告變量類型。由於不需要在執行期間檢查數據類型,因此靜態類型占有性能上的優勢。
在例如C++和Java等一般處理系統中,fields*和methods*等的內容是以數組儲存,以1:1位移(offset)對應fields和methods等的名稱(圖2)。個別變量和methods等儲存的位置,是針對各個類定義的。在C++和Java等語言中,已事先知道所存取的變量(類)類型,所以語言解釋系統(Interpreting system)只要利用數組和位移來存取field和method等。位移使它只要幾個機器語言指令,就可以存取field、找出field或執行其他任務。
圖2 JavaScript和C++、Java的不同 C++、Java及其他處理系統將fields和methods等,以它們的名稱以1:1對應數組內的位移值儲存在數組中。會事先知道要存取的變量類型(類),因此可以只用數組和位移就可以存取fields和methods等。然而在JavaScript,個別的對象都有自己屬性和方法等的表格。每一次程序存取屬性或是呼叫方法時,都必須檢查對象的類型並執行適當的處理。
* Field:屬對象的變量。C++中稱為成員變量。
* Method:屬對象的處理類型。C++中稱為成員函式。
* Property屬性:JavaScript屬性是對象自己擁有的變量。在JavaScript中,屬性中不只可以是標准的值,也可以是methods。
* Hash table哈希表:一種數據結構會傳回與特定關鍵相關之對應值。它有一個內部數組,使用鍵值(key)所產生之Hash值作為數組中特定位置清單值的位移。如果剛好在相同的位置上產生不同關鍵之Hash值時,清單位置會儲存多個值,這意味着在傳回任何值之前必須先檢查Hash值是否符合。
而另外一方面,JavaScript則是利用動態類型(dynamic typing)。 JavaScript變量沒有類型,而所指定對象的類型在第一次執行時(換言之,動態地)就已判定了。每次在JavaScript中存取屬性(property),或是尋求方法等,必須檢查對象的類型,並照着進行處理。
許多JavaScript引擎都使用哈希表(hash table)來存取屬性和尋找方法等。換言之,每次存取屬性或是尋找方法時,就會使用字符串作為尋找對象哈希表的鍵(key)(圖3)。
圖3 屬性存取時的內部JavaScript處理 使用對象x哈希表的字符串「foo」作為搜尋「foo」內容的關鍵字。
搜尋哈希表是一個連續動作,包含從散列(hashing)值中判定數組內位置,然后查看該位置的鍵值(key)是否符相等。然后可以使用位移直接讀取數據的數組比較起來,利用此方法存取較費時。
使用動態類型的其他語言,還有Smalltalk和Ruby等。這些語言基本上也是搜尋哈希表,但它們利用類來縮短搜尋時間。然而,JavaScript沒有類。除了「Numbers」指示數字值、「Strings」為字符串以及其他少數幾種類型外,其他對象都是「Object」型。程序員無法宣告類型(類),因此無法使用明確的類型來加速處理。
JavaScript的彈性允許在任何時間,在對象上新增或是刪除屬性和方法等(請參閱附錄)。JavaScript語言非常動態,而業界的一般看法是動態語言比C++或Java等靜態語言更難加速。盡管有困難,但V8利用好幾項技術來達到加速的目的:
1.JIT編譯 (JIT Compile)
不用字節碼(bytecode)生成機器語言
從性能的角度來看,V8具有4個主要特性。首先,它在執行時以稱為及時(just-in-time, JIT)的編譯方法,來產生機器語言。這是個普遍用來改善解釋速度的方法,在Java和.NET等語言中也可以發現此方法。V8比Firefox中的SpiderMonkey JavaScript引擎,或Safari的JavaScriptCore等競爭引擎還要早的實踐了這一技術。
V8 JIT編譯器在產生機器語言時,不會產生中間碼(圖4)。例如,在Java編譯器先將原始碼轉換成一個以虛擬中間語言(稱為字節碼,bytecode)表示的一類文件 (class file)。Java編譯器和字節碼編譯器產生字節碼,而非機器語言。Java VM按順序地在執行中解釋字節碼。此執行模式稱為字節碼解釋器(bytecode interpreter)。 Firefox的SpiderMonkey具有一個內部的字節碼編譯器和字節解釋器,將JavaScript原始碼轉換成它自家特色的字節代碼,以便執行。
圖4 V8的JIT編譯器直接輸出機器語言程序語言系統先使用語法分析器將原始碼轉換成抽象語法樹(abstract syntax tree, AST)。之前有幾種方式來處理。字節碼編譯器將抽象語法樹編譯為中間代碼,然后在編譯器中執行。如Java JIT等混合模式將這中間代碼的一部分編譯成機器語言,以改善處理性能。Chrome不使用中間代碼,JIT直接從抽象語法樹來編譯機器語言。也有抽象語法樹解釋器,直接解析抽象語法樹。
事實上,Java VM目前使用一個以HotSpot為基礎的JIT編譯器。它扮演字節碼解釋器的角色,來解析代碼,將常執行的代碼區塊轉換成機器語言然后執行,這就是混合模式(hybrid model)。
字節碼解釋器、混合模式等等,具有制作簡單且有絕佳可移植性的優點。只要是引擎可以編譯的原始碼,那么就可以在任何CPU架構上執行字節碼,這正是為什么該技術被稱為「虛擬機(VM)」的原因。即使在產生機器代碼的混合模式中,可以借由編寫字節碼的解釋器開始進行開發,然后實現機器語言生成器。通過使用簡單的位元碼,在機器代碼產生時,要將輸出最佳化就變得容易許多。
V8不是將原始程序轉換成中間語言,而是將抽象語法直接產生機器語言並加以執行。沒有虛擬機,且因為不需要中間表示式,程序處理會更早開始了。然而,另一方面,它也喪失了虛擬機的好處,例如透過字節碼解釋器和混合模式等,所帶來的高可移植性(portability)和優化的簡易性等。
2.垃圾回收管理
Java標准特性的精妙實現
第二個關鍵的特性是,V8將垃圾回收管理(garbage collection, GC*)實作為「精確的GC*」。相反的,大部分的JavaScript引擎、Ruby及其他語言編譯器都是使用保守的GC*(conservative GC),因為保守的GC實作簡單許多。雖然精確的GC更為復雜,但也有性能上的優點。Oracle(Sun)的Java VM就是使用精確GC。
* Garbage collection(GC)垃圾回收管理:自動偵測被程序保留但已不再使用的存儲器空間並釋放。
* 保守(conservative) GC:沒有分別嚴格管理指標器和數字值之存儲器回收管理。此方法是如果它可以成為指標,那就以指標來看待它,即使它可能個數值。此方法防止對象被意外回收,但它也無法釋出可能的存儲器。
雖然精確GC本身就是高效率的,但以精確GC為基礎的高級算法,如分代(Generational) GC、復制(copy) GC以及標記和精簡處理(mark-and-compact processing)等在性能上有明顯的改善。分代(Generational) GC藉由分開管理「年青分代(Young Generational)」對象(經常收集)和「舊分代(Old Generational)」對象(相對長壽的對象)而提升了GC效率。
V8使用了分代(Generational)GC,在新分代(Generational)處理上使用輕度(light-load)復制GC,而在舊GC上使用標記和精簡GC,因為它須在內存空間內移動對象。這很難在保守GC中執行。在對象的復制中,壓縮(compaction)(在硬盤方面稱為defrag)和類似動作時,對象的地址會改變,且基於這個原因,最普遍的方法是用「句柄」(handles)間接地引用地址。然而,V8不使用句柄(handles),而是重寫該對象引用的所有數據。不使用句柄(handles)會使實現更困難,但卻能改善性能因為少了間接引用。Java VM HotSpot也使用相同的技術。
3.內嵌緩存(inline cache)
JavaScript中不可用?
V8目前可以針對x86和ARM架構產生適合的機器語言。雖然沒采用C++或Java中傳統的優化方式,V8還是有動態語言與生俱來的速度。
其中一項良好范例是內嵌緩存(inline cache),這項技巧可以避免方法呼叫和屬性存取時的哈希表搜尋。它可以立即緩存之前的搜尋結果,因此稱為「內嵌」。人們知道此技術已有一段時間了,已經被應用在Smalltalk、Java和Ruby等語言中。
內嵌緩存假設對象都有類型之分,但在JavaScript語言中卻沒有。直到V8出現后,而這就是為什么以前的JavaScript引擎都沒有內嵌緩存的原因。
為了突破此限制,V8在執行時就分析程序操作,並利用「隱藏類」(hidden classes)為對象指定暫時的類。有了隱藏類,即使是JavaScript也可以使用內嵌緩存。但是這些類是提升執行速度之技巧,不是語言規范的延伸。所以它們無法在JavaScript代碼中引用。
4.隱藏類
儲存類型轉換信息
隱藏類為沒有類之分的JavaScript語言規范帶來有趣的挑戰,同時也是V8用來提升速度最獨特的技巧。它們值得更深入的探究。
在V8中建立類有兩個主要的理由,即(1)將屬性名稱相同的對象歸類,及(2)識別屬性名稱不同的對象。前一類中的對象有完全相同的對象描述,而這可以加速屬性存取。
在V8,符合歸類條件的類會配置在各種JavaScript對象上。對象引用所配置的類(圖5)。然而這些類只存在於V8作為方便之用,所以它們是「隱藏」的。
圖5 V8對象有隱藏類的引用 如果對象的描述是相同的,那么隱藏類也會相同。在此范例中,對象p和q都屬於相同的隱藏類
我上面提到隨時可以在JavaScript中新增或刪除屬性。然而當此事發生時會毀壞歸類條件(歸納名稱相同的屬性)。V8借由建立屬性變化所需的新類來解決。屬性改變的對象透過一個稱為「類型轉換(class transition)」的程序納入新級別中。
第二個目標-識別屬性名稱不同的對象-則是借由建立新類來達成。然而,如果每一次屬性改變就建立一個新類的話,那就無法持續達到第一個目標了(歸納名稱相同的屬性)。
圖6 配置新類:類型轉換屬性改變的對象會被歸為新類。當對象p增加了新屬性z時,對象p就會被歸為新類。
V8將變換信息儲存在類內,來解決此問題。考量圖7,它說明了圖6中所示的情形,當隱藏類Point有x和y屬性時,新屬性x就會新增至Point級的對象p中。當新屬性z加到對象p時,V8會將「新增屬性p,建立Point2類」的信息儲存在Point級的內部表格中(圖7,步驟1)。
圖7 在類中儲存類變換信息當在對象p中加入新屬性z時,V8會在Point類內的表格上記錄「加入屬性z,建立類Point2」(步驟1)。當同一Point類的對象q加入屬性z時,V8會先搜尋Point類表。如果它發現了Point2類已加入屬性z時,就會將對象q設定在Point2類(步驟2)。
當新屬性z新增至也是Point級的對象q時,V8會先搜尋Point級的表格,並發現Point2級已加入屬性z。在表格中找到類時,對象q就會被設定至該類(Point2),而不建立新類(圖7,步驟2)。這就達到了歸納屬性名稱相同的對象之目的。
然而此方法,意味着與隱藏類對應的空對象會有龐大的轉換表格。V8透過為各個建構函數建立隱藏類來處理。如果建構函數不同,就算對象的陳述(layout)完全相同,也會為它建立一個新的隱藏類。
內嵌緩存(Inline Cache)
其它的JavaScript引擎和V8不同,它們將對象屬性儲存在哈希表中,但V8則將它們儲存在數組中。位移信息-指定個別屬性在數組中的位置-是儲存在隱藏類的哈希表中。同一隱藏類的對象具有相同的屬性名稱。如果知道對象類,那么就可以利用位移依數組操作存取屬性。這比搜尋哈希表快許多。
然而,在JavaScript等動態語言中,很難事先知道對象類型。例如,圖8的原始碼為對象類型p和q呼叫lengthSquared()函數。對象類型p和q的屬性不同,隱藏類也不同。因此無法判定lengthSquared()函數代碼的參數(arguments)類型。
若要讀取函數中的對象屬性,必須先檢查對象的隱藏類,並有搜尋類的哈希表,以找出該屬性的位移。然后利用位移存取數組。盡管是在數組中存取屬性,要先搜尋哈希表的需求就毀掉了使用數組的優點。
然而,從不同的觀點來看,情況有所不同。在實際的程序中,依賴代碼執行判斷類型的情況並不多。例如,在圖8的lengthSquared()函數甚至假設大部分通過成為參數的值,都是Point類對象,而一般而言這是正確的。
- function lengthSquared(p) {
- return p.x* p.x+ p.y* p.y;
- }
- function LabeledLocation(name, x, y) {
- this.name= name;
- this.x= x;
- this.y= y;
- }
- var p= new Point(10, 20);
- var q= new LabeledLocation("hello", 10, 20);
- var plen= lengthSquared(p);
- var qlen= lengthSquared(q);
圖8 代碼樣本:JavaScript無法判斷函數參數類型在執行之前根本無法判斷參數是Point型或是lengthSquared()函數的LabeledLocation型。
內嵌緩存是一項加速技術,此設計是為了利用程序中局部(local)類別的方法。若要程序化的屬性存取,V8會產生一個指令串來搜尋隱藏類列表(圖9)。此代碼稱為premonomorphic stub。此stub是為了在函數存取屬性(圖10)。Premonomorphic stub擁有兩個信息:搜尋用的隱藏類,以及取自隱藏的位移。最后會產生新代碼以緩存此信息(圖11)。
- Object* find_x_for_p_premorphic(Object* p) {
- Class* klass= p->get_class();
- int offset = klass->lookup_offset("x");
- update_cache(klass, offset);
- return p->properties[offset];
- }
圖9 在偽代碼(pseudocode)中的premonomorphic stub 從隱藏類中取得屬性位移。
圖10 premonomorphic stub呼叫存取函數中的屬性時會呼叫premonomorphic stub。
- Object* find_x_for_p_monomorphic(Object* p) {
- if (CACHED_KLASS == p->get_class()) {
- return p->properties[CACHED_OFFSET];
- } else {
- return lookup_property_on_monomorphic(p, "x");
- }
- }
圖11偽代碼的monomorphic stub 處理直接嵌入代碼中的位移是用來存取屬性的常數。
在搜尋表格之前,帶有屬性的對象之隱藏類會與緩存隱藏類比較。如果相符就不需要再搜尋,且可以使用緩存的位移來存取屬性。如果隱藏類不相符,就透過隱藏類哈希表以一般方式判斷位移。
新產生的代碼被稱為monomorphic stub。「內嵌」這個字的意思是查詢隱藏類所需的位移,是以立即可用的形式嵌入在所產生的代碼中。當第一次叫出monomorphic stub時,它會將功能從pre-monomorphic stub位址中所叫出的第一個位址重寫成monomorphic stub位址(圖12)。自此,使用高速的monomorphic stub,單靠類比較和數組存取就可以處理屬性存取。
圖 12 monomorphic stub呼叫 當呼叫monomorphic stub時,它會將功能從premonomorphic stub位址中叫出的第一個位址,重寫成monomorphic stub位址。
如果只有一個具有屬性的對象,monomorphic stub的效率就會很高。然而,如果類型愈多,緩存失誤就會更頻繁,進而降低monomorphic stub的效率。
當緩存失誤時,V8藉由產生另一個稱為megamorphic stub的代碼來解決(圖13)。與個別類對應的monomorphic stub都寫在哈希表中,其在執行時搜尋和叫出stub。如果沒有類型對應的monomorphic stub時,就會從類型哈希表中搜尋位移。
- Object* find_x_for_p_megamorphic(Object* p) {
- Class* klass= p->get_class();
- //內嵌處理實際的搜尋
- Stub* stub= klass->lookup_cached_stub("x")
- if (NULL != stub) {
- return (*stub)(p);
- } else {
- return lookup_property_on_megamorphic(p, "x");
- }
- }
圖13偽代碼中的Megamorphic stub處理與類型對應的monomorphic stub事先儲存在哈希表中,並在執行時被搜尋和叫出。如果無法找到對應的monomorphic stub,就會在類型哈希表中搜尋位移。
當monomorphic stub發生緩存失誤時,monomorphic stub會將功能從monomorphic stub位址叫出的第一個位址以megamorphic stub位址重寫。在代碼搜尋方面,megamorphic stub的性能比monomorphic stub低,但是megamorphic代碼卻比使用緩存更新、代碼生成及其他輔助處理的premonomorphic stubs快許多。
涵蓋多種類的內嵌緩存稱為多型態內嵌緩存(polymorphic inline cache)。V8內嵌緩存系統被用來呼叫方法以及存取屬性。
機器語言的特性
如以上所述,V8在設計時使用了例如內嵌緩存等,來達到動態語言中天生的速度。創建使用於內嵌緩存之stub的機器語言生成模塊密切地與JIT編譯器連結。
一些經常使用的方法也被寫成機器語言以達到與內嵌拓展相同的效果,使它們成為「內在」的。V8原始碼列出了內在轉換的候選名單。
V8所含的shell程序可以用來檢查V8所產生的機器語言。所產生的指令串可以和V8代碼比較,以便顯出它的特性。
例如,在執行圖14a所示的JavaScript函數時,就會產生一個如圖14b所示的x86機器語言指令串。此函數在第39個指令中被呼叫,是個「n+one」加法。在JavaScript中,「+」操作數指示數字變量的加法,以及字符串的連續性。編譯器不是產生代碼來判決這是哪一種,而是呼叫函數來負責判斷。
圖14 V8從JavaScript代碼產生的機器語言加法處理被轉換成函數呼叫的機器語言(a、b)。
如果圖14的函數稍做更改(圖15),那圖14b的函數呼叫就會消失,但會有個加法指令(第20),及分支指令(JNZ的若不是零就跳出,第31)。當使用整數作為「+」操作數的操作數,V8編譯器在不呼叫函數下會產生一個有「加法」指令的指令串。如果發現操作數(在此為「n」)成了Number對象或String對象等的指標(pointer),
就會叫出函數。「加法」只會發生在當兩個「+」運算的操作數都是整數時。在這種情況下,因為可以跳過函數呼叫所以執行就會比較快。
圖15 V8從圖14之JavaScript中所產生的機器語言,經小幅修改
此外,0x2會加上「加法」指令,因為為最低有效位(least significant bit, LSB)被用來區別整數(0)和指標(1)。加0x2(二進制中的十)就如同在該值加上1,LSB除外。在jo指令的溢位(overflow)處理中,利用測試和jnz指令來判定指標,跳到下游處理(注1)。
這類的竅門在編譯器中到處都有。然而,產生器代碼也透露了編譯器的限制。具傳統最佳化的編譯器可以針對圖14和15產生完全一樣的機器語言,這是由於常數進位的關系。然而V8編譯器是在抽象語法樹*(abstract syntax tree)單元中產生代碼,因此在處理延伸多個節點時就沒有最佳化。這在大量的push和pop指令也非常明顯。
圖16顯示了C語言里相同的處理提供參考。由於C和JavaScript之間的語言規范不同,因此所產生的機器語言是圖14和圖15的不同,這和編譯器的性能無關。
圖16 C編譯器從C代碼所產生的機器語言所產生的機器語言比V8所產生的干凈許多(a、b),大部分是因為C和JavaScript語言規范的差異所致。
注1:當溢位信號出現時,jo指令會跳至特定的位址。測試指令將邏輯AND結果反映成零和符號指標等。除非零信號出現,否則jnz指令會跳至特定的位址。
* Abstract syntax tree抽象語法樹:在樹狀架構中代表程序架構的數據。
附錄:熟悉OOP的程序員之參考
也可以參考:http://blog.csdn.net/horkychen/article/details/7559134
JavaScript沒有類,但為了讓熟悉使用類(面向對象的代碼)之程序員更方便使用,可以使用「new」的操作數來建立對象,就像在Java一樣。在「new」操作數之后會定義一個特別的「constructor」建構函數(圖B-1 a, b)。
然而,即使沒有建構函數,也可以建立對象(圖B-1c)和設定屬性的(圖B-1 d)。JavaScript對象的屬性和法等隨時都可以新增或刪除。
除了用點標記(dot notation)存取JavaScript屬性以外,也可以使用括號,建議散列(hashing)存取(圖B-1 e、f)或是以變量特定屬性名稱字符串(圖B-1 g)。從這些范例中明確顯示JavaScript對象的設計是為了使用哈希表。
- a) 定義建構函數「Point」
- function Point(x, y) {
- // this是指它自己
- this.x= x;
- this.y= y;
- }
- b) 當增加新的及呼叫建構器函數時所建立的對象
- var p= new Point(10, 20);
- c) 沒有建構器函數也可以建立對象
- var p= { x: 10, y: 20 };
- d) 可以自由地在對象上新增屬性
- p.z= 30;
- e) 使用點標記存取屬性
- var y= p.y
- f) 使用括號之散列(hashing)存取
- var y= p["y"];
- g) 也可以使用變量進行散列(hashing)存取
- var name= "y";
- var p[name];
圖B-1 JavaScript代碼范例
本文雖然寫於2009年V8剛剛推出的時候,其中仍對理解V8有很大幫助。
原文地址:http://techon.nikkeibp.co.jp/article/HONSHI/20090106/163615/
繁體中文版地址: http://www.greenpublishers.com/neat/200901/3coverstory.pdf
*本文是以繁體中文版為基礎重新修訂的。看起來繁體中文版本多為機翻后人工校正的,除去兩岸的專業詞匯不同外,仍有不少不通的地方。最明顯的就是將class翻譯為層級。