Js是怎樣運行起來的?
點擊上方 程序員成長指北,關(guān)注公眾號
回復(fù)1,加入高級Node交流群
前言
不知道大家有沒有想過這樣一個問題,我們所寫的 JavaScript 代碼是怎樣被計算機認識并且執(zhí)行的呢?這中間的過程具體是怎樣的呢?
有的同學(xué)可能已經(jīng)知道,Js 是通過 Js 引擎運行起來的,那么
-
什么是 Js 引擎?
-
Js 引擎是怎樣編譯執(zhí)行和優(yōu)化 Js 代碼的?
Js 引擎有很多種,比如 Chrome 使用的 V8 引擎,Webkit 使用的是 JavaScriptCore,React Native 使用的是 Hermes。今天我們主要來分析一下比較主流的 V8 引擎是怎樣運行 Js 的。
V8 引擎
在介紹 V8 引擎的概念之前,我們先來回顧一下編程語言。編程語言可以分為機器語言、匯編語言、高級語言。
-
機器語言:由 0 和 1 組成的二進制碼,對于人類來說是很難記憶的,還要考慮不同 CPU 平臺的兼容性。
-
匯編語言:用更容易記憶的英文縮寫標(biāo)識符代替二進制指令,但還是需要開發(fā)人員有足夠的硬件知識。
-
高級語言:更簡單抽象且不需要考慮硬件,但是需要更復(fù)雜、耗時更久的翻譯過程才能被執(zhí)行。
到了這里我們知道,高級語言一定要轉(zhuǎn)化為機器語言才能被計算機執(zhí)行,而且越高級的語言轉(zhuǎn)化的時間越久。高級語言又可以分為解釋型語言、編譯型語言。
-
編譯型語言:需要編譯器進行一次編譯,被編譯過的文件可以多次執(zhí)行。如 C++、C 語言。
-
解釋型語言:不需要事先編譯,通過解釋器一邊解釋一邊執(zhí)行。啟動快,但執(zhí)行慢。
我們知道 JavaScript 是一門高級語言,并且是動態(tài)類型語言,我們在定義一個變量時不需要關(guān)心它的類型,并且可以隨意的修改變量的類型。而在像 C++這樣的靜態(tài)類型語言中,我們必須提前聲明變量的類型并且賦予正確的值才行。也正是因為 JavaScript 沒有像 C++那樣可以事先提供足夠的信息供編譯器編譯出更加低級的機器代碼,它只能在運行階段收集類型信息,然后根據(jù)這些信息進行編譯再執(zhí)行,所以 JavaScript 也是解釋型語言。
這也就意味著 JavaScript 要想被計算機執(zhí)行,需要一個能夠快速解析并且執(zhí)行 JavaScript 腳本的程序,這個程序就是我們平時所說的 JavaScript 引擎。這里我們給出 V8 引擎的概念:V8 是 Google 基于 C++ 編寫的開源高性能 Javascript 與 WebAssembly 引擎。用于 Google Chrome(Google 的開源瀏覽器) 以及 Node.js 等。
CPU 是如何執(zhí)行機器指令的?
將高級語言轉(zhuǎn)化為機器語言之后,CPU 又是怎樣執(zhí)行的呢?我們以一段 C 代碼為例:
int main()
{
int x = 1;
int y = 2;
int z = x + y;
return z;
}}
先來看一下以上代碼被轉(zhuǎn)換為機器語言是什么樣子。下圖左側(cè)是用十六進制表示的二進制機器碼,中間部分是匯編代碼,右側(cè)是指令的含義。
CPU 執(zhí)行機器指令的流程
-
首先程序在執(zhí)行之前會被裝進內(nèi)存。
-
系統(tǒng)會將二進制代碼中的第一條指令的地址寫入到 PC 寄存器中。
-
CPU 根據(jù) PC 寄存器中的地址,從內(nèi)存中取出指令。
-
將下一條指令的地址更新到 PC 寄存器中。
-
分析當(dāng)前取出指令,并識別出不同的類型的指令,以及各種獲取操作數(shù)的方法。
-
加載指令:從內(nèi)存中復(fù)制指定長度的內(nèi)容到通用寄存器中,并覆蓋寄存器中原來的內(nèi)容。
-
存儲指令:將寄存器中的內(nèi)容復(fù)制到內(nèi)存某個位置,并覆蓋掉內(nèi)存中的這個位置上原來的內(nèi)容。
上圖中 movl 指令后面的 %ecx 就是寄存器地址,-8(%rbp) 是內(nèi)存中的地址,這條指令的作用是將寄存器中的值拷貝到內(nèi)存中。
-
更新指令:復(fù)制兩個寄存器中的內(nèi)容到 ALU 中,也可以是一塊寄存器和一塊內(nèi)存中的內(nèi)容到 ALU 中,ALU 將兩個字相加,并將結(jié)果存放在其中的一個寄存器中,并覆蓋該寄存器中的內(nèi)容。
...
-
執(zhí)行指令完畢,進入下一個 CPU 時鐘周期。
V8 引擎的編譯流水線
接下來我們先從宏觀的角度來看一下 V8 是怎么執(zhí)行 JavaScript 代碼的,然后再對每一步進行分析。
-
初始化基礎(chǔ)環(huán)境; -
解析源碼生成 AST 和作用域; -
依據(jù) AST 和作用域生成字節(jié)碼; -
解釋執(zhí)行字節(jié)碼;監(jiān)聽熱點代碼; -
...
完整的分析一段 JavaScript 代碼是怎樣被執(zhí)行的
1、初始化基礎(chǔ)環(huán)境
V8 執(zhí)行 Js 代碼是離不開宿主環(huán)境的,V8 的宿主可以是瀏覽器,也可以是 Node.js。下圖是瀏覽器的組成結(jié)構(gòu),其中渲染引擎就是平時所說的瀏覽器內(nèi)核,它包括網(wǎng)絡(luò)模塊,Js 解釋器等。當(dāng)打開一個渲染進程時,就為 V8 初始化了一個運行時環(huán)境。
運行時環(huán)境為 V8 提供了堆空間,棧空間、全局執(zhí)行上下文、消息循環(huán)系統(tǒng)、宿主對象及宿主 API 等。V8 的核心是實現(xiàn)了 ECMAScript 標(biāo)準(zhǔn),此外還提供了垃圾回收器等內(nèi)容。
2、解析源碼生成 AST 和作用域
基礎(chǔ)環(huán)境準(zhǔn)備好之后,接下來就可以向 V8 提交要執(zhí)行的 JavaScript 代碼了。首先 V8 會接收到要執(zhí)行的 JavaScript 源代碼,不過這對 V8 來說只是一堆字符串,V8 并不能直接理解這段字符串的含義,它需要結(jié)構(gòu)化這段字符串。
function add(x, y) {
var z = x+y
return z
}
console.log(add(1, 2))
比如針對如上源代碼,V8 首先通過解析器(parser)解析成如下的抽象語法樹 AST:
[generating bytecode for function: add]
--- AST ---
FUNC at 12
. KIND 0
. LITERAL ID 1
. SUSPEND COUNT 0
. NAME "add"
. PARAMS
. . VAR (0x7fa7bf8048e8) (mode = VAR, assigned = false) "x"
. . VAR (0x7fa7bf804990) (mode = VAR, assigned = false) "y"
. DECLS
. . VARIABLE (0x7fa7bf8048e8) (mode = VAR, assigned = false) "x"
. . VARIABLE (0x7fa7bf804990) (mode = VAR, assigned = false) "y"
. . VARIABLE (0x7fa7bf804a38) (mode = VAR, assigned = false) "z"
. BLOCK NOCOMPLETIONS at -1
. . EXPRESSION STATEMENT at 31
. . . INIT at 31
. . . . VAR PROXY local[0] (0x7fa7bf804a38) (mode = VAR, assigned = false) "z"
. . . . ADD at 32
. . . . . VAR PROXY parameter[0] (0x7fa7bf8048e8) (mode = VAR, assigned = false) "x"
. . . . . VAR PROXY parameter[1] (0x7fa7bf804990) (mode = VAR, assigned = false) "y"
. RETURN at 37
. . VAR PROXY local[0] (0x7fa7bf804a38) (mode = VAR, assigned = false) "z"
V8 在生成 AST 的同時,還生成了 add 函數(shù)的作用域:
Global scope:
function add (x, y) { // (0x7f9ed7849468) (12, 47)
// will be compiled
// 1 stack slots
// local vars:
VAR y; // (0x7f9ed7849790) parameter[1], never assigned
VAR z; // (0x7f9ed7849838) local[0], never assigned
VAR x; // (0x7f9ed78496e8) parameter[0], never assigned
}
在解析期間,所有函數(shù)體中聲明的變量和函數(shù)參數(shù),都被放進作用域中,如果是普通變量,那么默認值是 undefined,如果是函數(shù)聲明,那么將指向?qū)嶋H的函數(shù)對象。在執(zhí)行階段,作用域中的變量會指向堆和棧中相應(yīng)的數(shù)據(jù)。
3、依據(jù) AST 和作用域生成字節(jié)碼
生成了作用域和 AST 之后,V8 就可以依據(jù)它們來生成字節(jié)碼了。AST 之后會被作為輸入傳到字節(jié)碼生成器 (BytecodeGenerator),這是 Ignition 解釋器中的一部分,用于生成以函數(shù)為單位的字節(jié)碼。
[generated bytecode for function: add (0x079e0824fdc1 <SharedFunctionInfo add>)]
Parameter count 3
Register count 2
Frame size 16
0x79e0824ff7a @ 0 : a7 StackCheck
0x79e0824ff7b @ 1 : 25 02 Ldar a1
0x79e0824ff7d @ 3 : 34 03 00 Add a0, [0]
0x79e0824ff80 @ 6 : 26 fb Star r0
0x79e0824ff82 @ 8 : 0c 02 LdaSmi [2]
0x79e0824ff84 @ 10 : 26 fa Star r1
0x79e0824ff86 @ 12 : 25 fb Ldar r0
0x79e0824ff88 @ 14 : ab Return
Constant pool (size = 0)
Handler Table (size = 0)
Source Position Table (size = 0)
4、解釋執(zhí)行字節(jié)碼
和 CPU 執(zhí)行二進制機器代碼類似:使用內(nèi)存中的一塊區(qū)域來存放字節(jié)碼;使通用寄存器用來存放一些中間數(shù)據(jù);PC 寄存器用來指向下一條要執(zhí)行的字節(jié)碼;棧頂寄存器用來指向當(dāng)前的棧頂?shù)奈恢谩?img data-ratio="0.4001751313485114" src="https://filescdn.proginn.com/9a8a9b4692d0a3fbd1113e5c17507e35/755ed66a820b3188071f7cb96a5cff93.webp" data-type="jpeg" data-w="2284" style="margin-right: auto;margin-left: auto;width: 100%;border-radius: 5px;display: block;margin-bottom: 15px;">
-
StackCheck 字節(jié)碼指令就是檢查棧是否達到了溢出的上限。
-
Ldar 表示將寄存器中的值加載到累加器中。
-
Add 表示寄存器加載值并將其與累加器中的值相加,然后將結(jié)果再次放入累加器。
-
Star 表示 把累加器中的值保存到某個寄存器中。
-
Return 結(jié)束當(dāng)前函數(shù)的執(zhí)行,并將控制權(quán)傳回給調(diào)用方。返回的值是累加器中的值。
5、即時編譯
在解釋器 Ignition 執(zhí)行字節(jié)碼的過程中,如果發(fā)現(xiàn)有熱點代碼(HotSpot),比如一段代碼被重復(fù)執(zhí)行多次,這種就稱為熱點代碼,那么后臺的編譯器 TurboFan 就會把該段熱點的字節(jié)碼編譯為高效的機器碼,然后當(dāng)再次執(zhí)行這段被優(yōu)化的代碼時,只需要執(zhí)行編譯后的機器碼就可以了,這樣就大大提升了代碼的執(zhí)行效率。這種字節(jié)碼配合解釋器和編譯器的技術(shù)被稱為即時編譯(JIT)。
V8 的優(yōu)化策略
下面我們來看一下,V8 為了提升解析和執(zhí)行 Js 的速度,做了哪些優(yōu)化。由于篇幅關(guān)系,這里只介紹 5 個優(yōu)化點。
1、重新引入字節(jié)碼
早期的 V8 團隊認為先生成字節(jié)碼再執(zhí)行字節(jié)碼的方式會降低代碼的執(zhí)行效率,于是直接將 JavaScript 代碼編譯成機器代碼。這樣做帶來的問題有兩點,一是需要較長的編譯時間,二是產(chǎn)生的二進制機器碼需要占用較大的內(nèi)存空間。使用字節(jié)碼的話雖然犧牲了一點執(zhí)行效率,但是節(jié)省了內(nèi)存空間并且降低了編譯時間。此外,字節(jié)碼也降低了 V8 代碼的復(fù)雜度,使得 V8 移植到不同的 CPU 架構(gòu)平臺更加容易。這是因為統(tǒng)一將字節(jié)碼轉(zhuǎn)換為不同平臺的二進制代碼要比編譯器編寫不同 CPU 體系的二進制代碼更加容易。
2、延遲解析
通過 V8 的編譯流程我們可以看出,V8 執(zhí)行 JavaScript 代碼需要經(jīng)過編譯和執(zhí)行兩個階段。
-
編譯過程:是指 V8 將 JavaScript 代碼轉(zhuǎn)換為字節(jié)碼,或者二進制機器代碼的階段。
-
執(zhí)行階段:是指解釋器解釋執(zhí)行字節(jié)碼,或者是 CPU 直接執(zhí)行二進制機器代碼的階段。
V8 并不會一次性將所有的 JavaScript 解析為中間代碼,這主要是基于以下兩點:
-
如果一次解析和編譯所有的 JavaScript 代碼,過多的代碼會增加編譯時間,這會嚴重影響到首次執(zhí)行 JavaScript 代碼的速度,讓用戶感覺到卡頓。
-
其次,解析完成的字節(jié)碼和編譯之后的機器代碼都會存放在內(nèi)存中,如果一次性解析和編譯所有 JavaScript 代碼,那么這些中間代碼和機器代碼將會一直占用內(nèi)存。
延遲解析是指解析器在解析的過程中,如果遇到函數(shù)聲明,那么會跳過函數(shù)內(nèi)部的代碼,并不會為其生成 AST 和字節(jié)碼。
3、隱藏類
我們可以結(jié)合一段代碼來分析下隱藏類是怎么工作的:
let point = {x:100,y:200}
當(dāng) V8 執(zhí)行到這段代碼時,會先為 point 對象創(chuàng)建一個隱藏類,在 V8 中,把隱藏類又稱為 map,每個對象都有一個 map 屬性,其值指向內(nèi)存中的隱藏類。隱藏類描述了對象的屬性布局,它主要包括了屬性名稱和每個屬性所對應(yīng)的偏移量,比如 point 對象的隱藏類就包括了 x 和 y 屬性,x 的偏移量是 4,y 的偏移量是 8。
有了隱藏類之后,那么當(dāng) V8 訪問某個對象中的某個屬性時,就會先去隱藏類中查找該屬性相對于它的對象的偏移量,有了偏移量和屬性類型,V8 就可以直接去內(nèi)存中取出對應(yīng)的屬性值,而不需要經(jīng)歷一系列的查找過程,那么這就大大提升了 V8 查找對象的效率。
4、快屬性與慢屬性
當(dāng)我們在控制臺輸入如下代碼時:
function Foo() {
this[100] = 'test-100'
this[1] = 'test-1'
this["B"] = 'bar-B'
this[50] = 'test-50'
this[9] = 'test-9'
this[8] = 'test-8'
this[3] = 'test-3'
this[5] = 'test-5'
this["A"] = 'bar-A'
this["C"] = 'bar-C'
}
var bar = new Foo()
for(key in bar){
console.log(`index:${key} value:${bar[key]}`)
}
打印出來的結(jié)果如下:
index:1 value:test-1
index:3 value:test-3
index:5 value:test-5
index:8 value:test-8
index:9 value:test-9
index:50 value:test-50
index:100 value:test-100
index:B value:bar-B
index:A value:bar-A
index:C value:bar-C
之所以出現(xiàn)這樣的結(jié)果,是因為在 ECMAScript 規(guī)范中定義了數(shù)字屬性應(yīng)該按照索引值大小升序排列,字符串屬性根據(jù)創(chuàng)建時的順序升序排列。
-
數(shù)字屬性稱為排序?qū)傩裕?V8 中被稱為 elements。
-
字符串屬性就被稱為常規(guī)屬性,在 V8 中被稱為 properties。
下面我們執(zhí)行這樣一段代碼,看一看當(dāng)對象中的屬性數(shù)目發(fā)生變化時,其在內(nèi)存中結(jié)構(gòu)是怎樣變化的。
function Foo(property_num,element_num) {
//添加排序?qū)傩?/span>
for (let i = 0; i < element_num; i++) {
this[i] = `element${i}`
}
//添加常規(guī)屬性
for (let i = 0; i < property_num; i++) {
let ppt = `property${i}`
this[ppt] = ppt
}
}
var bar = new Foo(10,10)
將 Chrome 開發(fā)者工具切換到 Memory 標(biāo)簽,然后點擊左側(cè)的小圓圈就可以捕獲以上代碼的內(nèi)存快照,最終截圖如下所示:
將創(chuàng)建的對象屬性的個數(shù)調(diào)整到 20 個
var bar2 = new Foo(20,10)
總結(jié):當(dāng)對象中的屬性過多時,或者存在反復(fù)添加或者刪除屬性的操作,那么 V8 就會將線性的存儲模式(快屬性)降級為非線性的字典存儲模式(慢屬性),這樣雖然降低了查找速度,但是卻提升了修改對象的屬性的速度。
5、內(nèi)聯(lián)緩存
我們再來看一段這樣的代碼。
function loadX(o) {
o.y = 4
return o.x
}
var o = { x: 1,y:3}
var o1 = { x: 3 ,y:6}
for (var i = 0; i < 90000; i++) {
loadX(o)
loadX(o1)
}
通常 V8 獲取 o.x 的流程是這樣的:查找對象 o 的隱藏類,再通過隱藏類查找 x 屬性偏移量,然后根據(jù)偏移量獲取屬性值,在這段代碼中 loadX 函數(shù)會被反復(fù)執(zhí)行,那么獲取 o.x 流程也需要反復(fù)被執(zhí)行。為了提升對象的查找效率。V8 執(zhí)行的策略就是使用內(nèi)聯(lián)緩存 (Inline Cache),簡稱為 IC。IC 會為每個函數(shù)維護一個反饋向量 (FeedBack Vector),反饋向量記錄了函數(shù)在執(zhí)行過程中的一些關(guān)鍵的中間數(shù)據(jù)。然后將這些數(shù)據(jù)緩存起來,當(dāng)下次再次執(zhí)行該函數(shù)時,V8 就可以直接利用這些中間數(shù)據(jù),節(jié)省了再次獲取這些數(shù)據(jù)的過程。V8 會在反饋向量中為每個調(diào)用點分配一個插槽(Slot),比如 o.y = 4 和 return o.x 這兩段就是調(diào)用點 (CallSite),因為它們使用了對象和屬性。每個插槽中包括了插槽的索引 (slot index)、插槽的類型 (type)、插槽的狀態(tài) (state)、隱藏類 (map) 的地址、還有屬性的偏移量,比如上面這個函數(shù)中的兩個調(diào)用點都使用了對象 o,那么反饋向量兩個插槽中的 map 屬性也都是指向同一個隱藏類的,因此這兩個插槽的 map 地址是一樣的。
通過內(nèi)聯(lián)緩存策略,就能夠提升下次執(zhí)行函數(shù)時的效率,但是這有一個前提,那就是多次執(zhí)行時,對象的形狀是固定的,如果對象的形狀不是固定的,這意味著 V8 為它們創(chuàng)建的隱藏類也是不同的。面對這種情況,V8 會選擇將新的隱藏類也記錄在反饋向量中,同時記錄屬性值的偏移量,這時,反饋向量中的一個槽里就會出現(xiàn)包含了多個隱藏類和偏移量的情況,如果超過 4 個,那么 V8 會采取 hash 表的結(jié)構(gòu)來存儲。講到這里我的分享就結(jié)束了,如果有不足之處歡迎大家多多批評指正。

“分享、點贊、在看” 支持一波 
參考鏈接
https://www.cnblogs.com/nickchen121/p/10722720.html
https://v8.dev/docs
https://juejin.cn/post/6844904161163608078
https://time.geekbang.org/column/article/211682
https://www.jianshu.com/p/e4a75cb6f268
