看文吃瓜:React 遭遇 V8 性能崩潰的故事

本篇文章主要講述 V8 如何選擇 JavaScript 值在內存中表現形式的優化方式,以及解釋 React core 在 V8 中出現的性能斷崖。

在這以前,咱們討論過 JavaScript 引擎如何經過使用內聯緩存 (Inline Caches) 和形狀 (Shapes) 優化 object 和數組的訪問, 而後咱們還特別展開講解了引擎是如何加快原型屬性的訪問速度。這篇文章主要講述 V8 如何選擇 JavaScript 值在內存中的表現形式的優化方式, 和這些優化是如何影響 Shape 機制的——這有助於解釋近期發生的一個 React core 在 V8 中出現的性能斷崖 (performance cliff) 。node

clipboard.png

JavaScript 類型數組


每一個 JavaScript 值的類型都必定是 8 個不一樣類型中的一個: Number, String, Symbol, BigInt, Boolean, Undefined, Null, 和 Object。緩存

除了一個顯著的例外,這些類型均可以經過 typeof 操做符來查看:安全

typeof 42;
// → 'number'
typeof 'foo';
// → 'string'
typeof Symbol('bar');
// → 'symbol'
typeof 42n;
// → 'bigint'
typeof true;
// → 'boolean'
typeof undefined;
// → 'undefined'
typeof null;
// → 'object' 🤔
typeof { x: 42 };
// → 'object'

typeof null 返回了’object’,並非 ‘null’, 儘管 Null 他本身就是一個類型。爲了理解其中的原因,咱們能夠先考慮把 Javascript 中的類型分紅兩組:ide

  • 對象 (i.e. the Object type)。
  • 基本類型 (i.e. 全部非對象的值)。

就此來講,null 意味着 " 不存在的對象 " 的值, 而 undefined 表明着 " 不存在 " 的值。性能

clipboard.png

跟着這條思路,Brendan Eich 按照 Java 的精神將 JavaScript 中 typeof 運算設計爲任何值都返回’object’,好比全部的對象和 null。這就是爲什麼儘管規範中有個單獨的 Null 類型,可是 typeof null === 'object’依然成立。測試

clipboard.png

類型表達優化


JavaScript 引擎必須能在內存中表達任意的 JavaScript 值。然而,有一點值得注意的地方,那就是 JavaScript 值的類型和值自己在 JavaScript 引擎中是分開表達的。this

好比 42 這個值,在 JavaScript 中是一個 number 類型。編碼

typeof 42;
// → 'number'

咱們有不少種方法在內存中表達 42 這個整形數值:

clipboard.png

ECMAScript 將 number 數據標準化位 64 位浮點數,一般叫 雙精度浮點數 和 Float64。然而這並不表明 JavaScript 引擎將 number 類型的數據一直都按照 Float64 的形式存儲 – 這樣作的話會很是的低效!引擎能夠選擇其餘的內部表達形式,直到肯定須要 Float64 特性的狀況出現。

現實中 JavaScript 應用的大部分 number 類型都是有效的 ECMAScript 數組下標,好比說在 0 到 2³²−2 之間的整數。

array[0]; // Smallest possible array index.
array[42];
array[2**32-2]; // Greatest possible array index.

JavaScript 引擎能夠爲這類 number 選擇一個在內存中最佳的表達方式來優化根據下標訪問數組元素操做的性能。對於處理器的訪問內存操做來講,數組下標必須是一個能用補碼形式表達的數字。用 Float64 的方式來表達數組下標是很是浪費的,由於引擎在每次訪問數組元素時不得不在 Float64 和補碼之間反覆轉換。

32 位補碼錶達形式不僅在數組操做中很實用。通常來講,處理器執行整型操做要比浮點型操做快很是多。這就是下面這個例子中,第一個循環要比第二個循環快 2 倍的緣由。

for (let i = 0; i < 1000; ++i) {
  // fast 🚀
}

for (let i = 0.1; i < 1000.1; ++i) {
  // slow 🐌
}
這種狀況在運算操做中也同樣。在下面這個例子中,取模運算的性能取決於你的操做數是否爲一個整型數據。
const remainder = value % divisor;
// Fast 🚀 if `value` and `divisor` are represented as integers,
// slow 🐌 otherwise.

若是全部的操做數都是整型,CPU 能夠很是高效地計算出結果。當除數爲 2 的指數時,V8 還有個額外的優化。若是操做數是浮點類型,這個計算將會複雜不少而且花費更長時間。

由於整型操做通常執行速度比浮點型要快很是多,看起來引擎應該一直使用補碼形式來表達全部的整型數據和整型數據的運算結果。不幸的是,這樣是違反 ECMAScript 規範的!ECMAScript 是用 Float64 來標準化的,因此 某些整型操做的結果其實是浮點型。在下面的例子中,這點對 JS 引擎能產出正確結果很重要。

// Float64 的安全整型範圍爲 53 位,
// 超過這個範圍你將丟失精度。
2**53 === 2**53+1;
// → true

// Float64 支持表達 -0,因此 -1 * 0 必須等於 -0
// 但在補碼形式中 -0 是沒辦法表達的。
-1*0 === -0;
// → true

// Float64 能夠表達由於除 0 而產生的 Infinity。
1/0 === Infinity;
// → true
-1/0 === -Infinity;
// → true

// Float64 還能表達 NaN。
0/0 === NaN;

雖然等號左邊的值都是整數,但等號右邊的全是浮點數。這就是使用 32 位二進制補碼沒法正確執行上述操做的緣由。JavaScript 引擎不得不特殊處理以確保整型計算能適當地回落到複雜的浮點結果。

對於小於 31 位的有符號整型,V8 有個被稱爲 Smi 的特別的表達方式。任何非 Smi 的數據將會被表達爲 HeapObject,即一些在內存中的實體的地址。對於 number 來講,咱們使用一個特殊的 HeapObject,或者叫 HeapNumber,來表達不在 Smi 範圍內的 number 數據。

-Infinity // HeapNumber
-(2**30)-1 // HeapNumber
  -(2**30) // Smi
       -42 // Smi
        -0 // HeapNumber
         0 // Smi
       4.2 // HeapNumber
        42 // Smi
   2**30-1 // Smi
     2**30 // HeapNumber
  Infinity // HeapNumber
       NaN // HeapNumber

正如上面例子所展現,一些 JavaScriptnumber 被表達爲 Smi,而其餘的表達爲 HeapNumber。V8 對 Smi 作了特殊的優化,由於在現實的 JavaScript 程序中小整型數據實在是太經常使用了。Smi 不須要在內存中爲其分配專門的實體,並且一般可使用快速的整型運算。

這裏最重要的一點是,做爲一個優化點,即使是同樣的 JavaScript 類型可是在內存中表達形式能夠徹底不同。

Smi vs. HeapNumber vs. MutableHeapNumbe
接下來講下這具體是如何執行的。首先你有以下的一個對象:

const o = {
  x: 42, // Smi
  y: 4.2, // HeapNumber
};

x 的值 42 能夠被編碼爲 Smi,因此它能夠被存儲在對象自身中。而 y 的 4.2 須要一個分開的實體來保存這個值,而後這個對象指向那個實體。

clipboard.png

如今,咱們執行下接下來的 JavaScript 片斷:

o.x += 10;
// → o.x is now 52
o.y += 1;
// → o.y is now 5.2

在這個例子中,因爲新值 52 也是 Smi,因此 x 的值能夠直接被替換。

clipboard.png

另外一方面,y=5.2 的新值不屬於 Smi,並且和以前的 4.2 也不一樣,因此 V8 分配了一個新的 HeapNumber 實體並將地址賦值給 y。

clipboard.png

HeapNumber 是沒法被修改的,由於這樣能夠進行某些優化。舉個例子,若是咱們把 y 賦值給 x:

o.x = o.y;
// → o.x is now 5.2

那麼咱們如今只須要指向相同的 HeapNumber 而沒必要爲相同的值分配一個新的對象。

clipboard.png

HeapNumber 不可變機制很差的一面是頻繁修改非 Smi 範圍內的屬性將會變得緩慢。就像下面這個例子:

// Create a `HeapNumber` instance.
const o = { x: 0.1 };

for (let i = 0; i < 5; ++i) {
  // Create an additional `HeapNumber` instance.
  o.x += 1;
}

第一行代碼將會建立一個 HeapNumber 實例並初始化其值爲 0.1。循環體將其改成 1.1,2.1,3.1,4.1 直到 5.1,總共建立了 6 個 HeapNumber 實例,其中 5 將會在循環結束後成爲內存垃圾。

clipboard.png

爲了不這個問題,V8 提供了一個優化更新非 Smi 的 number 字段的方法。當一個 number 字段保存了一個再也不 Smi 範圍內的值時,V8 在該對象的 shape 中將其標記爲 Double 字段,而且分配一個被稱爲 MutableHeapNumber 的對象以 Float64 編碼形式保存其值。

clipboard.png

當該字段變化時,V8 再也不須要去從新分配一個新的 HeapNumber,而是隻須要更新 MutableHeapNumber 中的值便可。

clipboard.png

可是,這種方法也有個問題。由於 MutableHeapNumber 的值能夠修改,因此它們不該該被傳遞出去。

clipboard.png

舉個例子,若是你將 o.x 賦值給另一個變量 y,你不會但願 y 值的改變也帶來 x.o 的改變 – 這是違反 JavaScript 規範的!因此當 o.x 被訪問時,這個數字必須得從新裝箱成一個正常的 HeapNumber,而後再賦值給 y。

對於浮點數來講,V8 在幕後完成了上面提到的全部「裝箱」操做。可是由於小整型數據也使用 MutableHeapNumber 機制是很是浪費的,所以 Smi 是一個更加有效的表達方式。

const object = { x: 1 };
// → no 「boxing」 for `x` in object

object.x += 1;
// → update the value of `x` inside object

爲了不低效,咱們爲了小整型數字所要作的事情就是將 shape 上的字段標記爲 Smi 表達,而後只要知足小整型範圍的更新就只執行數值替換。

clipboard.png

Shape 的棄用和整合


那麼若是一個字段一開始存的是 Smi 數據,可是後面又被更新成了一個小整數範圍以外的數據該怎麼辦?好比下面這個例子,2 個結構相同的對象,其中 x 都爲 Smi 表達的初始值:

const a = { x: 1 };
const b = { x: 2 };
// → objects have `x` as `Smi` field now

b.x = 0.2;
// → `b.x` is now represented as a `Double`

y = a.x;

那麼一開始這兩個對象都指向同一個 shape,其中 x 被標記爲 Smi 表達。

clipboard.png

當 b.x 修改成 Double 表達時,V8 分配了一個新的 shape 並且其中的 x 被指定爲 Double 表達,並指向空 shape。V8 也會爲屬性 x 分配一個 MutableHeapNumber 來保存這個新的值 0.2。而後當再更新對象 b 指向這個新的 shape,並更改對象中的槽以指向偏移 0 處的先前分配的 MutableHeapNumber。最後,咱們將舊的 shape 標記爲廢棄的而且將其從轉變樹 (transition tree) 中摘除。這是經過’x’從空 shape 到新建立的 shape 的轉變 (transition) 來完成的。

clipboard.png

此時咱們還不能徹底移除舊的 shape,由於它還在被 a 所使用,並且遍歷內存去尋找全部指向了舊 shape 的對線並馬上更新他們的將是很是昂貴的。相反,V8 使用了一個偷懶的辦法:任何對 a 的屬性訪問或者賦值都會先將其遷移到新的 shape 上。這個思路最終將使得廢棄的 shape 變得不可抵達而後被垃圾回收器刪除。

clipboard.png

若是更改表示的字段不是鏈中的最後一個字段,則會出現更棘手的狀況:

const o = {
  x: 1,
  y: 2,
  z: 3,
};

o.y = 0.1;

在這個例子中,V8 須要去尋找一個被稱爲 分離 shape(split shape) 的 shape,即指相關屬性引入以前鏈中的最後一個 shape。在這裏咱們修改了 y,因此咱們須要找到最後一個沒有包含 y 的 shape,在咱們這個例子中就是引入了 x 的那個 shape。

clipboard.png

從分離 shape 開始,咱們爲 y 建立了一個能夠重放全部以前的轉變的新轉變鏈 (transition chain),可是其中’y’被標記成 Double 表達。而後咱們使用這個新的轉變鏈並將舊的子樹標記爲廢棄的。在最後一步咱們把實例 o 遷移到了新的 shape,並使用了 MutableHeapNumber 來保存 y 的值。這樣,新的對象就不會使用老的路徑,並且一旦舊 shape 的引用小時,樹中廢棄的 shape 的那部分就會消失。

擴展性和完整性級別的轉換


Object.preventExtensions() 能夠阻止將新屬性添加到對象上。若是你嘗試去這麼作,它將會拋出一個異常。(若是你不在嚴格模式下,異常不會拋出但也不會發生任何修改)

const object = { x: 1 };
Object.preventExtensions(object);
object.y = 2;
// TypeError: Cannot add property y;
// object is not extensible

Object.seal 和 Object.preventExtensions 做用相同,可是它還會將全部屬性標記爲不可配置,意味着你不能刪除它們,或者改變它們的可枚舉性,能夠配置性或者可寫性。

const object = { x: 1 };
Object.seal(object);
object.y = 2;
// TypeError: Cannot add property y;
// object is not extensible
delete object.x;
// TypeError: Cannot delete property x

Object.freeze 也和 Object.seal 做用相同,可是它還會經過將屬性標記爲不可寫來阻止現有屬性被修改。

const object = { x: 1 };
Object.freeze(object);
object.y = 2;
// TypeError: Cannot add property y;
// object is not extensible
delete object.x;
// TypeError: Cannot delete property x
object.x = 3;
// TypeError: Cannot assign to read-only property x

讓咱們考慮下這個具體的例子,兩個對象都有一個屬性 x,而後咱們阻止任何對第二個對象進一步的擴展。

const a = { x: 1 };
const b = { x: 2 };

Object.preventExtensions(b);

如咱們以前所知,一切從空 shape 轉變到一個包含屬性’x’(以 Smi 形式表達) 的新 shape 開始。當咱們阻止了對 b 的擴展,咱們對新的 shape 進行了一個特殊的轉變 – 將其標記爲不可擴展。這個特殊的轉變沒有引入任何新的屬性 – 它實際上只是個標記。

clipboard.png

注意咱們爲什麼不能直接更新包含 x 的 shape,由於它被另一個對象 a 所引用,並且依然是可擴展的。

React 的性能問題
讓咱們把所前面提到的東西放到一塊兒,用咱們所學的東西去理解這個 issue 。當 React 團隊對一個真實的應用進行性能測試的時候,他們發現了一個影響 React 核心的奇怪的 V8 性能懸崖。這裏有個簡單的 bug 重現:

const o = { x: 1, y: 2 };
Object.preventExtensions(o);
o.y = 0.2;

咱們有個包含了 2 個 Smi 表達的字段。咱們阻止了全部其餘對這個對象的擴展,而後最終強制第二個字段變成 Double 表達。

如咱們以前所學,它大體創造瞭如下配置:

clipboard.png

全部屬性都被表達爲 Smi 形式,並且最終的轉變是將這個屬性標記爲不可擴展的擴展性轉變。

如今咱們須要將 y 修改成 Double 表達,意味着咱們須要從新開始找到分離 shape。在本例中,這是引入了 x 的那個 shape。可是如今 V8 有點困惑,由於分離 shape 是可擴展的但當前 shape 是被標記成了不可擴展的,並且 V8 不能確切地知道如何正確地重放轉變。因此 V8 實際上直接放棄理解這件事,與此相反地建立了一個和現有的 shape 樹沒有任何關聯的獨立 shape,也不會共享給任何其餘對象。把它想象成孤立的 shape:

clipboard.png

你能夠想象到若是有大量的這樣的對象出現這種狀況將是很是糟糕的,由於這會使整個 shape 系統變得無用。

這 React 的例子中,實際上發生的是:每一個 FiberNode 有幾個字段,用來在統計性能時保存一些時間戳。

class FiberNode {
  constructor() {
    this.actualStartTime = 0;
    Object.preventExtensions(this);
  }
}

const node1 = new FiberNode();
const node2 = new FiberNode();

這些字段(好比說 actualStartTime) 被初始化爲 0 或者 -1,所以一開始按照 Smi 表達。可是後面實際上存進來的是從 performance.now() 返回的浮點型時間戳,致使這些字段變成 Double 表達,由於這些數據不知足 Smi 表達的要求。最重要的是,React 還阻止了對 FiberNode 實例的擴展。

將上面的例子簡化以下:

clipboard.png

這裏有 2 個實例共享一個 shape 樹,一切運轉如咱們所想。可是接下來,當你儲存這個真實的時間戳,V8 開始困惑於尋找分離 shape:

clipboard.png

V8 指派了一個新的孤立 shape 給 node1,而後稍後 node2 也發生了一樣的狀況,致使了兩個孤島,每一個孤島都有着本身不相交的 shape。不少真實的 React 應用不止有 2 個,而是有超過成千上萬個 FiberNodes。如你所想,這種狀況對 V8 的性能來講不是什麼好事。

幸運的是,咱們已經在 V8 v7.4 中修復了這個性能懸崖,並且咱們正在想辦法讓字段表達的改變動加高效來消除任何潛在的性能懸崖。在這個 fix 後,V8 如今作了正確的事:

clipboard.png

這兩個 FiberNode 實例指向了不可擴展且 actualStartTime 爲 Smi 表達的 shape。當第一個對 node1.actualStartTime 的賦值發生時,一個新的轉變鏈被建立而且以前的轉變鏈被標記爲廢棄的:

clipboard.png

注意爲什麼擴展性轉變如今會正確的在新鏈中重放。

clipboard.png

在對 node2.actualStartTime 賦值後,全部的節點引用了新的 shape,並且轉變樹中廢棄的部分能夠被垃圾回收器清理。

注意:也許你會認爲 shape 的廢棄 / 遷移很複雜,那你是對的。實際上,咱們懷疑這個機制致使的問題(在性能,內存佔用和複雜度上)比它帶來的幫助要多,尤爲是由於使用指針壓縮,咱們將沒法再使用它來把 double-valued(雙精度?) 字段內聯到對象中。因此,咱們但願徹底移除掉 V8 的 shape 廢棄機制。You could say it’s puts on sunglasses being deprecated. YEEEAAAHHH…(不知道該怎麼翻譯了 - -)

React 團隊在他們那邊也經過確保 FiberNode 的全部的時間和持續時間字段都被初始化爲 Double 表達來規避這個問題。

class FiberNode {
  constructor() {
    // 從一開始就強制 w 誒 `Double` 表達
    this.actualStartTime = Number.NaN;
    // 而後你依然 k 惡意將這個值初始化爲任何你想要的值
    this.actualStartTime = 0;
    Object.preventExtensions(this);
  }
}

const node1 = new FiberNode();
const node2 = new FiberNode();

不僅是 Number.NaN,任何不在 Smi 範圍的浮點值均可以使用。好比說 0.000001,Number.MIN_VALUE,-0,Infinity。

值得指出的的是這個 React 的 Bug 是 V8 規範致使的,開發者不該該爲一個特定的 JavaScript 引擎作優化。儘管如此,當事情運轉不正常時有個解決方案仍是挺不錯的。

記住 JavaScript 引擎會在幕後作一些 magic 的優化,而你能夠經過儘量避免類型混用來有效的幫助它執行這些優化。舉個例子,不要用 null 來初始化 number 類型的字段,這不只能避免使得全部字段表達跟蹤帶來收益所有失效,還能讓你的代碼變得更可讀:

// Don’t do this!
class Point {
  x = null;
  y = null;
}

const p = new Point();
p.x = 0.1;
p.y = 402;

換句話說,寫可讀的代碼,而後性能天然就會提高。

最後總結


咱們在此次深刻探討中涵蓋了如下內容:

  • JavaScript 對「基本類型」和「對象」的區分,並且 typeof 是個騙子。
  • 即便具備相同 JavaScript 類型的值也能夠在幕後具備不一樣的表示。
  • 在你的 JavaScript 程序中,V8 會嘗試爲每一個屬性尋找最佳的表達方式。
  • 咱們討論了 V8 如何處理 shape 廢棄和遷移,包含了擴展性和轉變的一些內容。

基於這些知識,咱們能夠得出一些能幫助提高性能的 JavaScript 編碼實用提示:

  • 永遠用一樣的方式初始化你的對象,這樣 shape 機制能夠更有效。
  • 使用合理的值來初始化你的字段,這樣能夠幫助 JavaScript 引擎更好地選擇表達方式。
相關文章
相關標籤/搜索