原文地址:https://blog.sessionstack.com...javascript
數週以前,咱們開始寫做一檔專欄,旨在深刻挖掘JavaScript,但願能真正弄清楚它是怎麼工做的。咱們認爲,若是瞭解了JavaScript的構建模塊,以及它們之間是如何協同工做的,就能寫出更好的代碼和app。html
該專欄的第一篇文章,主要講了引擎、runtime和調用棧的概要知識。今天這第二篇,咱們會深刻地研究Google的V8 JS引擎的內部結構。此外,咱們還會提供一些快捷的技巧,幫助你們寫出更優質的JavaScript代碼——這些技巧是咱們在SessionStack的開發團隊開發產品時所發現的最佳方案。java
概述git
所謂的JavaScript引擎是一個能運行JavaScript代碼的程序(program)或解釋器(interpreter)。JavaScript引擎能夠是一個標準的解釋器,也能夠是一個將JavaScript編譯成某種形式的字節碼的即時編譯器。github
下面是一些正在開發JavaScript引擎的比較流行的工程:web
一、V8——開源,Google用C++開發的
二、Rhino——開源,火狐(Mozilla Foundation)徹底用Java開發
三、SpiderMonkey——最先的JavaScript引擎,過去在網景瀏覽器(Netscape Navigator)中使用,今天則在火狐瀏覽器(Firefox)中使用
四、JavaScriptCore——開源,市場上稱做Nitro,由Apple爲Safari開發
五、KJS——KDE的引擎,最初由Harri Porten爲KDE項目的Konqueror網頁瀏覽器所開發
六、Chakra(JScript9)——IE瀏覽器
七、Chakra(JavaScript)——Microsoft Edge
八、Nashorn——OpenJDK開源項目的一部分,用的是Oracle Java語言和工具組
九、JerryScript——用於物聯網的輕量級引擎編程
爲何要開發V8引擎?數組
V8引擎是由Google開發的開源產品,使用C++開發。該引擎在Google Chrome瀏覽器中使用。和其餘的引擎不一樣,V8還被流行的Node.js runtime使用。瀏覽器
最初,V8被設計用於提高web瀏覽器內部的JavaScript運行的性能。爲了提高速度,V8把JavaScript代碼翻譯成執行效率更高的機器碼(不使用解釋器來作這件事)。在執行JavaScript代碼時,V8像不少的現代JavaScript引擎——如SpiderMonkey或Rhino(Mozilla)——同樣,實現了一個JIT編譯器(即時編譯器),從而把JavaScript代碼編譯成機器語言。和其餘引擎最主要的差異在於,V8不會生成任何字節碼或是中間代碼。緩存
V8曾有兩個編譯器
在5.9版本(今年早些時候發佈)的V8出來以前,V8使用兩個編譯器:
一、full-codegen——一個簡單且快的編譯器,它能生成簡單和運行起來相對慢的機器碼
二、Grankshaft——一個相對來講更復雜的(實時)、優化的編譯器,生成高度優化的代碼
V8引擎在內部還使用至關多的線程:
一、主線程(main線程)作的是咱們一般能想到的事情:拿到咱們的代碼,編譯代碼,而後執行之
二、同時,還有一個獨立的用於編譯的線程,這樣主線程就能在該獨立用於編譯的線程優化代碼的時候不間斷地執行代碼
三、一個Pfofiler線程(分析器線程),它能告訴運行環境(runtime)咱們在哪些方法上花了大量的時間,以便Grankshaft能夠優化這些方法
四、一些處理垃圾回收清理的線程
第一次執行JavaScript代碼時,V8充分使用full-codegen來將解析過的JavaScript直接翻譯成機器碼,這個過程不會作任何的中間轉化。這種作法使得V8可以很是快速地開始執行機器碼。V8不使用中間字節碼的表示方式,就沒有必要用解釋器了。
當咱們的代碼運行了一段時間後,Profiler線程就會收集到足夠的數據,能夠判斷出哪些方法須要被優化。
接下來,在另外一個進程裏,Grankshaft優化就開始了。它將JavaScript的抽象語法樹翻譯成高度靜態單賦值的(SSA)表現形式——該表現形式被稱爲Hydrogen,而後設法優化Hydrogen圖。大部分的優化都是在這一層面完成的。
代碼嵌入(Inlining)
第一個優化是提早嵌入儘量多的代碼。
代碼嵌入(Inlining)是將一個調用點(調用某函數的那行代碼)替換成被調用函數的函數體。這個簡單的步驟使得接下來的優化更有意義。
隱藏類(Hidden class)
JavaScript是一門基於原型的語言:沒有什麼類或對象是經過克隆的方式生成的。JavaScript仍是一門動態的編程語言,意味着在一個對象實例化以後,能夠輕鬆地爲其增長或移除屬性。
大部分的JavaScript解釋器使用相似於字典的結構(基於hash函數)存儲對象屬性值在內存中的位置。這種結構使得相對於非動態編程語言(如Java或C#)而言,在JavaScript中檢索一個屬性值麻煩不少。Java中,在編譯以前,全部對象的屬性都由一個固定的對象佈局
所肯定,在運行時不會動態的增長或移除(固然,C#具備動態類型,那是另一個話題了)。因此,在非動態編程語言中,屬性值(或指向屬性的指針)在內存中能夠被儲存在一個連續的buffer裏,且兩兩之間的偏移量是固定的。
因爲使用字典在內存中查找對象屬性位置很是低效,V8使用了一種不一樣的方法:隱藏類(hidden classes)。隱藏類和與Java相似的語言中使用的固定對象佈局(類)的工做方式很是接近,只是隱藏類是在運行時被建立的。如今,咱們就來看看它們到底長什麼樣:
一旦「new Point(1,2)」被調用,V8就會建立一個隱藏類,稱爲 「 C0 」 。
到目前爲止,Point尚未被定義屬性,因此「 C0 」 目前仍是空的。
一旦第一個語句「this.x = x」被執行(在「Point」 方法中),V8就會建立基於「 C0 」的第二個隱藏類,稱爲「 C1 」。「 C1 」描述了在內存中屬性x的位置(相對於對象指針的)。在這個例子中,「x」被存儲在offset 0,表示在內存中把Point對象視爲連續的buffer時,它的第一個offset對應的就是屬性 「x」。V8還會用一個 「類轉換」對「 C0 」作個更新,該 「類轉換」描述的是若是一個屬性 「x」被添加到一個Point對象上,隱藏類須要從「 C0 」變爲「 C1 」。下面這個Point對象的隱藏類如今就是「 C1 」了。
每一次當一個新的屬性被添加到某個對象上時,舊的隱藏類就會經過一個轉換路徑被更新爲一個新的隱藏類。「隱藏類轉換」很是重要,由於它讓相同方式生成的對象們能共享隱藏類。若是兩個對象共享一個隱藏類,而且兩者都被增長了一個相同的屬性,「隱藏類轉換」能保證兩者能得到相同的新的隱藏類和全部與之關聯的優化代碼。
當執行 「this.y = y」語句(仍然是Point方法裏的;位於「this.x = x」語句以後的那條語句)時,上述過程會被重複一遍。
一個新的名爲「 C2 」隱藏類被建立,同時一個類轉換被添加到「 C1 」上——用來描述若是一個屬性 「y」被添加到Point對象(其已經包含了屬性 「x」)上,那麼隱藏類就要變成「 C2 」,而且Point對象的隱藏類被更新爲「 C2 」。
隱藏類轉換根據屬性被添加到對象上的順序而發生變化。咱們看看下面這一小段代碼:
你可能會說對p1和p2而言,它們會使用相同的隱藏類和類轉換。其實否則~ 對 「p1」來講,先是屬性 「a」被添加,而後是屬性 「b」。而對 「p2」來講,先是屬性 「b」被添加,而後纔是屬性 「a」。這樣, 「p1」和 「p2」就在不一樣的轉換路徑做用下,有了不一樣的隱藏類。在這兩種情形下,其實最好是用相同的順序初始化動態屬性,這樣隱藏類就能夠被複用了。
內聯緩存(Inline caching)
V8還使用另外一種優化動態類型語言的技巧,即所謂的內聯緩存。內聯緩存的使用,基於咱們發現:一般,同一個方法的重複調用是發生在相同類型的對象上的。內聯緩存的深度解讀可查看這裏。
這篇文章咱們來講說內聯緩存的大體概念。(以防您沒有時間閱讀上面提到的深度解讀文章)
因此內聯緩存是怎麼工做的呢?V8維護一個對象類型的緩存;這些對象在最近的方法調用中被當作傳參,而後V8根據這個緩存信息來推斷未來什麼樣類型的對象會再次被當成傳參。若是V8可以準確推斷出接下來被傳入的對象類型,那麼它就能繞開獲取對象屬性的計算步驟,而只是使用先前查找該對象的隱藏類時所存儲的信息。
那麼隱藏類和內聯緩存的概念是如何關聯的呢?當一個特定對象調用一個方法時,V8引擎須要查找這個對象的隱藏類,以便肯定獲取某個特定屬性時的offset。在對於同一個隱藏類兩次成功地調用相同的方法後,V8就略去隱藏類的查找,而將這個屬性的offset添加到對象自身的指針上。對於將來全部對該方法的調用,V8引擎都假設隱藏類沒有發生變化,並使用以前查詢中存儲的offset值直接跳到特定屬性的內存地址裏。這個過程極大地提高了執行速度。
內聯緩存的使用也是爲何同類型對象共享隱藏類是如此重要的緣由。若是咱們建立同一個類型的兩個對象,而它們隱藏類不一樣(就如同咱們在前面的例子中作的那樣),V8就不能使用內聯緩存了,由於即便兩個對象類型相同,它們對應的隱藏類會給它們的屬性分配不一樣的offset。
這兩個對象基本相同,可是「a」 和 「b」屬性建立的順序不一樣。
編譯成機器語言
一旦Hydrogen圖被優化,Crankshaft就將這個圖降級到一個較低水平的表現形式——稱爲Lithium。大多數的Lithium實現都是面向特定系統結構的。寄存器分配(Register allocation)發生在這一層面。
最後,Lithium被編譯成機器碼。而後會發生一些其餘的事情,即所謂的OSR:on-stack replacement(堆棧上替換)。當咱們開始編譯和優化一個明顯耗時的方法時,咱們極可能以前一直在運行它。V8不會將它以前執行的很慢的代碼拋在一邊,再從新執行優化後的代碼。相反,他會對這些慢代碼所擁有的所有上下文(堆棧,寄存器)作一個轉換,以便能
夠在執行這些慢代碼的過程當中直接切換到優化後的版本。這是一個很是複雜的任務,要知道,V8已經在其餘的優化中將代碼嵌入了(inlined the code initially)。固然,V8不是惟一一個能作到這一點的引擎。
咱們還有被稱爲 「去優化」的保障措施,可以作相反的轉換,將代碼逆轉成未優化的代碼,防止引擎作的假定再也不爲真時負面效應的出現。
垃圾回收
說到垃圾回收,V8使用一種傳統的分代式標記清除方法(a traditional generational approach of mark-and-sweep),來清除老一代。標記階段會阻止JavaScript執行過程。爲了控制垃圾回收的成本,並使代碼執行更穩定,V8使用增量標記:和遍歷整個堆(heap)、試圖標記全部可能的對象不一樣,它僅遍歷部分堆,而後恢復正常的執行。下一次垃圾回收將從上一次堆遍歷中止的地方開始。這就使得每一次正常執行之間的停頓很是短暫。如前文所述,清除操做是由獨立的進程來處理的。
點火和渦輪風扇(Ignition and TurboFan)
隨着2017年早些時候V8 5.9版本的發佈,一個新的執行管線(execution pipeline)被引入了。該新型管線在真實世界的JavaScript應用中甚至取得了更大的性能提高和巨大的內存節約。
該新型管線構建於V8解釋器Ignition和最新的優化編譯器TurboFan之上。
你能夠在此查看V8團隊有關該主題的博文。
V8 5.9版本問世後,因爲V8團隊力爭和新的JavaScript語言特性以及針對這些新特性所須要的優化保持一致,full-codegen和Crankshaft(這兩項技術從2010年開始爲V8服務)再也不被V8用來運行JavaScript。
Web和Node.js基準上的改進
這意味着整個V8將擁有更簡單和更易維護的架構。
這些改進只是一個開始。新的Ignition和TurboFan管線爲將來的優化鋪平了道路,將來JavaScript的性能會有更加巨大的提高,並能讓V8在Chrome和Node.js中節約資源。
最後,這裏提供一些小技巧,幫助你們寫出更優化的、更優質的JavaScript。從上文中您必定能夠輕鬆地總結出一些技巧,不過爲了方便,仍然爲您提供一份總結。
如何寫出優化的JavaScript
一、對象屬性的順序:永遠用相同的順序爲您的對象屬性實例化,這樣隱藏類和隨後的優化代碼才能共享。
二、動態屬性:在對象實例化後爲其新增屬性會致使隱藏類變化,從而會減慢爲舊隱藏類所優化的方法的執行。因此,儘可能在構造函數中分配對象的全部屬性。
三、方法:重複執行相同方法的代碼會比不一樣的方法只執行一次的代碼運行得更快(因爲內聯緩存)。
四、數組:避免使用keys不是遞增數字的稀疏數組(sparse arrays)。並不爲每一個元素分配內存的稀疏數組實質上是一個hash表。這種數組中的元素比一般數組的元素會花銷更大才能獲取到。此外,避免使用預申請的大型數組。最好隨着須要慢慢增長數組的大小。最後,不要刪除數組中的元素,因這會使得keys變得稀疏。
五、標記值(Tagged values): V8用32個比特來表示對象和數字。它使用1個比特來區分是一個對象(flag = 1)仍是一個整型(flag = 0)(被稱爲SMI或SMall Integer,小整型,因其只有31比特來表示值)。而後,若是一個數值大於31比特,V8就會給這個數字進行裝箱操做(boxing),將其變成double型,並建立一個新的對象將這個double型數字放入其中。因此,爲了不代價很高的boxing操做,儘可能使用31比特的有符號數。
參考資源:
https://docs.google.com/docum...
https://github.com/thlorenz/v...
http://code.google.com/p/v8/w...
http://mrale.ph/v8/resources....