身為JavaScript欄位開發人員,深入了解 JavaScript 引擎的工作原理有助於你了解自己程式碼的效能特性。這篇文章對所有 JavaScript 引擎中常見的一些關鍵基礎知識進行了介紹,不僅限於 V8 引擎。
這一切都要從你寫的 JavaScript 程式碼開始。 JavaScript 引擎解析原始程式碼並將其轉換為抽象語法樹(AST)。基於 AST,解釋器便可以開始工作並產生字節碼。就在此時,引擎開始真正運行 JavaScript 程式碼。為了讓它運作得更快,字節碼能與分析資料一起傳送到最佳化編譯器。最佳化編譯器基於現有的分析資料做出某些特定的假設,然後產生高度最佳化的機器碼。
如果某個時刻某一個假設被證明是不正確的,那麼最佳化編譯器將取消最佳化並回到解釋器階段。
現在,讓我們來看看實際執行JavaScript 程式碼的這部分流程,即程式碼被解釋和優化的部分,並討論其在主要的JavaScript 引擎之間存在的一些差異。
一般來說,JavaSciript 引擎都有一個包含解釋器和最佳化編譯器的處理流程。其中,解釋器可以快速產生未最佳化的字節碼,而最佳化編譯器會耗費更長的時間,但最終可產生高度最佳化的機器碼。這個通用流程和 Chrome 和 Node.js 中使用的 Javascript 引擎, V8 的工作流程幾乎一致:
V8 中的解釋器稱為 Ignition,負責產生和執行字節碼。當它運行字節碼時,它會收集分析數據,這些數據可用於後面加快程式碼的執行速度。當一個函數變成 hot 時,例如當它經常運行時,生成的字節碼和分析資料將傳遞給我們的最佳化編譯器 Turbofan,以根據分析資料產生高度最佳化的機器碼。
Mozilla 在 Firefox 和 Spidernode 中使用的 JavaScript 引擎 SpiderMonkey ,則不太一樣。它們有兩個最佳化編譯器,而不是一個。解釋器先通過 Baseline 編譯器,產生一些最佳化的程式碼。然後,結合執行程式碼時收集的分析數據,IonMonkey 編譯器可以產生更高程度最佳化的程式碼。如果嘗試最佳化失敗,IonMonkey 將會回到 Baseline 階段的程式碼。
Chakra,在 Edge 中使用的 Microsoft 的 JavaScript 引擎,非常相似的,也有2個最佳化編譯器。解釋器最佳化程式碼到 SimpleJIT(JIT 代表 Just-In-Time 編譯器,即時編譯器),SimpleJIT 會產生稍微最佳化的程式碼。而 FullJIT 結合分析數據,可以產生更優化的程式碼。JavaScriptCore(縮寫為 JSC),在 Safari 和 React Native 中使用的 Apple 的 JavaScript 引擎,它透過三種不同的最佳化編譯器將其發揮到極致。低層解釋器LLInt 最佳化程式碼到Baseline 編譯器中,然後最佳化程式碼到DFG(Data Flow Graph)編譯器中,DFG(Data Flow Graph)編譯器又可以將最佳化後的程式碼傳送到FTL(Faster Than Light)編譯器中。
為什麼有些引擎有更多的最佳化編譯器?這是權衡利弊的結果。解釋器可以快速產生字節碼,但字節碼通常效率不高。另一方面,最佳化編譯器需要更長的時間,但最終會產生更有效率的機器碼。在快速讓程式碼運行(解釋器)或花費更多時間,但最終以最佳效能運行程式碼(最佳化編譯器)之間需要權衡。一些引擎選擇添加具有不同時間/效率特性的多個最佳化編譯器,允許在額外的複雜性的代價下對這些權衡進行更細粒度的控制。另一個需要權衡的方面與記憶體使用有關,後續將有專門的文章詳細介紹。
我們剛剛強調了每個 JavaScript 引擎中解釋器和最佳化編譯器流程中的主要差異。除了這些差異之外,在高層上,所有 JavaScript 引擎都有相同的架構:那就是有解析器和某種解釋器/編譯器流程。
讓我們透過放大一些方面的實作來看看 JavaScript 引擎還有什麼共同點。
例如,JavaScript 引擎如何實作 JavaScript 物件模型,以及它們使用哪些技巧來加速存取 JavaScript 物件的屬性?事實證明,所有主要引擎在這一點上的實現都很相似。
ECMAScript 規格基本上將所有物件定義為由字串鍵值對應到 property 屬性的字典。
除了[[Value]] 本身,規格也定義了這些屬性:
[[雙方括號]] 的符號表示看起來有些特別,但這正是規範定義不能直接暴露給 JavaScript 的屬性的表示方法。在 JavaScript 中你仍然可以透過 Object.getOwnPropertyDescriptor API 來取得指定物件的屬性值:
const object = { foo: 42 };Object.getOwnPropertyDescriptor(object, 'foo');// → { value: 42, writable: true, enumerable: true, configurable: true }复制代码
這就是 JavaScript 定義物件的方式,那麼陣列呢?
你可以把數組看成是一個特殊的對象,其中的一個區別就是數組會對數組索引進行特殊的處理。這裡的陣列索引是 ECMAScript 規範中的一個特殊術語。在 JavaScript 中限制數組最多有 2³²−1個元素,數組索引是在該範圍內的任何有效索引,即 0 到 2³²−2 的任何整數。
另一個差異是數組還有一個特殊的 length 屬性。
const array = ['a', 'b']; array.length; // → 2array[2] = 'c'; array.length; // → 3复制代码
在該例中,陣列被建立時 length 為 2。當我們為索引為 2 的位置分配另一個元素時,length 自動更新了。
JavaScript 定義陣列的方式和物件類似。例如,所有的鍵值, 包括數組的索引, 都明確地表示為字串。數組中的第一個元素,就是儲存在鍵值 '0' 下。“length” 屬性是另一個不可枚舉且不可設定的屬性。 當一個元素被加入到陣列中, JavaScript 會自動更新 “length“ 屬性的 [[value]] 屬性。
知道了物件在 JavaScript 中是如何定義的, 那麼就讓我們來深入地了解一下 JavaScript 引擎是如何高效地使用物件的。 整體來說,存取屬性是至今為止 JavaScript 程式中最常見的操作。因此,JavaScript 引擎是否能快速地存取屬性是至關重要的。
在 JavaScript 程式中,多個物件有相同的鍵值屬性是非常常見的。可以說,這些物件有相同的 shape。
const object1 = { x: 1, y: 2 };const object2 = { x: 3, y: 4 };// object1 and object2 have the same shape.复制代码
存取具有相同 shape 的物件相同的屬性也是非常常見的:
function logX(object) { console.log(object.x); }const object1 = { x: 1, y: 2 };const object2 = { x: 3, y: 4 }; logX(object1); logX(object2);复制代码
考慮到這一點,JavaScript 引擎可以基於物件的 shape 來優化物件的屬性存取。下面我們就來介紹其原理。
假設我們有一個具有屬性 x 和 y 的對象,它使用我們前面討論過的字典資料結構:它包含字串形式的鍵,這些鍵指向它們各自的屬性值。
如果你存取某個屬性,例如 object.y,JavaScript 引擎會在 JSObject 中尋找鍵值 'y',然後載入對應的屬性值,最後回傳 [[Value]]。
但這些屬性值儲存在記憶體中的什麼位置呢?我們是否應該將它們作為 JSObject 的一部分進行儲存?假設我們稍後會遇到更多同 shape 的對象,那麼在 JSObject 自身儲存包含屬性名稱和屬性值的完整字典便是一種浪費,因為對於具有相同 shape 的所有對象,屬性名稱都是重複的。這是大量的重複和不必要的記憶體使用。作為一種優化,引擎將物件的 Shape 分開儲存。shape 包含除了 [[Value]] 以外所有屬性名稱和屬性。另外,shape 還包含了 JSObject 內部值的偏移量,以便 JavaScript 引擎知道在哪裡可以找到值。每個具有相同 shape 的 JSObject 都指向該 shape 實例。現在每個 JSObject 只需要儲存對這個物件來說唯一的值。
當我們有多個物件時,好處就顯而易見了。不管有多少個對象,只要它們有相同的 shape,我們只需要儲存 shape 和屬性資訊一次!
所有的 JavaScript 引擎都使用了 shapes 作為最佳化,但稱呼各有不同:
本文中,我们将继续使用术语 shapes.
如果你有一个具有特定 shape 的对象,但你又向它添加了一个属性,此时会发生什么? JavaScript 引擎是如何找到这个新 shape 的?
const object = {}; object.x = 5; object.y = 6;复制代码
这些 shapes 在 JavaScript 引擎中形成所谓的转换链(transition chains)。下面是一个例子:
该对象开始没有任何属性,因此它指向一个空的 shape。下一个语句为该对象添加一个值为 5 的属性 "x",所以 JavaScript 引擎转向一个包含属性 "x" 的 shape,并在第一个偏移量为 0 处向 JSObject 添加了一个值 5。 下一行添加了一个属性 'y',引擎便转向另一个包含 'x' 和 'y' 的 shape,并将值 6 添加到 JSObject(位于偏移量 1 处)。
我们甚至不需要为每个 shape 存储完整的属性表。相反,每个shape 只需要知道它引入的新属性。例如,在本例中,我们不必将有关 “x” 的信息存储在最后一个 shape 中,因为它可以在更早的链上找到。要实现这一点,每个 shape 都会链接回其上一个 shape:
如果你在 JavaScript 代码中写 o.x,JavaScript 引擎会沿着转换链去查找属性 "x",直到找到引入属性 "x" 的 Shape。
但是如果没有办法创建一个转换链会怎么样呢?例如,如果有两个空对象,并且你为每个对象添加了不同的属性,该怎么办?
const object1 = {}; object1.x = 5;const object2 = {}; object2.y = 6;复制代码
在这种情况下,我们必须进行分支操作,最终我们会得到一个转换树而不是转换链。
这里,我们创建了一个空对象 a,然后给它添加了一个属性 ‘x’。最终,我们得到了一个包含唯一值的 JSObject 和两个 Shape :空 shape 以及只包含属性 x 的 shape。
第二个例子也是从一个空对象 b 开始的,但是我们给它添加了一个不同的属性 ‘y’。最终,我们得到了两个 shape 链,总共 3 个 shape。
这是否意味着我们总是需要从空 shape 开始呢? 不一定。引擎对已含有属性的对象字面量会进行一些优化。比方说,我们要么从空对象字面量开始添加 x 属性,要么有一个已经包含属性 x 的对象字面量:
const object1 = {}; object1.x = 5;const object2 = { x: 6 };复制代码
在第一个例子中,我们从空 shape 开始,然后转到包含 x 的shape,这正如我们之前所见那样。
在 object2 的例子中,直接在一开始就生成含有 x 属性的对象,而不是生成一个空对象是有意义的。
包含属性 ‘x’ 的对象字面量从含有 ‘x’ 的 shape 开始,有效地跳过了空 shape。V8 和 SpiderMonkey (至少)正是这么做的。这种优化缩短了转换链并且使从字面量构建对象更加高效。
下面是一个包含属性 ‘x'、'y' 和 'z' 的 3D 点对象的示例。
const point = {}; point.x = 4; point.y = 5; point.z = 6;复制代码
正如我们之前所了解的, 这会在内存中创建一个有3个 shape 的对象(不算空 shape 的话)。 当访问该对象的属性 ‘x’ 的时候,比如, 你在程序里写 point.x,javaScript 引擎需要循着链接列表寻找:它会从底部的 shape 开始,一层层向上寻找,直到找到顶部包含 ‘x’ 的 shape。
当这样的操作更频繁时, 速度会变得非常慢,特别是当对象有很多属性的时候。寻找属性的时间复杂度为 O(n), 即和对象上的属性数量线性相关。为了加快属性的搜索速度, JavaScript 引擎增加了一种 ShapeTable 的数据结构。这个 ShapeTable 是一个字典,它将属性键映射到描述对应属性的 shape 上。
现在我们又回到字典查找了我们添加 shape 就是为了对此进行优化!那我们为什么要去纠结 shape 呢? 原因是 shape 启用了另一种称为 Inline Caches 的优化。
shapes 背后的主要动机是 Inline Caches 或 ICs 的概念。ICs 是让 JavaScript 快速运行的关键要素!JavaScript 引擎使用 ICs 来存储查找到对象属性的位置信息,以减少昂贵的查找次数。
这里有一个函数 getX,该函数接收一个对象并从中加载属性 x:
function getX(o) { return o.x; }复制代码
如果我们在 JSC 中运行该函数,它会产生以下字节码:
第一条 get_by_id 指令从第一个参数(arg1)加载属性 ‘x’,并将结果存储到 loc0 中。第二条指令将存储的内容返回给 loc0。
JSC 还将一个 Inline Cache 嵌入到 get_by_id 指令中,该指令由两个未初始化的插槽组成。
现在, 我们假设用一个对象 { x: 'a' },来执行 getX 这个函数。正如我们所知,,这个对象有一个包含属性 ‘x’ 的 shape, 该 shape存储了属性 ‘x’ 的偏移量和特性。当你在第一次执行这个函数的时候,get_by_id 指令会查找属性 ‘x’,然后发现其值存储在偏移量为 0 的位置。
嵌入到 get_by_id 指令中的 IC 存储了 shape 和该属性的偏移量:
对于后续运行,IC 只需要比较 shape,如果 shape 与之前相同,只需从存储的偏移量加载值。具体来说,如果 JavaScript 引擎看到对象的 shape 是 IC 以前记录过的,那么它根本不需要接触属性信息,相反,可以完全跳过昂贵的属性信息查找过程。这要比每次都查找属性快得多。
对于数组,存储数组索引属性是很常见的。这些属性的值称为数组元素。为每个数组中的每个数组元素存储属性特性是非常浪费内存的。相反,默认情况下,数组索引属性是可写的、可枚举的和可配置的,JavaScript 引擎基于这一点将数组元素与其他命名属性分开存储。
思考下面的数组:
const array = [ '#jsconfeu', ];复制代码
引擎存储了数组长度(1),并指向包含偏移量和 'length' 属性特性的 shape。
这和我们之前看到的很相似……但是数组的值存到哪里了呢?
每个数组都有一个单独的元素备份存储区,包含所有数组索引的属性值。JavaScript 引擎不必为数组元素存储任何属性特性,因为它们通常都是可写的、可枚举的和可配置的。
那么,在非通常情况下会怎么样呢?如果更改了数组元素的属性特性,该怎么办?
// Please don’t ever do this!const array = Object.defineProperty( [], '0', { value: 'Oh noes!!1', writable: false, enumerable: false, configurable: false, });复制代码
上面的代码片段定义了名为 “0” 的属性(恰好是数组索引),但将其特性设置为非默认值。
在这种边缘情况下,JavaScript 引擎将整个元素备份存储区表示成一个字典,该字典将数组索引映射到属性特性。
即使只有一个数组元素具有非默认特性,整个数组的备份存储区也会进入这种缓慢而低效的模式。避免对数组索引使用Object.defineProperty!
我们已经了解了 JavaScript 引擎如何存储对象和数组,以及 shape 和 ICs 如何优化对它们的常见操作。基于这些知识,我们确定了一些可以帮助提高性能的实用的 JavaScript 编码技巧:
#相關免費學習推薦:javascript(影片)
以上是拿下JavaScript引擎的基本原理的詳細內容。更多資訊請關注PHP中文網其他相關文章!