本文首發於公衆號:符合預期的CoyPan本文是一篇譯文。javascript
原文標題:A Deep Dive Into V8html
大部分前端開發人員都會遇到一個流行詞:V8。它的流行程度很大一部分是由於它將JavaScript的性能提高到了一個新的水平。java
是的,V8很快。但它是如何發揮它的魔力?爲何它反映如此迅速呢?算法
官方文檔指出:V8是谷歌開源高性能JavaScript和WebAssembly引擎,用C++編寫。它主要用在Chrome和Node.js中,等等。編程
換句話說,V8是一種C++開發的軟件,它將JavaScript編譯成可執行代碼,即機器碼。segmentfault
如今,咱們開始看得更清楚,Chrome和Node.js只是一個橋樑,負責把JS代碼運送到最終的目的地:在特定機器上運行的機器碼。緩存
V8性能的另外一個重要角色是它的分代和超精確的垃圾收集器。它被優化爲使用低內存收集JavaScript再也不須要的對象。服務器
除此以外,V8還依靠一組其餘的工具和特性來改進JS的一些固有功能。這些功能每每會使JS變慢(例如JS的動態特性)。網絡
在本文中,咱們將更詳細地探討這些工具(Ignition 和 TurboFan)和特性。除此以外,咱們還將介紹V8的內部功能、編譯和垃圾回收過程、單線程特性等基礎知識。
機器碼是如何工做的呢?簡單地說,機器代碼是在機器內存的特定部分執行的一組很是低級的指令。
生成機器碼的過程,用C++舉例,大概像下面這樣:
在進一步討論以前,必須指出這是一個編譯過程,它不一樣於JavaScript解釋過程。實際上,編譯器在進程結束時生成一個完整的程序,而解釋器做爲一個程序自己工做,它經過讀取指令(一般是腳本,如JavaScript腳本)並將其轉換爲可執行命令來完成任務。
解釋過程能夠是動態的(解釋器解析並只運行當前命令)或徹底解析(即解釋器在繼續執行相應的機器指令以前首先徹底翻譯腳本)。
回到圖中,編譯過程一般從源代碼開始。你實現代碼,保存並運行。運行的進程依次從編譯器開始。編譯器是一個程序,和其餘程序同樣,運行在你的機器上。而後它遍歷全部代碼並生成對象文件。那些文件是機器代碼。它們是在特定機器上運行的優化代碼,這就是爲何當你從一個操做系統轉移到另外一個操做系統時必須使用特定的編譯器。
可是你不能執行單獨的對象文件,你須要把它們組合成一個文件,即衆所周知的.exe文件(可執行文件)。這是Linker的工做。
最後,Loader是代理,負責將exe文件中的代碼傳輸到操做系統的虛擬內存中。它基本上是一個運輸工具。在這裏,你的程序終於開始運行了。
聽起來是一個漫長的過程,不是嗎?
大多數時候(除非你是在銀行大型機上使用匯編的開發人員),你會花時間用高級語言編程:Java、C#、Ruby、JavaScript等。
語言越高級,速度越慢。這就是爲何C和C++速度更快,由於它們很是接近機器代碼語言:彙編語言。
除了性能以外,V8的主要優勢之一是超越ECMAScript標準的可能性,而且理解C++。
JavaScript僅限於ECMAScript。而V8引擎,爲了存在,必須是兼容的,但不限於JavaScript。
具備將C++特性集成到V8中的能力是很是棒的。因爲C++已經發展到很是好的OS操做的文件處理和內存/線程處理的特殊性——在JavaScript中擁有全部這些能力是很是有用的。
若是你仔細想一想,Node.js它自己也是以相似的方式誕生的。它遵循與V8類似的路徑,外加服務器和網絡功能。
若是你是一個Node開發者,你應該很熟悉V8的單線程特性。一個JS執行上下文與線程數量成正比。
固然,V8在後臺管理操做系統線程機制。它能夠與多個線程一塊兒工做,由於它是一個複雜的軟件,能夠同時執行許多任務。
可是,V8爲每一個JavaScript的執行上下文只建立一個單線程的環境。其他的都在V8的控制之下。
想象一下JavaScript代碼應該進行的函數調用堆棧。JavaScript的工做原理是將一個函數堆疊在另外一個函數之上,遵循每一個函數的插入/調用順序。在到達每一個函數的內容以前,咱們沒法知道它是否調用其餘函數。若是發生這種狀況,那麼被調用的函數將被放在堆棧中調用者的後面。
例如,當涉及回調時,它們被放在堆棧的末尾。
管理這個堆棧組織和進程所需的內存是V8的主要任務之一。
自2017年5月發佈的5.9版以來,V8附帶了一個新的JavaScript執行管道,它構建在V8的解釋器Ignition之上。它還包括一個更新和更好的優化編譯器-TurboFan。
這些變化徹底集中在總體性能上,以及Google開發人員在調整引擎以適應JavaScript領域帶來的全部快速而顯著的變化時所面臨的困難。
從項目一開始,V8的維護人員就一直在擔憂如何在JavaScript不斷髮展的同時,找到一種提升V8性能的好方法。
如今,咱們能夠看到新引擎的Benchmarks測試結果,已經有了巨大提高:
這是V8的另外一個魔術。JavaScript是一種動態語言。這意味着能夠在執行期間添加、替換和刪除新屬性。例如,在Java這樣的語言中,這是不可能的,在Java中,全部的東西(類、方法、對象和變量)都必須在程序執行以前定義,而且在應用程序啓動後不能動態更改。
因爲它的特殊性質,JavaScript解釋器一般基於散列函數(hash算法)執行字典查找,以準確地知道這個變量或對象在內存中的分配位置。
這對最後一道工序來講代價很大。在其餘語言中,當對象被建立時,它們接收一個地址(指針)做爲其隱式屬性之一。這樣,咱們就能夠準確地知道它們在內存中的位置以及要分配多少空間。
對於JavaScript,這是不可能的,由於咱們沒法映射出不存在的內容。這就是Hidden Classes發揮做用的地方。
隱藏類與Java中的類幾乎相同:靜態類和固定類具備惟一的地址來定位它們。然而,V8並非在程序執行以前執行,而是在運行過程當中,每次對象結構發生「動態變化」時執行。
讓咱們看一個例子來講明問題。考慮如下代碼片斷:
function User(name, fone, address) { this.name = name this.phone = phone this.address = address }
在JavaScript基於原型的特性中,每次實例化一個新的用戶對象時,假設:
var user = new User("John May", "+1 (555) 555-1234", "123 3rd Ave")
而後V8建立一個新的隱藏類。咱們稱之爲_User0
。
每一個對象在內存中都有一個對其類表示的引用。它是類指針。此時,因爲咱們剛剛實例化了一個新對象,因此在內存中只建立了一個隱藏類。如今是空的。
當你在這個函數中執行第一行代碼時,將在上一個基礎上建立一個新的隱藏類,此次是_User1
它基本上是具備name屬性的User的內存地址。在咱們的示例中,咱們沒有使用僅將name做爲屬性的user,但每次這樣作時,這就是V8將做爲引用加載的隱藏類。
name屬性被添加到內存緩衝區的偏移量0,這意味着這將被視爲最後順序中的第一個屬性。
V8還將向_User0
隱藏類添加一個轉換值。這有助於解釋器理解:每次向User對象添加name屬性時,必須處理從_User0
到_User1
的轉換。
當調用函數中的第二行時,一樣的過程再次發生,並建立一個新的隱藏類:
你能夠看到隱藏類跟蹤堆棧。在由轉換值維護的鏈中,一個隱藏類通向另外一個。
屬性添加的順序決定了V8將要建立多少個隱藏類。若是您更改咱們所建立的代碼段中的行的順序,那麼也將建立不一樣的隱藏類。這就是爲何有些開發人員試圖保持重用隱藏類的順序,從而減小開銷。
這是JIT(Just-in-Time)編譯器中很是常見的一個術語。它與隱藏類的概念直接相關。
例如,每當你調用一個函數,將一個對象做爲參數傳遞時,V8會看到這個動做,而後想:「嗯,這個對象做爲參數成功地傳遞了兩次或更屢次……爲何不把它存儲在個人緩存中以備未來調用,而不是再次執行整個耗時的隱藏類驗證過程?」
讓咱們回顧上一個例子:
function User(name, fone, address) { // Hidden class _User0 this.name = name // Hidden class _User1 this.phone = phone // Hidden class _User2 this.address = address // Hidden class _User3 }
當咱們將User對象的實例兩次做爲參數傳遞給函數後,V8將跳轉到隱藏類查找並直接轉到偏移量的屬性。這要快得多。
可是,請記住,若是更改函數中任何屬性賦值的順序,則會致使不一樣的隱藏類,所以V8將沒法使用內聯緩存功能。
這是一個很好的例子,說明開發人員不該該避免更深刻地瞭解引擎。相反,擁有這些知識將有助於代碼更好地執行。
你還記得咱們提到過V8在另外一個線程中收集內存垃圾嗎?這頗有幫助,由於咱們的程序執行不會受到影響。
V8使用衆所周知的「標記和掃描」策略來收集內存中的舊對象。在這種策略中,GC掃描內存對象以「標記」它們以進行收集的階段有點慢,由於這須要暫停代碼執行。
可是,V8是遞增的,也就是說,對於每一個GC停頓,V8嘗試標記儘量多的對象。它使一切變得更快,由於在集合完成以前不須要中止整個執行。在大型應用程序中,性能的提升有很大的不一樣。
關於垃圾回收的詳細內容,能夠移步我以前翻譯的文章:
關於V8更多相關內容,能夠移步我以前翻譯的文章: