V8 性能優化殺手

V8 性能優化殺手

簡介

這篇文章將會給你一些建議,讓你避免寫出性能遠低於指望值的代碼。在此特別指出有一些代碼會致使 V8 引擎(涉及到 Node.JS、Opera、Chromium 等)沒法對相關函數進行優化。html

vhf 正在作一個相似的項目,試圖將 V8 引擎的性能殺手所有列出來:V8 Bailout Reasons前端

V8 引擎背景知識

V8 引擎中沒有解釋器,但有 2 種不一樣的編譯器:普通編譯器與優化編譯器。編譯器會將你的 JavaScript 代碼編譯成彙編語言後直接運行。但這並不意味着運行速度會很快。被編譯成彙編語言後的代碼並不能顯著地提升其性能,它只能省去解釋器的性能開銷,若是你的代碼沒有被優化的話速度依然會很慢。node

例如,在普通編譯器中 a + b 將會被編譯成下面這樣:react

mov eax, a
mov ebx, b
call RuntimeAdd複製代碼

換句話說,其實它僅僅調用了 runtime 函數。但若是 ab 能肯定都是整型變量,那麼編譯結果會是下面這樣:android

mov eax, a
mov ebx, b
add eax, ebx複製代碼

它的執行速度會比前面那種去在 runtime 中調用複雜的 JavaScript 加法算法快得多。ios

一般來講,使用普通編譯器將會獲得前面那種代碼,使用優化編譯器將會獲得後面那種代碼。走優化編譯器的代碼能夠說比走普通編譯器的代碼性能好上 100 倍。可是請注意,並非任何類型的 JavaScript 代碼都能被優化。在 JS 中,有不少種狀況(甚至包括一些咱們經常使用的語法)是不能被優化編譯器優化的(這種狀況被稱爲「bailout」,從優化編譯器降級到普通編譯器)。git

記住一些會致使整個函數沒法被優化的狀況是很重要的。JS 代碼被優化時,將會逐個優化函數,在優化各個函數的時候不會關心其它的代碼作了什麼(除非那些代碼被內聯在即將優化的函數中。)。github

這篇文章涵蓋了大多數會致使函數墜入「沒法被優化的深淵」的狀況。不過在將來,優化編譯器進行更新後可以識別愈來愈多的狀況時,下面給出的建議與各類變通方法可能也會變的再也不必要或者須要修改。算法

主題

  1. 工具
  2. 不支持的語法
  3. 使用 arguments
  4. Switch-case
  5. For-in
  6. 退出條件藏的很深,或者沒有定義明確出口的無限循環

1. 工具

你能夠在 node.js 中使用一些 V8 自帶的標記來驗證不一樣的代碼用法對優化的影響。一般來講你能夠建立一個包括特定模式的函數,而後使用全部容許的參數類型去調用它,再使用 V8 的內部去優化與檢查它:後端

test.js:

//建立包含須要檢查的狀況的函數(檢查使用 `eval` 語句是否能被優化)
function exampleFunction() {
    return 3;
    eval('');
}

function printStatus(fn) {
    switch(%GetOptimizationStatus(fn)) {
        case 1: console.log("Function is optimized"); break;
        case 2: console.log("Function is not optimized"); break;
        case 3: console.log("Function is always optimized"); break;
        case 4: console.log("Function is never optimized"); break;
        case 6: console.log("Function is maybe deoptimized"); break;
        case 7: console.log("Function is optimized by TurboFan"); break;
        default: console.log("Unknown optimization status"); break;
    }
}

//識別類型信息
exampleFunction();
//這裏調用 2 次是爲了讓這個函數狀態從 uninitialized -> pre-monomorphic -> monomorphic
exampleFunction();

%OptimizeFunctionOnNextCall(exampleFunction);
//再次調用
exampleFunction();

//檢查
printStatus(exampleFunction);複製代碼

運行它:

$ node --trace_opt --trace_deopt --allow-natives-syntax test.js
(v0.12.7) Function is not optimized
(v4.0.0) Function is optimized by TurboFan複製代碼

codereview.chromium.org/1962103003

爲了檢驗咱們作的這個工具是否真的有用,註釋掉 eval 語句而後再運行一次:

$ node --trace_opt --trace_deopt --allow-natives-syntax test.js
[optimizing 000003FFCBF74231 <JS Function exampleFunction (SharedFunctionInfo 00000000FE1389E1)> - took 0.345, 0.042, 0.010 ms]
Function is optimized複製代碼

事實證實,使用這個工具來驗證處理方法是可行且必要的。

2. 不支持的語法

有一些語法結構是不支持被編譯器優化的,用這類語法將會致使包含在其中的函數不能被優化。

請注意,即便這些語句不會被訪問到或者不會被執行,它仍然會致使整個函數不能被優化。

例以下面這樣作是沒用的:

if (DEVELOPMENT) {
    debugger;
}複製代碼

即便 debugger 語句根本不會被執行到,上面的代碼將會致使包含它的整個函數都不能被優化。

目前不可被優化的語法有:

  • Generator 函數V8 5.7 對其作了優化)
  • 包含 for of 語句的函數 (V8 commit 11e1e20 對其作了優化)
  • 包含 try catch 語句的函數 (V8 commit 9aac80f / V8 5.3 / node 7.x 對其作了優化)
  • 包含 try finally 語句的函數 (V8 commit 9aac80f / V8 5.3 / node 7.x 對其作了優化)
  • 包含let 複合賦值的函數 (Chrome 56 / V8 5.6! 對其作了優化)
  • 包含 const 複合賦值的函數 (Chrome 56 / V8 5.6! 對其作了優化)
  • 包含 __proto__ 對象字面量、get 聲明、set 聲明的函數

看起來永遠不會被優化的語法有:

  • 包含 debugger 語句的函數
  • 包含字面調用 eval() 的函數
  • 包含 with 語句的函數

最後明確一下:若是你用了下面任何一種狀況,整個函數將不能被優化:

function containsObjectLiteralWithProto() {
    return {__proto__: 3};
}複製代碼
function containsObjectLiteralWithGetter() {
    return {
        get prop() {
            return 3;
        }
    };
}複製代碼
function containsObjectLiteralWithSetter() {
    return {
        set prop(val) {
            this.val = val;
        }
    };
}複製代碼

另外在此要特別提一下 evalwith,它們會致使它們的調用棧鏈變成動態做用域,可能會致使其它的函數也受到影響,由於這種狀況沒法從字面上判斷各個變量的有效範圍。

變通辦法

前面提到的不能被優化的語句用在生產環境代碼中是沒法避免的,例如 try-finallytry-catch。爲了讓使用這些語句的影響儘可能減少,它們須要被隔離在一個最小化的函數中,這樣主要的函數就不會被影響:

var errorObject = {value: null};
function tryCatch(fn, ctx, args) {
    try {
        return fn.apply(ctx, args);
    }
    catch(e) {
        errorObject.value = e;
        return errorObject;
    }
}

var result = tryCatch(mightThrow, void 0, [1,2,3]);
//明確地報出 try-catch 會拋出什麼
if(result === errorObject) {
    var error = errorObject.value;
}
else {
    //result 是返回值
}複製代碼

3. 使用 arguments

有許多種使用 arguments 的方式會致使函數不能被優化。所以當使用 arguments 的時候須要格外當心。

3.1. 在非嚴格模式中,對一個已經被定義,同時在函數體中被 arguments 引用的參數從新賦值。典型案例:

function defaultArgsReassign(a, b) {
     if (arguments.length < 2) b = 5;
}複製代碼

變通方法 是將參數值保存在一個新的變量中:

function reAssignParam(a, b_) {
    var b = b_;
    //與 b_ 不一樣,能夠安全地對 b 進行從新賦值
    if (arguments.length < 2) b = 5;
}複製代碼

若是僅僅是像上面這樣用 arguments(上面代碼做用爲檢測第二個參數是否存在,若是不存在則賦值爲 5),也能夠用 undefined 檢測來代替這段代碼:

function reAssignParam(a, b) {
    if (b === void 0) b = 5;
}複製代碼

可是以後若是須要用到 arguments,很容易忘記須要在這兒加上從新賦值的語句。

變通方法 2:爲整個文件或者整個函數開啓嚴格模式 ('use strict')。

3.2. arguments 泄露:

function leaksArguments1() {
    return arguments;
}複製代碼
function leaksArguments2() {
    var args = [].slice.call(arguments);
}複製代碼
function leaksArguments3() {
    var a = arguments;
    return function() {
        return a;
    };
}複製代碼

arguments 對象在任何地方都不容許被傳遞或者被泄露。

變通方法 能夠經過建立一個數組來代理 arguments 對象:

function doesntLeakArguments() {
                    //.length 僅僅是一個整數,不存在泄露
                    //arguments 對象自己的問題
    var args = new Array(arguments.length);
    for(var i = 0; i < args.length; ++i) {
                //i 是 arguments 對象的合法索引值
        args[i] = arguments[i];
    }
    return args;
}

function anotherNotLeakingExample() {
    var i = arguments.length;
    var args = [];
    while (i--) args[i] = arguments[i];
    return args
}複製代碼

可是這樣要寫不少讓人煩的代碼,所以得判斷是否真的值得這麼作。後面一次又一次的優化會代理更多的代碼,愈來愈多的代碼意味着代碼自己的意義會被逐漸淹沒。

不過,若是你有 build 這個過程,能夠將上面這一系列過程由一個不須要 source map 的宏來實現,保證代碼爲合法的 JavaScript:

function doesntLeakArguments() {
    INLINE_SLICE(args, arguments);
    return args;
}複製代碼

Bluebird 就使用了這個技術,上面的代碼通過 build 以後會被拓展成下面這樣:

function doesntLeakArguments() {
    var $_len = arguments.length;
    var args = new Array($_len); 
    for(var $_i = 0; $_i < $_len; ++$_i) {
        args[$_i] = arguments[$_i];
    }
    return args;
}複製代碼

3.3. 對 arguments 進行賦值:

在非嚴格模式下能夠這麼作:

function assignToArguments() {
    arguments = 3;
    return arguments;
}複製代碼

變通方法:犯不着寫這麼蠢的代碼。另外,在嚴格模式下它會報錯。

那麼如何安全地使用 arguments 呢?

只使用:

  • arguments.length
  • arguments[i] i 須要始終爲 arguments 的合法整型索引,且不容許越界
  • 除了 .length[i],不要直接使用 arguments
  • 嚴格來講用 fn.apply(y, arguments) 是沒問題的,但除此以外都不行(例如 .slice)。 Function#apply 是特別的存在。
  • 請注意,給函數添加屬性值(例如 fn.$inject = ...)和綁定函數(即 Function#bind 的結果)會生成隱藏類,所以此時使用 #apply 不安全。

若是你按照上面的安全方式作,毋需擔憂使用 arguments 致使不肯定 arguments 對象的分配。

4. Switch-case

在之前,一個 switch-case 語句最多隻能包含 128 個 case 代碼塊,超過這個限制的 switch-case 語句以及包含這種語句的函數將不能被優化。

function over128Cases(c) {
    switch(c) {
        case 1: break;
        case 2: break;
        case 3: break;
        ...
        case 128: break;
        case 129: break;
    }
}複製代碼

你須要讓 case 代碼塊的數量保持在 128 個以內,不然應使用函數數組或者 if-else。

這個限制如今已經被解除了,請參閱此 comment

5. For-in

For-in 語句在某些狀況下會致使整個函數沒法被優化。

這也解釋了」For-in 速度不快「之類的說法。

5.1. 鍵不是局部變量:

function nonLocalKey1() {
    var obj = {}
    for(var key in obj);
    return function() {
        return key;
    };
}複製代碼
var key;
function nonLocalKey2() {
    var obj = {}
    for(key in obj);
}複製代碼

這兩種用法db都將會致使函數不能被優化的問題。所以鍵不能在上級做用域定義,也不能在下級做用域被引用。它必須是一個局部變量。

5.2. 被遍歷的對象不是一個」簡單可枚舉對象「

5.2.1. 處於」哈希表模式「(又被稱爲」歸一化對象「或」字典模式對象「 - 這種對象將哈希表做爲其數據結構)的對象不是簡單可枚舉對象。
function hashTableIteration() {
    var hashTable = {"-": 3};
    for(var key in hashTable);
}複製代碼

若是你給一個對象動態增長了不少的屬性(在構造函數外)、delete 屬性或者使用不合法的標識符做爲屬性,這個對象將會變成哈希表模式。換句話說,當你把一個對象當作哈希表來用,它就真的會變成哈希表。請不要對這種對象使用 for-in。你能夠用過開啓 Node.JS 的 --allow-natives-syntax,調用 console.log(%HasFastProperties(obj)) 來判斷一個對象是否爲哈希表模式。


5.2.2. 對象的原型鏈中存在可枚舉屬性
Object.prototype.fn = function() {};複製代碼

上面這麼作會給全部對象(除了用 Object.create(null) 建立的對象)的原型鏈中添加一個可枚舉屬性。此時任何包含了 for-in 語法的函數都不會被優化(除非僅遍歷 Object.create(null) 建立的對象)。

你可使用 Object.defineProperty 建立不可枚舉屬性(不推薦在 runtime 中調用,可是在定義一些例如原型屬性之類的靜態數據的時候它很高效)。


5.2.3. 對象中包含可枚舉數組索引

ECMAScript 262 規範 定義了一個屬性是否有數組索引:

數組對象會給予一些種類的屬性名特殊待遇。對一個屬性名 P(字符串形式),當且僅當 ToString(ToUint32(P)) 等於 P 而且 ToUint32(P) 不等於 232−1 時,它是個 數組索引 。一個屬性名是數組索引的屬性也叫作元素 。

通常只有數組有數組索引,可是有時候通常的對象也可能擁有數組索引: normalObj[0] = value;

function iteratesOverArray() {
    var arr = [1, 2, 3];
    for (var index in arr) {

    }
}複製代碼

所以使用 for-in 進行數組遍歷不只會比 for 循環要慢,還會致使整個包含 for-in 語句的函數不能被優化。


若是你試圖使用 for-in 遍歷一個非簡單可枚舉對象,它會致使包含它的整個函數不能被優化。

變通方法:只對 Object.keys 使用 for-in,若是要遍歷數組需使用 for 循環。若是非要遍歷整個原型鏈上的屬性,須要將 for-in 隔離在一個輔助函數中以下降影響:

function inheritedKeys(obj) {
    var ret = [];
    for(var key in obj) {
        ret.push(key);
    }
    return ret;
}複製代碼

6. 退出條件藏的很深,或者沒有定義明確出口的無限循環

有時候在你寫代碼的時候,你須要用到循環,可是不肯定循環體內的代碼以後會是什麼樣子。因此這時候你用了一個 while (true) { 或者 for (;;) {,在以後將終止條件放在循環體中,打斷循環進行後面的代碼。然而你寫完這些以後就忘了這回事。在重構時,你發現這個函數很慢,出現了反優化狀況 - 上面的循環極可能就是罪魁禍首。

重構時將循環內的退出條件放到循環的條件部分並非那麼簡單。

  1. 若是代碼中的退出條件是循環最後的 if 語句的一部分,且代碼至少要運行一輪,那麼你能夠將這個循環重構爲 do{} while ();
  2. 若是退出條件在循環的開頭,請將它放在循環的條件部分中去。
  3. 若是退出條件在循環體中部,你能夠嘗試」滾動「代碼:試着依次將一部分退出條件前的代碼移到後面去,而後在以前的位置留下它的引用。當退出條件能夠放在循環條件部分,或者至少變成一個淺顯的邏輯判斷時,這個循環就再也不會出現反優化的狀況了。

掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃

相關文章
相關標籤/搜索