Js是怎樣運(yùn)行起來的?
前言
不知道大家有沒有想過這樣一個(gè)問題,我們所寫的 JavaScript 代碼是怎樣被計(jì)算機(jī)認(rèn)識(shí)并且執(zhí)行的呢?這中間的過程具體是怎樣的呢?
有的同學(xué)可能已經(jīng)知道,Js 是通過 Js 引擎運(yùn)行起來的,那么
什么是 Js 引擎?
Js 引擎是怎樣編譯執(zhí)行和優(yōu)化 Js 代碼的?
Js 引擎有很多種,比如 Chrome 使用的 V8 引擎,Webkit 使用的是 JavaScriptCore,React Native 使用的是 Hermes。今天我們主要來分析一下比較主流的 V8 引擎是怎樣運(yùn)行 Js 的。
V8 引擎
在介紹 V8 引擎的概念之前,我們先來回顧一下編程語言。編程語言可以分為機(jī)器語言、匯編語言、高級(jí)語言。
機(jī)器語言:由 0 和 1 組成的二進(jìn)制碼,對(duì)于人類來說是很難記憶的,還要考慮不同 CPU 平臺(tái)的兼容性。
匯編語言:用更容易記憶的英文縮寫標(biāo)識(shí)符代替二進(jìn)制指令,但還是需要開發(fā)人員有足夠的硬件知識(shí)。
高級(jí)語言:更簡(jiǎn)單抽象且不需要考慮硬件,但是需要更復(fù)雜、耗時(shí)更久的翻譯過程才能被執(zhí)行。
到了這里我們知道,高級(jí)語言一定要轉(zhuǎn)化為機(jī)器語言才能被計(jì)算機(jī)執(zhí)行,而且越高級(jí)的語言轉(zhuǎn)化的時(shí)間越久。高級(jí)語言又可以分為解釋型語言、編譯型語言。
編譯型語言:需要編譯器進(jìn)行一次編譯,被編譯過的文件可以多次執(zhí)行。如 C++、C 語言。
解釋型語言:不需要事先編譯,通過解釋器一邊解釋一邊執(zhí)行。啟動(dòng)快,但執(zhí)行慢。
我們知道 JavaScript 是一門高級(jí)語言,并且是動(dòng)態(tài)類型語言,我們?cè)诙x一個(gè)變量時(shí)不需要關(guān)心它的類型,并且可以隨意的修改變量的類型。而在像 C++這樣的靜態(tài)類型語言中,我們必須提前聲明變量的類型并且賦予正確的值才行。也正是因?yàn)?JavaScript 沒有像 C++那樣可以事先提供足夠的信息供編譯器編譯出更加低級(jí)的機(jī)器代碼,它只能在運(yùn)行階段收集類型信息,然后根據(jù)這些信息進(jìn)行編譯再執(zhí)行,所以 JavaScript 也是解釋型語言。
這也就意味著 JavaScript 要想被計(jì)算機(jī)執(zhí)行,需要一個(gè)能夠快速解析并且執(zhí)行 JavaScript 腳本的程序,這個(gè)程序就是我們平時(shí)所說的 JavaScript 引擎。這里我們給出 V8 引擎的概念:V8 是 Google 基于 C++ 編寫的開源高性能 Javascript 與 WebAssembly 引擎。用于 Google Chrome(Google 的開源瀏覽器) 以及 Node.js 等。
CPU 是如何執(zhí)行機(jī)器指令的?
將高級(jí)語言轉(zhuǎn)化為機(jī)器語言之后,CPU 又是怎樣執(zhí)行的呢?我們以一段 C 代碼為例:
int main()
{
int x = 1;
int y = 2;
int z = x + y;
return z;
}}
先來看一下以上代碼被轉(zhuǎn)換為機(jī)器語言是什么樣子。下圖左側(cè)是用十六進(jìn)制表示的二進(jìn)制機(jī)器碼,中間部分是匯編代碼,右側(cè)是指令的含義。
CPU 執(zhí)行機(jī)器指令的流程
首先程序在執(zhí)行之前會(huì)被裝進(jìn)內(nèi)存。
系統(tǒng)會(huì)將二進(jìn)制代碼中的第一條指令的地址寫入到 PC 寄存器中。

CPU 根據(jù) PC 寄存器中的地址,從內(nèi)存中取出指令。
將下一條指令的地址更新到 PC 寄存器中。
分析當(dāng)前取出指令,并識(shí)別出不同的類型的指令,以及各種獲取操作數(shù)的方法。
加載指令:從內(nèi)存中復(fù)制指定長(zhǎng)度的內(nèi)容到通用寄存器中,并覆蓋寄存器中原來的內(nèi)容。
存儲(chǔ)指令:將寄存器中的內(nèi)容復(fù)制到內(nèi)存某個(gè)位置,并覆蓋掉內(nèi)存中的這個(gè)位置上原來的內(nèi)容。
上圖中 movl 指令后面的 %ecx 就是寄存器地址,-8(%rbp) 是內(nèi)存中的地址,這條指令的作用是將寄存器中的值拷貝到內(nèi)存中。
更新指令:復(fù)制兩個(gè)寄存器中的內(nèi)容到 ALU 中,也可以是一塊寄存器和一塊內(nèi)存中的內(nèi)容到 ALU 中,ALU 將兩個(gè)字相加,并將結(jié)果存放在其中的一個(gè)寄存器中,并覆蓋該寄存器中的內(nèi)容。
...
執(zhí)行指令完畢,進(jìn)入下一個(gè) CPU 時(shí)鐘周期。
V8 引擎的編譯流水線
接下來我們先從宏觀的角度來看一下 V8 是怎么執(zhí)行 JavaScript 代碼的,然后再對(duì)每一步進(jìn)行分析。
初始化基礎(chǔ)環(huán)境; 解析源碼生成 AST 和作用域; 依據(jù) AST 和作用域生成字節(jié)碼; 解釋執(zhí)行字節(jié)碼;監(jiān)聽熱點(diǎn)代碼; ...

完整的分析一段 JavaScript 代碼是怎樣被執(zhí)行的
1、初始化基礎(chǔ)環(huán)境
V8 執(zhí)行 Js 代碼是離不開宿主環(huán)境的,V8 的宿主可以是瀏覽器,也可以是 Node.js。下圖是瀏覽器的組成結(jié)構(gòu),其中渲染引擎就是平時(shí)所說的瀏覽器內(nèi)核,它包括網(wǎng)絡(luò)模塊,Js 解釋器等。當(dāng)打開一個(gè)渲染進(jìn)程時(shí),就為 V8 初始化了一個(gè)運(yùn)行時(shí)環(huán)境。
運(yùn)行時(shí)環(huán)境為 V8 提供了堆空間,棧空間、全局執(zhí)行上下文、消息循環(huán)系統(tǒng)、宿主對(duì)象及宿主 API 等。V8 的核心是實(shí)現(xiàn)了 ECMAScript 標(biāo)準(zhǔn),此外還提供了垃圾回收器等內(nèi)容。
2、解析源碼生成 AST 和作用域
基礎(chǔ)環(huán)境準(zhǔn)備好之后,接下來就可以向 V8 提交要執(zhí)行的 JavaScript 代碼了。首先 V8 會(huì)接收到要執(zhí)行的 JavaScript 源代碼,不過這對(duì) V8 來說只是一堆字符串,V8 并不能直接理解這段字符串的含義,它需要結(jié)構(gòu)化這段字符串。
function add(x, y) {
var z = x+y
return z
}
console.log(add(1, 2))
比如針對(duì)如上源代碼,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 的同時(shí),還生成了 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ù),都被放進(jìn)作用域中,如果是普通變量,那么默認(rèn)值是 undefined,如果是函數(shù)聲明,那么將指向?qū)嶋H的函數(shù)對(duì)象。在執(zhí)行階段,作用域中的變量會(huì)指向堆和棧中相應(yīng)的數(shù)據(jù)。
3、依據(jù) AST 和作用域生成字節(jié)碼
生成了作用域和 AST 之后,V8 就可以依據(jù)它們來生成字節(jié)碼了。AST 之后會(huì)被作為輸入傳到字節(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í)行二進(jìn)制機(jī)器代碼類似:使用內(nèi)存中的一塊區(qū)域來存放字節(jié)碼;使通用寄存器用來存放一些中間數(shù)據(jù);PC 寄存器用來指向下一條要執(zhí)行的字節(jié)碼;棧頂寄存器用來指向當(dāng)前的棧頂?shù)奈恢谩?img data-ratio="0.4001751313485114" src="https://filescdn.proginn.com/f284b88fdb7203ffd4e6da1537e41555/4a0651d2fcde7faab3b3b7b49f5df104.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é)碼指令就是檢查棧是否達(dá)到了溢出的上限。
Ldar 表示將寄存器中的值加載到累加器中。
Add 表示寄存器加載值并將其與累加器中的值相加,然后將結(jié)果再次放入累加器。
Star 表示 把累加器中的值保存到某個(gè)寄存器中。
Return 結(jié)束當(dāng)前函數(shù)的執(zhí)行,并將控制權(quán)傳回給調(diào)用方。返回的值是累加器中的值。
5、即時(shí)編譯
在解釋器 Ignition 執(zhí)行字節(jié)碼的過程中,如果發(fā)現(xiàn)有熱點(diǎn)代碼(HotSpot),比如一段代碼被重復(fù)執(zhí)行多次,這種就稱為熱點(diǎn)代碼,那么后臺(tái)的編譯器 TurboFan 就會(huì)把該段熱點(diǎn)的字節(jié)碼編譯為高效的機(jī)器碼,然后當(dāng)再次執(zhí)行這段被優(yōu)化的代碼時(shí),只需要執(zhí)行編譯后的機(jī)器碼就可以了,這樣就大大提升了代碼的執(zhí)行效率。這種字節(jié)碼配合解釋器和編譯器的技術(shù)被稱為即時(shí)編譯(JIT)。
V8 的優(yōu)化策略
下面我們來看一下,V8 為了提升解析和執(zhí)行 Js 的速度,做了哪些優(yōu)化。由于篇幅關(guān)系,這里只介紹 5 個(gè)優(yōu)化點(diǎn)。
1、重新引入字節(jié)碼
早期的 V8 團(tuán)隊(duì)認(rèn)為先生成字節(jié)碼再執(zhí)行字節(jié)碼的方式會(huì)降低代碼的執(zhí)行效率,于是直接將 JavaScript 代碼編譯成機(jī)器代碼。這樣做帶來的問題有兩點(diǎn),一是需要較長(zhǎng)的編譯時(shí)間,二是產(chǎn)生的二進(jìn)制機(jī)器碼需要占用較大的內(nèi)存空間。使用字節(jié)碼的話雖然犧牲了一點(diǎn)執(zhí)行效率,但是節(jié)省了內(nèi)存空間并且降低了編譯時(shí)間。此外,字節(jié)碼也降低了 V8 代碼的復(fù)雜度,使得 V8 移植到不同的 CPU 架構(gòu)平臺(tái)更加容易。這是因?yàn)榻y(tǒng)一將字節(jié)碼轉(zhuǎn)換為不同平臺(tái)的二進(jìn)制代碼要比編譯器編寫不同 CPU 體系的二進(jìn)制代碼更加容易。
2、延遲解析
通過 V8 的編譯流程我們可以看出,V8 執(zhí)行 JavaScript 代碼需要經(jīng)過編譯和執(zhí)行兩個(gè)階段。
編譯過程:是指 V8 將 JavaScript 代碼轉(zhuǎn)換為字節(jié)碼,或者二進(jìn)制機(jī)器代碼的階段。
執(zhí)行階段:是指解釋器解釋執(zhí)行字節(jié)碼,或者是 CPU 直接執(zhí)行二進(jìn)制機(jī)器代碼的階段。
V8 并不會(huì)一次性將所有的 JavaScript 解析為中間代碼,這主要是基于以下兩點(diǎn):
如果一次解析和編譯所有的 JavaScript 代碼,過多的代碼會(huì)增加編譯時(shí)間,這會(huì)嚴(yán)重影響到首次執(zhí)行 JavaScript 代碼的速度,讓用戶感覺到卡頓。
其次,解析完成的字節(jié)碼和編譯之后的機(jī)器代碼都會(huì)存放在內(nèi)存中,如果一次性解析和編譯所有 JavaScript 代碼,那么這些中間代碼和機(jī)器代碼將會(huì)一直占用內(nèi)存。
延遲解析是指解析器在解析的過程中,如果遇到函數(shù)聲明,那么會(huì)跳過函數(shù)內(nèi)部的代碼,并不會(huì)為其生成 AST 和字節(jié)碼。
3、隱藏類
我們可以結(jié)合一段代碼來分析下隱藏類是怎么工作的:
let point = {x:100,y:200}
當(dāng) V8 執(zhí)行到這段代碼時(shí),會(huì)先為 point 對(duì)象創(chuàng)建一個(gè)隱藏類,在 V8 中,把隱藏類又稱為 map,每個(gè)對(duì)象都有一個(gè) map 屬性,其值指向內(nèi)存中的隱藏類。隱藏類描述了對(duì)象的屬性布局,它主要包括了屬性名稱和每個(gè)屬性所對(duì)應(yīng)的偏移量,比如 point 對(duì)象的隱藏類就包括了 x 和 y 屬性,x 的偏移量是 4,y 的偏移量是 8。
有了隱藏類之后,那么當(dāng) V8 訪問某個(gè)對(duì)象中的某個(gè)屬性時(shí),就會(huì)先去隱藏類中查找該屬性相對(duì)于它的對(duì)象的偏移量,有了偏移量和屬性類型,V8 就可以直接去內(nèi)存中取出對(duì)應(yīng)的屬性值,而不需要經(jīng)歷一系列的查找過程,那么這就大大提升了 V8 查找對(duì)象的效率。
4、快屬性與慢屬性
當(dāng)我們?cè)诳刂婆_(tái)輸入如下代碼時(shí):
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é)果,是因?yàn)樵?ECMAScript 規(guī)范中定義了數(shù)字屬性應(yīng)該按照索引值大小升序排列,字符串屬性根據(jù)創(chuàng)建時(shí)的順序升序排列。
數(shù)字屬性稱為排序?qū)傩裕?V8 中被稱為 elements。
字符串屬性就被稱為常規(guī)屬性,在 V8 中被稱為 properties。
下面我們執(zhí)行這樣一段代碼,看一看當(dāng)對(duì)象中的屬性數(shù)目發(fā)生變化時(shí),其在內(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)簽,然后點(diǎn)擊左側(cè)的小圓圈就可以捕獲以上代碼的內(nèi)存快照,最終截圖如下所示:
將創(chuàng)建的對(duì)象屬性的個(gè)數(shù)調(diào)整到 20 個(gè)
var bar2 = new Foo(20,10)
總結(jié):當(dāng)對(duì)象中的屬性過多時(shí),或者存在反復(fù)添加或者刪除屬性的操作,那么 V8 就會(huì)將線性的存儲(chǔ)模式(快屬性)降級(jí)為非線性的字典存儲(chǔ)模式(慢屬性),這樣雖然降低了查找速度,但是卻提升了修改對(duì)象的屬性的速度。
5、內(nèi)聯(lián)緩存
我們?cè)賮砜匆欢芜@樣的代碼。
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 的流程是這樣的:查找對(duì)象 o 的隱藏類,再通過隱藏類查找 x 屬性偏移量,然后根據(jù)偏移量獲取屬性值,在這段代碼中 loadX 函數(shù)會(huì)被反復(fù)執(zhí)行,那么獲取 o.x 流程也需要反復(fù)被執(zhí)行。為了提升對(duì)象的查找效率。V8 執(zhí)行的策略就是使用內(nèi)聯(lián)緩存 (Inline Cache),簡(jiǎn)稱為 IC。IC 會(huì)為每個(gè)函數(shù)維護(hù)一個(gè)反饋向量 (FeedBack Vector),反饋向量記錄了函數(shù)在執(zhí)行過程中的一些關(guān)鍵的中間數(shù)據(jù)。然后將這些數(shù)據(jù)緩存起來,當(dāng)下次再次執(zhí)行該函數(shù)時(shí),V8 就可以直接利用這些中間數(shù)據(jù),節(jié)省了再次獲取這些數(shù)據(jù)的過程。V8 會(huì)在反饋向量中為每個(gè)調(diào)用點(diǎn)分配一個(gè)插槽(Slot),比如 o.y = 4 和 return o.x 這兩段就是調(diào)用點(diǎn) (CallSite),因?yàn)樗鼈兪褂昧藢?duì)象和屬性。每個(gè)插槽中包括了插槽的索引 (slot index)、插槽的類型 (type)、插槽的狀態(tài) (state)、隱藏類 (map) 的地址、還有屬性的偏移量,比如上面這個(gè)函數(shù)中的兩個(gè)調(diào)用點(diǎn)都使用了對(duì)象 o,那么反饋向量?jī)蓚€(gè)插槽中的 map 屬性也都是指向同一個(gè)隱藏類的,因此這兩個(gè)插槽的 map 地址是一樣的。
通過內(nèi)聯(lián)緩存策略,就能夠提升下次執(zhí)行函數(shù)時(shí)的效率,但是這有一個(gè)前提,那就是多次執(zhí)行時(shí),對(duì)象的形狀是固定的,如果對(duì)象的形狀不是固定的,這意味著 V8 為它們創(chuàng)建的隱藏類也是不同的。面對(duì)這種情況,V8 會(huì)選擇將新的隱藏類也記錄在反饋向量中,同時(shí)記錄屬性值的偏移量,這時(shí),反饋向量中的一個(gè)槽里就會(huì)出現(xiàn)包含了多個(gè)隱藏類和偏移量的情況,如果超過 4 個(gè),那么 V8 會(huì)采取 hash 表的結(jié)構(gòu)來存儲(chǔ)。講到這里我的分享就結(jié)束了,如果有不足之處歡迎大家多多批評(píng)指正。
參考鏈接
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
以上便是本次分享的全部?jī)?nèi)容,希望對(duì)你有所幫助^_^
喜歡的話別忘了 分享、點(diǎn)贊、收藏 三連哦~。
歡迎關(guān)注公眾號(hào) 前端Sharing 收貨大廠一手好文章~
