当前位置: 首页 > Web前端 > HTML

精读《JS 数组的内部实现》

时间:2023-03-28 14:52:15 HTML

每个JS执行引擎都有自己的实现。这次我们主要关注V8引擎是如何实现数组的。本周主要精读文章《HowJavaScriptArrayWorksInternally?》,简单介绍了V8引擎的数组实现机制。笔者也会参考其他一些文章和源码进行说明。概述JS数组的内部类型有多种模式,如:PACKED_SMI_ELEMENTSPACKED_DOUBLE_ELEMENTSPACKED_ELEMENTSHOLEY_SMI_ELEMENTSHOLEY_DOUBLE_ELEMENTSHOLEY_ELEMENTSPACKED翻译为packed,其实就是“一个连续值的数组”;HOLEY译为洞,意思是数组有很多无效项,如holes,其实就是“洞的数组”的意思,这两个名词是互斥的。SMI表示数据类型是32位整型,DOUBLE表示浮点型,没有写类型,表示数组的类型也混杂了字符串,函数等,这里面的描述立场也是互斥的。所以可以这样看数组的内部类型:[PA??CKED,HOLEY]_[SMI,DOUBLE,'']_ELEMENTS。最高效的类型PACKED_SMI_ELEMENTS最简单的空数组类型默认为PACKED_SMI_ELEMENTS:constarr=[]//PACKED_SMI_ELEMENTSPACKED_SMI_ELEMENTS类型是最佳性能模式,存储类型默认为连续整数。当我们插入一个整数时,V8会自动扩展数组。这时候类型还是PACKED_SMI_ELEMENTS:constarr=[]//PACKED_SMI_ELEMENTSarr.push(1)//PACKED_SMI_ELEMENTS或者直接创建一个有内容的数组,也是这个类型:constarr=[1,2,3]//PACKED_SMI_ELEMENTSautomaticallydowngrades当我们使用数组操作时,V8会默默地降级类型。比如突然访问第100项:constarr=[1,2,3]//PACKED_SMI_ELEMENTSarr[100]=4//如果突然插入浮点类型,HOLEY_SMI_ELEMENTS会降级为DOUBLE:constarr=[1,2,3]//PACKED_SMI_ELEMENTSarr.push(4.1)//PACKED_DOUBLE_ELEMENTS当然,如果你结合这两个操作,HOLEY_DOUBLE_ELEMENTS将由你成功创建:constarr=[1,2,3]//PACKED_SMI_ELEMENTSarr[100]=4.1//HOLEY_DOUBLE_ELEMENTS比较狠,插入一个字符串或者函数,那么就是最底层的类型,HOLEY_ELEMENTS:constarr=[1,2,3]//PACKED_SMI_ELEMENTSarr[100]='4'//HOLEY_ELEMENTS是否有从Empty情况来看,PACKED>HOLEY的性能在Benchmark测试结果中快了大约23%。从类型上看,SMI>DOUBLE>空类型。原因是类型决定了数组中每一项的长度。DOUBLE类型意味着每一项可能是SMI或DOUBLE,而空类型的每一项的类型是完全不可确认的,额外的开销会花费在长度确认上。因此,HOLEY_ELEMENTS是性能最差的后备类型。降级的不可逆性文中提到了一个重要的点,就是说降级是不可逆的。详见下图:其实要表达的规律很简单,即PACKED只会变差HOLEY,而SMI只会变差DOUBLE和empty类型,而且这两个变化是不可逆的。精读为了验证文章的猜想,笔者使用v8-debug进行了调试。使用v8-debug调试介绍v8-debug,它是一个v8引擎调试工具,首先执行如下命令行安装jsvu:npmi-gjsvu然后执行jsvu,根据引导选择自己的系统类型,第二步选择Thejsenginetobeinstalled,选择v8和v8-debug:jsvu//选择macos//选择v8,v8-debug并创建一个js文件,比如test.js,然后通过~/.jsvu/v8-debug./test.js可以调试。默认不输出任何调试内容。我们根据需求添加参数来输出需要调试的信息,比如:~/.jsvu/v8-debug./test.js--print-ast这样就会把test.js文件的语法树打印出来。使用v8-debug调试数组内部实现为了观察数组内部实现,显然不能使用console.log(arr),我们需要使用%DebugPrint(arr)打印数组在debug模式下,而这个%DebugPrint函数是V8提供的NativeAPI,在普通的js脚本中是无法识别的,所以我们需要在执行的时候加上参数--allow-natives-syntax:~/.jsvu/v8-debug。/test.js--allow-natives-syntax同时,在test.js中使用%DebugPrint打印我们要调试的数组,如:constarr=[]%DebugPrint(arr)输出结果为:DebugPrint:0x120d000ca0b9:[JSArray]-map:0x120d00283a71[FastProperties]即arr=[]创建的数组的内部类型是PACKED_SMI_ELEMENTS,符合预期。验证不可逆转换如果不看源码,让我们相信原文中提到的类型转换是不可逆的,那么我们做一个测试:constarr=[1,2,3]arr.push(4.1)console.log(arr);%DebugPrint(arr)arr.pop()console.log(arr);%DebugPrint(arr)打印核心结果:1,2,3,4.1DebugPrint:0xf91000ca195:[JSArray]-地图:0x0f9100283b11[FastProperties]1,2,3DebugPrint:0xf91000ca195:[JSArray]-map:0x0f9100283b11[FastProperties]即使在返回完整的原始数组后也可以看到pop,DOUBLE不会针对SMI进行优化。再看长度测试:constarr=[1,2,3]arr[4]=4console.log(arr);%DebugPrint(arr)arr.pop()arr.pop()console.log(arr);%DebugPrint(arr)打印核心结果:1,2,3,,4DebugPrint:0x338b000ca175:[JSArray]-map:0x338b00283ae9[FastProperties]1,2,3DebugPrint:0x338b000ca175]-[JSArray]map:0x338b00283ae9[FastProperties]也证明了从PACKED到HOLEY的不可逆性。字典模式数组的另一个内部实现是DictionaryElements,它使用HashTable作为底层结构来模拟数组的操作。当数组的长度非常大时使用这种模式。它不需要不断地开辟内存空间,而是使用一个分散的内存空间,通过一个HashTable地址来处理数据存储。当数据量很大时,这种模式可以节省存储空间。空间,但会带来额外的查询开销。当赋值给数组的值远大于当前数组大小时,V8会考虑将数组转换为字典元素进行存储,以节省存储空间。做一个测试:constarr=[1,2,3];%DebugPrint(arr);arr[3000]=4;%DebugPrint(arr);主要输出是:DebugPrint:0x209d000ca115:[JSArray]-map:0x209d00283a71[FastProperties]DebugPrint:0x209d000ca115:[JSArray]-map:0x209d00283a71=JSObject::kMaxGap)返回真;*new_capacity=JSObject::NewElementsCapacity(index+1);DCHECK_LT(索引,*新容量);如果(*new_capacity<=JSObject::kMaxUncheckedOldFastElementsLength||(*new_capacity<=JSObject::kMaxUncheckedFastElementsLength&&ObjectInYoungGeneration(object))){returnfalse;}returnShouldConvertToSlowElements(object.GetFastElementsUsage(),*new_capacity);}ShouldConvertToSlowElements函数重载了两次,所以有两个判断第一个new_capacity>size_threshold变成了字典模式,new_capacity代表新的size,size_threshold是根据existingsize2of3.第二个索引-capacity>=JSObject::kMaxGap变为字典模式,其中kMaxGap为常量1024,即新加入的HOLEY(空洞)大于1024,则转为字典模式.而由字母模型转换为通用模型的函数是ShouldConvertToFastElements:staticboolShouldConvertToFastElements(JSObjectobject,NumberDictionarydictionary,uint32_tindex,uint32_t*new_capacity){//如果添加了具有非标准属性或访问器的属性,我们//不能去回到快速元素。如果(dictionary.requires_slow_elements())返回false;//添加具有此索引的属性将需要慢速元素。如果(index>=static_cast(Smi::kMaxValue))返回false;if(object.IsJSArray()){对象长度=JSArray::cast(object).length();如果(!length.IsSmi())返回false;*new_capacity=static_cast(Smi::ToInt(length));}elseif(object.IsJSArgumentsObject()){返回false;}else{*new_capacity=dictionary.max_number_key()+1;}*new_capacity=std::max(index+1,*new_capacity);uint32_tdictionary_size=static_cast(dictionary.Capacity())*NumberDictionary::kEntrySize;//如果字典只节省50%的空间,则转快。return2*dictionary_size>=*new_capacity;}重点是最后一行return2*dictionary_size>=*new_capacity表示字典模式只节省50%的空间,不如切换到普通模式(快速模式)和不具体测试。有兴趣的同学可以用上面介绍的方法用v8-debug测试一下。综上所述,JS数组的使用非常灵活,但是V8用C++实现时,必须转换为更底层的类型,所以为了兼顾性能,做了一个快慢模式,而快速模式又分为SMI、DOUBLE;PACKED、HOLEY模式分别处理,尽可能提高速度。也就是说,当我们随意创建一个数组时,V8会分析数组的元素组成和长度变化,并自动分发到各个子模式中进行处理,以最大限度地提高性能。这种模式可以让JS开发者获得更好的开发体验,实际上执行性能和C++原生优化差不多。因此,从这个角度来看,JS是一种封装程度更高的语言,大大降低了开发者的学习门槛。当然,JS也提供了一些相对原生的语法比如ArrayBuffer,或者WASM让开发者直接操作更底层的特性,可以让性能控制更精准,但是带来了更大的学习和维护成本,开发者需要基于实际情况重。讨论地址为:Jingdu《JS 数组的内部实现》·Issue#414·dt-fe/weekly想参与讨论的请戳这里,每周都有新话题,周末或周一发布。前端精读——帮你过滤靠谱的内容。关注前端精读微信公众号版权声明:免费转载-非商业-非衍生保留属性(CreativeCommons3.0License)