上世紀九十年代的第一次瀏覽器大戰,微軟憑藉其雄厚的資金和超過90%的桌面操做系統覆蓋率,毫無懸念地擠掉了網景,讓IE成爲當時市場上的主導瀏覽器,巔峯時一度達到了96%的市場份額。此後數年,IE一枝獨秀,沒有了競爭對手的同時也放緩(能夠說是停滯)了瀏覽器技術革新的腳步。javascript
瀏覽器html |
JavaScript引擎前端 |
發佈時間java |
Firefox 3.5python |
TraceMonkeyc++ |
2009web |
Chromewindows |
V8數組 |
2008瀏覽器 |
Safari |
Nitro |
2008 |
Opera |
CaraKan |
2009 |
圖1 瀏覽器與JavaScript引擎
直到2004年11月的firefox1.0發佈開始,瀏覽器市場才又開始活躍起來,2008~2009年,Chrome,Safari,Opera羣雄發力,這個階段這也被業界稱做是第二次瀏覽器大戰。從下面這個表中,能夠看到各家瀏覽器廠商在這一階段都引入了全新的JavaScript引擎。能夠說此次瀏覽器大戰的核心就是JavaScript引擎的較量。
此時一貫不太努力的IE瀏覽器,也確定不會無動於衷,因而咱們在第二年的春天(2010年3月)迎來了IE9,而微軟的應對策略便是最新引入的JavaScript引擎Chakra。
JavaScript引擎的性能對整個瀏覽器的影響能夠說是相當重要,就在過去的幾年裏,各個瀏覽器的JavaScript引擎性能都有質的飛躍。引用Dave Mandelin在「How to Make Your JavaScript Fast」一文中一張圖:
圖2 JavaScript引擎的性能提高示意圖
在過去5~6年,JavaScript引擎的性能廣泛提升了十倍甚至幾十倍以上, 運行時性能已經很是接近C/C++了。在享受了性能提高所帶來的超炫體驗以後,咱們也來探探性能提高背後的技術變遷。
本文經過分析開源的瀏覽器項目中的JavaScript引擎,歷數JavaScript引擎的幾種實現方式:從原始的遍歷語法樹,到字節碼方式(bytecode interpreter),直到引入了JIT編譯方式。每一種新方式的出現都是JavaScript引擎性能的一次飛躍。
遍歷語法樹(Syntax Tree Walker)
和不少腳本語言的解釋器同樣,JavaScript引擎最早也是採用遍歷語法樹(syntax tree walker)的方式。執行一條語句的過程就是遍歷一次相對應的語法樹的過程。舉個例子:
一條賦值語句:
i = a + b * c;
通過詞法分析後,就生成了一堆單詞流:
"i", "=", "a", "+", "b", "*", "c";
再通過語法分析後,就生成了以下的語法樹:
圖3 賦值語句的語法樹
執行這條語句,就是遍歷這顆語法樹的過程。遍歷語法樹的過程在程序設計上通常採用訪問者模式(vistor pattern)來實現。要遍歷這顆語法樹,只要將根節點傳給visit函數, 而後這個函數遞歸調用相應子節點的visit函數,如此反覆直到葉子節點。例如,在這個例子中根節點是個賦值語句,他知道應該計算出右邊表達式的值,而後賦給左邊的地址;而在計算右邊表達式的時候,發現是一個加法表達式,因而接着遞歸計算加法表達式的值,如此遞歸進行直到這顆樹的葉子節點,而後一步步回溯,將值傳到到根節點,就完成了一次遍歷,也即完成了一次執行。
這樣的方式雖然原始,可是實現起來簡單,對於性能要求不高,只是完成小任務的一些腳本語言,也是能夠接受的。WebKit的JavaScript引擎在2008年6月(SquirrelFish的推出)以前就是用這種方式。
每次執行這條語句,都要進行一次遍歷樹的過程,這種方式存在着很大的性能缺陷:
1. 語法樹只是描述語法結構,並非執行這條語句要進行的操做。例如對於語句:{x = 1; y = x;},根節點是個複合語句「{...}」,他的子節點是兩個賦值語句,解釋器首先訪問這個複合語句節點,但實際上沒有作任何事情,而後訪問第一個賦值語句,接着訪問第二個賦值語句。在一個複雜的程序中,這種不是執行單元,但倒是語法樹不可或缺的節點不少。所以就會致使作不少無用功。
2. 訪問每一個節點的代價太大。遍歷語法樹通常採用訪問者模式,訪問每一個節點至少須要一次虛函數調用和返回,也即有兩個間接跳轉,而對於現代CPU而言,這種間接跳轉意味着大大增長了分支預測失敗的可能行(後文還有關於分支預測的討論)。
所以採用遍歷語法樹方式的JavaScript引擎是很低效的,並且有很大的提高空間。因此即便WebKit團隊對這種syntax tree walker已經優化到了極致,可是因爲這個方式中存在着上述提到的固有缺陷,以後他們也跟其它的JavaScript引擎同樣引入了字節碼(bytecode)。
字節碼(bytecode)
從上面介紹的遍歷語法樹方式中能夠發現,要執行一棵語法樹,其實是一個後序遍歷樹的過程。以上面這個例子,要計算賦值語句,先計算加法表達式,那就必須先計算乘法表達式,也就是說只有子結點計算好了以後,父節點才能計算,典型的後序遍歷。
若是在後序遍歷這棵樹後,生成對應的後綴記法(逆波蘭式)的操做序列,而後在執行時,直接解釋執行這個後綴記法的操做序列。那麼這時候就把這種樹狀結構,變換成了一種線性結構。這種操做序列就是字節碼(bytecode),這種執行方式就是字節碼解釋方式(bytecode interpreter)。沿用上面那個遍歷語法樹的例子,轉成相應的字節碼,以下圖所示:
圖4語法樹轉換成bytecode
在JavaScript引擎中直接執行字節碼,確定比每次都遍歷一遍語法樹高效。
字節碼,是一種與平臺無關的,須要在對應的虛擬機中執行的中間表示。如Java編譯器把Java源代碼編譯成Java字節碼,而後在對應平臺的Java虛擬機中執行; ActiveScript語言,也是經過轉換成字節碼,而後在對應的FLASH虛擬機中執行的。Java和ActionScript語言都有標準的字節碼格式,可是JavaScript的字節碼沒有標準的格式,每一個JavaScript引擎廠商都有本身的標準。
雖然標準不一,可是JavaScript字節碼在設計上大致上均可歸類爲如下兩類:基於棧(stack-based)和基於寄存器(register-based)。
傳統的字節碼設計大可能是基於棧的,這種方式將全部的操做數和中間表示都保存在一個數據棧中。如語句:c = a + b,轉換後的字節碼以下:
LOAD a # 將a推入棧頂
LOAD b # 將b推入棧頂
ADD # 從棧頂彈出兩個操做數,相加後,將結果推入棧頂
STORE c #將棧頂數據保存到C中
基於寄存器的字節碼經過一些槽(slot)或稱爲寄存器(register)的方式保存操做數。這裏的寄存器與彙編代碼中的寄存器是兩個概念。存在寄存器(或槽)中能夠想象成就是存入一個固定數組中了。上面例子要是轉換成基於寄存器的代碼以下:
ADD c, a, b # 兩個操做數分別存在a和b中,將結果放在c中。
這兩種字節碼設計各有優劣,如棧式字節碼每條的指令更短(由於操做樹是隱式得存在棧中),可是總的指令條數更多;棧式虛擬機實現起來比寄存器式來得簡單。目前這兩種方式都有各自的實現:如Flash Player的ActionScript虛擬機Tamarin,Firefox的JagerMonkey,採用的是棧式字節碼設計;而webkit,carakan採用基於寄存器方式。
採用哪一種設計取決於設計者關注的側重點不一樣。想詳細瞭解這兩種設計的優劣,可參考一些論文(The case for virtual register machines 和Virtual machine showdown: Stack versus registers)。
字節碼是須要在虛擬機中執行的,而虛擬機的執行過程與CPU過程相似,也是取指,解碼,執行的過程。一般狀況下,每一個操做碼對應一段處理函數,而後經過一個無限循環加一個switch的方式進行分派。如:
圖5 JavaScript引擎的Switch Loop分派方式
這裏的vpc是一個指向字節碼數組的指針,在虛擬機中做用與PC寄存器在實際機器中的做用相似,因此稱做虛擬PC(virtual program counter)。
與遍歷語法樹方式相比,字節碼方式就消除了遍歷語法樹所引發的大部分性能負擔。首先字節碼序列直接描述了須要執行的動做,去除了多餘的語法信息;其次,執行一條字節碼語句,只是一次的內存訪問(取指令)再加上一次間接跳轉(分派到對應的處理函數),這也比訪問語法樹中一個節點開銷來的要小。
所以,字節碼方式與遍歷語法樹相比在性能上有很大的提高。雖然從語法樹生成字節碼也是須要時間的,可是這一小段時間能夠從直接執行字節碼所得到的性能提高上獲得補償。畢竟在實際的代碼中,不會全部的代碼都只被執行一次。並且生成了字節碼以後,就能夠對於這種中間代碼進行各類優化,好比常量傳播,常量摺疊,公共子表達式刪除等等。固然這些優化都是有針對性和選擇性的,畢竟優化的過程也是須要消耗時間的。而這些優化要想直接在語法樹上進行幾乎是不可能的。
雖然字節碼方式相對於遍歷語法樹已經前進了一大步,可是在分派方式上還能夠再改進。在圖5中,Switch Loop分派方式每次處理完一條指令後,都要回到循環的開始,處理下一條,而且每次switch操做,可能都是一次線性搜索(現代編譯器通常都能對switch語句進行優化, 以消除這種線性搜索開銷,但這種優化也是隻限於特定條件,如case的數量和值的跨度範圍等),這對於通常的函數,只有有限的幾個switch case,尚可接受,可是對於虛擬機來講,有上百個switch case而且是頻繁地執行,執行一條指令就須要一次線性搜索,那仍是太慢了。若是能用查表的方式直接跳轉,就能夠省去線性搜索的過程了。因而在字節碼的分派方式上,有的新的改進,稱做Direct Threading。
Direct Threading
Direct Threading,這裏的threading與咱們一般理解的線程沒有任何關係,能夠理解成是針線中的那個「線」。以這種方式執行時,每執行完一條指令後不是回到循環的開始,而是直接跳到下一條要執行的指令地址。這種方式就比原來的Switch Loop方式有效許多。可是要想有效的實現Direct Threading,須要用到一個gcc的擴展「Labels As Values」,普通的goto語句的標號是在編譯時指定的,可是利用「Labels As Values」擴展,goto語句的標號是就能夠在運行時計算(這種goto語句也叫Computed Goto),利用這個特性就能夠很容易地實現Direct Threading。(想在windows平臺用這個特性,也有幾個GCC的windows移植版本,如MinGW, Cygwin等)
上面的Switch Loop若是用Direct Threading方式,就以下圖右邊所示:
圖6 Direct Threading分派方式
右圖中的Direct Threading方式已經沒有了循環和switch分支,全部的字節碼分派就是經過「goto *vpc++」進行的。
vpc在這裏是指向字節碼數組的指針,字節碼數組裏的元素就是各個標號的地址。例如,若是有個指令序列是:
mov, add, ret
那麼對應的字節碼數組就是:
[&&mov, &&add, &&ret]
一開始,vpc指向數組的第一條指令,即vpc = &&mov, 那麼goto *vpc,就會跳到標號爲「mov」的地方開始執行(普通的goto語句沒法完成,這是利用gcc的「labels as values」特性);在執行「mov」處理函數末尾的「goto *vpc++」以後,就直接跳轉到標號「add」的地方開始執行;直到最後。
Direct Threading的執行過程以下圖所示:
圖7 Direct Threading執行過程
最左邊是生成的字節碼序列,中間就是字節碼序列對應的數組,右邊是對應的虛擬機實現代碼。開始執行時,vpc指向字節碼數組的開始,即「enter」指令,虛擬機開始執行「enter」指令對應的操做,在「enter」對應的操做的末尾有個「goto *vpc++」,這時的vpc就指向字節碼數組的下一條字節碼,在圖7中即爲mov指令,而後進入mov指令對應的操做。如此反覆直到執行完這個字節碼數組中的指令。每執行完一條指令,就直接跳轉到下一條指令的地址處,這就跟一根「線」穿過一條彎曲的隧道,雖然道路是彎曲的,但每次都是前進的,而不是想Switch Loop那樣,每次執行完一條字節碼後,又回到起點。
在引入即時編譯(JIT)以前,Direct Threading方式基本上就是採用字節碼方式的解釋器的最有效和最塊的分派方式了。對於通常的JavaScript運算,這種方式也足夠用了。可是畢竟解釋執行方式確定比不上直接執行二進制代碼。因而接下來即時編譯(JIT)技術被引入了JavaScript引擎。
即時編譯(Just-In-Time)
其實JIT這種技術自己很古老,能夠追溯到上世紀60年代的LISP語言;而且現代的大部分運行時環境(runtime environment),如微軟的.NET框架和大多數的Java實現都是依賴JIT技術來提升運行性能。在JavaScript引擎中引入JIT技術則是在2008年纔開始。
JIT編譯技術是一種提升程序運行性能的方法。一般一個程序有兩種方式執行:靜態編譯和解釋執行。靜態編譯就是在運行前先將源代碼(如c,c++)針對特定平臺(如x86,arm,mips)編譯成機器代碼,在運行時就能夠直接在相應的平臺上執行;而解釋執行則是每次運行的時候,將每條源代碼(如python, javascript)翻譯成相應的機器碼並馬上執行,並不保存翻譯後的機器碼,周而復始。能夠看到解釋執行的運行效率很低,由於每次執行都須要逐句地翻譯成機器碼而後執行;而靜態編譯在運行前就編譯成相應平臺的代碼。可是靜態編譯使得平臺移植性不好,也沒法實施運行時優化,並且對於動態語言(弱類型語言),變量的類型在運行前未知,很難作到靜態編譯。JIT編譯則是這兩種方式的混合,在運行時將源代碼翻譯成機器碼(這一點與解釋執行相似),可是會保存已翻譯的機器代碼,下次執行同一代碼段時無需再翻譯(這又與靜態編譯相似)。
雖然對於通常的運算,Direct Threading方式已經很好了,可是對性能的追求永無之境,沒有最好,只有更好。Direct Threading方式曾經是解釋器(不只僅JavaScript引擎)很是有效的分派方式。可是有研究和數據顯示在現代體系結構下,這種方式也有很大的侷限性(The structure and perfromance of efficient interpreters)。
如今的微處理器大量應用流水線構架來達到提升性能的目的。要讓流水線老是保持滿負荷運轉,微處理器有一個專門的硬件設備「分支預測器」來預判分支的目標地址。這樣在執行一條指令時,能提早將接下來可能執行的其餘指令放入流水線中,上一條指令執行一結束,接下來的指令都已經完成取值和解碼階段,就能夠直接執行。因此若是分支預測正確,將會大大提升處理器的性能。可是若是分支預測失敗,那麼就須要清空整個流水線,從新加載新的指令,而這會致使很嚴重的性能損耗。
分支預測是經過利用PC寄存器和分支目標的相關性來進行預測。而從圖7的Direct Threading執行過程,能夠看到分支跳轉的目標(如goto *vpc++),是與vPC相關,而不是與實際的硬件PC寄存器相關。因此分支預測器沒有足夠的信息來進行有效的預測,這就致使的大量的分支預測失敗。舉個例子,圖7中,當運行到add處理函數的尾部,在運行"goto *vpc++"以前,「分支預測器」是沒法判斷實際的跳轉目標是在哪裏,而只有等到執行完這句的時候才能準確知道要跳轉到哪,而這樣就會致使分支預測的時候不能把正確的後續指令推入流水線。數據代表30%~40%的執行時間會消耗在這種因爲分支預測失敗引發的額外處理上。因此要有效地下降分支預測的失敗機率,就要給「分支預測器」提供足夠的上下文信息。Context threading技術就是以此得名。
圖8 Context Threading執行過程
能夠看到最左邊的圖仍是原來那個字節碼數組,Context Threading是在這個表的基礎上又增長了一個表Context Threading Table(從這個表中能夠看到已經開始有即時編譯了)。將字節碼數組中的每條指令編譯成一條條的本地調用(call指令)。上圖與圖7相比,除了多一個Context Threading Table外,在每一個處理函數的結尾(右圖紅色標示)是一個與call 指令對應的ret指令。現代CPU對本地調用的返回地址提供一個很是有效的預測機制,從而就能避免大量的分支預測失敗。如上圖CPU在執行到「call add」時,在進入add的處理函數以前,會將返回地址(在這裏就是下一條指令「call sub」指令的地址)保存在棧中,而後進入add的處理函數,當執行到add處理函數末尾ret時,「分支預測器」這時固然能夠預測到要跳到哪了,就是剛纔進入add處理函數以前保存的返回地址。因此在實際執行ret以前就能夠準確的判斷到將要執行的指令。而以前的Direct Threading方式沒法作到這一點。
是否有必要將字節碼數組中的每條指令都編譯成一條條的本地調用呢?是的,沒有必要。在實際的JavaScript引擎實現中,對於簡單的指令,如mov,就直接即時編譯,inline到機器碼中;而對於複雜的指令,如add指令(由於JavaScript是動態語言,是無類型的,因此在運行add指令時須要作一系列的類型判斷,如操做數都是數值類型,那麼就進行數值加運算;若是操做數都是字符串類型,就進行字符串加運算,還有其餘各類類型,因此add指令也算是一個比較複雜的指令),也會對它的經常使用方式(如操做數都是數值,或都是字符串)直接生成對應的機器碼,對於add的其餘不經常使用狀況(如一個操做數是數值,另外一個是字符串,或者發生溢出了等等狀況)則是生成一條call本地調用。
在上文中提到從語法樹生成字節碼會消耗執行時間,而將字節碼編譯成本地機器碼(JIT的過程)也須要消耗執行時間。在生成機器碼過程當中,實施越多的優化,生成的機器碼質量越高,同時延遲時間也越長,因此須要權衡延遲的時間與生成的代碼質量。
因此通常狀況下,JavaScript引擎並非對全部代碼都會生成機器碼,而是隻對熱點(hot spot)片斷進行即時編譯,同時在運行中會隨時跟蹤熱點的狀態,若是熱點的程度越高(被執行得越頻繁),實施的優化也越激進。
以FireFox3.5 JavaScript引擎爲例(FireFox的JavaScript引擎叫SpiderMonkey, 在FireFox3.5中,他的JIT編譯器叫TraceMonkey),在開始執行時,將源代碼生成字節碼,而後解釋執行字節碼,在執行過程當中,若是發現一條路徑屢次執行(好比一個循環體),那麼就標記爲「HOT」,同時將這條路徑上的代碼即時編譯成機器碼,當下次再運行到這條路徑時,就直接運行機器碼。
在FireFox4.0中,引入了新的JIT編譯器JaegerMonkey替換原來的TraceMonkey,JaegerMonke其實是TraceMonkey的增強版,它除了跟蹤熱點路徑外,同時又加入的熱點函數的跟蹤。
以下圖:
圖9 FireFox4.0的JavaScript引擎執行過程
在上圖判斷熱點的虛框中,若是一個路徑被執行了超過16次(好比「循環」迭代了超過16次),或一個函數被調用超過16次,那麼就進行即時編譯;不然解釋執行。以這種方式,在JavaScript代碼運算強度越大時JavaScript引擎性能提升得越明顯,由於對於越頻繁執行的代碼,不只是已經被編譯成機器碼了,同時執行的編譯時優化和運行時優化也越充分。目前JIT編譯已是主流瀏覽器中JavaScript引擎的標配了。
隨着JavaScript引擎性能顯著提高,如今網站開發人員就能夠在服務器端或者客戶端上,充分利用JavaScript語言來完成一些繁重的運算任務。若是說AJAX讓JavaScript煥發了第二春,那麼HTML5的普及,則是進一步鞏固了JavaScript在Web前端技術中的地位。因此JavaScript引擎的性能,一直會是各個瀏覽器廠商之間相互較量的重要利器。
轉載自:http://djt.qq.com/article/view/489