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前端后端产品设计 等领域,想要查看更多优质译文请持续关注 掘金翻译计划

相关文章
相关标签/搜索