V8工作原理:14 | 編譯器和解釋器:V8是如何執行一段JavaScript代碼的?


前言:該篇說明:請見 說明 —— 瀏覽器工作原理與實踐 目錄

 

  前面我們已經花了很多篇幅來介紹 JavaScript 是如何工作的,了解了這些內容能幫助你從底層理解 JavaScript 的工作機制,從而能幫助你更好地理解和應用 JavaScript。

 

  今天這篇文章我們就繼續 “向下” 分析,站在 JavaScript 引擎 V8 的視角,來分析 JavaScript 代碼是如何被執行的。

 

  前端工具和框架的自身更新速度非常快,而且還不斷有新的出現。要想追趕上前端工具和框架的更新速度,你就需要抓住那些本質的知識,然后才能更加輕松地理解這些上層應用。比如我們接下來要介紹的 V8 執行機制,能幫助你從底層了解 JavaScript,也能幫助你深入理解語言轉換器 Babel、語法檢查工具 ESLint、前端框架 Vue 和 React 的一些底層實現機制。因此,了解 V8 的編譯流程能讓你對語言以及相關工具有更加充分的認識。

 

  要深入理解 V8 的工作原理,你需要搞清楚一些概念和原理,比如接下來我們要詳細講解的編譯器(Compiler)、解釋器(Interpreter)、抽象語法樹(AST)、字節碼(Bytecode)、即時編譯器(JIT)等概念,都是你需要重點關注的。

 

編譯器和解釋器

  之所以存在編譯器和解釋器,是因為機器不能直接理解我們所寫的代碼,所以在執行程序之前,需要將我們所寫的代碼 ”翻譯“ 成機器能讀懂的機器語言。按語言的執行流程,可以把語言划分為編譯型語言和解釋型語言。

 

  編譯型語言在程序執行之前,需要經過編譯器的編譯階段,並且編譯之后會直接保留機器能讀懂的二進制文件,這樣每次運行程序時,都可以直接運行該二進制文件,而不需要再次重新編譯了。比如 C/C++、GO 等都是編譯型語言。

 

  而由解釋型語言編寫的程序,在每次運行時都需要通過解釋器對程序進行動態解釋和執行。比如 Python、JavaScript 等都屬於解釋型語言。

 

  那編譯器和解釋器是如何 “翻譯” 代碼的呢?具體流程你可以參考下圖:

編譯器和解釋器 “翻譯” 代碼

 

  從圖中你可以看出這二者的執行流程,大致可闡述為如下:

  1. 在編譯型語言的編譯過程中,編譯器首先會依次對源代碼進行詞法分析、語法分析,生成抽象語法樹(AST),然后是代碼優化,最后再生成處理器能夠理解的機器碼。如果編譯成功,將會生成一個可執行的文件。但如果編譯過程發生了語法或者其他的錯誤,那么編譯器就會拋出異常,最后的二進制文件也不會生成成功。
  2. 在解釋型語言的解釋過程中,同樣解釋器也會對源代碼進行詞法分析、語法分析,並生成抽象語法樹(AST),不過它會再基於抽象語法樹生成字節碼,最后再根據字節碼來執行程序、輸出結果。

 

V8 是如何執行一段 JavaScript 代碼的

  通過上面的介紹,相信你已經了解編譯器和解釋器了。那接下來,我們就重點分析下 V8 是如何執行一段 JavaScript 代碼的。你可以先來 “一覽全局”,參考下圖:

V8 執行一段代碼流程圖

 

  從圖中可以清楚地看到,V8 在執行過程中既有解釋器 lgnition,又有編譯器 TurboFan,那么它們是如何配合去執行一段 JavaScript 代碼的呢?下面我們就按照上圖來 ——— 分解其執行流程。

 

1. 生成抽象語法樹(AST)和執行上下文

  將源代碼轉換為抽象語法樹,並生成執行上下文,而執行上下文我們在前面的文章中已經介紹過很多了,主要是代碼在執行過程中的環境信息。

 

  那么下面我們就得重點講解下抽象語法樹(下面表述中就直接用它的簡稱 AST 了),看看什么是 AST 以及 AST 的生成過程是怎樣的。

 

  高級語言是開發者可以理解的語言,但是讓編譯器或者解釋器來理解就非常困難了。對於編譯器或者解釋器來說。它們可以理解的就是 AST 了。所以無論你使用的是解釋型語言還是編譯型語言,在編譯過程中,它們都會生成一個 AST。這和渲染引擎將 HTML 格式文件轉換為計算機可以理解的 DOM 樹的情況類似。

 

  你可以結合下面這段代碼來直觀地感受下什么是 AST:

var myName = "極客時間"
function foo(){
    return 23;
}
myName = "geektime"
foo()

  這段代碼經過 javascript-ast 站點處理后,生成的 AST 結構如下:

 

抽象語法樹(AST)結構

 

  從圖中可以看出,AST 的結構和代碼的結構非常相似,其實你也可以把 AST 看成代碼的結構化的表示,編譯器或者解釋器后續的工作都需要依賴於 AST,而不是源代碼。

 

  AST 是非常重要的一種數據結構,在很多項目中有着廣泛的應用。其中最著名的一個項目是 Babel。Babel 是一個被廣泛使用的代碼轉碼器,可以將 ES6 代碼轉為 ES5 代碼,這意味着你可以現在就用 ES6 編寫程序,而不同擔心現有環境是否支持 ES6。Babel 的工作原理就是先將 ES6 源碼轉換為 AST,然后再將 ES6 語法的 AST 轉換為 ES5 語法的 AST,最后利用 ES5 的 AST 生成 JavaScript 源代碼。

 

  除了 Babel 外,還有 ESLint 也使用 AST。ESLint 是一個用來檢查 JavaScript 編寫規范的插件,其檢測流程也是需要將源碼轉換為 AST,然后再利用 AST 來檢查代碼規范化的問題。

 

  現在你知道了什么是 AST 以及它的一些應用,那接下來我們再來看下 AST 是如何生成的。通常,生成 AST 需要經過兩個階段。

 

  第一階段是分詞(tokenize),又稱為詞法分析,其作用是將一行行的源碼拆解成一個個 token。所謂 token,指的是語法上不可能再分的、最小的單個字符或字符串。你可以參考下圖來更好地理解什么是token。

分解 token 示意圖

 

  從圖中可以看出,通過 var myName = "極客時間" 簡單地定義了一個變量,其中關鍵字 “var” 、標識符 “myName”、賦值運算符 “=” 、字符串 “極客時間” 四個都是 token,而且它們代表的屬性還不一樣。

 

  第二階段是解析(parse),又稱為語法分析,其作用是將上一步生成的 token 數據,根據語法規則轉為 AST。如果源碼符合語法規則,這一步就會順利完成。但如果源碼存在語法錯誤,這一步就會終止,並拋出一個 “語法錯誤”。

 

  這就是 AST 的生成過程,先分詞,再解析。

 

  有了 AST 后,那接下來 V8 就會生成該段代碼的只想上下文。至於執行上下文的具體內容,你可以參考前面幾篇文章的講解。

 

2. 生成字節碼

  有了 AST 和執行上下文后,那接下來的第二步,解釋器 lgnition 就登場了,它會根據 AST 生成字節碼,並解釋執行字節碼。

 

  其實一開始 V8 並沒有字節碼,而是直接將 AST 轉換為機器碼,由於執行機器碼的效率是非常高效的,所以這種方式在發布后的一段時間內運行效果是非常好的。但是隨着 Chrome 在手機上的廣泛普及,特別是運行在 512M 內存的手機上,內存占用問題也暴露出來了,因為 V8 需要消耗大量的內存來存放轉換后的機器碼。為了解決內存占用問題,V8 團隊大幅重構了引擎架構,引入字節碼,並且拋棄了之前的編譯器,最終花了將近四年的時間,實現了現在的這套架構。

 

  那什么是字節碼呢?為什么引入字節碼就能解決內存占用問題呢?

 

  字節碼就是介於 AST 和機器碼之間的一種代碼。但是與特定類型的機器碼無關,字節碼需要通過解釋器將其轉換為機器碼后才能執行。

 

  理解了什么是字節碼,我們再來對比下高級代碼、字節碼和機器碼,你可以參考下圖:

字節碼和機器碼占用空間對比

 

  從圖中可以看出,機器碼所占用的空間遠遠超過了字節碼,所以使用字節碼可以減少系統的內存使用。

 

3.執行代碼

  生成字節碼之后,接下來就要進入執行階段了。

 

  通常,如果有一段第一次執行的字節碼,解釋器 lgnition 會逐條解釋執行。到了這里,相信你已經發現了,解釋器 lgnition 除了負責生成字節碼之外,它還有另外一個作用,就是解釋執行字節碼。在 lgnition 執行字節碼的過程中,如果發現有熱點代碼(HotSpot),比如一段代碼被重復執行多次,這種就稱為熱點代碼,那么后台的編譯器 TurboFan 就會把該段熱點的字節碼編譯為高校的機器碼,然后當再次執行這段被優化的代碼時,只需要執行編譯后的機器碼就可以了,這樣就大大提升了代碼的執行效率。

 

  V8 的解釋器和編譯器的取名也很有意思。解釋器 lgnition 是點火器的意思,編譯器 TurboFan 是渦輪增壓的意思,寓意着代碼啟動時通過點火器慢慢發動,一旦啟動,渦輪增壓介入,其執行效率隨着執行時間越來越高效率,因為熱點代碼都被編譯器 TurboFan 轉換了機器碼,直接執行機器碼就省去了字節碼 “翻譯” 為機器碼的過程。

 

  其實字節碼配合解釋器和編譯器是最近一段時間很火的技術,比如 Java 和 Python 的虛擬機也都是基於這種技術實現的,我們把這種技術稱為即時編譯(JIT)。具體到 V8,就是指解釋器 lgnition 在解釋執行字節碼的同時,收集代碼信息,當它發現某一部分代碼變熱了之后,TurboFan 編譯器便閃亮登場,把熱點的字節碼轉換為機器碼,並把轉換后的機器碼保存起來,以備下次使用。

 

  對於 JavaScript 工作引擎,除了 V8 使用了 “字節碼 + JIT” 技術之外,蘋果的 SquirrelFish Extreme 和 Mozilla 的 SpiderMonkey 也都使用了該技術。

 

  這么多語言的工作引擎都使用了 “字節碼 + JIT” 技術,因此理解 JIT 這套工作機制還是很有必要的。你可以結合下圖看看 JIT 的工作過程:

 

即時編譯(JIT)技術

 

JavaScript 的性能優化

  到這里相信你現在已經了解 V8 是如何執行一段 JavaScript 代碼的了。在過去幾年中,JavaScript 的性能得到了大幅提升,這得益於 V8 團隊對解釋器和編譯器的不斷改進和優化。

 

  雖然在 V8 誕生之初,也出現過一系列針對 V8 而專門優化 JavaScript 性能的方案,比如隱藏類、內聯緩存等概念都是那時候提出來的。不過隨着 V8 的架構調整,你越來越不需要這些微優化策略了,相反,對於優化 JavaScript 執行效率,你應該將優化的中心聚焦在單次腳本的執行時間和腳本的網絡下載上,主要關注以下三點內容:

  1. 提升單次腳本的執行速度,避免 JavaScript 的長任務霸占主線程,這樣可以使得頁面快速響應交互;
  2. 避免大的內聯腳本,因為在解析 HTML 的過程中,解析和編譯也會占用主線程;
  3. 減少 JavaScript 文件的容量,因為更小的文件會提升下載速度,並且占用更低的內存。

 

總結

  好了,今天就講到這里,下面我來總結下今天的內容。

  • 首先我們介紹了編譯器和解釋器的區別。
  • 緊接着又詳細分析了 V8 是如何執行一段 JavaScript 代碼的:V8 依據 JavaScript 代碼生成 AST 和執行上下文,再基於 AST 生成字節碼,然后通過解釋器執行字節碼,通過編譯器來優化編譯字節碼。
  • 基於字節碼和編譯器,我們又介紹了 JIT 技術。
  • 最后我們延伸說明了下優化 JavaScript 性能的一些策略。

 

  之所以在本專欄里講 V8 的執行流程,是因為我覺得編譯器和解釋器的相關概念和理論對於程序員來說至關重要,向上能讓你充分理解一些前端應用的本質,向下能打開計算機編譯原理的大門。通過這些知識的學習能讓你將很多模糊的概念關聯起來,使其變得更加清楚,從而擴寬視野,上升到更高的層次。

 

思考時間

  最后留給你個思考題:你是怎么理解 “V8 執行時間越久,執行效率越高” 這個性質的?

 

問題記錄

1、重復看之前的文章,受益良多,在此表示感謝!
不過有幾個疑問,老師有空的解答下哈!

問題一: 渲染進程里的input標簽上傳圖片,通過與瀏覽器主進程通信,主進程讀取硬磁盤圖片數據返回給渲染進程,渲染進程里的js發起ajax請求,是通過瀏覽器主進程去調用網絡進程發起請求,還是渲染進程可以直接調用網絡進程發起請求?

問題二: 請求長時間處於pending狀態或者腳本執行死循環,這時刷新或前進后退頁面不響應,刷新或前進后退頁面是屬於瀏覽器主進程的UI交互行為,為什么渲染進程里的js引擎執行會影響到主進程?

問題三:

function fn(){
  var a =10
  function f1(){
      console.log(a)
  };
  function f2(){
      console.log('f2')
  };
  f2();
};
fn();

我在函數f2里打斷點,當執行到函數f2時,chrome里顯示Closure:{a:10},如果把這個原因解釋為在fn函數里會預掃描f1函數,那我現在把fn2函數和調用都注釋了,現在執行fn函數時不產生Closure,為什么就不預掃描f1函數了?這是為什么?

作者回復:
第一個問題:
xmlhttprequest 可以直接走網絡進程,不需要瀏覽器進程介入

第二個問題:
因為前進或者后退也需要執行當前頁面腳本啊,比如要執行beforeunload事件,執行的時候頁面沒響應了,所以前進后退也就失效了

第三個問題:
你把f2注釋了,當執行fn函數時,照樣會預掃描f1,照樣會產生閉包,只不過當fn執行結束之后,閉包的內容沒有外部引用,那么下次垃圾回收直接把比閉包的內容回收掉

 

2、老師,編譯的基本單位是一段JS代碼(內斂JS)或者一個JS文件嗎(還是以當前調用棧將要執行函數為單位)?

作者回復: 全局代碼,或者函數 !

比如下載完一個js文件,先編譯這個js文件,但是js文件內定義的函數是不會編譯的。

等調用到該函數的時候,Javascript引擎才會去編譯該函數!

 

3、執行時間越長,執行效率越高。是因為更多的代碼成為熱點代碼之后,轉為了機器碼來執行嗎?

作者回復: 是的

 

4、避免大的內聯腳本,因為在解析 HTML 的過程中,解析和編譯也會占用主線程;這句話可以理解為解析HTML代碼的時候需要解析內聯代碼,而放到js文件的時候不需要嗎?

另外思考題應該是執行越久,熱點代碼越多,即時編譯的作用越大。

作者回復: 只要是同步腳本都會阻塞,這里我可能沒說清楚。

我的表達的以上是同步腳本盡量小,盡量能內聯。

其它的盡量采用異步腳本,如使用aysnc和defer。

 

5、

字節碼最終也會轉成機器碼來執行的吧?因為最終都是cpu來執行,cpu只能執行機器碼

作者回復: 是的

 

6、

前面第7和第12講,變量提升說js的執行過程,是有編譯過程的,變量提升就發生在編譯過程,經過編譯后,會生成兩部分內容,執行上下文和可執行代碼,但是在這一講中,卻並沒有編譯過程,在AST生成后,解釋器就開始執行生成字節碼執行了,這幾講的內容有點互相沖突,那么詳細的過程到底是怎樣的呢
我在查看其它資料,出現了預編譯這個名詞,這個又怎么解釋呢
希望能解答下

作者回復:
你可以把JavaScript的編譯看成了部分:

第一部分從一段JavaScript代碼編譯到字節碼,然后解釋器解釋執行字節碼!

第二部分深度編譯,將活躍的字節碼編譯成二進制,然后直接執行二進制。

無論哪個階段都需要編譯。

 

7、我理解,V8執行越久,被編譯成機器碼的熱點代碼就越多,所以整體執行效率就越高。如果是這樣的話,那么V8內存占用也會越來越多,會面臨的問題會和

作者回復: 引入了字節碼,就有彈性空間了,可以在內存和執行速度之間做調節。

相比之前的V8,將JS代碼全部編譯成字節碼,這種模式就沒有協商的空間了!

 

8、

總結說:V8 依據 JavaScript 代碼生成 AST 和執行上下文,再基於 AST 生成字節碼,然后通過解釋器執行字節碼,通過編譯器來優化編譯字節碼。但是第二節生成字節碼那一段 說:解釋器 Ignition 就登場了,它會根據 AST 生成字節碼,並解釋執行字節碼。還有即時編譯(JIT)技術那張圖片,看起來也是先生成字節碼 再經過解釋器 。 所以字節碼是解釋器生成的嗎?我都看懵了,求解答。

作者回復: 流程是這樣的:

v8先生成ast!

然后ignition根據ast生成字節碼。

在然后ignition解釋執行字節碼。

所以ignition生成了字節碼並解釋執行字節碼。

 

9、V8執行越久,被編譯成機器碼的熱點就越多,這些機器碼幫助字節碼可以直接執行而不用再使用解釋器逐行執行,這相當於瀏覽器緩存,提高了執行性能。這些生成的機器碼也會帶來內存占用升高的問題,這里應該會有一個權衡措施吧,根據已占用的內存權衡如何判定是熱點並生成機器碼保存。

作者回復: 是的,可以實現很多策略來權衡不同系統的情況


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM