JavaScript引擎

本節主要介紹JavaScriptCore引擎和V8引擎

概述

JavaScript語言

JavaScript是一種腳本語言,主要用在Web的客戶端,它的出現主要是控制網頁客戶端的邏輯,例如同用戶的交互、異步通信等需求,本質上看它是一種解釋型語言,函數是它的第一等公民,也就是函數也能夠當作參數或返回值來傳遞。JavaScript是一種無類型語言,或者說是動態類型語言,而c++或Java等語言是靜態類型語言,它們在編譯的時候就能夠知道每個變量的類型,但JavaScript只能在運行時候才能確定,而運行時計算和決定類型會帶來嚴重的性能損失,這導致了JavaScript語言的運行效率比c++和Java低很多。
獲取對象屬性值的具體位置也稱爲相對於對象基地址的偏移位置,JavaScript和c++語言存在一下幾個部分的區別:

  • 編譯確定位置:c++有明確的兩個階段,而編譯這些位置的偏移信息都是編譯器在編譯的時候就決定下來的,當c++代碼編程成本地代碼之後,對象的屬性和偏移信息都計算完成,因爲JavaScript沒有類型,所以只有在對象創建的時候纔有這些信息,因而只能在執行階段確定,而且JavaScript語言能夠在執行時修改對象的屬性
  • 偏移信息共享:c++因爲有類型定義,所有所有對象都是按照該類型來確定的,而且不能在執行的時候動態改變類型,因爲這些對象都是共享偏移信息的,訪問它們只需要按照編譯時確定的偏移量就可以了,而對於c++模版的支持,其實都是多份代碼,本質上是相同的,JavaScript則不同,每個對象都是自我描述,屬性和位置偏移信息都包含在自身的結構中
  • 偏移信息查找:c++中查找偏移地址簡單,都是在編譯代碼時對使用到某類型的成員變量直接設置偏移值,而對於JavaScript使用到一個對象則需要通過屬性名匹配才能查找對應的值
JavaScript引擎

JavaScript引擎是指能夠將JavaScript代碼處理並執行的運行環境,其主要包含一下幾個部分:

  • 編譯器:主要工作是將源代碼編譯成抽象語法樹,在某些引擎中還包含將抽象語法樹轉換成字節碼
  • 解釋器:在某些引擎中,解釋器主要是接收字節碼,解釋執行這個字節碼,同時也依賴垃圾回收機制等
  • JIT工具:將字節碼或者抽象語法樹轉換成本地代碼
  • 垃圾回收器和分析工具:它們負責垃圾回收和收集引擎中的信息,幫助改善引擎的性能和功效
JavaScript引擎和渲染引擎

JavaScript引擎負責執行JavaScript代碼,渲染引擎負責渲染網頁,JavaScript引擎提供調用接口給渲染引擎,以便讓渲染引擎使用JavaScript引擎來處理JavaScript代碼並獲取結果,此外JavaScript引擎需要能夠訪問渲染引擎構建的DOM樹,所以JavaScript引擎通常需要提供橋接的接口,而渲染引擎則根據橋接接口來提供讓JavaScript訪問DOM的能力,下圖表示了兩種引擎之間的關係
這裏寫圖片描述

V8引擎

應用程序編程接口(API)

V8是一個開源項目,是一個JavaScript引擎的實現,它支持衆多的操作系統,同時也能支持衆多的硬件架構。V8所提供的應用編程接口它們在V8代碼目錄的include/V8.h中,其中一些主要的類如下:

  • 各種各樣的基礎類:這裏麪包含對象引用類(如WeakReferenceCallbacks)、基本數據類型類(如Int32、Integer、Number、String、StringObject)和JavaScript對象(Object),這些都是基礎抽象類,沒有包含實現的實現,真正的實現在src目錄中的“object.h/cc”中
  • Vaue:所有JavaScript數據和對象的基類,如上面的Integer、Number、String等
  • V8數據的句柄類:以上數據類型的對象在V8中有不同的生命週期,需要使用句柄來描述它們的生命週期,以及垃圾回收期如何使用句柄來管理這些數據,句柄類包括Local、Persistent和Handle
  • Isolate:這個類是一個V8引擎實例,包括相關狀態信息、堆等,它是一個能夠執行JavaScript代碼的類,不能被多個線程同時訪問,如果非要這麼做的話需要使用鎖,V8使用者可以使用創建多個該類的實例,但每個實例之間就像這個類的名字一樣,都是孤立的
  • Context:執行上下文,包含內置的對象和方法,如print方法等,還包括JavaScript內置的庫,如math等
  • Extension:V8的擴展類,用於擴展JavaScript接口,V8使用者基於該類來實現相應的接口,被V8引擎調用
  • Handle:句柄類,主要用來管理基礎數據和對象,以便被垃圾回收器管理,主要有兩個類型,一個是Local(Local類,繼承自Handler類),另一個是Persistent(Persistent類,繼承自Handler類),前者表示本地棧上的數據,所以量級比較輕,後者表示函數間的數據和對象訪問
  • Script:用於表示被編譯過的JavaScript源代碼,V8的內部表示
  • HandleScope:包含一組Handle的容器類,幫助一次性刪除這些Handle,避免重複調用
  • FunctionTemplate:綁定C++函數到JavaScript,函數模版的一個例子就是將JavaScript接口的C++實現綁定到JavaScript引擎
  • ObjectTemplate:綁定C++對象到JavaScript,對象模版的典型應用是Chromium中將DOM節點通過該模版包裝成JavaScript對象
接口使用實例

借鑑書中用例:
這裏寫圖片描述
上圖沒有創建Isolate對象,此對象可以通過Isolate::GetCurrent()來獲取,它會創建一個V8引擎實例,後面的操作都是在它提供的環境中來進行的,下面按語句編號進行分析:
1 建立一個域,用於包含一組Handler對象,便於釋放它們
2 根據Isolate對象來獲取一個Context對象,使用Handle來管理,Handle對象本身存放在棧上,而實際的Context對象保存在堆中
3 根據兩個對象Isolate和Context來創建一個函數間使用的對象,所以使用Persistent類來管理,這裏展示的是它們的用處和含義,在本里中不是必需的,其句柄和數據都單獨存儲在另外的地方
4 爲Context對象創建一個基於棧的域,下面的執行步驟都是在該域中對應的上下文來進行的
5 從命令行參數讀入JavaScript代碼,也就是一段字符串
6 將字符串編譯成V8的內部表示,並保存爲一個Script對象
7 執行編譯後的內部表示,然後獲得生成的結果

工作原理
數據表示

在JavaScript中,只有基本數據類型Boolean、Number、String、Null和Undefined,其他數據都是對象,在V8中,數據的表示被分成兩個部分,第一部分是數據的實際內容,它們是變長的,而且內容的類型也是不一樣的,如String、對象等,第二部分是數據的句柄,句柄的大小是固定的,句柄中包涵指向數據的指針,之所以要這樣設計是因爲V8需要進行垃圾回收,並需要移動這些數據內容,如果直接使用指針的話會出現問題或需要比較大的開銷,使用句柄的話就不存在這些問題,只需要將句柄中的指針修改即可,使用這使用的還是句柄,本身沒有發生變化,除了極少數的數據例如整形數據,其他的內容都是從堆中申請內存來存儲它們,因爲Handler本身能夠存儲整形,同時也爲了快速訪問,而對於其他類型,受限於Handle的大小和變長等原因,都存儲在堆中。JavaScript對象的實現在V8中包含3個成員,第一個是隱藏類的指針,這是V8爲JavaScript對象創建的隱藏類,第二個指向這個對象包含的屬性值,第三個指向這個對象包含的元素。

V8工作過程

V8的工作過程包括兩個階段,第一是編譯,第二是運行,在V8中存在延遲思想,這使得JavaScript代碼的編譯直到運行的時候被調用到纔會發生,這樣可以減少時間開銷。對於編譯階段,下圖展示了V8從源代碼到本地代碼的過程:
這裏寫圖片描述
不同與JavaScriptCore引擎,V8引擎並不將抽象語法樹轉變成字節碼或者其他中間表示,而是通過JIT編譯器的全代碼生成器從抽象語法樹直接生成本地代碼,這樣做的原因在於減少抽象語法樹到字節碼的轉換,這樣雖然可以提高優化的可能,但是也存在一些不足,第一是在某些JavaScript使用場景中使用解釋器更爲合適,因爲沒有必要生成本地代碼,第二是沒有中間表示會減少優化的機會,因爲缺少一箇中間表示層。
下圖說明了V8引擎編譯JavaScript生成本地代碼使用了哪些主要的類和過程:
這裏寫圖片描述

  • Script:表示的是JavaScript代碼,既包含源代碼,又包含編譯之後生成的本地代碼,因此即是編譯入口,又是運行入口
  • Compiler:編譯器類,輔助Script類來編譯生成代碼,主要起一個協調者的作用,會調用解釋器Parse來生成抽象語法樹和全代碼生成器,爲了抽象語法樹生成本地代碼
  • Parser:將源代碼解釋並構建成抽象語法樹,使用AstNodeFactory類來創建它們,並使用Zone類來分配內存
  • AstNode:抽象語法樹節點類,是其他所有節點的基類,包含很多的子類,後面會針對不同的子類生成不同的本地代碼
  • AstVisitor:抽象語法樹的訪問者類,基於Visitor設計模式來設計,主要用來遍歷異構的抽象語法樹
  • FullCodeGenerator:AstVisitor類的子類,通過遍歷抽象語法樹來爲JavaScipt生成本地可執行代碼

對於編譯器的全代碼來說,本地代碼跟具體的硬件平臺密切相關,因此它使用多個後端來生成實際的代碼,如下圖所示,V8引擎至少包含四個跟平臺相關的後端,用於生成不同平臺上的本地彙編代碼。
這裏寫圖片描述
當代碼生成器遍歷AST樹的時候,FullCodeGenerator會爲每個節點生成相應的彙編代碼,不過沒有了全局的視圖,因此沒有爲節點之間考慮可能的優化,在不同的平臺上,FullCodeGenerator有不同的實現。在V8生成本地代碼之後爲了考慮性能,通過數據分析器(Profiler)來採集一些信息,以幫助決策哪些本地代碼需要優化,以生成效率更高的本地代碼,這是一個逐步改進的過程,同時V8中當發現本地優化後的代碼性能並沒有提高甚至還有所降低的時候,V8能夠退回到原來的代碼,這些都是在運行階段涉及到的。
下面介紹運行階段的代碼,以下是主要的類:

  • Script:包含編譯之後的生成的本地代碼,運行代碼的入口
  • Execution:運行代碼的輔助類,包含一些重要的函數,例如call,它輔助進入和執行Script中的本地代碼
  • JSFunction:需要執行的JavaScript函數表示類
  • Runtime:運行這些本地代碼的輔助類,它的功能主要是提供運行時各種各樣的輔助函數,包括但是不限於屬性訪問、類型轉換、編譯、算數、位操作、比較、正則表達式等
  • Heap:運行本地代碼需要使用內存堆
  • MarkCompactCollector:垃圾回收機制的主要實現類,用來標記(Mark)、清除(Sweep)和整理(Compact)等基本的垃圾回收過程
  • SweeperThread:負責垃圾回收的線程

下圖描述了V8支持JavaScript代碼運行的主要類:
這裏寫圖片描述

結合這些類,V8引擎是按照下圖描述的過程來執行的,調用發生在圖中的三個階段,第一是延遲編譯,爲CompileLazy這個函數的調用,在V8中函數是一個基本單位,當某個JavaScript函數被調用的時候,屬於該函數的本地代碼就會生成,具體的工作方式是V8查找該函數是否已經生成本地代碼,如果已經生成,那麼直接調用,否則V8引擎會觸發生成本地代碼,目的是節約時間,減少去處理那些用不到的代碼時間,第二是下圖1.2.3,這時執行編譯後的代碼就會爲JavaScript構建JS對象,這需要Runtime類來輔助創建對象,並需要從Heap類分配內存,第三是1.2.4階段,此階段需要藉助Runtime類中的輔助函數來完成一些功能,如屬性訪問、類型轉換等。
這裏寫圖片描述

優化回滾

爲了性能的考慮,編譯器通常會做比較樂觀和大膽的預測,編譯器會認爲某些代碼比較穩定,變量類型不會發生改變,所以能夠生成高效的本地代碼,這是理想情況,實際引擎會發現一些變量的類型已經發生變化,在這種情況下,V8使用一種機制來將它做的這些錯誤決定回滾到之前的情況,這個過程稱爲優化回滾。下面舉個例子來說明:

var counter = 0;
function Func(x,y){
  counter++;
  if (counter <= 1000000) {
    return counter;
  }
  var unknown = new Date();
  printf(unknown);
}

函數Func被調用多次後V8引擎可能會觸發Crankshaft編譯器來生成優化代碼,優化的代碼認爲示例代碼的類型等信息都已經被獲知,但事實是對於代碼中的unknown變量的類型還一無所知,在這種情況下V8只能將該端代碼回滾到一個通用的狀態。

隱藏類和內嵌緩存

V8實用類和編譯位置思想,將本來需要通過字符串匹配來查找屬性值的算法改進爲使用類似C++編譯器的偏移位置的機制來實現,這就是隱藏類,隱藏類將對象劃分成不同的組,對於相同的組,也就是該組內的對象擁有相同的屬性名和屬性值的情況,將這些屬性名和對應的偏移值保存在一個隱藏類中,組內的所有對象共享信息,同時也可以識別屬性不同的對象,下圖舉例加以說明:
這裏寫圖片描述
JavaScript沒有辦法定義類型,上圖代碼部分創建了兩個對象a和b,這兩個對象包含相同的屬性名,在V8中它們被歸爲同一個組,也就是一個隱藏類,這些屬性在隱藏類中有相同的偏移值,這樣,對象a和b可以共享這個類型信息,當訪問這些對象屬性的時候,根據隱藏類的偏移值就可以知道它們的位置並進行訪問,由於JavaScript是動態類型語言,假如上述代碼之後加入b.z=2。那麼b所對應的將是一個行的隱藏類,這樣a和b將屬於不同的組。在理解了V8的隱藏類之後,以下面代碼來了解一下是如何使用隱藏類來高效訪問對象屬性的,function add(a) {return a.x;},首先看最基本的情況,訪問對象屬性的過程是這樣的:首先獲取隱藏類的地址,然後根據屬性名查找偏移值,計算該屬性的地址,不過,這個過程比較費時,實際上的情況可能要好很多,因爲很多情況下該函數中的參數a可能是同一種類型,那麼可以使用內嵌緩存機制,它可以避免方法和屬性被存取的時候出現的因哈希表查找而帶來的問題,該機制的基本思想是使用之前查找的結果緩存起來,也就是說V8可以將之前查找的隱藏類和偏移值保存下來,當下次查找的時候,首先比較當前對象是否也是之前保存的隱藏類,如果是的話可以直接使用之前緩存的偏移值,從而減少查找表的時間。如果該函數中的對象a出現多個類型,那麼緩存失誤的機率就會高很多,當出現緩存失誤的時候,V8可以按照上面說的退回到之前的方式來查找哈希表,但是因爲效率問題,V8會在緩存失敗後通過對象a的隱藏類來查找該類中有無一段代碼,這段代碼可以快速查找對象,這段代碼保存在a對象的隱藏類對應的表中,所以如果該端代碼已經生成,就同樣可以較快的實現屬性值的查找。

if (a->hiddenClass() == cachedClass){
  return a->properties[cachedOffset];
} else {
  // 退回到原來的方法
}
內存管理

V8的內存管理部分主要有兩點,1 V8內存的劃分;2 V8對於JavaScript代碼的垃圾回收機制。對於內存的劃分,首先看Zone類,它的特點是管理一系列的小塊內存,如果用戶想使用一系列的小內存,並且這些小內存的生命週期類似,這時可以使用一個Zone對象,這些小內存都是從Zone對象中申請的,Zone對象首先自己申請一塊內存,然後管理和分配一些小內存,當一塊小內存被分配之後,不能夠被Zone回收,只能一次性回收Zone分配的所有小塊內存,例如抽象語法樹的內存分配和使用,在構建抽象語法樹之後,會生成本地代碼,然後抽象語法樹的內存在這之後被一次性全部收回,效率高,但是該機制有一個嚴重的缺陷,假如這一過程需要很多內存,那麼Zone就需要爲系統分配大量的內存,但是又不能夠釋放,這會導致系統出現需要過多的內存而導致內存不夠的情況。其次是堆,V8使用堆來管理JavaScript使用的數據,以及生成的代碼、哈希表等,爲了更方面的實現垃圾回收,同很多虛擬機一樣,V8將堆分成三個部分,第一個是年輕分代,第二個是年老分代,其中還分成多個子部分,第三個是大對象保存的空間。對於年輕分代,主要是爲新創建的對象分配內存空間,因爲年輕分代中的對象較容易被回收,爲了方面垃圾回收,可以使用複製方式,將年輕分代分成兩半,一半用來分配,另外一半在回收的時候負責將之前保留的對象複製過來,對於年輕分代,經常需要進行垃圾回收,對於年老分代,主要是根據需要將年老的對象、指針、代碼等數據使用的內存較少做垃圾回收,而對於大對象空間,主要是用來爲那些需要較多內存的大對象進行分配回收,當然同樣可能包含數據和代碼等分配的內存,需要注意的是每個頁面只分配一個對象。對於垃圾回收,因爲使用了分代和大數據的內存分配,V8需要使用精簡的算法,用來標記那些還被引用的對象,然後消除那些沒有被標記的對象,最後整理和壓縮那些還需要保存的對象。

快照

在V8引擎開始啓動的時候,需要加載很多內置的全局對象,同時也要建立內置的函數,如Array、String、Math等,爲了讓引擎更加整潔,加載對象和建立函數等任務都是使用JS文件來實現的,V8引擎負責提供機制來支持,就是在編譯和執行輸入的JavaScript代碼之前,先加載它們,根據前面介紹的V8引擎需要編譯和執行這些內置的JS代碼,同時使用堆等來保存執行過程中創建的對象、代碼等,這些需要較多的時間,爲此,V8引入了快照機制,快照機制就是將這些內置的對象和函數加載之後的內存保存並序列化,序列化的結果很容易被反序列化,經過快照機制的啓動時間,可以縮減幾毫秒,在編譯的時候打開選項”snapshot=on“可以讓V8支持快照機制,在V8中,mksnapshot工具能夠幫助生成快照,快照機制同時也能夠將一些開發者認爲需要的js文件序列化,以減少以後處理的時間。

JavaScriptCore引擎

架構和模塊
數據表示

JavaScriptCore引擎使用句柄來表示數據,對於簡單類型的數據則直接包含在句柄中,對於對象來說,則使用指針來指向數據在堆中的位置,同V8引擎不同,在32位和64位機器上,句柄都是使用64位來表示的。首先在32位平臺上,每個句柄都是使用兩個32位數據來表示,對於整數、布爾和指針而言,前面32位用來標記它們,後面32位用來表示這些數據,對於雙浮點,前32位在區間FFFFFFF8~00000000都是用來表示浮點類型,可能稍微比原來雙浮點表示範圍小一些,但是這個範圍已經足夠使用了,同樣在64位機器上,因爲標記指針需要64位,只好使用前面16位,而後面48位表示地址,同V8引擎相比,JavaScriptCore引擎因爲在32位上使用64位來表示句柄,所以除了小整數之外,對於浮點類型同樣可以不需要訪問堆中的數據,當然,缺點就是每個句柄都需要2倍的內存空間。

模塊

JavaScriptCore引擎與V8相比有很多不同之處,最典型的就是使用了字節碼的中間表示,並加入了多層JIT編譯器幫助改善性能,不停的優化編譯之後的本地代碼,具體表現爲:
第一,不同於V8引擎,JavaScriptCore不是從抽象語法樹生成本地代碼,而是生成平臺無關的字節碼,JavaScriptCore引擎自己定義了一套字節碼規範,該字節碼與平臺無關,而且有了該字節碼,JavaScriptCore就可以基於其進行很多在抽象語法樹之上不能或者很難做到的優化,不同於V8,在這之後,因爲有了字節碼,所以JavaScriptCore就不在需要JavaScript源代碼,而V8使用Crankshaft編譯器進行進一步優化,則需要繼續從JavaScript源代碼重新開始。
這裏寫圖片描述
第二,在字節碼之後,JavaScriptCore依然包含了字節碼解釋器,這點類似與Java虛擬機中的解釋器,它們能夠解釋字節碼然後生成結果,而不同於Java虛擬機的解釋器是,JavaScriptCore是基於虛擬寄存器的虛擬機,而Java是基於棧式的虛擬機,因爲一些JavaScript代碼不需要經過很強的優化,只需要直接執行即可,複雜的處理可能帶來額外的開銷反而抵消優化帶來的全部好處,同時在字節碼執行期間,信息收集器會收集熱點函數,以方便之後的JIT編譯器做之後的優化工作。
這裏寫圖片描述
第三,JavaScriptCore引擎在獲悉熱點函數後,需要對它們進行優化,就會使用到簡單JIT編譯器,該編譯器根據信息收集器中的信息,將對應函數的字節碼解釋成本地代碼,不僅因爲時間問題,而且並不是所有代碼都合適做深層此的優化,而是直接做轉換,下圖描述了這一過程,在實行這些本地代碼的時候,會有信息收集器2來收集代碼並作進一步的優化。
這裏寫圖片描述
第四,簡單的JIT編譯器並不能滿足性能的要求,特別是對V8的Crankshaft編譯器來說,性能的差距就顯示出來了,爲了提高性能,JavaScriptCore又引入了DFG(Data-Flow Graph)JIT編譯器,該編譯器是在字節碼基礎上,生成基於SSA(Static Single Assignment)的中間表示(IR),當然具體哪些字節碼需要重新生成優化的本地代碼,就依賴之前的信息收集器2,如下圖,優化後的本地代碼相比,對於性能有了很好的提升
這裏寫圖片描述
第五,後期會將LLVM技術引入到JavaScriptCore,LLVM是一個編譯器,能夠將多個不同的前端語言轉化成不同的後端本地代碼,如下圖:
這裏寫圖片描述
該編譯器在前端和後端都能做優化,這些優化是可配置的,同時,隨着項目越來越成功,加入的優化也越來越多,JavaScriptCore希望將LLVM編譯器的中間表示引入其中,這樣將很容易將這些優化使用在該引擎中
這裏寫圖片描述
這一過程是基於DFG JIT中間表示開始的,爲了節省時間,使用了並行編譯算法,之後,生成LLVM的中間表示,這樣就可以使用LLVM中間表示之後的衆多優化,而且可以按需配置它們,這一過程僅僅對於那些最熱點的函數使用,因爲其層次太多,消耗的時間更多,所以慎用。

發佈了39 篇原創文章 · 獲贊 11 · 訪問量 2萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章