關於JS引擎優化的理解

以前在網上斷續地瞭解過JS引擎對JS源代碼的優化過程,但都不是特別明白,最近閱讀Mathias Bynens(V8做者之一)的關於JS引擎的優化原理的博文後以爲相對來講是講得最明白易懂的,讓我用最簡單的方式對這個問題有了本身的理解。javascript

這篇筆記是我對這個問題的我的理解的簡單總結。原文已經寫得足夠明白足夠好了,我是但願用本身的方式來描述一下,幫助理解和記憶。也許深刻的原理我還有一些不能準確描述,建議你們如有時間閱讀原文來進一步學習。java

注:文中大部分圖片源自做者原文node

JS引擎的工做方式

首先是一些背景知識,例如JS引擎都有哪些, 以及它們如何工做。數組

目前的主流JS引擎:

  1. V8(Chrome和NodeJS)
  2. SpiderMonkey(FireFox)
  3. Chakra(IE和Eage)
  4. JavaScriptCore(Safari/ReactNative)

JS引擎執行代碼的流程

不一樣JS引擎對執行和優化的一些細節上有差異,可是它們有如下通用的流程。瀏覽器

  1. JS源碼會先被解析器parser解析生成抽象語法樹(AST, Abstract Syntax Tree);
  2. 解釋器能夠在AST基礎上產生字節碼並執行;
  3. 對於部分"hot"(例如被頻繁調用)的代碼,解釋器會連同一些分析信息(profiling data)發送到編譯器中進行優化;
  4. 優化是在現有代碼及分析信息的基礎上做出必定的推測,而後生成優化後的機器碼;優化完成後, 該部分的代碼就由優化後的機器碼代替, 優化後產生機器碼,能夠直接在系統處理器中執行;
  5. 在某個節點發現優化時的特定推測是錯誤的,編譯器也會進行「去優化」而將代碼還原給解釋器。
代碼 生成者 執行者 生成效率 執行效率 空間效率
字節碼 解釋器(interpreter) 解釋器
機器碼 編譯器(optimizer) CPU*

*注:此處CPU是我本身的理解,原文爲bytecode needs an interpreter to run, whereas the optimized code can be executed directly by the processor緩存

簡單說就是解釋器能夠從抽象語法樹很快地拿到第一手字節碼並執行,可是代碼是未通過優化的,假如某個頻繁調用的方法須要從一個對象中訪問某個特定的屬性,那麼每一次調用都會執行完整的查詢過程,效率就會顯得比較低;安全

優化代碼須要時間,也須要更多的空間去存儲優化相關的信息和體積變大的優化代碼,但卻可讓諸如以上狀況的代碼執行效率更高。架構

因此這裏就是啓動時間-佔用空間-執行效率多方面的權衡。以前的V8是採用將源碼所有編譯爲機器碼的策略,跳過字節碼的步驟,犧牲了部分啓動時間,可使執行效率很是高,但是機器碼佔用內存也會很是大,這樣給代碼的緩存也帶來了很大的問題。某種程度上是有一點「過分優化」了。並非優化越多越好,而是「好鋼用在刀刃上」,只對「優化代碼能夠顯著提升運行效率」的那部分代碼進行優化。也就是做者口中的「Hot Code」。ide

不一樣瀏覽器引擎的實現

JS引擎 interpreter optimizer
V8 ignition TurboFan
SpiderMonkey interpreter Baseline + IonMonkey
Chakra interpreter SimpleJIT + FullJIT
JavaScriptCore LLInt Baseline + DFG + FTL

雖然它們的解釋器和優化編譯器看起來有不一樣的名字,可是全部JS引擎都具備相同的架構:parser(用於生成AST)和解析器 + 優化編譯器的管道結構。說是管道結構是由於解析器執行字節碼和優化編譯器能夠並行執行,當解釋器把待優化的代碼發送給另外一個線程的編譯器執行優化時,依然能夠繼續執行當前未優化的字節碼;而優化過程完成後優化後的代碼將會合流至主線程然後執行通過優化的代碼。學習

而採用多個優化層,也是在「未優化」和「高度優化」之間設立了更多的中間節點,至關於「分級」--根據「Hot」的程度相應增長優化的程度,從而能夠更細粒度地對時間/空間/執行效率之間的權衡決策進行控制。

對象和數組

在EcmaScript中,全部object實質上能夠認爲是字典,也就說字符串類型的鍵與屬性值構成的鍵值對集合。對象的屬性也有「屬性」,就是定義屬性自身的特性而不直接暴露給JavaScript的描述符:[[Value]], [[Writable]], [[Enumerable]], [[Configurable]]。每一個屬性都有對應的描述符,對於咱們給對象添加的自定義屬性,[[Value]]即咱們賦給該屬性的值,而其餘描述符都會被默認爲true。

至於數組,實際上也能夠看做對象,不過數組對數值索引會有特別處理,有效字符串整數i的範圍縮小到+0 <= i < 232-1, 而普通對象中的整數索引只需是安全整數(+0 <= i <= 253-1)的範圍。數組包含length屬性,它不可枚舉也不可配置,修改數組元素後會自動更新;數組以數值索引的元素與自定義對象屬性的描述符默認處理是類似的。

JS引擎的優化方式

Shapes和Inline Caches

想象一個書架(對象)有不少格子(連續的存儲位置),每一個格子能夠放一本書(屬性),咱們每次買來新書都直接放在下一個空格子中。當咱們想要去查看一本書的信息,須要從頭開始一本一本檢查書名,找一次就算了,若是每次都這樣找,效率會很低。因此咱們能夠想辦法把以前找到的位置序號記住,避免下次重複勞動。

可是這樣有個問題,若是書架上的書有增減,位置發生變更了怎麼辦?那原來保存的信息就不可靠了。可又怎麼知道有沒有發生過變更呢?

咱們創建一個圖書名單,上面寫了書名和它對應的位置,若是有變更就更新而且作必定標記,那就能夠經過對比這個名單確認是否有過變動。採用這種方式對於須要常常來找某一本書的人來講就很是方便,他只須要記住是哪個書名單和本身要找的書的位置,下次來只要書名單沒有發生過變更,連查找書名那一步都省了,直接能夠從對應位置取到他要的書。

若是比喻對象的屬性值都是書而屬性名是書名,Shape就是相似於上面所說「圖書名單」的東西。Shape是一個統稱,在不一樣的JS引擎中叫法不一,但含義類似。Shape只和屬性信息(包括屬性所在的內存位置和描述信息)有關,和實際對象的值之間是解耦的,因此只要兩個對象的屬性名稱/描述信息和屬性順序都同樣,那就能夠共用一個Shape。

Inline Caches(ICs)是加速執行JS的關鍵所在,能夠理解爲爲了減小對Hot代碼執行重複檢索而緩存下來的重要信息。之因此叫這個名字(內聯緩存),大概是由於這種緩存信息是嵌入Hot Code所在命令的結構中保存的,在每次執行這段代碼時進行即時校驗和取用。

對象的存儲和訪問

實際上在JS引擎中對象的屬性名和屬性值是分別存儲的,屬性值自己被按順序保存在對象中,而屬性名則創建一個列表(Shape),存儲每一個屬性名的「偏移量(offset)」和其餘描述符屬性

若是一個對象在運行時增長了新的屬性,那麼這個屬性名單會過渡到一個新的Shape(只包含了新添加的屬性)並連接回原Shape(原文中稱爲「過渡鏈」,transition chains),這樣訪問屬性時若是最新的屬性列表中沒有找到,能夠回溯到上一個列表去檢索。

由於存在不一樣的對象有相同的屬性名稱列表而重用Shape,當它們發生不一樣改變會分別過渡到各自的新Shape,造成分叉結構(原文中稱爲「過渡樹」,transition tree)。

可是若是頻繁擴展對象使得Shape鏈很是長怎麼辦呢?引擎內部會針對這樣的狀況再整理一張表(ShapeTable),把全部屬性名都列出來而後分別連接至它們所屬的Shape...這看起來仍是比較繁瑣,但都是爲了避免要浪費「已經作過的工做」,使保留有用的檢索信息——Inline Caches更加方便。

引用文中的例子:

function getX(o) {
    return o.x;
}
// 第一次執行,檢索並緩存Shape連接和offset
getX({x: "a"});
// 以後執行,檢查Shape是否相同,決定是否使用緩存
getX({x: "b"});
複製代碼

第一次執行時檢索Shape,獲得offset後取出對象中的值;同時,Shape的連接和此次檢索的結果也被內聯緩存在代碼結構中。

以後再訪問時,若是對比Shape仍是和以前同樣(對象重用Shape的好處),就直接用緩存的offset。

數組的存儲和訪問

數組自己就是一種特殊的對象。數組的length屬性與對象的屬性存儲方式相同。而對於數組的元素,本質上也是以字符串(數值)做爲key的屬性值,且默認狀況下與對象自定義屬性的描述信息相同(除[[value]]外,均可寫,可枚舉,可配置)。

JS引擎會把全部數值索引的元素單獨存儲在該數組的elements backing store中,能夠理解爲它的物品擺放整齊的後備倉庫。若是沒有人爲修改任何索引的屬性描述信息,不須要再存儲「offset",由於經過數值索引訪問時索引自己就是「offset」,而屬性描述符只需存儲一份給每個索引屬性共用。

可是以上是通常的狀況,若是不幸遇到了數組索引的描述符被從新定義的狀況,即便只是改變了一個,JS引擎也不得不放棄上面的優化策略,它的倉庫也不得不變成「字典」同樣的結構,爲每一個元素開闢更大的地方,爲其索引屬性保存完整的描述信息。這樣數組操做相對來講會變得低效。

這裏很容易讓人想起特別常見的一個關於「手動緩存屬性」的例子:

const arr = new Array(100000);
// arr.length內聯在每次循環的檢查條件中
for (let i = 0; i < arr.length; i++) {
    // ...
}
複製代碼

這裏的for循環中,每次循環的檢查條件是i < arr.length,這樣至關於每次都要對arr進行檢索取出length屬性值,循環的次數越多這種操做就越浪費。因此通常的建議是將arr.length提早用變量緩存,而後循環過程當中直接使用變量,這樣對數組length屬性讀取只需執行一次。

以前在某些文章見到過說這種最佳實踐在最新JS引擎的優化功能下已經不那麼重要,若是我沒有理解錯應該就是指即便沒有手動緩存,JS引擎中也能夠發現這段Hot代碼並使用Inline Cache進行結果的緩存。可是這裏並不是直接緩存length的結果,而只是緩存可直接用於讀取length的內存位置,因此仍是沒有把基本值緩存在變量裏快。

粗略在console裏經過循環測試了下,1000000次循環,結果是緩存變量執行<20ms能夠完成的狀況下,每次讀取length屬性須要~150ms。如下是測試代碼:

// 每次循環讀取arr.length
const arr = new Array(1000000);
let count = 0;
console.time("inline")
// arr.length內聯在每次循環的檢查條件中
for (let i = 0; i < arr.length; i++) {
    count++;
}
console.timeEnd("inline")
console.log(count);
// inline: 148.780029296875ms
// 1000000

// 將 length 緩存變量
const arr1 = new Array(1000000);
const len = arr1.length;
let count1 = 0;
console.time("len")

for (let i = 0; i < len; i++) {
    count1++;
}
console.timeEnd("len")
console.log(count1);
// len: 13.648193359375ms
// 1000000
複製代碼

原型鏈優化

原型自己也是對象,當經過一個對象訪問屬性,若是在當前對象沒有找到,會沿着原型鏈向上一級一級查找直到找到或原型爲null時中止而返回undefined。

若是把原型和對象同樣處理,當訪問一個對象的屬性,須要先在它自己的Shape中查找是否存在,若是沒有,再訪問該對象的原型,而後檢查原型的Shape,以此類推——每次訪問一個原型,至關於要完成在當前Shape中查找屬性經過對象訪問原型兩次檢索。而實際上,在JS引擎中,原型的引用被保存在了對象的Shape上而非對象自己,這樣能夠在檢查當前Shape中沒有目標屬性的時候直接連接至下一個原型對象,使每跳轉一次原型只需完成一次檢索。

可是這樣作仍是須要沿着原型鏈檢索屬性,對於重複訪問特定屬性的操做優化十分有限。沿着原型鏈查找屬性是比較昂貴的操做,尤爲是有不少狀況下對象的原型鏈可能會很長而經常使用的重要操做都在原型上,好比做者舉的HTML中a元素的例子,咱們能夠用下面代碼在console中打印出它的原型鏈:

function protoChain(node) {
    const p = Object.getPrototypeOf(node); // 或node.__proto__
    console.dir(p);
    return p == null || protoChain(p);
};
const a = document.createElement("a");
protoChain(a);
複製代碼

打印出的結果是:

若是目標屬性在比較深的原型上,每次檢索都是一串昂貴操做。按照對象中緩存屬性offset的思路,咱們能夠把原型上的屬性位置也緩存一下,顯然同時還必須把這個原型對象也保存一份引用,這樣若是下次訪問時原型鏈和原型對象自己沒有發生過變化,就能夠直接用上次緩存的結果,跳過查找操做。須要注意的是,任何對象的原型能夠動態修改,如何肯定原型鏈是否變化了呢?

JS引擎的作法是,每個原型對象都有一個惟一的Shape(不和任何其餘對象重用),Shape上會連接一個校驗位(ValidityCell),標記「這個原型及其上游的原型鏈是否發生過變化」。當一個原型對象的屬性發生變更,那這個原型和原型鏈中在它下游的全部原型的ValidityCell都會被置爲false。因此爲了保證緩存有效,只要確認實例對象的直接原型的這個校驗位是否依然爲true。

因此,除了緩存實例對象自己的Shape連接、offset和目標屬性所在的原型對象,還須要保存該實例對象的直接原型的ValidityCell的連接。

好比如下這段代碼:

class Bar {
    constructor(x) { this.x = x; }
    getX() { return this.x; }
}

const foo = new Bar(true);
const $getX = foo.getX;
複製代碼

當執行$getX = foo.getX,其實是先加載出foo.getX對應的值,而後將其賦值給$getX,第一步就是訪問對象屬性的過程,很明顯它須要從原型中獲取到,那麼這段代碼的Inline Cache在一次檢索後會保存如下信息:

  • offset結果---目標屬性的內存位置
  • 實例對象自己的Shape連接---對象的屬性列表和直接原型是否發生過改變
  • 目標屬性所在的原型對象連接---獲取屬性值
  • 實例對象的直接原型的ValidityCell的連接---確認原型鏈是否發生過改變

下次調用這段代碼時,除了須要對比實例對象的Shape,還要對比原型鏈上是否有變化,若是都沒有改變,那麼再也不須要檢索,直接用緩存的offset取出對應原型對象的屬性值便可。這將大大節省查找原型屬性所耗費的時間。

而假如此期間修改了原型鏈的任何一環,原先保存的ValidityCell連接指向的valid值會被置爲false,這時緩存就失效了,下次就須要把標準的檢索重來一遍。

特別須要注意的一點是,當原型鏈上的原型對象發生改變時,其下游的任何原型對象原先的Shape對應的ValidityCell都會被標記爲「無效」。能夠想象,在代碼執行過程當中當Object.prototype這樣的頂級原型被修改時,多少基於原型屬性的Inline Cache會失效。

如上面提到過的HTML中a元素的例子,做者有很是形象的示意圖:

當執行Object.prototype.x = 42,使頂級原型發生改變:

優化代碼的建議

綜合以上信息,做者站在引擎的角度給JS開發者如下幾方面的建議:

  1. 始終以相同的方式初始化對象。

一方面提升Shape的重用性,另外一方面儘可能下降過渡鏈或過渡樹的長度/深度,縮短沿Shape鏈檢索屬性的時間;

  1. 不要對數組的元素(數值索引屬性)修改屬性描述.

這樣能夠保留引擎對數組的優化處理,使數組的存儲和訪問更高效;

  1. 不要修改原型,尤爲是層級較深的原型如Object.prototype等,即便確實有必要修改,也應該在全部代碼執行以前修改而不要在代碼執行過程當中修改。

不然引擎爲了保證取到正確的值而不得不放棄以前的內聯緩存,從新以最笨的方法從新去查找和獲取屬性。

原文連接:

中文譯版:

參考:

相關文章
相關標籤/搜索