天天看點

在V8引擎中JavaScript是如何工作的

作者:小牆神遊

今天我們将深入了解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;

}

這段代碼将生成以下樹結構:

在V8引擎中JavaScript是如何工作的

抽象文法樹示例

可以通過執行前序周遊(根,左,右)來執行這段代碼:

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引擎中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引擎中JavaScript是如何工作的

物體結構示例

在上面的例子中,你可以看到每個對象都有一個指向對象形狀的連結,對于每個屬性名,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引擎中JavaScript是如何工作的

V8概覽

我們來逐漸地回顧:

1. 一切都從從網絡擷取JavaScript代碼開始。

2. V8解析源代碼并将其轉換為抽象文法樹(AST)。

3. 基于這個AST, Ignition解釋器可以開始做它的事情并産生位元組碼。

4. 此時,引擎開始運作代碼并收集類型回報。

5. 為了使它運作得更快,可以将位元組代碼與回報資料一起發送到優化編譯器。優化編譯器在此基礎上進行某些假設,然後生成高度優化的機器代碼。

6. 如果在某個時刻,其中一個假設被證明是不正确的,優化編譯器就會去優化并傳回到解釋器過程。

完結撒花!如果您對某個特定階段有任何疑問或想了解更多細節,您可以深入源代碼或在Twitter上與我聯系。

繼續閱讀