原发表于:hax的技术部落格
今天看了微软JScript官方blog上去年的两篇文章:
http://blogs.msdn.com/jscript/archive/2008/03/25/performance-optimization-of-arrays-part-i.aspx
http://blogs.msdn.com/jscript/archive/2008/04/08/performance-optimization-of-arrays-part-ii.aspx
讲的是IE8对Array性能的改进,其中也解释了过去JScript对Array的内部实现及导致的性能问题。
我狗狗了一下,似乎没有中文文章介绍过,所以我就写一篇来稍作介绍——不过并非翻译——微软的人总不好意思自己骂自己——而我素来要痛打落水狗的。
首先,目前JScript中数组操作是很慢滴,有多慢呢?我们引用一下JScript团队第一篇blog的数据:
提示:您可以先修改部分代码再运行
此段代码,IE7用了7秒,而IE8仅仅用时18毫秒。
如果你看了第一篇blog中所解释的原因,请注意
——其实它在忽悠你,真正的原因在第二篇里。
我这里有有个更加简单的测试代码:
提示:您可以先修改部分代码再运行
你可以改变SIZE和count的值来观察。
基本上,pop()方法或者任何导致length减小的操作,都是非常耗费的——大体上与数组实际包含元素个数成正比。
这才是真正的性能杀手。
而且这一操作所要遍历的元素甚至包括非正整数索引(而只有索引为uint32才会被认为是数组元素):
提示:您可以先修改部分代码再运行
可以发现结果也是基本与元素个数成正比。但是比前面要快一些。原因后面解释。
之所以有这样的结果,是因为length减小时,引擎不仅仅是改变了length的值,而且要删除所有索引大于等于新的length的元素。
本来这件事情可以这样做(我相信大多数程序员的第一感觉也就是这样做):
如果确实这样做的话,性能绝不会如此糟糕(不信你可以写个自己的MyArray来试验一下——用JS写出来的居然比built-in的C实现还要快)。
但是,JScript团队的程序员,据说认为JS的Array其实是稀疏数组,或者说就是一个hash——理论上也确实如此,所以采用了大概是这样的算法(以JS来示意):
其中toUint32(s)将一个32位无符号整数的十进制字符串表达转为数字,如果转换失败则返回-1。【注意,此toUint32与parseInt不同,传入'0123'、'123.00'、'+123'、'1e4'等都会转换失败。】
这段代码遍历所有的key(因为Array本质上是把数字索引转换为字符串索引来保存的),如果这个key可以被转回无符号整数(也就是数字索引),则看看它是否大于指定的长度,大于的话就删掉。
好了,现在我们就不难理解为什么性能会与元素个数成正比了。假如一个50000个元素的数组,pop()了一下之后,居然也要遍历50000个元素!如果是在一个循环中不断pop()可想而知会有多慢。
那么为什么我们第二个例子会快一些呢?
这是因为我们的字符串索引是字母"i"开头的,而toUint32的实现,应该会一个字符一个字符的解析,当第一个字母是i时已然可知转换失败,直接返回了。以下是也以JS示意:
你也可以把前面第二段代码中的:
arr['i' + i] = i;
换为
arr[i + '00000000x'] = i
结果就会发现耗费时间更长了,因为toUint32必须解析到最后一个字符才知道转换失败。
【注:这里可以优化,因为uint32十进制最长是10个字符,所以可以先判断string的长度,超过10直接失败——问题是从测试结果来看,JScript实际的实现并没有做此优化】
那么JScript的开发者为什么要采用这样一个算法呢?
其实,如果从稀疏数组的角度看,这个算法是可行的,而且在某些条件下性能很好,比如对于以下:
var a = new Array(100000)
a.length = 10
在这次对length的改变中,上述算法飞快的完成,因为这个数组没并有什么实际元素可遍历!
所以JScript的开发者或许是特意做了这样的优化算法!!
然而讽刺的是,这本质上是一个极其愚蠢的优化!!
通常我们在用Array的时候,很少拿它当稀疏数组用!因为如果我们确实要用一个稀疏数组,我们完全可以直接用普通对象(即hash)。
就算我们经常使用稀疏数组——诚然,在一个稀疏数组时,前面所描述的最显白的算法,可能并不高效,但是稍加分析就可以看出,那样的算法要出现性能问题,只能是如下形式的代码:
其中每个arrays[i]都是稀疏数组。
这样的代码我是没有见过。
对于单个稀疏数组来说,要出现性能问题,除非你这样:
然而几乎没有人会写这样的代码
——而且 array.length=0 这句造成性能问题的语句根本是多余的。
归根到底,既然是一个稀疏数组,则通常不会进行遍历,更没有理由执行 array.length = 0 之类的语句,如果要抛弃一个数组,直接 array = null 即可。
相反,普通(非稀疏)的数组,就是用来遍历的。因此很容易写出这样的代码:
或者
所以我所能得出的结论是,JScript的Array.length的算法实现实在非常愚蠢。幼稚程度堪比我们诟病的白痴的垃圾回收策略。
当然,比起垃圾到极点的垃圾回收算法给我们造成的麻烦,这个length的问题还是可以有workaround的,不必干等IE8那样复杂到狂出bug(见第一篇blog下面的comments)的设计。比如IE8 beta1的platform performance improvements白皮书里就提到,有人自己造轮子:
一个用js代码自造的轮子,居然比c写的built-in函数跑的还要快,真是让人汗颜。
另外一个更简便的方式,就是记住这个简单的最佳实践:
少去改length!特别不要在循环中做pop()或length--之类的操作。如果要减,等到循环结束,一次性减到底就好了!
BTW,我们也要注意shift()、unshift()、splice()之类的操作,因为这些操作可能会改变大量数组元素的索引——按照稀疏数组的实现,显然会比单单改length更慢上许多!如果你真的需要shift()、unshift(),那还是自制一个类似上面的轮子吧(提示:上面代码的start属性就是为此准备的)。
今天看了微软JScript官方blog上去年的两篇文章:
http://blogs.msdn.com/jscript/archive/2008/03/25/performance-optimization-of-arrays-part-i.aspx
http://blogs.msdn.com/jscript/archive/2008/04/08/performance-optimization-of-arrays-part-ii.aspx
讲的是IE8对Array性能的改进,其中也解释了过去JScript对Array的内部实现及导致的性能问题。
我狗狗了一下,似乎没有中文文章介绍过,所以我就写一篇来稍作介绍——不过并非翻译——微软的人总不好意思自己骂自己——而我素来要痛打落水狗的。
首先,目前JScript中数组操作是很慢滴,有多慢呢?我们引用一下JScript团队第一篇blog的数据:
提示:您可以先修改部分代码再运行
此段代码,IE7用了7秒,而IE8仅仅用时18毫秒。
如果你看了第一篇blog中所解释的原因,请注意
——其实它在忽悠你,真正的原因在第二篇里。
我这里有有个更加简单的测试代码:
提示:您可以先修改部分代码再运行
你可以改变SIZE和count的值来观察。
基本上,pop()方法或者任何导致length减小的操作,都是非常耗费的——大体上与数组实际包含元素个数成正比。
这才是真正的性能杀手。
而且这一操作所要遍历的元素甚至包括非正整数索引(而只有索引为uint32才会被认为是数组元素):
提示:您可以先修改部分代码再运行
可以发现结果也是基本与元素个数成正比。但是比前面要快一些。原因后面解释。
之所以有这样的结果,是因为length减小时,引擎不仅仅是改变了length的值,而且要删除所有索引大于等于新的length的元素。
本来这件事情可以这样做(我相信大多数程序员的第一感觉也就是这样做):
Code:
function get_length() { return this._length }
function set_length(len) {
if (len < 0 || len > 0xffffffff) throw RangeError()
if (len > this._length) {
this._length = len; return
}
while (len < this._length) {
delete this[--this._length]
}
}
如果确实这样做的话,性能绝不会如此糟糕(不信你可以写个自己的MyArray来试验一下——用JS写出来的居然比built-in的C实现还要快)。
但是,JScript团队的程序员,据说认为JS的Array其实是稀疏数组,或者说就是一个hash——理论上也确实如此,所以采用了大概是这样的算法(以JS来示意):
Code:
function get_length() { return this._length }
function set_length(len) {
if (len < 0 || len > 0xffffffff) throw RangeError()
if (len < this._length) {
for (var k in this) {
var i = toUint32(k)
if (i >= len) delete(this[k])
}
}
this._length = len
}
其中toUint32(s)将一个32位无符号整数的十进制字符串表达转为数字,如果转换失败则返回-1。【注意,此toUint32与parseInt不同,传入'0123'、'123.00'、'+123'、'1e4'等都会转换失败。】
这段代码遍历所有的key(因为Array本质上是把数字索引转换为字符串索引来保存的),如果这个key可以被转回无符号整数(也就是数字索引),则看看它是否大于指定的长度,大于的话就删掉。
好了,现在我们就不难理解为什么性能会与元素个数成正比了。假如一个50000个元素的数组,pop()了一下之后,居然也要遍历50000个元素!如果是在一个循环中不断pop()可想而知会有多慢。
那么为什么我们第二个例子会快一些呢?
这是因为我们的字符串索引是字母"i"开头的,而toUint32的实现,应该会一个字符一个字符的解析,当第一个字母是i时已然可知转换失败,直接返回了。以下是也以JS示意:
Code:
function toUint32(s) {
if (s == '') return -1
if (s.charCodeAt(0) == 48 && s.length > 1) return -1
var n = 0, d
for (var i = 0; i < s.length; i++) {
n *= 10
d = s.charCodeAt(i) - 48
if (d < 0 || d > 9) return -1
n += d
if (n >= 0xffffffff) return -1
}
return n
}
你也可以把前面第二段代码中的:
arr['i' + i] = i;
换为
arr[i + '00000000x'] = i
结果就会发现耗费时间更长了,因为toUint32必须解析到最后一个字符才知道转换失败。
【注:这里可以优化,因为uint32十进制最长是10个字符,所以可以先判断string的长度,超过10直接失败——问题是从测试结果来看,JScript实际的实现并没有做此优化】
那么JScript的开发者为什么要采用这样一个算法呢?
其实,如果从稀疏数组的角度看,这个算法是可行的,而且在某些条件下性能很好,比如对于以下:
var a = new Array(100000)
a.length = 10
在这次对length的改变中,上述算法飞快的完成,因为这个数组没并有什么实际元素可遍历!
所以JScript的开发者或许是特意做了这样的优化算法!!
然而讽刺的是,这本质上是一个极其愚蠢的优化!!
通常我们在用Array的时候,很少拿它当稀疏数组用!因为如果我们确实要用一个稀疏数组,我们完全可以直接用普通对象(即hash)。
就算我们经常使用稀疏数组——诚然,在一个稀疏数组时,前面所描述的最显白的算法,可能并不高效,但是稍加分析就可以看出,那样的算法要出现性能问题,只能是如下形式的代码:
Code:
for(var i=0;i<arrays.length;i++) {
arrays[i].length = 0
}
其中每个arrays[i]都是稀疏数组。
这样的代码我是没有见过。
对于单个稀疏数组来说,要出现性能问题,除非你这样:
Code:
for(var i=0;i<count;i++) {
array = new Array(50000)
...
array.length = 0
}
然而几乎没有人会写这样的代码
——而且 array.length=0 这句造成性能问题的语句根本是多余的。
归根到底,既然是一个稀疏数组,则通常不会进行遍历,更没有理由执行 array.length = 0 之类的语句,如果要抛弃一个数组,直接 array = null 即可。
相反,普通(非稀疏)的数组,就是用来遍历的。因此很容易写出这样的代码:
Code:
while (array.length > 0) {
var e = array.pop()
// do sth for e
}
或者
Code:
while (array.length > 0) {
var e = array[array.length - 1]
// do sth for e
array.length--
}
所以我所能得出的结论是,JScript的Array.length的算法实现实在非常愚蠢。幼稚程度堪比我们诟病的白痴的垃圾回收策略。
当然,比起垃圾到极点的垃圾回收算法给我们造成的麻烦,这个length的问题还是可以有workaround的,不必干等IE8那样复杂到狂出bug(见第一篇blog下面的comments)的设计。比如IE8 beta1的platform performance improvements白皮书里就提到,有人自己造轮子:
Code:
//user defined array type called 'prototypeArray'
var prototypeArray = function() {
this._array = [];
this.length = 0;
this.start = 0;
this.end = 0;
}
//push method for user defined array type 'prototypeArray' prototypeArray.prototype.push = function() {
var l = arguments.length;
for (var i=0;i<l;i++){
this.length++;
this._array[this.end++] = arguments[i];
}
}
//pop method for user defined array type 'prototypeArray' prototypeArray.prototype.pop = function() {
var obj = null;
if (this.length>0){
this.length--;
obj = this._array[--this.end];
delete this._array[this.end];
}
return obj;
}
//creating an object of user defined array type 'prototypeArray' var myArray = new prototypeArray();
//accessing push and pop methods of user defined array type 'prototypeArray'
myArray.push("Test String")
myArray.pop();
一个用js代码自造的轮子,居然比c写的built-in函数跑的还要快,真是让人汗颜。
另外一个更简便的方式,就是记住这个简单的最佳实践:
少去改length!特别不要在循环中做pop()或length--之类的操作。如果要减,等到循环结束,一次性减到底就好了!
BTW,我们也要注意shift()、unshift()、splice()之类的操作,因为这些操作可能会改变大量数组元素的索引——按照稀疏数组的实现,显然会比单单改length更慢上许多!如果你真的需要shift()、unshift(),那还是自制一个类似上面的轮子吧(提示:上面代码的start属性就是为此准备的)。