[译]JavaScript V8性能小贴士

译自:Performance Tips for JavaScript in V8

简介

关于如何巧妙提高V8 JavaScript性能的话题,Daniel Clifford在Google I/O上做了一次非常精彩的分享。Daniel鼓励我们“追求更快”,认真的分析C++和JavaScript之间的性能差距,根据JavaScript的工作原理撰写代码。在Daniel的分享中,有一个核心要点的归纳,我们也会根据性能指导的变化保持对这篇文章的更新。

最重要的建议

最重要的是要把任何性能建议放在特定的情境当中。性能建议是附加的东西,有时一开始就特别注意深层的建议反而会对我们造成干扰。你需要从一个综合的角度看待你的Web应用的性能——在关注这些性能建议之前,你应该找PageSpeed之类的工具大概分析一下你的代码,也算是跑个分先。这会防止你过度优化。

对Web应用的性能优化,几个原则性的建议是:

  • 首先,未雨绸缪
  • 然后,找到症结
  • 最后,修复它

为了完成这几个步骤,理解V8如何优化JS是一件很重要的事情,这样你就可以根据其对JS运行时的设计撰写代码。同样重要的是掌握一些帮得上忙的工具。Daniel也交代了一些开发者工具的用法,它们刚好抓住了一些V8引擎设计上最重要的部分。

OK。开始V8小贴士。


隐藏类

JavaScript限制编译时的类型信息:类型可以在运行时被改变,可想而知这导致JS类型在编译时代价昂贵。那么你一定会问:JavaScript的性能有机会和C++相提并论吗?尽管如此,V8在运行时隐藏了内部创建对象的类型,隐藏类相同的对象可以使用相同的生成码以达到优化的目的。

比如:

function Point(x, y) {
  this.x = x;
  this.y = y;
}

var p1 = new Point(11, 22);
var p2 = new Point(33, 44);
// At this point, p1 and p2 have a shared hidden class
// 这里的p1和p2拥有共享的隐藏类
p2.z = 55;
// warning! p1 and p2 now have different hidden classes!
// 注意!这时p1和p2的隐藏类已经不同了!

在我们为p2添加“z”这个成员之前,p1和p2一直共享相同的内部隐藏类——所以V8可以生成一段单独版本的优化汇编码,这段代码可以同时封装p1和p2的JavaScript代码。我们越避免隐藏类的派生,就会获得越高的性能。

结论

  • 在构造函数里初始化所有对象的成员(所以这些实例之后不会改变其隐藏类)
  • 总是以相同的次序初始化对象成员

数字

当类型可以改变时,V8使用标记来高效的标识其值。V8通过其值来推断你会以什么类型的数字来对待它。因为这些类型可以动态改变,所以一旦V8完成了推断,就会通过标记高效完成值的标识。不过有的时候改变类型标记还是比较消耗性能的,我们最好保持数字的类型始终不变。通常标识为有符号的31位整数是最优的。

比如:

var i = 42; // 这是一个31位有符号整数
var j = 4.2; // 这是一个双精度浮点数

结论

  • 尽量使用可以用31位有符号整数表示的数。

数组

为了掌控大而稀疏的数组,V8内部有两种数组存储方式:

  • 快速元素:对于紧凑型关键字集合,进行线性存储
  • 字典元素:对于其它情况,使用哈希表

最好别导致数组存储方式在两者之间切换。

结论

  • 使用从0开始连续的数组关键字
  • 别预分配大数组(比如大于64K个元素)到其最大尺寸,令其尺寸顺其自然发展就好
  • 别删除数组里的元素,尤其是数字数组
  • 别加载未初始化或已删除的元素:

a = new Array();
for (var b = 0; b < 10; b++) {
  a[0] |= b;  // 杯具!
}
//vs.
a = new Array();
a[0] = 0;
for (var b = 0; b < 10; b++) {
  a[0] |= b;  // 比上面快2倍
}

同样的,双精度数组会更快——数组的隐藏类会根据元素类型而定,而只包含双精度的数组会被拆箱(unbox),这导致隐藏类的变化。对数组不经意的封装就可能因为装箱/拆箱(boxing/unboxing)而导致额外的开销。比如:

var a = new Array();
a[0] = 77; // 分配
a[1] = 88;
a[2] = 0.5; // 分配,转换
a[3] = true; // 分配,转换

下面的写法效率更高:

var a = [77, 88, 0.5, true];

因为第一个例子是一个一个分配赋值的,并且对a[2]的赋值导致数组被拆箱为了双精度。但是对a[3]的赋值又将数组重新装箱回了任意值(数字或对象)。第二种写法时,编译器一次性知道了所有元素的字面上的类型,隐藏隐藏类可以直接确定。

结论

  • 初始化小额定长数组时,用字面量进行初始化
  • 小数组(小于64k)在使用之前先预分配正确的尺寸
  • 请勿在数字数组中存放非数字的值(对象)
  • 如果通过非字面量进行初始化小数组时,切勿触发类型的重新转换

JavaScript编译

尽管JavaScript是个非常动态的语言,且原本的实现是解释性的,但现代的JavaScript运行时引擎都会进行编译。V8(Chrome的JavaScript)有两个不同的运行时(JIT)编译器:

  • “完全”编译器,可以为任何JavaScript生成优秀的代码
  • 优化编译器,可以为大部分JavaScript生成伟大(汗一下自己的翻译)的代码,但会更耗时

完全编译器

在V8中,完全编译器会以最快的速度运行在任何代码上,快速生成优秀但不伟大的代码。该编译器在编译时几乎不做任何有关类型的假设——它预测类型在运行时会发生改变。完全编译器的生成码通过内联缓存(ICs)在程序运行时提炼类型相关的知识,以便将来改进和优化。

内联缓存的目的是,通过缓存依赖类型的代码进行操作,更有效率的掌控类型。当代吗运行时,它会先验证对类型的假设,然后使用内联缓存快速执行操作。这也意味着可以接受多种类型的操作会变得效率低下。

结论

  • 单态操作优于多态操作

如果一个操作的输入总是相同类型的,则其为单态操作。否则,操作调用时的某个参数可以跨越不同的类型,那就是多态操作。比如add()的第二个调用就触发了多态操作:

function add(x, y) {
  return x + y;
}

add(1, 2);     // add中的+操作是单态操作
add("a", "b"); // add中的+操作变成了多态操作

优化编译器

V8有一个和完全编译器并行的优化编译器,它会重编那些最“热门”(即被调用多次)的函数。优化编译器通过类型反馈来使得编译过的代码更快——事实上它就是使用了我们之前谈到的ICs的类型信息!

在优化编译器里,操作都是内联的(直接出现在被调用的地方)。它加速了执行(拿内存空间换来的),同时也进行了各种优化。单态操作的函数和构造函数可以整个内联起来(这是V8中单态操作的有一个好处)。

你可以使用单独的“d8”版本的V8引擎来获取优化记录:

d8 --trace-opt primes.js

(其会把被优化的函数名输出出来)

不是所有的函数都可以被优化,有些特性会阻止优化编译器运行一个已知函数(bail-out)。目前优化编译器会排除有try/catch的代码块的函数。

结论

  • 如果存在try/catch代码快,则将性能敏感的代码放到一个嵌套的函数中:

function perf_sentitive() {
 // 把性能敏感的工作放置于此
}

try {
 perf_sentitive()
} catch (e) {
 // 在此处理异常
}

这个建议可能会在未来发生改变,因为我们会在优化编译器里开启try/catch代码块。你可以通过使用上述的d8选项“--trace-opt”得到更多有关这些函数的信息来检验优化编译器如何排除这些函数。

d8 --trace-opt primes.js

取消优化

最终,编译器的性能优化是有针对性的——有时它的变现并不好,我们就不得不回退。“取消优化”的过程实际上就是把优化过的代码扔掉,恢复执行完全编译器的代码。重优化可能稍后再打开,但是短期内性能会下降。尤其是取消优化的发生会导致其函数的变量的隐藏类的变化。

结论

  • 回避在优化过后函数内隐藏类改变

你可以像其它优化一样,通过V8的一个日志标识来取消优化。

d8 --trace-deopt primes.js

其它V8工具

顺便提一下,你还可以在Chrome启动时传递V8跟踪选项:

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --js-flags="--trace-opt --trace-deopt"

额外使用开发者工具分析,你可以使用d8进行分析:

% out/ia32.release/d8 primes.js --prof

它通过内建的采样分析器,对每毫秒进行采样,并写入v8.log。

回到摘要……

重要的是认识和理解V8引擎如何处理你的代码,进而为优化JavaScript做好准备。再次强调我们的基础建议:

  • 首先,未雨绸缪
  • 然后,找到症结
  • 最后,修复它

这意味着你应该通过PageSpeed之类的工具先确定你的JavaScript中的问题,在收集指标之前尽可能减少至纯粹的JavaScript(没有DOM),然后通过指标来定位瓶颈所在,评估重要程度。希望Daniel的分享会帮助你更好的理解V8如何运行JavaScript——但是也要确保专注于优化你自身的算法!

参考资料