JavaScript优化模式(一)——作者Benedikt Meurer
已经有一段时间没有在博客上发帖了,主要是因为我真的没时间精力坐下来,把想写的东西写出来。一部分原因是Chrome浏览器59版V8引擎的点火装置(Ignition)翻译器和涡轮风扇(TurboFan)编译器启动工作搞得我相当得忙,还好目前看来算是一个巨大的成功。不过还有一部分原因是我抽了些时间陪家人。最后一点,我还去了 欧盟JS大会(JSConf EU)和Web Rebels活动。写这篇帖子的同时,我还在参加enterJS的活动,磨蹭着为我的发言稿作最后修整。
与此同时,我刚Brian Terlson,Ada Rose Edwards和Ashely Williams一起吃饭回来。我们讨论了一下JavaScript中有什么良好的优化模式,思考了一下什么样的建议给别人最没风险,还特别谈到想出这些建议有多难,很有意思。我特别提出了一点,理想的性能常常取决于代码运行的背景环境,而这部分通常都是最难的,所以我觉得可能这个信息值得与大家分享一下。这个帖子是我博客上一系列帖子的开始,在这个第一部分里,我要强调一下具体的执行环境对JavaScript代码的性能会有多大的影响。
想一下以下的自制类点(Point)
,它有一个方法叫距离(distance)
,能计算两个点之间的曼哈顿距离(Manhatten distance)
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
distance(other) {
const dx = Math.abs(this.x - other.x);
const dy = Math.abs(this.y - other.y);
return dx + dy;
}
}
另外,再考虑一下下面的测试(test)
驱动函数,它创建几个点(Point)
实例,并计算点之间的距离(distance)
几百万次,把结果相加。好吧,我知道这算一个微基准程序(micro-benchmark),不过等会再说:
function test() {
const points = [
new Point(10, 10),
new Point(1, 1),
new Point(8, 9)
];
let result = 0;
for (let i = 0; i < 10000000; ++i) {
for (const point1 of points) {
for (const point2 of points) {
result += point1.distance(point2);
}
}
}
return result;
}
点(Point)
这个类,尤其是它的距离(distance)
方法现在就有了合适的基准函数。让我们来运行几次这个测试(test)
驱动程序,看看性能如何。用以下HTML代码片断:
<script>
function test() {
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
distance(other) {
const dx = Math.abs(this.x - other.x);
const dy = Math.abs(this.y - other.y);
return dx + dy;
}
}
const points = [
new Point(10, 10),
new Point(1, 1),
new Point(8, 9)
];
let result = 0;
for (let i = 0; i < 10000000; ++i) {
for (const point1 of points) {
for (const point2 of points) {
result += point1.distance(point2);
}
}
}
return result;
}
for (let i = 1; i <= 5; ++i) {
console.time("test " + i);
test();
console.timeEnd("test " + i);
}
</script>
如果是在Chrome浏览器61版(canary)中运行,那么在Chrome的开发者工具终端中就会看到以下输出结果:
test 1: 595.248046875ms
test 2: 765.451904296875ms
test 3: 930.452880859375ms
test 4: 994.2890625ms
test 5: 3894.27392578125ms
每一轮测试的性能结果都有很大区别,可以看到越往后性能越差。性能变差的原因是点(Point)
这个类处于测试(test)
函数的内部。
<script>
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
distance(other) {
const dx = Math.abs(this.x - other.x);
const dy = Math.abs(this.y - other.y);
return dx + dy;
}
}
function test() {
const points = [
new Point(10, 10),
new Point(1, 1),
new Point(8, 9)
];
let result = 0;
for (let i = 0; i < 10000000; ++i) {
for (const point1 of points) {
for (const point2 of points) {
result += point1.distance(point2);
}
}
}
return result;
}
for (let i = 1; i <= 5; ++i) {
console.time("test " + i);
test();
console.timeEnd("test " + i);
}
</script>
我们把这个片断稍微改一下,将点(Point)
类的定义放到测试(test)
函数外部,结果就不同了:
test 1: 598.794921875ms
test 2: 599.18115234375ms
test 3: 600.410888671875ms
test 4: 608.98388671875ms
test 5: 605.36376953125ms
现在,性能基本稳定,起伏也在正常范围内。注意,在这两个例子里,点(Point)
类的代码和测试(test)
驱动函数的逻辑完全相同。唯一不同的是到底点(Point)
这个类放在代码中的什么位置。
另外值得注意的是这个和新的ES2015版本类(class)
的定义句法无关。用旧的ES5版本句法格式来定义点(Point)
类也会产生同样的性能结果:
function Point(x, y) {
this.x = x;
this.y = y;
}
Point.prototype.distance = function (other) {
var dx = Math.abs(this.x - other.x);
var dy = Math.abs(this.y - other.y);
return dx + dy;
}
当点(Point)
类处于测试(test)
函数内部时,性能会有差异,其根本原因在于类(class)
这个字面变量被多次执行。在上面的例子里,类正好被定义了5次。而当类处于测试(test)
函数外部时,定义只执行一次。每执行一次类(class)
定义,就会生成一个新的原型对象,它带有这个类的所有方法。此外,还产生了一个与类(class)
相对应的新构造(constructor)函数,那个原型对象就成了这个构造函数的"原型(prototype)"
属性。
以这个"原型(prototype)"
属性作为原型对象,类的新实例就此生成。但因为V8引擎会追溯每个实例的原型,把这个原型作为对象形态(object shape)或隐藏类(hidden class)的一部分。(关于这一点,参见V8引擎下设置原型,有详细解释) 为了优化原型链上属性的访问,生成的原型不同自然意味着生成的对象形态也不同。因而,当类(class)
定义执行了多次时,生成的代码就会变得更多态。最终,V8看到有超过4种不同的对象形态,就会放弃多态,进入一种所谓的超态(megamorphic)状态,这就意味着它基本上不再生成高度优化的代码了。
所以从这个练习我们可以知道:相同的代码,只是放在略微不同的位置就可能很轻易地造成6.5倍的性能差异!知道这一点是极其重要的。因为那些常用的基准测试框架和网站如esbench.com在运行代码时,代码很可能是处在另一个环境中,与你的程序环境并不相同。也就是说,那些网站把代码包裹在底层函数中,而底层函数又运行了多次。这样做出来基准性能测试结果就可能有相当高的误导性。