今天我們将深入了解JavaScript的V8引擎,并弄清楚JavaScript是如何執行的。
在中,我們了解了浏覽器的結構,并對。讓我們來回顧一下,這樣有利于我們進行更深入的研究。
背景
一系列浏覽器實作的規則。它們定義和描述了網際網路的各個方面。
W3C是一個為Web領域開發開放标準的國際組織。他們確定每個開發者都遵循相同的準則,而無須支援許多完全不同的環境。
現代浏覽器是一個相當複雜的軟體,它的代碼庫有。是以它被分成了很多負責不同邏輯的子產品。
浏覽器最重要的兩個部分是JavaScript引擎和渲染引擎。
是一個渲染引擎,負責整個渲染管線(包括DOM樹、樣式、事件和V8內建),并解析DOM樹,解析樣式,并确定所有元素的視覺幾何形狀。
在通過動畫幀持續監控動态變化的同時,Blink會将内容繪制在螢幕上。JS引擎是浏覽器的一個重要組成部分——但我們還沒有讨論到這些細節。
JavaScript引擎101
JavaScript引擎執行JavaScript并将其編譯成原生機器代碼。每個主流浏覽器都開發了自己的JS引擎:谷歌的Chrome使用V8,Safari使用JavaScriptCore,Firefox使用SpiderMonkey。
本文使用的是V8,因為它在Node.js和Electron中可以使用,但其他引擎的建構也是類似的。
每個步驟都有一個指向負責該步驟的代碼連結,這樣您就可以熟悉代碼庫,并且可以繼續本文之外的研究。
我們使用,因為它提供了一個友善和知名的UI來浏覽代碼庫。
準備源代碼
V8需要做的第一件事是下載下傳源代碼。可以通過網絡、緩存或service worker來完成。
一旦擷取到代碼,我們需要以編譯器能夠了解的方式對其進行更改。這個過程稱為解析,由兩部分組成:掃描器和解析器本身。
擷取JS檔案并将其轉換為内置的令牌清單。在檔案中有一個所有JS令牌的清單。
拿到令牌清單,然後建立:以樹形來表示源代碼。樹的每個節點表示代碼中出現的一個結構。
讓我們看一個簡單的例子:
function foo() {
let bar = 1;
return bar;
}
這段代碼将生成以下樹結構:
抽象文法樹示例
可以通過執行前序周遊(根,左,右)來執行這段代碼:
1. 定義foo函數
2. 聲明bar變量
3. 把1指派給bar
4. 從函數中傳回bar。
您還将看到VariableProxy—一個将抽象變量連接配接到記憶體中某個位置的元素。解析VariableProxy的過程稱為作用域分析。
在我們的示例中,該過程的結果将是所有VariableProxys都指向相同bar變量。
即時編譯機制
通常,要運作代碼,就需要将程式設計語言轉換為機器代碼。對于如何以及何時發生這種轉變,有幾種方法。
轉換代碼最常見的方法是執行預編譯。它的工作正如它的字面意思:在編譯階段執行程式之前,代碼被轉換為機器代碼。
這種方法被許多程式設計語言使用,比如C++、Java和還有一些其他語言。
此外需要說明一下:代碼的每一行都将在運作時執行。動态類型語言(如JavaScript和Python)通常采用這種方法,因為在執行之前不可能知道确切的類型。
因為預編譯可以一起評估所有代碼,是以它可以提供更好的優化,并最終生成性能更好的代碼。另一方面,解釋實作起來更簡單,但它通常比編譯好的代碼更慢。
為了更快更有效地轉換動态語言的代碼,建立了一種稱為即時(JIT)編譯的新方法。它充分結合了解釋和編譯。
在使用解釋作為基本方法時,V8可以檢測到使用頻率較高的函數,并使用以前執行的類型資訊編譯它們。
然而,類型可能會發生變化。我們需要對編譯後的代碼去優化,轉而回退到解釋(之後,我們可以在獲得新的類型回報後重新編譯函數)。
讓我們來更詳細地探讨JIT編譯的每個部分。
解釋器
V8使用一個名為的解釋器。最初,它采用抽象文法樹并生成位元組碼。
位元組碼指令也有中繼資料,例如用于将來調試的源行位置。通常,位元組碼指令與JS抽象相比對。
現在讓我們為上面的例子手動生成位元組碼:
LdaSmi #1 // write 1 to accumulator
Star r0 // read to r0 (bar) from accumulator
Ldar r0 // write from r0 (bar) to accumulator
Return // returns accumulator
Ignition有一個叫做累加器的東西——一個你可以存/取值的地方。
這個累加器避免了入棧和出棧的需要。它也是許多位元組碼的隐式參數,通常儲存操作的結果。Return隐式傳回累加器。
您可以在中檢出所有相關位元組碼。如果你對其他JS概念(如循環和async/await)如何在位元組碼中呈現感興趣,我發現閱讀這些例子很有用。
執行
在生成後,Ignition使用一個由位元組碼鍵控的處理程式表來解釋指令。對于每個位元組碼,Ignition可以查找相應的處理程式函數并傳入提供的參數,然後執行。
正如前面提到的,執行階段還提供了代碼的類型回報。讓我們來搞明白它是如何收集和管理的。
首先,我們應該讨論JavaScript對象是如何在記憶體中表示的。一個簡單的方法是,可以為每個對象建立一個字典并将其連結到記憶體。
第一個存儲對象的方法
然而,我們通常有很多具有相同結構的對象,是以存儲大量重複的字典效率不高。
為了解決這個問題,V8使用Object Shapes (或内部的映射)和記憶體中的值向量将對象的結構與值本身分離。
例如,我們建立一個對象字面值:
let c = { x: 3 }
let d = { x: 5 }
c.y = 4
第一行中,生成一個結構Map[c],其屬性為x,偏移量為0。
第二行中,V8将為一個新變量重用相同的結構。
第三行中,為屬性y建立一個偏移量為1的新結構Map[c1],并建立一個到前一個結構Map[c]的連結。
物體結構示例
在上面的例子中,你可以看到每個對象都有一個指向對象形狀的連結,對于每個屬性名,V8可以在記憶體中找到值的偏移量。
對象結構本質上是連結清單。如果你寫c.x, V8會去到清單的頭,在那裡找到y,移動到連接配接的結構,最後擷取x并從中讀取偏移量。然後它會去記憶體向量并傳回它的第一個元素。
可想而知,在大型web應用中,你會看到大量互相連接配接的形狀。同時,在連結清單中搜尋需要線性時間,這使得屬性查找成為非常昂貴的操作。
為了在V8中解決這個問題,你可以使用内聯緩存()。它會記住在哪裡查找對象屬性的資訊,以減少查找的次數。
您可以将其視為代碼中的監聽站點:它跟蹤函數中的所有CALL、STORE和LOAD事件,并記錄所有經過的形狀。
儲存IC的資料結構稱為。它隻是一個數組,用來儲存函數的所有IC。
function load(a) {
return a.key;
}
對于上面的函數,回報向量看起來像這樣:
[{ slot: 0, icType: LOAD, value: UNINIT }]
這是一個簡單的函數,隻有一個IC,其類型為LOAD,值為UNINIT。這意味着它是未初始化的,我們不知道接下來會發生什麼。
用不同的參數調用這個函數,看看内聯緩存将如何改變。
let first = { key: 'first' } // shape A
let fast = { key: 'fast' } // the same shape A
let slow = { foo: 'slow' } // new shape B
load(first)
load(fast)
load(slow)
在第一次調用load函數之後,我們的内聯緩存将得到一個更新的值:
[{ slot: 0, icType: LOAD, value: MONO(A) }]
這個值現在變成了單态的,這意味着這個緩存隻能解析成結構A。
在第二次調用之後,V8将檢查IC的值,它将看到它是單态的,并且具有與快速變量相同的形狀。它會很快傳回offset并解析它。
第三次,結構與存儲的不同。是以V8将手動對其進行解析,并将值更新為具有兩種可能結構的數組的多态狀态。
[{ slot: 0, icType: LOAD, value: POLY[A,B] }]
現在,每當我們調用這個函數時,V8不僅需要檢查一個結構,還需要周遊幾種可能性。
為了代碼更快,可以初始化具有相同類型的對象,而不需要過多地更改它們的結構。
注意:您可以記住這一點,但如果它會導緻代碼重複或表達性較差的代碼,就不要這樣做。
内聯緩存還會跟蹤調用它們的頻率,以決定它是否是優化編譯器的良好候選者——Turbofan。
編譯器
Ignition就到此為止。如果一個函數會被調用多次,這個函數會在編譯器中進行優化,使其變得更快。
Turbofan從Ignition擷取位元組碼,并将類型回報(回報向量)用于函數,在此基礎上應用一系列縮減,并生成機器代碼。
正如我們前面看到的,類型回報并不能保證它在将來不會改變。
例如,Turbofan優化的代碼基于一個假設,即某些加法總是加整數。
但是如果它接收到一個字元串會發生什麼呢?這個過程被稱為去優化。丢棄優化的代碼,回到解釋代碼,繼續執行,并更新類型回報。
總結
在本文中,我們讨論了JS引擎的實作以及如何執行JavaScript的确切步驟。
總之,讓我們從頭再來看一看編譯管線。
V8概覽
我們來逐漸地回顧:
1. 一切都從從網絡擷取JavaScript代碼開始。
2. V8解析源代碼并将其轉換為抽象文法樹(AST)。
3. 基于這個AST, Ignition解釋器可以開始做它的事情并産生位元組碼。
4. 此時,引擎開始運作代碼并收集類型回報。
5. 為了使它運作得更快,可以将位元組代碼與回報資料一起發送到優化編譯器。優化編譯器在此基礎上進行某些假設,然後生成高度優化的機器代碼。
6. 如果在某個時刻,其中一個假設被證明是不正确的,優化編譯器就會去優化并傳回到解釋器過程。
完結撒花!如果您對某個特定階段有任何疑問或想了解更多細節,您可以深入源代碼或在Twitter上與我聯系。