精讀《JS 引擎基礎之 Shapes and Inline Caches》

黃子毅發表於2018-06-25

1 引言

本期精讀的文章是:JS 引擎基礎之 Shapes and Inline Caches

一起了解下 JS 引擎是如何運作的吧!

JS 的運作機制可以分為 AST 分析、引擎執行兩個步驟:

image

JS 原始碼通過 parser(分析器)轉化為 AST(抽象語法樹),再經過 interperter(直譯器)解析為 bytecode(位元組碼)。

為了提高執行效率,optimizing compiler(優化編輯器)負責生成 optimized code(優化後的機器碼)。

本文主要從 AST 之後說起。

2 概述

JS 的直譯器、優化器

JS 程式碼可能在位元組碼或者優化後的機器碼狀態下執行,而生成位元組碼速度很快,而生成機器碼就要慢一些了。

image

V8 也類似,V8 將 interpreter 稱為 Ignition(點火器),將 optimizing compiler 成為 TurboFan(渦輪風扇發動機)。

image

可以理解為將程式碼先點火啟動後,逐漸進入渦輪發動機提速。

程式碼先快速解析成可執行的位元組碼,在執行過程中,利用執行中獲取的資料(比如執行頻率),將一些頻率高的方法,通過優化編譯器生成機器碼以提速。

image

火狐使用的 Mozilla 引擎有一點點不同,使用了兩個優化編譯器,先將位元組碼優化為部分機器碼,再根據這個部分優化後的程式碼執行時拿到的資料進行最終優化,生成高度優化的機器碼,如果優化失敗將會回退到部分優化的機器碼。

筆者:不同前端引擎對 JS 優化方式大同小異,後面會繼續列舉不同前端引擎在解析器、編譯器部分優化的方式。

image

微軟的 Edge 瀏覽器,使用的 Chakra 引擎,優化方式與 Mozilla 很像,區別是第二個最終優化的編譯器同時接收位元組碼和部分優化的機器碼產生的資料,並且在優化失敗後回退到第一步位元組碼而不是第二步。

image

Safari、React Native 使用的 JSC 引擎則更為極端,使用了三個優化編譯器,其優化是一步步漸進的,優化失敗後都會回退到第一步部分優化的機器碼。

為什麼不同前端引擎會使用不同的優化策略呢?這是由於 JS 要麼使用直譯器快速執行(生成位元組碼),或者優化成機器碼後再執行,但優化消耗時間的並不總是小於位元組碼低效執行損耗的時間,所以有些引擎選擇了多個優化編譯器,逐層優化,儘可能在解析時間與執行效率中找到一個平衡點。

JS 的物件模型

JS 是基於物件導向的,那麼 JS 引擎是如何實現 JS 物件模型的呢?他們用了哪些技巧加速訪問 JS 物件的屬性?

和解析器、優化器一樣,大部分主流 JS 引擎在物件模型實現上也很類似。

image

ECMAScript 規範確定了物件模型就是一個以字串為 key 的字典,除了其值以外,還定義了 Writeable Enumerable Configurable 這些配置,表示這個 key 能否被重寫、遍歷訪問、配置。

雖然規範定義了 [[]] 雙括號的寫法,那這不會暴露給使用者,暴露給使用者的是 Object.getOwnPropertyDescriptor 這個 API,可以拿到某個屬性的配置。


在 JS 中,陣列是物件的特殊場景,相比物件,陣列擁有特定的下標,根據 ECMAScript 規範規定,陣列下標的長度最大為 2³²−1。同時陣列擁有 length 屬性:

image

length 只是一個不可列舉、不可配置的屬性,並且在陣列賦值時,會自動更新數值:

image

所以陣列是特殊的物件,結構完全一致。

屬性訪問效率優化

屬性訪問是最常見的,所以 JS 引擎必須對屬性訪問做優化。

Shapes

JS 程式設計中,給不同物件相同的 key 名很常見,訪問不同物件的同一個 propertyKey 也很常見:

const object1 = { x: 1, y: 2 };
const object2 = { x: 3, y: 4 };

function logX(object) {
  console.log(object.x);
  //          ^^^^^^^^
}

logX(object1);
logX(object2);
複製程式碼

這時 object1object2 擁有一個相同的 shape。拿擁有 xy 屬性的物件來看:

image

如果訪問 object.y,JS 引擎會先找到 key y,再查詢 [[value]]

如果將屬性值也儲存在 JSObject 中,像 object1 object2 就會出現許多冗餘資料,因此引擎單獨儲存 Shape,與真實物件隔離:

image

這樣具有相同結構的物件可以共享 Shape。所有 JS 引擎都是用這種方式優化物件,但並不都稱為 Shape,這裡就不詳細羅列了,可以去原文檢視在各引擎中 Shape 的別名。

Transition chains 和 Transition trees

如果給一個物件增加了 key,JS 引擎如何生成新的 Shape 呢?

這種 Shape 鏈式建立的過程,稱為 Transition chains:

image

開始建立空物件時,JSObject 和 Shape 都是空,當為 x 賦值 5 時,在 JSObject 下標 0 的位置新增了 5,並且 Shape 指向了擁有欄位 xShape(x),當賦值 y6 時,在 JSObject 下標 1 的位置新增了 6,並將 Shape 指向了擁有欄位 xyShape(x, y)

而且可以再優化,Shape(x, y) 由於被 Shape(x) 指向,所以可以省略 x 這個屬性:

image

筆者:當然這裡說的主要是優化技巧,我們可以看出來,JS 引擎在做架構設計時沒有考慮優化問題,而在架構設計完後,再回過頭對時間和空間進行優化,這是架構設計的通用思路。

如果沒有連續的父 Shape,比如分別建立兩個物件:

const object1 = {};
object1.x = 5;
const object2 = {};
object2.y = 6;
複製程式碼

這時要通過 Transition trees 來優化:

image

可以看到,兩個 Shape(x) Shape(y) 別分繼承 Shape(empty)。當然也不是任何時候都會建立空 Shape,比如下面的情況:

const object1 = {};
object1.x = 5;
const object2 = { x: 6 };
複製程式碼

生成的 Shape 如下圖所示:

image

可以看到,由於 object2 並不是從空物件開始的,所以並不會從 Shape(empty) 開始繼承。

Inline Caches

大概可以翻譯為“區域性快取”,JS 引擎為了提高物件查詢效率,需要在區域性做高效快取。

比如有一個函式 getX,從 o.x 獲取值:

function getX(o) {
  return o.x;
}
複製程式碼

JSC 引擎 生成的位元組碼結構是這樣的:

image

get_by_id 指令是獲取 arg1 引數指向的物件 x,並儲存在 loc0,第二步則返回 loc0

當執行函式 getX({ x: 'a' }) 時,引擎會在 get_by_id 指令中快取這個物件的 Shape

image

這個物件的 Shape 記錄了自己擁有的欄位 x 以及其對應的下標 offset

image

執行 get_by_id 時,引擎從 Shape 查詢下標,找到 x,這就是 o.x 的查詢過程。但一旦找到,引擎就會將 Shape 儲存的 offset 快取起來,下次開始直接跳過 Shape 這一步:

image

以後訪問 o.x 時,只要 Shape 相同,引擎直接從 get_by_id 指令中快取的下標中可以直接命中要查詢的值,而這個快取在指令中的下標就是 Inline Cache.

陣列儲存優化

和物件一樣,陣列的儲存也可以被優化,而由於陣列的特殊性,不需要為每一項資料做完整的配置。

比如這個陣列:

const array = ["#jsconfeu"];
複製程式碼

JS 引擎同樣通過 Shape 與資料分離的方式儲存:

image

JS 引擎將陣列的值單獨儲存在 Elements 結構中,而且它們通常都是可讀可配置可列舉的,所以並不會像物件一樣,為每個元素做配置。

但如果是這種例子:

// 永遠不要這麼做
const array = Object.defineProperty([], "0", {
  value: "Oh noes!!1",
  writable: false,
  enumerable: false,
  configurable: false
});
複製程式碼

JS 引擎會儲存一個 Dictionary Elements 型別,為每個陣列元素做配置:

image

這樣陣列的優化就沒有用了,後續的賦值都會基於這種比較浪費空間的 Dictionary Elements 結構。所以永遠不要用 Object.defineProperty 運算元組。

通過對 JS 引擎原理的認識,作者總結了下面兩點程式碼中的注意事項:

  1. 儘量以相同方式初始化物件,因為這樣會生成較少的 Shapes
  2. 不要混淆物件的 propertyKey 與陣列的下標,雖然都是用類似的結構儲存,但 JS 引擎對陣列下標做了額外優化。

3 精讀

這次原理系列解讀是針對 JS 引擎執行優化這個點的,而網頁渲染流程大致如下:

image

可以看到 Script 在整個網頁解析鏈路中位置是比較靠前的,JS 解析效率會直接影響網頁的渲染,所以 JS 引擎通過直譯器(parser)和優化器(optimizing compiler)儘可能 對 JS 程式碼提效。

Shapes

需要特別說明的是,Shapes 並不是 原型鏈,原型鏈是面向開發者的概念,而 Shapes 是面向 JS 引擎的概念。

比如如下程式碼:

const a = {};
const b = {};
const c = {};
複製程式碼

顯然物件 a b c 之間是沒有關聯的,但共享一個 Shapes。

另外理解引擎的概念有助於我們站在語法層面對立面的角度思考問題:在 JS 學習階段,我們會執著于思考如下幾種建立物件方式的異同:

const a = {};
const b = new Object();
const c = new f1();
const d = Object.create(null);
複製程式碼

比如上面四種情況,我們要理解在什麼情況下,用何種方式建立物件效能最優。

但站在 JS 引擎優化角度去考慮,JS 引擎更希望我們都通過 const a = {} 這種看似最沒有難度的方式建立物件,因為可以共享 Shape。而與其他方式混合使用,可能在邏輯上做到了優化,但阻礙了 JS 引擎做自動優化,可能會得不償失。

Inline Caches

物件級別的優化已經很極致了,工程程式碼中也沒有機會幫助 JS 引擎做得更好,值得注意的是不要對陣列使用 Object 物件下的方法,尤其是 defineProperty,因為這會讓 JS 引擎在儲存陣列元素時,使用 Dictionary Elements 結構替代 Elements,而 Elements 結構是共享 PropertyDescriptor 的。

但也有難以避免的情況,比如使用 Object.defineProperty 監聽陣列變化時,就不得不破壞 JS 引擎渲染了。

筆者寫 dob 的時候,使用 proxy 監聽陣列變化,這並不會改變 Elements 的結構,所以這也從另一個側面證明了使用 proxy 監聽物件變化比 Object.defineProperty 更優,因為 Object.defineProperty 會破壞 JS 引擎對陣列做的優化。

4 總結

本文主要介紹了 JS 引擎兩個概念: ShapesInline Caches,通過認識 JS 引擎的優化方式,在程式設計中需要注意以下兩件事:

  1. 儘量以相同方式初始化物件,因為這樣會生成較少的 Shapes
  2. 不要混淆物件的 propertyKey 與陣列的下標,雖然都是用類似的結構儲存,但 JS 引擎對陣列下標做了額外優化。

5 更多討論

討論地址是:精讀《JS 引擎基礎之 Shapes and Inline Caches》 · Issue #91 · dt-fe/weekly

如果你想參與討論,請點選這裡,每週都有新的主題,週末或週一釋出。

相關文章