Js是怎樣運行起來的?
前言
不知道大家有沒有想過這樣一個問題,我們所寫的 JavaScript 代碼是怎樣被計算機認識并且執(zhí)行的呢?這中間的過程具體是怎樣的呢?
有的同學可能已經(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 平臺的兼容性。
匯編語言:用更容易記憶的英文縮寫標識符代替二進制指令,但還是需要開發(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 寄存器中。
分析當前取出指令,并識別出不同的類型的指令,以及各種獲取操作數(shù)的方法。
加載指令:從內(nèi)存中復制指定長度的內(nèi)容到通用寄存器中,并覆蓋寄存器中原來的內(nèi)容。
存儲指令:將寄存器中的內(nèi)容復制到內(nèi)存某個位置,并覆蓋掉內(nèi)存中的這個位置上原來的內(nèi)容。
上圖中 movl 指令后面的 %ecx 就是寄存器地址,-8(%rbp) 是內(nèi)存中的地址,這條指令的作用是將寄存器中的值拷貝到內(nèi)存中。
更新指令:復制兩個寄存器中的內(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 解釋器等。當打開一個渲染進程時,就為 V8 初始化了一個運行時環(huán)境。
運行時環(huán)境為 V8 提供了堆空間,棧空間、全局執(zhí)行上下文、消息循環(huán)系統(tǒng)、宿主對象及宿主 API 等。V8 的核心是實現(xiàn)了 ECMAScript 標準,此外還提供了垃圾回收器等內(nèi)容。
2、解析源碼生成 AST 和作用域
基礎(chǔ)環(huá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í)行階段,作用域中的變量會指向堆和棧中相應的數(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é)碼;棧頂寄存器用來指向當前的棧頂?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é)碼指令就是檢查棧是否達到了溢出的上限。
Ldar 表示將寄存器中的值加載到累加器中。
Add 表示寄存器加載值并將其與累加器中的值相加,然后將結(jié)果再次放入累加器。
Star 表示 把累加器中的值保存到某個寄存器中。
Return 結(jié)束當前函數(shù)的執(zhí)行,并將控制權(quán)傳回給調(diào)用方。返回的值是累加器中的值。
5、即時編譯
在解釋器 Ignition 執(zhí)行字節(jié)碼的過程中,如果發(fā)現(xiàn)有熱點代碼(HotSpot),比如一段代碼被重復執(zhí)行多次,這種就稱為熱點代碼,那么后臺的編譯器 TurboFan 就會把該段熱點的字節(jié)碼編譯為高效的機器碼,然后當再次執(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 代碼的復雜度,使得 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}
當 V8 執(zhí)行到這段代碼時,會先為 point 對象創(chuàng)建一個隱藏類,在 V8 中,把隱藏類又稱為 map,每個對象都有一個 map 屬性,其值指向內(nèi)存中的隱藏類。隱藏類描述了對象的屬性布局,它主要包括了屬性名稱和每個屬性所對應的偏移量,比如 point 對象的隱藏類就包括了 x 和 y 屬性,x 的偏移量是 4,y 的偏移量是 8。
有了隱藏類之后,那么當 V8 訪問某個對象中的某個屬性時,就會先去隱藏類中查找該屬性相對于它的對象的偏移量,有了偏移量和屬性類型,V8 就可以直接去內(nèi)存中取出對應的屬性值,而不需要經(jīng)歷一系列的查找過程,那么這就大大提升了 V8 查找對象的效率。
4、快屬性與慢屬性
當我們在控制臺輸入如下代碼時:
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ù)字屬性應該按照索引值大小升序排列,字符串屬性根據(jù)創(chuàng)建時的順序升序排列。
數(shù)字屬性稱為排序?qū)傩裕?V8 中被稱為 elements。
字符串屬性就被稱為常規(guī)屬性,在 V8 中被稱為 properties。
下面我們執(zhí)行這樣一段代碼,看一看當對象中的屬性數(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 標簽,然后點擊左側(cè)的小圓圈就可以捕獲以上代碼的內(nèi)存快照,最終截圖如下所示:
將創(chuàng)建的對象屬性的個數(shù)調(diào)整到 20 個
var bar2 = new Foo(20,10)
總結(jié):當對象中的屬性過多時,或者存在反復添加或者刪除屬性的操作,那么 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ù)會被反復執(zhí)行,那么獲取 o.x 流程也需要反復被執(zhí)行。為了提升對象的查找效率。V8 執(zhí)行的策略就是使用內(nèi)聯(lián)緩存 (Inline Cache),簡稱為 IC。IC 會為每個函數(shù)維護一個反饋向量 (FeedBack Vector),反饋向量記錄了函數(shù)在執(zhí)行過程中的一些關(guān)鍵的中間數(shù)據(jù)。然后將這些數(shù)據(jù)緩存起來,當下次再次執(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
以上便是本次分享的全部內(nèi)容,希望對你有所幫助^_^
喜歡的話別忘了 分享、點贊、收藏 三連哦~。
歡迎關(guān)注公眾號 前端Sharing 收貨大廠一手好文章~
