瀏覽器是如何工作的:Chrome V8讓你更懂JavaScript

??
作者:孤蓬
來源:SegmentFault 思否社區(qū)
V8 是由 Google 開發(fā)的開源 JavaScript 引擎,也被稱為虛擬機,模擬實際計算機各種功能來實現(xiàn)代碼的編譯和執(zhí)行。
記得那年花下,深夜,初識謝娘時
為什么需要 JavaScript 引擎
??
我們寫的 JavaScript 代碼直接交給瀏覽器或者 Node 執(zhí)行時,底層的 CPU 是不認識的,也沒法執(zhí)行。CPU 只認識自己的指令集,指令集對應的是匯編代碼。寫匯編代碼是一件很痛苦的事情。并且不同類型的 CPU 的指令集是不一樣的,那就意味著需要給每一種 CPU 重寫匯編代碼。
??
JavaScirpt 引擎可以將 JS 代碼編譯為不同 CPU(Intel, ARM 以及 MIPS 等)對應的匯編代碼,這樣我們就不需要去翻閱每個 CPU 的指令集手冊來編寫匯編代碼了。當然,JavaScript 引擎的工作也不只是編譯代碼,它還要負責執(zhí)行代碼、分配內(nèi)存以及垃圾回收。
1000100111011000??#機器指令
mov?ax,bx?????????#匯編指令
資料拓展:?匯編語言入門教程【阮一峰】?|?理解 V8 的字節(jié)碼「譯」
https://zhuanlan.zhihu.com/p/28590489
熱門 JavaScript 引擎
V8 (Google),用 C++編寫,開放源代碼,由 Google 丹麥開發(fā),是 Google Chrome 的一部分,也用于 Node.js。 JavaScriptCore (Apple),開放源代碼,用于 webkit 型瀏覽器,如 Safari ,2008 年實現(xiàn)了編譯器和字節(jié)碼解釋器,升級為了 SquirrelFish。蘋果內(nèi)部代號為“Nitro”的 JavaScript 引擎也是基于 JavaScriptCore 引擎的。 Rhino,由 Mozilla 基金會管理,開放源代碼,完全以 Java 編寫,用于 HTMLUnit SpiderMonkey (Mozilla),第一款 JavaScript 引擎,早期用于 Netscape Navigator,現(xiàn)時用于 Mozilla Firefox。 Chakra (JScript 引擎),用于 Internet Explorer。 Chakra (JavaScript 引擎),用于 Microsoft Edge。 KJS,KDE 的 ECMAScript/JavaScript 引擎,最初由哈里·波頓開發(fā),用于 KDE 項目的 Konqueror 網(wǎng)頁瀏覽器中。 JerryScript — 三星推出的適用于嵌入式設備的小型 JavaScript 引擎。 其他:Nashorn、QuickJS 、 Hermes
V8

與君初相識,猶如故人歸
什么是 D8
安裝 D8
方法一:自行下載編譯 v8 google 下載及編譯使用 官方文檔:Using d8
方法二:使用編譯好的 d8 工具
mac 平臺: https://storage.googleapis.com/chromium-v8/official/canary/v8-mac64-dbg-8.4.109.zip linux32 平臺: https://storage.googleapis.com/chromium-v8/official/canary/v8-linux32-dbg-8.4.109.zip linux64 平臺: https://storage.googleapis.com/chromium-v8/official/canary/v8-linux64-dbg-8.4.109.zip win32 平臺: https://storage.googleapis.com/chromium-v8/official/canary/v8-win32-dbg-8.4.109.zip win64 平臺: https://storage.googleapis.com/chromium-v8/official/canary/v8-win64-dbg-8.4.109.zip
//?解壓文件,點擊d8打開(mac安全策略限制的話,按住control,再點擊,彈出菜單中選擇打開)
??V8?version?8.4.109
??d8>?1?+?2
????3
??d8>?2?+?'4'
????"24"
??d8>?console.log(23)
????23
????undefined
??d8>?var?a?=?1
????undefined
??d8>?a?+?2
????3
??d8>?this
????[object?global]
??d8>
?V8:
????#?d8可執(zhí)行文件
????d8
????icudtl.dat
????libc++.dylib
????libchrome_zlib.dylib
????libicui18n.dylib
????libicuuc.dylib
????libv8.dylib
????libv8_debug_helper.dylib
????libv8_for_testing.dylib
????libv8_libbase.dylib
????libv8_libplatform.dylib
????obj
????snapshot_blob.bin
????v8_build_config.json
????#?新建的js示例文件
????test.js
方法三:mac
#?如果已有HomeBrew,忽略第一條命令
??ruby?-e?"$(curl?-fsSL?https://raw.githubusercontent.com/Homebrew/install/master/install)"
??brew?install?v8
方法四:使用 node 代替,比如可以用node --print-bytecode ./test.js,打印出 Ignition(解釋器)生成的 Bytecode(字節(jié)碼)。
都有哪些 d8 命令可供使用?
查看 d8 命令
#?如果不想使用./d8這種方式進行調(diào)試,可將d8加入環(huán)境變量,之后就可以直接`d8?--help`了
??./d8?--help
過濾特定的命令,如:
#?如果是?Windows?系統(tǒng),可能缺少?grep?程序,請自行下載安裝并添加環(huán)境變量
??./d8?--help?|grep?print
print-bytecode 查看生成的字節(jié)碼 print-opt-code 查看優(yōu)化后的代碼 print-ast 查看中間生成的 AST print-scopes 查看中間生成的作用域 trace-gc 查看這段代碼的內(nèi)存回收狀態(tài) trace-opt 查看哪些代碼被優(yōu)化了 trace-deopt 查看哪些代碼被反優(yōu)化了 turbofan-stats 打印優(yōu)化編譯器的一些統(tǒng)計數(shù)據(jù)
使用 d8 進行調(diào)試
//?test.js
function?sum(a)?{
??var?b?=?6;
??return?a?+?6;
}
console.log(sum(3));#?d8?后面跟上文件名和要執(zhí)行的命令,如執(zhí)行下面這行命令,就會打印出?test.js?文件所生成的字節(jié)碼。
??./d8?./test.js?--print-bytecode
??#?執(zhí)行以下命令,輸出9
??./d8?./test.js
內(nèi)部方法
function?Foo(property_num,?element_num)?{
??//添加可索引屬性
??for?(let?i?=?0;?i?????this[i]?=?`element${i}`;
??}
??//添加常規(guī)屬性
??for?(let?i?=?0;?i?????let?ppt?=?`property${i}`;
????this[ppt]?=?ppt;
??}
}
var?bar?=?new?Foo(10,?10);
//?檢查一個對象是否擁有快屬性
console.log(%HasFastProperties(bar));
delete?bar.property2;
console.log(%HasFastProperties(bar));
??./d8?--allow-natives-syntax?./test.js
??#?依次打印:true?false
心似雙絲網(wǎng),中有千千結
V8 引擎的內(nèi)部結構
Parser:負責將 JavaScript 源碼轉換為 Abstract Syntax Tree (AST) Ignition:interpreter,即解釋器,負責將 AST 轉換為 Bytecode,解釋執(zhí)行 Bytecode;同時收集 TurboFan 優(yōu)化編譯所需的信息,比如函數(shù)參數(shù)的類型;解釋器執(zhí)行時主要有四個模塊,內(nèi)存中的字節(jié)碼、寄存器、棧、堆。 通常有兩種類型的解釋器,基于棧 (Stack-based)和基于寄存器 (Register-based),基于棧的解釋器使用棧來保存函數(shù)參數(shù)、中間運算結果、變量等;基于寄存器的虛擬機則支持寄存器的指令操作,使用寄存器來保存參數(shù)、中間計算結果。通常,基于棧的虛擬機也定義了少量的寄存器,基于寄存器的虛擬機也有堆棧,其區(qū)別體現(xiàn)在它們提供的指令集體系。大多數(shù)解釋器都是基于棧的,比如 Java 虛擬機,.Net 虛擬機,還有早期的 V8 虛擬機。基于堆棧的虛擬機在處理函數(shù)調(diào)用、解決遞歸問題和切換上下文時簡單明快。而現(xiàn)在的 V8 虛擬機則采用了基于寄存器的設計,它將一些中間數(shù)據(jù)保存到寄存器中。
基于寄存器的解釋器架構:
TurboFan:compiler,即編譯器,利用 Ignitio 所收集的類型信息,將 Bytecode 轉換為優(yōu)化的匯編代碼; Orinoco:garbage collector,垃圾回收模塊,負責將程序不再需要的內(nèi)存空間回收。
其中,Parser,Ignition 以及 TurboFan 可以將 JS 源碼編譯為匯編代碼,其流程圖如下:

??
簡單地說,Parser 將 JS 源碼轉換為 AST,然后 Ignition 將 AST 轉換為 Bytecode,最后 TurboFan 將 Bytecode 轉換為經(jīng)過優(yōu)化的 Machine Code(實際上是匯編代碼)。
如果函數(shù)沒有被調(diào)用,則 V8 不會去編譯它。 如果函數(shù)只被調(diào)用 1 次,則 Ignition 將其編譯 Bytecode 就直接解釋執(zhí)行了。TurboFan 不會進行優(yōu)化編譯,因為它需要 Ignition 收集函數(shù)執(zhí)行時的類型信息。這就要求函數(shù)至少需要執(zhí)行 1 次,TurboFan 才有可能進行優(yōu)化編譯。 如果函數(shù)被調(diào)用多次,則它有可能會被識別為熱點函數(shù),且 Ignition 收集的類型信息證明可以進行優(yōu)化編譯的話,這時 TurboFan 則會將 Bytecode 編譯為 Optimized Machine Code(已優(yōu)化的機器碼),以提高代碼的執(zhí)行性能。
function?add(x,?y)?{
??return?x?+?y;
}
add(3,?5);
add('3',?'5');
V8 是怎么執(zhí)行一段 JavaScript 代碼的
在 V8 出現(xiàn)之前,所有的 JavaScript 虛擬機所采用的都是解釋執(zhí)行的方式,這是 JavaScript 執(zhí)行速度過慢的一個主要原因。而 V8 率先引入了即時編譯(JIT)的雙輪驅(qū)動的設計(混合使用編譯器和解釋器的技術),這是一種權衡策略,混合編譯執(zhí)行和解釋執(zhí)行這兩種手段,給 JavaScript 的執(zhí)行速度帶來了極大的提升。V8 出現(xiàn)之后,各大廠商也都在自己的 JavaScript 虛擬機中引入了 JIT 機制,所以目前市面上 JavaScript 虛擬機都有著類似的架構。另外,V8 也是早于其他虛擬機引入了惰性編譯、內(nèi)聯(lián)緩存、隱藏類等機制,進一步優(yōu)化了 JavaScript 代碼的編譯執(zhí)行效率。
V8 執(zhí)行一段 JavaScript 的流程圖: 
V8 本質(zhì)上是一個虛擬機,因為計算機只能識別二進制指令,所以要讓計算機執(zhí)行一段高級語言通常有兩種手段: 第一種是將高級代碼轉換為二進制代碼,再讓計算機去執(zhí)行; 另外一種方式是在計算機安裝一個解釋器,并由解釋器來解釋執(zhí)行。
解釋執(zhí)行和編譯執(zhí)行都有各自的優(yōu)缺點,解釋執(zhí)行啟動速度快,但是執(zhí)行時速度慢,而編譯執(zhí)行啟動速度慢,但是執(zhí)行速度快。為了充分地利用解釋執(zhí)行和編譯執(zhí)行的優(yōu)點,規(guī)避其缺點,V8 采用了一種權衡策略,在啟動過程中采用了解釋執(zhí)行的策略,但是如果某段代碼的執(zhí)行頻率超過一個值,那么 V8 就會采用優(yōu)化編譯器將其編譯成執(zhí)行效率更加高效的機器代碼。
總結:
V8 執(zhí)行一段 JavaScript 代碼所經(jīng)歷的主要流程包括:
初始化基礎環(huán)境; 解析源碼生成 AST 和作用域; 依據(jù) AST 和作用域生成字節(jié)碼; 解釋執(zhí)行字節(jié)碼; 監(jiān)聽熱點代碼; 優(yōu)化熱點代碼為二進制的機器代碼; 反優(yōu)化生成的二進制機器代碼。
一等公民與閉包
一等公民的定義
在編程語言中,一等公民可以作為函數(shù)參數(shù),可以作為函數(shù)返回值,也可以賦值給變量。
如果某個編程語言的函數(shù),可以和這個語言的數(shù)據(jù)類型做一樣的事情,我們就把這個語言中的函數(shù)稱為一等公民。例如,字符串在幾乎所有編程語言中都是一等公民,字符串可以做為函數(shù)參數(shù),字符串可以作為函數(shù)返回值,字符串也可以賦值給變量。對于各種編程語言來說,函數(shù)就不一定是一等公民了,比如 Java 8 之前的版本。 對于 JavaScript 來說,函數(shù)可以賦值給變量,也可以作為函數(shù)參數(shù),還可以作為函數(shù)返回值,因此 JavaScript 中函數(shù)是一等公民。
動態(tài)作用域與靜態(tài)作用域
如果一門語言的作用域是靜態(tài)作用域,那么符號之間的引用關系能夠根據(jù)程序代碼在編譯時就確定清楚,在運行時不會變。某個函數(shù)是在哪聲明的,就具有它所在位置的作用域。它能夠訪問哪些變量,那么就跟這些變量綁定了,在運行時就一直能訪問這些變量。即靜態(tài)作用域可以由程序代碼決定,在編譯時就能完全確定。大多數(shù)語言都是靜態(tài)作用域的。 動態(tài)作用域(Dynamic Scope)。也就是說,變量引用跟變量聲明不是在編譯時就綁定死了的。在運行時,它是在運行環(huán)境中動態(tài)地找一個相同名稱的變量。在 macOS 或 Linux 中用的 bash 腳本語言,就是動態(tài)作用域的。
閉包的三個基礎特性
JavaScript 語言允許在函數(shù)內(nèi)部定義新的函數(shù) 可以在內(nèi)部函數(shù)中訪問父函數(shù)中定義的變量 因為 JavaScript 中的函數(shù)是一等公民,所以函數(shù)可以作為另外一個函數(shù)的返回值
//?閉包(靜態(tài)作用域,一等公民,調(diào)用棧的矛盾體)
function?foo()?{
??var?d?=?20;
??return?function?inner(a,?b)?{
????const?c?=?a?+?b?+?d;
????return?c;
??};
}
const?f?=?foo();
惰性解析
在編譯 JavaScript 代碼的過程中,V8 并不會一次性將所有的 JavaScript 解析為中間代碼,這主要是基于以下兩點: 首先,如果一次解析和編譯所有的 JavaScript 代碼,過多的代碼會增加編譯時間,這會嚴重影響到首次執(zhí)行 JavaScript 代碼的速度,讓用戶感覺到卡頓。因為有時候一個頁面的 JavaScript 代碼很大,如果要將所有的代碼一次性解析編譯完成,那么會大大增加用戶的等待時間; 其次,解析完成的字節(jié)碼和編譯之后的機器代碼都會存放在內(nèi)存中,如果一次性解析和編譯所有 JavaScript 代碼,那么這些中間代碼和機器代碼將會一直占用內(nèi)存。
基于以上的原因,所有主流的 JavaScript 虛擬機都實現(xiàn)了惰性解析。 閉包給惰性解析帶來的問題:上文的 d 不能隨著 foo 函數(shù)的執(zhí)行上下文被銷毀掉。
預解析器
判斷當前函數(shù)是不是存在一些語法上的錯誤,發(fā)現(xiàn)了語法錯誤,那么就會向 V8 拋出語法錯誤; 檢查函數(shù)內(nèi)部是否引用了外部變量,如果引用了外部的變量,預解析器會將棧中的變量復制到堆中,在下次執(zhí)行到該函數(shù)的時候,直接使用堆中的引用,這樣就解決了閉包所帶來的問題。
V8 內(nèi)部是如何存儲對象的:快屬性和慢屬性
//?test.js
function?Foo()?{
??this[200]?=?'test-200';
??this[1]?=?'test-1';
??this[100]?=?'test-100';
??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['D']?=?'bar-D';
??this['C']?=?'bar-C';
}
var?bar?=?new?Foo();
for?(key?in?bar)?{
??console.log(`index:${key}??value:${bar[key]}`);
}
//輸出:
//?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:200??value:test-200
//?index:B??value:bar-B
//?index:D??value:bar-D
//?index:C??value:bar-C
function?Foo(property_num,?element_num)?{
??//添加可索引屬性
??for?(let?i?=?0;?i?????this[i]?=?`element${i}`;
??}
??//添加常規(guī)屬性
??for?(let?i?=?0;?i?????let?ppt?=?`property${i}`;
????this[ppt]?=?ppt;
??}
}
var?bar?=?new?Foo(10,?10);

堆空間和棧空間
棧空間
現(xiàn)代語言都是基于函數(shù)的,每個函數(shù)在執(zhí)行過程中,都有自己的生命周期和作用域,當函數(shù)執(zhí)行結束時,其作用域也會被銷毀,因此,我們會使用棧這種數(shù)據(jù)結構來管理函數(shù)的調(diào)用過程,我們也把管理函數(shù)調(diào)用過程的棧結構稱之為調(diào)用棧。
棧空間主要是用來管理 JavaScript 函數(shù)調(diào)用的,棧是內(nèi)存中連續(xù)的一塊空間,同時棧結構是“先進后出”的策略。在函數(shù)調(diào)用過程中,涉及到上下文相關的內(nèi)容都會存放在棧上,比如原生類型、引用到的對象的地址、函數(shù)的執(zhí)行狀態(tài)、this 值等都會存在在棧上。當一個函數(shù)執(zhí)行結束,那么該函數(shù)的執(zhí)行上下文便會被銷毀掉。
棧空間的最大的特點是空間連續(xù),所以在棧中每個元素的地址都是固定的,因此棧空間的查找效率非常高,但是通常在內(nèi)存中,很難分配到一塊很大的連續(xù)空間,因此,V8 對棧空間的大小做了限制,如果函數(shù)調(diào)用層過深,那么 V8 就有可能拋出棧溢出的錯誤。
棧的優(yōu)勢和缺點: 棧的結構非常適合函數(shù)調(diào)用過程。 在棧上分配資源和銷毀資源的速度非常快,這主要歸結于棧空間是連續(xù)的,分配空間和銷毀空間只需要移動下指針就可以了。 雖然操作速度非常快,但是棧也是有缺點的,其中最大的缺點也是它的優(yōu)點所造成的,那就是棧是連續(xù)的,所以要想在內(nèi)存中分配一塊連續(xù)的大空間是非常難的,因此棧空間是有限的。 //?棧溢出
function?factorial(n)?{
??if?(n?===?1)?{
????return?1;
??}
??return?n?*?factorial(n?-?1);
}
console.log(factorial(50000));
堆空間
堆空間是一種樹形的存儲結構,用來存儲對象類型的離散的數(shù)據(jù),JavaScript 中除了原生類型的數(shù)據(jù),其他的都是對象類型,諸如函數(shù)、數(shù)組,在瀏覽器中還有 window 對象、document 對象等,這些都是存在堆空間的。 宿主在啟動 V8 的過程中,會同時創(chuàng)建堆空間和棧空間,再繼續(xù)往下執(zhí)行,產(chǎn)生的新數(shù)據(jù)都會存放在這兩個空間中。
繼承
隱藏屬性__proto__
var?animal?=?{
??type:?'Default',
??color:?'Default',
??getInfo:?function?()?{
????return?`Type?is:?${this.type},color?is?${this.color}.`;
??},
};
var?dog?=?{
??type:?'Dog',
??color:?'Black',
};
dog.__proto__?=?animal;
dog.getInfo();
首先,這是隱藏屬性,并不是標準定義的; 其次,使用該屬性會造成嚴重的性能問題。因為 JavaScript 通過隱藏類優(yōu)化了很多原有的對象結構,所以通過直接修改__proto__會直接破壞現(xiàn)有已經(jīng)優(yōu)化的結構,觸發(fā) V8 重構該對象的隱藏類!
構造函數(shù)是怎么創(chuàng)建對象的?
function?DogFactory(type,?color)?{
??this.type?=?type;
??this.color?=?color;
}
var?dog?=?new?DogFactory('Dog',?'Black');
var?dog?=?{};
dog.__proto__?=?DogFactory.prototype;
DogFactory.call(dog,?'Dog',?'Black');
機器碼、字節(jié)碼
V8 為什么要引入字節(jié)碼
早期的 V8 為了提升代碼的執(zhí)行速度,直接將 JavaScript 源代碼編譯成了沒有優(yōu)化的二進制機器代碼,如果某一段二進制代碼執(zhí)行頻率過高,那么 V8 會將其標記為熱點代碼,熱點代碼會被優(yōu)化編譯器優(yōu)化,優(yōu)化后的機器代碼執(zhí)行效率更高。
隨著移動設備的普及,V8 團隊逐漸發(fā)現(xiàn)將 JavaScript 源碼直接編譯成二進制代碼存在兩個致命的問題: 時間問題:編譯時間過久,影響代碼啟動速度; 空間問題:緩存編譯后的二進制代碼占用更多的內(nèi)存。
這兩個問題無疑會阻礙 V8 在移動設備上的普及,于是 V8 團隊大規(guī)模重構代碼,引入了中間的字節(jié)碼。字節(jié)碼的優(yōu)勢有如下三點: 解決啟動問題:生成字節(jié)碼的時間很短; 解決空間問題:字節(jié)碼雖然占用的空間比原始的 JavaScript 多,但是相較于機器代碼,字節(jié)碼還是小了太多,緩存字節(jié)碼會大大降低內(nèi)存的使用。 代碼架構清晰:采用字節(jié)碼,可以簡化程序的復雜度,使得 V8 移植到不同的 CPU 架構平臺更加容易。
Bytecode 某種程度上就是匯編語言,只是它沒有對應特定的 CPU,或者說它對應的是虛擬的 CPU。這樣的話,生成 Bytecode 時簡單很多,無需為不同的 CPU 生產(chǎn)不同的代碼。要知道,V8 支持 9 種不同的 CPU,引入一個中間層 Bytecode,可以簡化 V8 的編譯流程,提高可擴展性。
如果我們在不同硬件上去生成 Bytecode,會發(fā)現(xiàn)生成代碼的指令是一樣的。
如何查看字節(jié)碼
//?test.js
function?add(x,?y)?{
??var?z?=?x?+?y;
??return?z;
}
console.log(add(1,?2));
[generated?bytecode?for?function:?add?(0x01000824fe59?)]
Parameter?count?3?#三個參數(shù),包括了顯式地傳入的?x?和?y,還有一個隱式地傳入的?this
Register?count?1
Frame?size?8
?????????0x10008250026?@????0?:?25?02?????????????Ldar?a1?#將a1寄存器中的值加載到累加器中,LoaD?Accumulator?from?Register
?????????0x10008250028?@????2?:?34?03?00??????????Add?a0,?[0]
?????????0x1000825002b?@????5?:?26?fb?????????????Star?r0?#Store?Accumulator?to?Register,把累加器中的值保存到r0寄存器中
?????????0x1000825002d?@????7?:?aa????????????????Return??#結束當前函數(shù)的執(zhí)行,并將控制權傳回給調(diào)用方
Constant?pool?(size?=?0)
Handler?Table?(size?=?0)
Source?Position?Table?(size?=?0)
3
Ldar:表示將寄存器中的值加載到累加器中,你可以把它理解為 LoaD Accumulator from Register,就是把某個寄存器中的值,加載到累加器中。
Star:表示 Store Accumulator Register, 你可以把它理解為 Store Accumulator to Register,就是把累加器中的值保存到某個寄存器中
Add:Add a0, [0]是從 a0 寄存器加載值并將其與累加器中的值相加,然后將結果再次放入累加器。 add a0 后面的[0]稱之為 feedback vector slot,又叫反饋向量槽,它是一個數(shù)組,解釋器將解釋執(zhí)行過程中的一些數(shù)據(jù)類型的分析信息都保存在這個反饋向量槽中了,目的是為了給 TurboFan 優(yōu)化編譯器提供優(yōu)化信息,很多字節(jié)碼都會為反饋向量槽提供運行時信息。
LdaSmi:將小整數(shù)(Smi)加載到累加器寄存器中
Return:結束當前函數(shù)的執(zhí)行,并將控制權傳回給調(diào)用方。返回的值是累加器中的值。

隱藏類和內(nèi)聯(lián)緩存
為什么靜態(tài)語言的效率更高?
將靜態(tài)的特性引入到 V8
V8 采用的一個思路就是將 JavaScript 中的對象靜態(tài)化,也就是 V8 在運行 JavaScript 的過程中,會假設 JavaScript 中的對象是靜態(tài)的。
具體地講,V8 對每個對象做如下兩點假設: 對象創(chuàng)建好了之后就不會添加新的屬性; 對象創(chuàng)建好了之后也不會刪除屬性。
符合這兩個假設之后,V8 就可以對 JavaScript 中的對象做深度優(yōu)化了。V8 會為每個對象創(chuàng)建一個隱藏類,對象的隱藏類中記錄了該對象一些基礎的布局信息,包括以下兩點: 對象中所包含的所有的屬性; 每個屬性相對于對象的偏移量。
有了隱藏類之后,那么當 V8 訪問某個對象中的某個屬性時,就會先去隱藏類中查找該屬性相對于它的對象的偏移量,有了偏移量和屬性類型,V8 就可以直接去內(nèi)存中取出對應的屬性值,而不需要經(jīng)歷一系列的查找過程,那么這就大大提升了 V8 查找對象的效率。
在 V8 中,把隱藏類又稱為 map,每個對象都有一個 map 屬性,其值指向內(nèi)存中的隱藏類;
map 描述了對象的內(nèi)存布局,比如對象都包括了哪些屬性,這些數(shù)據(jù)對應于對象的偏移量是多少。
通過 d8 查看隱藏類
//?test.js
let?point1?=?{?x:?100,?y:?200?};
let?point2?=?{?x:?200,?y:?300?};
let?point3?=?{?x:?100?};
%DebugPrint(point1);
%DebugPrint(point2);
%DebugPrint(point3);./d8?--allow-natives-syntax?./test.js#?===============
DebugPrint:?0x1ea3080c5bc5:?[JS_OBJECT_TYPE]
#?V8?為?point1?對象創(chuàng)建的隱藏類
?-?map:?0x1ea308284ce9?
多個對象共用一個隱藏類
在 V8 中,每個對象都有一個 map 屬性,該屬性值指向該對象的隱藏類。不過如果兩個對象的形狀是相同的,V8 就會為其復用同一個隱藏類,這樣有兩個好處: 減少隱藏類的創(chuàng)建次數(shù),也間接加速了代碼的執(zhí)行速度; 減少了隱藏類的存儲空間。
那么,什么情況下兩個對象的形狀是相同的,要滿足以下兩點: 相同的屬性名稱; 相等的屬性個數(shù)。
重新構建隱藏類
給一個對象添加新的屬性,刪除新的屬性,或者改變某個屬性的數(shù)據(jù)類型都會改變這個對象的形狀,那么勢必也就會觸發(fā) V8 為改變形狀后的對象重建新的隱藏類。
//?test.js
let?point?=?{};
%DebugPrint(point);
point.x?=?100;
%DebugPrint(point);
point.y?=?200;
%DebugPrint(point);#?./d8?--allow-natives-syntax?./test.js
DebugPrint:?0x32c7080c5b2d:?[JS_OBJECT_TYPE]
?-?map:?0x32c7082802d9?
每次給對象添加了一個新屬性之后,該對象的隱藏類的地址都會改變,這也就意味著隱藏類也隨著改變了;如果刪除對象的某個屬性,那么對象的形狀也就隨著發(fā)生了改變,這時 V8 也會重建該對象的隱藏類;
最佳實踐 使用字面量初始化對象時,要保證屬性的順序是一致的; 盡量使用字面量一次性初始化完整對象屬性; 盡量避免使用 delete 方法。
通過內(nèi)聯(lián)緩存來提升函數(shù)執(zhí)行效率
function?loadX(obj)?{
??return?obj.x;
}
var?obj?=?{?x:?1,?y:?3?};
var?obj1?=?{?x:?3,?y:?6?};
var?obj2?=?{?x:?3,?y:?6,?z:?8?};
for?(var?i?=?0;?i?90000;?i++)?{
??loadX(obj);
??loadX(obj1);
??//?產(chǎn)生多態(tài)
??loadX(obj2);
}
找對象 obj 的隱藏類; 再通過隱藏類查找 x 屬性偏移量; 然后根據(jù)偏移量獲取屬性值,在這段代碼中 loadX 函數(shù)會被反復執(zhí)行,那么獲取 obj.x 的流程也需要反復被執(zhí)行;
函數(shù) loadX 在一個 for 循環(huán)里面被重復執(zhí)行了很多次,因此 V8 會想盡一切辦法來壓縮這個查找過程,以提升對象的查找效率。這個加速函數(shù)執(zhí)行的策略就是內(nèi)聯(lián)緩存 (Inline Cache),簡稱為 IC; IC 的原理:在 V8 執(zhí)行函數(shù)的過程中,會觀察函數(shù)中一些調(diào)用點 (CallSite)?上的關鍵中間數(shù)據(jù),然后將這些數(shù)據(jù)緩存起來,當下次再次執(zhí)行該函數(shù)的時候,V8 就可以直接利用這些中間數(shù)據(jù),節(jié)省了再次獲取這些數(shù)據(jù)的過程,因此 V8 利用 IC,可以有效提升一些重復代碼的執(zhí)行效率。 IC 會為每個函數(shù)維護一個反饋向量 (FeedBack Vector),反饋向量記錄了函數(shù)在執(zhí)行過程中的一些關鍵的中間數(shù)據(jù)。 反饋向量其實就是一個表結構,它由很多項組成的,每一項稱為一個插槽 (Slot),V8 會依次將執(zhí)行 loadX 函數(shù)的中間數(shù)據(jù)寫入到反饋向量的插槽中。 當 V8 再次調(diào)用 loadX 函數(shù)時,比如執(zhí)行到 loadX 函數(shù)中的 return obj.x 語句時,它就會在對應的插槽中查找 x 屬性的偏移量,之后 V8 就能直接去內(nèi)存中獲取 obj.x 的屬性值了。這樣就大大提升了 V8 的執(zhí)行效率。
如果一個插槽中只包含 1 個隱藏類,那么我們稱這種狀態(tài)為單態(tài) (monomorphic); 如果一個插槽中包含了 2 ~ 4 個隱藏類,那我們稱這種狀態(tài)為多態(tài) (polymorphic); 如果一個插槽中超過 4 個隱藏類,那我們稱這種狀態(tài)為超態(tài) (magamorphic)。 單態(tài)的性能優(yōu)于多態(tài)和超態(tài),所以我們需要稍微避免多態(tài)和超態(tài)的情況。要避免多態(tài)和超態(tài),那么就盡量默認所有的對象屬性是不變的,比如你寫了一個 loadX(obj) 的函數(shù),那么當傳遞參數(shù)時,盡量不要使用多個不同形狀的 obj 對象。
V8 引入了內(nèi)聯(lián)緩存(IC),IC 會監(jiān)聽每個函數(shù)的執(zhí)行過程,并在一些關鍵的地方埋下監(jiān)聽點,這些包括了加載對象屬性 (Load)、給對象屬性賦值 (Store)、還有函數(shù)調(diào)用 (Call),V8 會將監(jiān)聽到的數(shù)據(jù)寫入一個稱為反饋向量 (FeedBack Vector) 的結構中,同時 V8 會為每個執(zhí)行的函數(shù)維護一個反饋向量。有了反饋向量緩存的臨時數(shù)據(jù),V8 就可以縮短對象屬性的查找路徑,從而提升執(zhí)行效率。但是針對函數(shù)中的同一段代碼,如果對象的隱藏類是不同的,那么反饋向量也會記錄這些不同的隱藏類,這就出現(xiàn)了多態(tài)和超態(tài)的情況。我們在實際項目中,要盡量避免出現(xiàn)多態(tài)或者超態(tài)的情況。
異步編程與消息隊列
V8 是如何執(zhí)行回調(diào)函數(shù)的
??

??
setTimeout 的執(zhí)行流程其實是比較簡單的,在 setTimeout 函數(shù)內(nèi)部封裝回調(diào)消息,并將回調(diào)消息添加進消息隊列,然后主線程從消息隊列中取出回調(diào)事件,并執(zhí)行回調(diào)函數(shù)。 XMLHttpRequest 稍微復雜一點,因為下載過程需要放到單獨的一個線程中去執(zhí)行,所以執(zhí)行 XMLHttpRequest.send 的時候,宿主會將實際請求轉發(fā)給網(wǎng)絡線程,然后 send 函數(shù)退出,主線程繼續(xù)執(zhí)行下面的任務。網(wǎng)絡線程在執(zhí)行下載的過程中,會將一些中間信息和回調(diào)函數(shù)封裝成新的消息,并將其添加進消息隊列中,然后主線程從消息隊列中取出回調(diào)事件,并執(zhí)行回調(diào)函數(shù)。
宏任務和微任務
調(diào)用棧:調(diào)用棧是一種數(shù)據(jù)結構,用來管理在主線程上執(zhí)行的函數(shù)的調(diào)用關系。主線程在執(zhí)行任務的過程中,如果函數(shù)的調(diào)用層次過深,可能造成棧溢出的錯誤,我們可以使用 setTimeout 來解決棧溢出的問題。setTimeout 的本質(zhì)是將同步函數(shù)調(diào)用改成異步函數(shù)調(diào)用,這里的異步調(diào)用是將回調(diào)函數(shù)封裝成宏任務,并將其添加進消息隊列中,然后主線程再按照一定規(guī)則循環(huán)地從消息隊列中讀取下一個宏任務。
宏任務:就是指消息隊列中的等待被主線程執(zhí)行的事件。每個宏任務在執(zhí)行時,V8 都會重新創(chuàng)建棧,然后隨著宏任務中函數(shù)調(diào)用,棧也隨之變化,最終,當該宏任務執(zhí)行結束時,整個棧又會被清空,接著主線程繼續(xù)執(zhí)行下一個宏任務。
微任務:你可以把微任務看成是一個需要異步執(zhí)行的函數(shù),執(zhí)行時機是在主函數(shù)執(zhí)行結束之后、當前宏任務結束之前。
JavaScript 中之所以要引入微任務,主要是由于主線程執(zhí)行消息隊列中宏任務的時間顆粒度太粗了,無法勝任一些對精度和實時性要求較高的場景,微任務可以在實時性和效率之間做一個有效的權衡。另外使用微任務,可以改變我們現(xiàn)在的異步編程模型,使得我們可以使用同步形式的代碼來編寫異步調(diào)用。
微任務是基于消息隊列、事件循環(huán)、UI 主線程還有堆棧而來的,然后基于微任務,又可以延伸出協(xié)程、Promise、Generator、await/async 等現(xiàn)代前端經(jīng)常使用的一些技術。

//?不會使瀏覽器卡死
function?foo()?{
??setTimeout(foo,?0);
}
foo();
微任務:
//?瀏覽器console控制臺可使瀏覽器卡死(無法響應鼠標事件等)
function?foo()?{
??return?Promise.resolve().then(foo);
}
foo();
如果當前的任務中產(chǎn)生了一個微任務,通過 Promise.resolve() 或者 Promise.reject() 都會觸發(fā)微任務,觸發(fā)的微任務不會在當前的函數(shù)中被執(zhí)行,所以*執(zhí)行微任務時,不會導致棧的無限擴張; 和異步調(diào)用不同,微任務依然會在當前任務執(zhí)行結束之前被執(zhí)行,這也就意味著在當前微任務執(zhí)行結束之前,消息隊列中的其他任務是不可能被執(zhí)行的。因此在函數(shù)內(nèi)部觸發(fā)的微任務,一定比在函數(shù)內(nèi)部觸發(fā)的宏任務要優(yōu)先執(zhí)行。 微任務依然是在當前的任務中執(zhí)行的,所以如果在微任務中循環(huán)觸發(fā)新的微任務,那么將導致消息隊列中的其他任務沒有機會被執(zhí)行。
前端異步編程方案史

Callback 模式的異步編程模型需要實現(xiàn)大量的回調(diào)函數(shù),大量的回調(diào)函數(shù)會打亂代碼的正常邏輯,使得代碼變得不線性、不易閱讀,這就是我們所說的回調(diào)地獄問題。 Promise 能很好地解決回調(diào)地獄的問題,我們可以按照線性的思路來編寫代碼,這個過程是線性的,非常符合人的直覺。 但是這種方式充滿了 Promise 的 then() 方法,如果處理流程比較復雜的話,那么整段代碼將充斥著大量的 then,語義化不明顯,代碼不能很好地表示執(zhí)行流程。我們想要通過線性的方式來編寫異步代碼,要實現(xiàn)這個理想,最關鍵的是要能實現(xiàn)函數(shù)暫停和恢復執(zhí)行的功能。而生成器就可以實現(xiàn)函數(shù)暫停和恢復,我們可以在生成器中使用同步代碼的邏輯來異步代碼 (實現(xiàn)該邏輯的核心是協(xié)程)。 但是在生成器之外,我們還需要一個觸發(fā)器來驅(qū)動生成器的執(zhí)行。前端的最終方案就是 async/await,async 是一個可以暫停和恢復執(zhí)行的函數(shù),在 async 函數(shù)內(nèi)部使用 await 來暫停 async 函數(shù)的執(zhí)行,await 等待的是一個 Promise 對象,如果 Promise 的狀態(tài)變成 resolve 或者 reject,那么 async 函數(shù)會恢復執(zhí)行。因此,使用 async/await 可以實現(xiàn)以同步的方式編寫異步代碼這一目標。和生成器函數(shù)一樣,使用了 async 聲明的函數(shù)在執(zhí)行時,也是一個單獨的協(xié)程,我們可以使用 await 來暫停該協(xié)程,由于 await 等待的是一個 Promise 對象,我們可以 resolve 來恢復該協(xié)程。
垃圾回收
垃圾數(shù)據(jù)
垃圾回收算法
第一步,通過 GC Root 標記空間中活動對象和非活動對象。目前 V8 采用的可訪問性(reachability)算法來判斷堆中的對象是否是活動對象。具體地講,這個算法是將一些 GC Root 作為初始存活的對象的集合,從 GC Roots 對象出發(fā),遍歷 GC Root 中的所有對象: 全局的 window 對象(位于每個 iframe 中); 文檔 DOM 樹,由可以通過遍歷文檔到達的所有原生 DOM 節(jié)點組成; 存放棧上變量。 通過 GC Root 遍歷到的對象,我們就認為該對象是可訪問的(reachable),那么必須保證這些對象應該在內(nèi)存中保留,我們也稱可訪問的對象為活動對象; 通過 GC Roots 沒有遍歷到的對象,則是不可訪問的(unreachable),那么這些不可訪問的對象就可能被回收,我們稱不可訪問的對象為非活動對象。 在瀏覽器環(huán)境中,GC Root 有很多,通常包括了以下幾種 (但是不止于這幾種):
第二步,回收非活動對象所占據(jù)的內(nèi)存。其實就是在所有的標記完成之后,統(tǒng)一清理內(nèi)存中所有被標記為可回收的對象。
第三步,做內(nèi)存整理。一般來說,頻繁回收對象后,內(nèi)存中就會存在大量不連續(xù)空間,我們把這些不連續(xù)的內(nèi)存空間稱為內(nèi)存碎片。當內(nèi)存中出現(xiàn)了大量的內(nèi)存碎片之后,如果需要分配較大的連續(xù)內(nèi)存時,就有可能出現(xiàn)內(nèi)存不足的情況,所以最后一步需要整理這些內(nèi)存碎片。但這步其實是可選的,因為有的垃圾回收器不會產(chǎn)生內(nèi)存碎片(比如副垃圾回收器)。
垃圾回收
V8 依據(jù)代際假說,將堆內(nèi)存劃分為新生代和老生代兩個區(qū)域,新生代中存放的是生存時間短的對象,老生代中存放生存時間久的對象。代際假說有兩個特點: 第一個是大部分對象都是“朝生夕死”的,也就是說大部分對象在內(nèi)存中存活的時間很短,比如函數(shù)內(nèi)部聲明的變量,或者塊級作用域中的變量,當函數(shù)或者代碼塊執(zhí)行結束時,作用域中定義的變量就會被銷毀。因此這一類對象一經(jīng)分配內(nèi)存,很快就變得不可訪問; 第二個是不死的對象,會活得更久,比如全局的 window、DOM、Web API 等對象。
為了提升垃圾回收的效率,V8 設置了兩個垃圾回收器,主垃圾回收器和副垃圾回收器。 主垃圾回收器主要負責老生代中的垃圾回收。除了新生代中晉升的對象,一些大的對象會直接被分配到老生代里。 老生代中的對象有兩個特點:一個是對象占用空間大;另一個是對象存活時間長。 這種角色翻轉的操作還能讓新生代中的這兩塊區(qū)域無限重復使用下去。 副垃圾回收器每次執(zhí)行清理操作時,都需要將存活的對象從對象區(qū)域復制到空閑區(qū)域,復制操作需要時間成本,如果新生區(qū)空間設置得太大了,那么每次清理的時間就會過久,所以為了執(zhí)行效率,一般新生區(qū)的空間會被設置得比較小。 副垃圾回收器還會采用對象晉升策略,也就是移動那些經(jīng)過兩次垃圾回收依然還存活的對象到老生代中。 主垃圾回收器負責收集老生代中的垃圾數(shù)據(jù),副垃圾回收器負責收集新生代中的垃圾數(shù)據(jù)。 副垃圾回收器采用了 Scavenge 算法,是把新生代空間對半劃分為兩個區(qū)域(有些地方也稱作From和To空間),一半是對象區(qū)域,一半是空閑區(qū)域。新的數(shù)據(jù)都分配在對象區(qū)域,等待對象區(qū)域快分配滿的時候,垃圾回收器便執(zhí)行垃圾回收操作,之后將存活的對象從對象區(qū)域拷貝到空閑區(qū)域,并將兩個區(qū)域互換。 主垃圾回收器回收器主要負責老生代中的垃圾數(shù)據(jù)的回收操作,會經(jīng)歷標記、清除和整理過程。
Stop-The-World
V8 最開始的垃圾回收器有兩個特點: 第一個是垃圾回收在主線程上執(zhí)行, 第二個特點是一次執(zhí)行一個完整的垃圾回收流程。
由于這兩個原因,很容易造成主線程卡頓,所以 V8 采用了很多優(yōu)化執(zhí)行效率的方案。 第一個方案是并行回收,在執(zhí)行一個完整的垃圾回收過程中,垃圾回收器會使用多個輔助線程來并行執(zhí)行垃圾回收。 第二個方案是增量式垃圾回收,垃圾回收器將標記工作分解為更小的塊,并且穿插在主線程不同的任務之間執(zhí)行。采用增量垃圾回收時,垃圾回收器沒有必要一次執(zhí)行完整的垃圾回收過程,每次執(zhí)行的只是整個垃圾回收過程中的一小部分工作。 第三個方案是并發(fā)回收,回收線程在執(zhí)行 JavaScript 的過程,輔助線程能夠在后臺完成的執(zhí)行垃圾回收的操作。 主垃圾回收器就綜合采用了所有的方案(并發(fā)標記,增量標記,輔助清理),副垃圾回收器也采用了部分方案。
似此星辰非昨夜,為誰風露立中宵
Breaking the JavaScript Speed Limit with V8
在構造函數(shù)里初始化所有對象的成員(所以這些實例之后不會改變其隱藏類); 總是以相同的次序初始化對象成員; 盡量使用可以用 31 位有符號整數(shù)表示的數(shù); 為數(shù)組使用從 0 開始的連續(xù)的主鍵; 別預分配大數(shù)組(比如大于 64K 個元素)到其最大尺寸,令其尺寸順其自然發(fā)展就好; 別刪除數(shù)組里的元素,尤其是數(shù)字數(shù)組; 別加載未初始化或已刪除的元素; 對于固定大小的數(shù)組,使用”array literals“初始化(初始化小額定長數(shù)組時,用字面量進行初始化); 小數(shù)組(小于 64k)在使用之前先預分配正確的尺寸; 請勿在數(shù)字數(shù)組中存放非數(shù)字的值(對象); 盡量使用單一類型(monomorphic)而不是多類型(polymorphic)(如果通過非字面量進行初始化小數(shù)組時,切勿觸發(fā)類型的重新轉換); 不要使用 try{} catch{}(如果存在 try/catch 代碼快,則將性能敏感的代碼放到一個嵌套的函數(shù)中); 在優(yōu)化后避免在方法中修改隱藏類。
在 V8 引擎里 5 個優(yōu)化代碼的技巧
對象屬性的順序: 在實例化你的對象屬性的時候一定要使用相同的順序,這樣隱藏類和隨后的優(yōu)化代碼才能共享; 動態(tài)屬性: 在對象實例化之后再添加屬性會強制使得隱藏類變化,并且會減慢為舊隱藏類所優(yōu)化的代碼的執(zhí)行。所以,要在對象的構造函數(shù)中完成所有屬性的分配; 方法: 重復執(zhí)行相同的方法會運行的比不同的方法只執(zhí)行一次要快 (因為內(nèi)聯(lián)緩存); 數(shù)組: 避免使用 keys 不是遞增的數(shù)字的稀疏數(shù)組,這種 key 值不是遞增數(shù)字的稀疏數(shù)組其實是一個 hash 表。在這種數(shù)組中每一個元素的獲取都是昂貴的代價。同時,要避免提前申請大數(shù)組。最好的做法是隨著你的需要慢慢的增大數(shù)組。最后,不要刪除數(shù)組中的元素,因為這會使得 keys 變得稀疏; 標記值 (Tagged values): V8 用 32 位來表示對象和數(shù)字。它使用一位來區(qū)分它是對象 (flag = 1) 還是一個整型 (flag = 0),也被叫做小整型(SMI),因為它只有 31 位。然后,如果一個數(shù)值大于 31 位,V8 將會對其進行 box 操作,然后將其轉換成 double 型,并且創(chuàng)建一個新的對象來裝這個數(shù)。所以,為了避免代價很高的 box 操作,盡量使用 31 位的有符號數(shù)。
JavaScript 啟動性能瓶頸分析與解決方案
JavaScript Start-up Performance :
https://medium.com/reloading/javascript-start-up-performance-69200f43b201
JavaScript 啟動性能瓶頸分:析與解決方案:
https://zhuanlan.zhihu.com/p/25221314
抽絲剝繭有窮時,V8 綿綿無絕期
v8官方文檔(https://v8.dev/) 圖解 Google V8(https://time.geekbang.org/column/intro/296) 瀏覽器工作原理與實踐(https://time.geekbang.org/column/intro/216) [[譯] JavaScript 如何工作:對引擎、運行時、調(diào)用堆棧的概述]:https://juejin.im/post/6844903510538993671) [[譯] JavaScript 如何工作的: 事件循環(huán)和異步編程的崛起 + 5 個關于如何使用 async/await 編寫更好的技巧](https://juejin.im/post/6844903518319411207)
番外篇
Console Importer:Easily import JS and CSS resources from Chrome console. (可以在瀏覽器控制臺安裝 loadsh、moment、jQuery 等庫,在控制臺直接驗證、使用這些庫。)
效果圖:


