JS 編譯器都做了啥?

樑仔發表於2019-01-02

在寫這篇文章之前,小編工作中從來沒有問過自己這個問題,不就是寫程式碼,編譯器將程式碼編輯成計算機能識別的 01 程式碼,有什麼好了解的。

其實不然,編譯器在將 JS 程式碼變成可執行程式碼,做了很多繁雜的工作,只有深入瞭解背後編譯的原理,我們才能寫出更優質的程式碼,瞭解各種前端框架背後的本質。

為了寫這篇文章,小編也是誠惶誠恐,閱讀了相關的資料,也是一個學習瞭解的過程,難免有些問題,歡迎各位指正,共同提高。

題外話——重回孩童時代的好奇心

現在的生活節奏和壓力,也許讓我們透不過氣,我們日復一日的寫著程式碼,疲於學習各種各樣前端框架,學習的速度總是趕不上更新的速度,經常去尋找解決問題或修復 BUG 的最佳方式,卻很少有時間去真正的靜下心來研究我們最基礎工具——JavaScript 語言。

不知道大家是否還記得自己孩童時代,看到一個新鮮的事物或玩具,是否有很強的好奇心,非要打破砂鍋問你到底。但是在我們的工作中,遇到的各種程式碼問題,你是否有很強的好奇心,一探究竟,還是把這些問題加入"黑名單",下次不用而已,不知所以然。

其實我們應該重回孩童時代,不應滿足會用,只讓程式碼工作而已,我們應該弄清楚"為什麼",只有這樣你才能擁抱整個 JavaScript。掌握了這些知識後,無論什麼技術、框架你都能輕鬆理解,這也前端達人公眾號一直更新 javaScript 基礎的原因。

不要混淆 JavaScipt 與瀏覽器

語言和環境是兩個不同的概念。提及 JavaScript,大多數人可能會想到瀏覽器,脫離瀏覽器 JavaScipt 是不可能執行的,這與其他系統級的語言有著很大的不同。例如 C 語言可以開發系統和製造環境,而 JavaScript 只能寄生在某個具體的環境中才能夠工作。

JavaScipt 執行環境一般都有宿主環境和執行期環境。如下圖所示:

執行環境

宿主環境是由外殼程式生成的,比如瀏覽器就是一個外殼環境(但是瀏覽器並不是唯一,很多伺服器、桌面應用系統都能也能夠提供 JavaScript 引擎執行的環境)。執行期環境則有嵌入到外殼程式中的 JavaScript 引擎(比如 V8 引擎,稍後會詳細介紹)生成,在這個執行期環境,首先需要建立一個程式碼解析的初始環境,初始化的內容包含:

  1. 一套與宿主環境相關聯絡的規則
  2. JavaScript 引擎核心(基本語法規則、邏輯、命令和演算法)
  3. 一組內建物件和 API
  4. 其他約定

雖然,不同的 JavaScript 引擎定義初始化環境是不同的,這就形成了所謂的瀏覽器相容性問題,因為不同的瀏覽器使用不同 JavaScipt 引擎。

不過最近的這條訊息想必大家都知道——瀏覽器市場,微軟居然放棄了自家的 EDGE(IE 的繼任者),轉而投靠競爭對手 Google 主導的 Chromium 核心(國產瀏覽器百度、搜狗、騰訊、獵豹、UC、傲遊、360 用的都是 Chromium(Chromium 用的是鼎鼎大名的 V8 引擎,想必大家都十分清楚吧),可以認為全是 Chromium 的馬甲),真是大快人心,我們終於在同一環境下愉快的編寫程式碼了,想想真是開心!

重溫編譯原理

一提起 JavaScript 語言,大部分的人都將其歸類為“動態”或“解釋執行”語言,其實他是一門“編譯性”語言。與傳統的編譯語言不同,它不是提前編譯的,編譯結果也不能在分散式系統中進行移植。在介紹 JavaScript 編譯器原理之前,小編和大家一起重溫下基本的編譯器原理,因為這是最基礎的,瞭解清楚了我們更能瞭解 JavaScript 編譯器。

編譯程式一般步驟分為:詞法分析、語法分析、語義檢查、程式碼優化和生成位元組碼。
具體的編譯流程如下圖:

編譯流程

分詞/詞法分析(Tokenizing/Lexing)

所謂的分詞,就好比我們將一句話,按照詞語的最小單位進行分割。計算機在編譯一段程式碼前,也會將一串串程式碼拆解成有意義的程式碼塊,這些程式碼塊被稱為詞法單元(token)

例如,考慮程式 var a=2。這段程式通常會被分解成為下面這些詞法單元:vara=2;空格是否作為當為詞法單位,取決於空格在這門語言中是否具有意義。

解析/語法分析(Parsing)

這個過程是將詞法單元流轉換成一個由元素逐級巢狀所組成的代表了程式語法結構的樹。這個樹稱為“抽象語法樹”(Abstract Syntax Tree,AST)。

詞法分析和語法分析不是完全獨立的,而是交錯進行的,也就是說,詞法分析器不會在讀取所有的詞法記號後再使用語法分析器來處理。在通常情況下,每取得一個詞法記號,就將其送入語法分析器進行分析。

執行環境

語法分析的過程就是把詞法分析所產生的記號生成語法樹,通俗地說,就是把從程式中收集的資訊儲存到資料結構中。注意,在編譯中用到的資料結構有兩種:符號表和語法樹。

符號表:就是在程式中用來儲存所有符號的一個表,包括所有的字串變數、直接量字串,以及函式和類。

語法樹:就是程式結構的一個樹形表示,用來生成中間程式碼。下面是一個簡單的條件結構和輸出資訊程式碼段,被語法分析器轉換為語法樹之後,如:

if (typeof a == "undefined") {
  a = 0;
} else {
  a = a;
}
alert(a);
複製程式碼

執行環境

如果 JavaScript 直譯器在構造語法樹的時候發現無法構造,就會報語法錯誤,並結束整個程式碼塊的解析。對於傳統強型別語言來說,在通過語法分析構造出語法樹後,翻譯出來的句子可能還會有模糊不清的地方,需要進一步的語義檢查。

**語義檢查的主要部分是型別檢查。**例如,函式的實參和形參型別是否匹配。但是,對於弱型別語言來說,就沒有這一步。

經過編譯階段的準備, JavaScript 程式碼在記憶體中已經被構建為語法樹,然後 JavaScript 引擎就會根據這個語法樹結構邊解釋邊執行。

程式碼生成

將 AST 轉換成可執行程式碼的過程被稱為程式碼生成。這個過程與語言、目標平臺相關。

瞭解完編譯原理後,其實 JavaScript 引擎要複雜的許多,因為大部分情況,JavaScript 的編譯過程不是發生在構建之前,而是發生在程式碼執行前的幾微妙,甚至時間更短。為了保證效能最佳,JavaScipt 使用了各種辦法,稍後小編將會詳細介紹。

神祕的 JavaScipt 編譯器——V8 引擎

由於 JavaScipt 大多數都是執行在瀏覽器上,不同瀏覽器的使用的引擎也各不相同,以下是目前主流瀏覽器引擎:

執行環境

由於谷歌的 V8 編譯器的出現,由於效能良好吸引了相當的注目,正式由於 V8 的出現,我們目前的前端才能大放光彩,百花齊放,V8 引擎用 C++進行編寫, 作為一個 JavaScript 引擎,最初是服役於 Google Chrome 瀏覽器的。它隨著 Chrome 的第一版釋出而釋出以及開源。現在它除了 Chrome 瀏覽器,已經有很多其他的使用者了。諸如 NodeJS、MongoDB、CouchDB 等。

最近最讓人振奮前端新聞莫過於微軟居然放棄了自家的 EDGE(IE 的繼任者),轉而投靠競爭對手 Google 主導的 Chromium 核心(國產瀏覽器百度、搜狗、騰訊、獵豹、UC、傲遊、360 用的都是 Chromium(Chromium 用的是鼎鼎大名的 V8 引擎,想必大家都十分清楚吧),看來 V8 引擎在不久的將來就會一統江湖,下面小編將重點介紹 V8 引擎。

當 V8 編譯 JavaScript 程式碼時,解析器(parser)將生成一個抽象語法樹(上一小節已介紹過)。語法樹是 JavaScript 程式碼的句法結構的樹形表示形式。直譯器 Ignition 根據語法樹生成位元組碼。TurboFan 是 V8 的優化編譯器,TurboFan 將位元組碼(Bytecode)生成優化的機器程式碼(Machine Code)

v8圖

V8 曾經有兩個編譯器

在 5.9 版本之前,該引擎曾經使用了兩個編譯器:

full-codegen - 一個簡單而快速的編譯器,可以生成簡單且相對較慢的機器程式碼。

Crankshaft - 一種更復雜的(即時)優化編譯器,可生成高度優化的程式碼。

V8 引擎還在內部使用多個執行緒:

  • 主執行緒:獲取程式碼,編譯程式碼然後執行它
  • 優化執行緒:與主執行緒並行,用於優化程式碼的生成
  • Profiler 執行緒:它將告訴執行時我們花費大量時間的方法,以便 Crankshaft 可以優化它們
  • 其他一些執行緒來處理垃圾收集器掃描

位元組碼

位元組碼是機器程式碼的抽象。如果位元組碼採用和物理 CPU 相同的計算模型進行設計,則將位元組碼編譯為機器程式碼更容易。這就是為什麼直譯器(interpreter)常常是暫存器或堆疊。 Ignition 是具有累加器的暫存器。

位元組碼

您可以將 V8 的位元組碼看作是小型的構建塊(bytecodes as small building blocks),這些構建塊組合在一起構成任何 JavaScript 功能。V8 有數以百計的位元組碼。比如 Add 或 TypeOf 這樣的操作符,或者像 LdaNamedProperty 這樣的屬性載入符,還有很多類似的位元組碼。 V8 還有一些非常特殊的位元組碼,如 CreateObjectLiteral 或 SuspendGenerator。標頭檔案 bytecodes.h(github.com/v8/v8/blob/… 定義了 V8 位元組碼的完整列表。

在早期的 V8 引擎裡,在多數瀏覽器都是基於位元組碼的,V8 引擎偏偏跳過這一步,直接將 jS 編譯成機器碼,之所以這麼做,就是節省了時間提高效率,但是後來發現,太佔用記憶體了。最終又退回位元組碼了,之所以這麼做的動機是什麼呢?

  1. 減輕機器碼佔用的記憶體空間,即犧牲時間換空間。(主要動機)
  2. 提高程式碼的啟動速度 對 v8 的程式碼進行重構。
  3. 降低 v8 的程式碼複雜度。

每個位元組碼指定其輸入和輸出作為暫存器運算元。Ignition 使用暫存器 r0,r1,r2,... 和累加器暫存器(accumulator register)。幾乎所有的位元組碼都使用累加器暫存器。它像一個常規暫存器,除了位元組碼沒有指定。 例如,Add r1 將暫存器 r1 中的值和累加器中的值進行加法運算。這使得位元組碼更短,節省記憶體。

許多位元組碼以 Lda 或 Sta 開頭。Lda 和 Stastands 中的 a 為累加器(accumulator)。例如,LdaSmi [42] 將小整數(Smi)42 載入到累加器暫存器中。Star r0 將當前在累加器中的值儲存在暫存器 r0 中。

以現在掌握的基礎知識,花點時間來看一個具有實際功能的位元組碼。

function incrementX(obj) {
  return 1 + obj.x;
}
incrementX({ x: 42 }); // V8 的編譯器是惰性的,如果一個函式沒有執行,V8 將不會解釋它
複製程式碼

如果要檢視 V8 的 JavaScript 位元組碼,可以使用在命令列引數中新增 --print-bytecode 執行 D8 或 Node.js(8.3 或更高版本)來列印。對於 Chrome,請從命令列啟動 Chrome,使用 --js-flags="--print-bytecode",請參考 Run Chromium with flags。

$ node --print-bytecode incrementX.js
...
[generating bytecode for function: incrementX]
Parameter count 2
Frame size 8
  12 E> 0x2ddf8802cf6e @ StackCheck
  19 S> 0x2ddf8802cf6f @ LdaSmi [1]
        0x2ddf8802cf71 @ Star r0
  34 E> 0x2ddf8802cf73 @ LdaNamedProperty a0, [0], [4]
  28 E> 0x2ddf8802cf77 @ Add r0, [6]
  36 S> 0x2ddf8802cf7a @ Return
Constant pool (size = 1)
0x2ddf8802cf21: [FixedArray] in OldSpace
- map = 0x2ddfb2d02309 <Map(HOLEY_ELEMENTS)>
- length: 1 0: 0x2ddf8db91611 <String[1]: x>
Handler Table (size = 16)
複製程式碼

我們忽略大部分輸出,專注於實際的位元組碼。

這是每個位元組碼的意思,每一行:

LdaSmi [1]

位元組碼

Star r0

接下來,Star r0 將當前在累加器中的值 1 儲存在暫存器 r0 中。

位元組碼

LdaNamedProperty a0, [0], [4]

LdaNamedProperty 將 a0 的命名屬性載入到累加器中。ai 指向 incrementX() 的第 i 個引數。在這個例子中,我們在 a0 上查詢一個命名屬性,這是 incrementX() 的第一個引數。該屬性名由常量 0 確定。LdaNamedProperty 使用 0 在單獨的表中查詢名稱:

- length: 1
          0: 0x2ddf8db91611 <String[1]: x>
複製程式碼

可以看到,0 對映到了 x。因此這行位元組碼的意思是載入 obj.x。

那麼值為 4 的運算元是幹什麼的呢? 它是函式 incrementX() 的反饋向量的索引。反饋向量包含用於效能優化的 runtime 資訊。

現在暫存器看起來是這樣的:

位元組碼
Add r0, [6]

最後一條指令將 r0 加到累加器,結果是 43。 6 是反饋向量的另一個索引。

位元組碼

Return 返回累加器中的值。返回語句是函式 incrementX() 的結束。此時 incrementX() 的呼叫者可以在累加器中獲得值 43,並可以進一步處理此值。

V8 引擎為啥這麼快?

由於 JavaScript 弱語言的特性(一個變數可以賦值不同的資料型別),同時很彈性,允許我們在任何時候在物件上新增或是刪除屬性和方法等, JavaScript 語言非常動態,我們可以想象會大大增加編譯引擎的難度,儘管十分困難,但卻難不倒 V8 引擎,v8 引擎運用了好幾項技術達到加速的目的:

內聯(Inlining):

內聯特性是一切優化的基礎,對於良好的效能至關重要,所謂的內聯就是如果某一個函式內部呼叫其它的函式,編譯器直接會將函式中的執行內容,替換函式方法。如下圖所示:

內聯

如何理解呢?看如下程式碼

function add(a, b) {
  return a + b;
}
function calculateTwoPlusFive() {
  var sum;
  for (var i = 0; i <= 1000000000; i++) {
    sum = add(2 + 5);
  }
}
var start = new Date();
calculateTwoPlusFive();
var end = new Date();
var timeTaken = end.valueOf() - start.valueOf();
console.log("Took " + timeTaken + "ms");
複製程式碼

由於內聯屬性特性,在編譯前,程式碼將會被優化成

function add(a, b) {
  return a + b;
}
function calculateTwoPlusFive() {
  var sum;
  for (var i = 0; i <= 1000000000; i++) {
    sum = 2 + 5;
  }
}
var start = new Date();
calculateTwoPlusFive();
var end = new Date();
var timeTaken = end.valueOf() - start.valueOf();
console.log("Took " + timeTaken + "ms");
複製程式碼

如果沒有內聯屬性的特性,你能想想執行的有多慢嗎?把第一段 JS 程式碼嵌入 HTML 檔案裡,我們用不同的瀏覽器開啟(硬體環境:i7,16G 記憶體,mac 系統),用 safari 開啟如下圖所示,17 秒:

內聯

如果用 Chrome 開啟,還不到 1 秒,快了 16 秒!

內聯

隱藏類(Hidden class):

例如 C++/Java 這種靜態型別語言的每一個變數,都有一個唯一確定的型別。因為有型別資訊,一個物件包含哪些成員和這些成員在物件中的偏移量等資訊,編譯階段就可確定,執行時 CPU 只需要用物件首地址 —— 在 C++中是 this 指標,加上成員在物件內部的偏移量即可訪問內部成員。這些訪問指令在編譯階段就生成了。

但對於 JavaScript 這種動態語言,變數在執行時可以隨時由不同型別的物件賦值,並且物件本身可以隨時新增刪除成員。訪問物件屬性需要的資訊完全由執行時決定。為了實現按照索引的方式訪問成員,V8“悄悄地”給執行中的物件分了類,在這個過程中產生了一種 V8 內部的資料結構,即隱藏類。隱藏類本身是一個物件。

考慮以下程式碼:

function Point(x, y) {
  this.x = x;
  this.y = y;
}
var p1 = new Point(1, 2);
複製程式碼

如果 new Point(1, 2)被呼叫,v8 引擎就會建立一個引隱藏的類 C0,如下圖所示:

隱藏類

由於 Point 沒有定於任何屬性,因此C0為空

一旦this.x = x被執行,v8 引擎就會建立一個名為“C1”的第二個隱藏類。基於“c0”,“c1”描述了可以找到屬性 X 的記憶體中的位置(相當指標)。在這種情況下,隱藏類則會從 C0 切換到 C1,如下圖所示:

隱藏類

每次向物件新增新的屬性時,舊的隱藏類會通過路徑轉換切換到新的隱藏類。由於轉換的重要性,因為引擎允許以相同的方式建立物件來共享隱藏類。如果兩個物件共享一個隱藏類的話,並且向兩個物件新增相同的屬性,轉換過程中將確保這兩個物件使用相同的隱藏類和附帶所有的程式碼優化。

當執行 this.y = y,將會建立一個 C2 的隱藏類,則隱藏類更改為 C2

隱藏類

隱藏類的轉換的效能,取決於屬性新增的順序,如果新增順序的不同,效果則不同,如以下程式碼:

function Point(x, y) {
  this.x = x;
  this.y = y;
}
var p1 = new Point(1, 2);
p1.a = 5;
p1.b = 6;
var p2 = new Point(3, 4);
p2.b = 7;
p2.a = 8;
複製程式碼

你可能以為 P1、p2 使用相同的隱藏類和轉換,其實不然。對於 P1 物件而言,隱藏類先 a 再 b,對於 p2 而言,隱藏類則先 b 後 a,最終會產生不同的隱藏類,增加編譯的運算開銷,這種情況下,應該以相同的順序動態的修改物件屬性,以便可以複用隱藏類。

內聯快取(Inline caching)

  • 正常訪問物件屬性的過程是:首先獲取隱藏類的地址,然後根據屬性名查詢偏移值,然後計算該屬性的地址。雖然相比以往在整個執行環境中查詢減小了很大的工作量,但依然比較耗時。能不能將之前查詢的結果快取起來,供再次訪問呢?當然是可行的,這就是內嵌快取。
  • 內嵌快取的大致思路就是將初次查詢的隱藏類和偏移值儲存起來,當下次查詢的時候,先比較當前物件是否是之前的隱藏類,如果是的話,直接使用之前的快取結果,減少再次查詢表的時間。當然,如果一個物件有多個屬性,那麼快取失誤的概率就會提高,因為某個屬性的型別變化之後,物件的隱藏類也會變化,就與之前的快取不一致,需要重新使用以前的方式查詢雜湊表。

記憶體管理

記憶體的管理組要由分配回收兩個部分構成。V8 的記憶體劃分如下:

  • Zone:管理小塊記憶體。其先自己申請一塊記憶體,然後管理和分配一些小記憶體,當一塊小記憶體被分配之後,不能被 Zone 回收,只能一次性回收 Zone 分配的所有小記憶體。當一個過程需要很多記憶體,Zone 將需要分配大量的記憶體,卻又不能及時回收,會導致記憶體不足情況。
  • 堆:管理 JavaScript 使用的資料、生成的程式碼、雜湊表等。為方便實現垃圾回收,堆被分為三個部分:
    1.年輕分代:為新建立的物件分配記憶體空間,經常需要進行垃圾回收。為方便年輕分代中的內容回收,可再將年輕分代分為兩半,一半用來分配,另一半在回收時負責將之前還需要保留的物件複製過來。
    2.年老分代:根據需要將年老的物件、指標、程式碼等資料儲存起來,較少地進行垃圾回收。
    3.大物件:為那些需要使用較多記憶體物件分配記憶體,當然同樣可能包含資料和程式碼等分配的記憶體,一個頁面只分配一個物件。

垃圾回收

V8 使用了分代和大資料的記憶體分配,在回收記憶體時使用精簡整理的演算法標記未引用的物件,然後消除沒有標記的物件,最後整理和壓縮那些還未儲存的物件,即可完成垃圾回收。

為了控制 GC 成本並使執行更加穩定, V8 使用增量標記, 而不是遍歷整個堆,它試圖示記每個可能的物件,它只遍歷一部分堆,然後恢復正常的程式碼執行。下一次 GC 將繼續從之前的遍歷停止的位置開始。這允許在正常執行期間非常短的暫停。如前所述,掃描階段由單獨的執行緒處理。

優化回退

V8 為了進一步提升 JavaScript 程式碼的執行效率,編譯器直接生成更高效的機器碼。程式在執行時,V8 會採集 JavaScript 程式碼執行資料。當 V8 發現某函式執行頻繁(行內函數機制),就將其標記為熱點函式。針對熱點函式,V8 的策略較為樂觀,傾向於認為此函式比較穩定,型別已經確定,於是編譯器,生成更高效的機器碼。後面的執行中,萬一遇到型別變化,V8 採取將 JavaScript 函式回退到優化前的編譯成機器位元組碼。如以下程式碼:

function add(a, b) {
  return a + b;
}
for (var i = 0; i < 10000; ++i) {
  add(i, i);
}
add("a", "b"); //千萬別這麼做!
複製程式碼

再來看下面的一個例子:

// 片段 1
var person = {
  add: function(a, b) {
    return a + b;
  }
};
obj.name = "li";
// 片段 2
var person = {
  add: function(a, b) {
    return a + b;
  },
  name: "li"
};
複製程式碼

以上程式碼實現的功能相同,都是定義了一個物件,這個物件具有一個屬性 name 和一個方法 add()。但使用片段 2 的方式效率更高。片段 1 給物件 obj 新增了一個屬性 name,這會造成隱藏類的派生。**給物件動態地新增和刪除屬性都會派生新的隱藏類。**假如物件的 add 函式已經被優化,生成了更高效的程式碼,則因為新增或刪除屬性,這個改變後的物件無法使用優化後的程式碼。

從例子中我們可以看出

函式內部的引數型別越確定,V8 越能夠生成優化後的程式碼。

結束語

好了,本篇的內容終於完了,說了這麼多,你是否真正的理解了,我們如何迎合編譯器的嗜好編寫更優化的程式碼呢?

物件屬性的順序:始終以相同的順序例項化物件屬性, 以便可以共享隱藏類和隨後優化的程式碼。

動態屬性:在例項化後向物件新增屬性將強制隱藏類更改, 並任何為先前隱藏類優化的方法變慢. 所以, 使用在建構函式中分配物件的所有屬性來代替。

方法:重複執行相同方法的程式碼將比只執行一次的程式碼(由於內聯快取)執行得快。

陣列:避免鍵不是增量數字的稀疏陣列. 稀疏陣列是一個雜湊表. 這種陣列中的元素訪問消耗較高. 另外, 儘量避免預分配大型陣列, 最好按需分配, 自動增加. 最後, 不要刪除陣列中的元素, 它使鍵稀疏。

相關文章