Loading...
Loading...
- 最后编辑:2026/01/31 09:37:26 -
Loading...
昨天晚上睡前,躺在床上刷着B站,突然看到了一个视频,内容是关于JS中如何最快清除一个数组,而视频给出的解答,类似于下面这样:
const list = [1,2,3,4,5]
// clear list
list.length = 0是的,解法是直接令数组的length为零,但up主并没有详细讲解这种做法的原理,也没有解释为什么这是最快的,于是我就去问了一下AI,没想到这一问,给我问出了一大堆涉及到V8引擎底层实现原理的话题。理解过后,我认为这是一个非常值得记录和深究的话题,所以就有了这篇文章。
在开始前,我让AI画了一个简单的流程图,便于大家在阅读下文时直观理解我的意思,毕竟我的表达能力不算很好,刚好这些原理还有点复杂,有可能会出现我写嗨了,但是其他人却读不懂的情况(雾

这个流程图乍一看很唬人,但其实讲的挺详细的,所以接下来我就照着这个图的内容详细的讲一下这个过程:
在计算机内存中,有两个非常常见的概念——堆(Stack)和栈(Heap),他们分别代表着内存的两种不同的功能区,有不同的性质和用途:
malloc等相关函数申请和管理的内存空间都属于堆,堆内存的特点就是不讲究先来后到,而是可以根据程序的需要而灵活调整,因此也就不存在像栈那样的随着程序的进行而自动压入和弹出的过程,堆里面的先后顺序并不代表存入顺序,甚至可能会出现一片无用的空内存,正因如此,堆在一些低级语言中是由程序本身来完全管理的,程序不动它,它里面的内容是不会主动发生改变的,因此,在写C或C++等语言时,经常需要反复的申请和释放内存,如果一直申请新内存而不释放冗余内存的话,堆内存就会一直膨胀,最终导致内存溢出,而像JavaScript这种语言就设计了一个在主线程外异步执行的垃圾回收机制(Gabage Collection,下称GC),帮助程序不断释放无用内存,提高了编程的容错率。而在V8的设计中,变量本身被存储在栈中,但它不存储变量内的内容,而是像Python之类的语言一样,采用了一种“引用”的机制,即将变量的内的数据存储在堆或栈的其他地方,而变量本身只存储数据所在的内存地址,并让指针去引用它,这样做的好处就是实现了一个极其宽松的动态数据类型机制,例如下面这段代码就是只在JS这种弱类型语言中才能运行的:
let str = "I'm a string data!"
// ...其它无关代码
str = 12 // 变量类型从字符串变成了整数!在上面这个案例中,str变量的类型在代码上下文中从字符串变成了整数,这在其它强类型语言(如C、C++、Rust等)中必然会导致报错,因为字符串类型的数据和整数类型的数据所对应的字节长度不一致,无法做到相互兼容,所以不可能被改变,但在JS中,这完全可以做到,因为str变量本质上只是一个指向"I'm a string data!"这串字符串数据的指针,而"I'm a string data!"的本体并不在这里,而是在内存的其他地方,JS通过引用机制为这两个数据类型建立了联系,而在后文中,str被重新赋值,那么原来指向"I'm a string data!"的引用就不存在了,而是指向了一个新的地址,那个地址上存着整数12,但这并不代表原来的"I'm a string data!"这个数据不存在,相反,如果没有GC的话,它会永远存在于这里,直到程序结束运行,因此,JS才设计了GC,来手动的管理堆内存,它会只沿着“引用链”遍历所有对象,从根引用出发,追踪所有可达对象,当一个对象没有被任何上下文引用时,就会被GC标记为不可达,随后这片内存空间就会被释放,其内部的数据自然也就消失了。
小整数和指针是V8底层为变量设计的两种编码类型,V8对所有数据都使用8字节对齐的堆内存来存储(因为现代CPU一般是一次性读8个字节,因此8字节长度的读取效率最高),那么所有堆对象的内存空间就都是8的倍数,而8的倍数在二进制中有一个特点:
000,例如 0x10 (16) = 10000₂,0x18 (24) = 11000₂ → 末 3 位均为 000观察到这个特点后,V8在设计时就想到:既然最后三位始终都是0,那我能不能利用这个特点,将后三位利用起来呢?
不得不承认,这的确是一个十分天才的想法,V8就是利用这个特点,定义了小整数和指针两种编码类型在内存中的”长相“,又称”标记位“:
[-1073741824 ~ 1073741823]。小整数可以被用来存一些比较简单的数据类型,例如取值范围内的整数,而指针则用于存储比较复杂的内容,如字符串、浮点数、对象等,举个具体的例子就是:
const list = [1, 2, 3, {x:1, y:2}]此时,list这个变量本身会被存在栈中,而变量中的指针会指向list数组所在的具体位置,这是一片连续堆内存空间,存着list所有成员的信息,前三位都是整数,属于小整数,那么这三位就会被编码成二进制格式,存到内存中,而第四位是一个对象,对象显然是不能直接转成二进制存进去的,因此JS会为它申请一片新的内存空间,专门用来存放{x:1, y:2}这个对象,然后再将这片内存的起始地址转成二进制数据存进内存。
然后,假设程序在下文某处遍历了一次这个数组,那么它就要一个一个的取出数组元素,在读取的时候,它发现前三位数据的标记位都是0,就直接将二进制编码成对应的数字进行其它操作,而当遍历到第四位的时候,它注意到这个数据的标记位是1,那么就会将它编码成一个内存地址,再跳到这个地址上,继续读取对象的数据,这样就完成了一次完整的遍历。
这样做的好处就是,一些整数类型的变量,他们所对应的数据不用存进堆内存中,而是可以直接存在栈内,而栈的执行效率会比堆快得多,并且无需GC,无论是堆内的小整数还是栈内的都不需要,因为小整数存进堆内的大部分情况都是作为对象的子元素被一起存进去,而GC释放的是对象所包含的一整片内存空间,当它被释放时,其内部的小整数数据全都会被删除,但如果是指针,那么GC在释放对象所在的内存时,清除的仅仅是一堆指针引用,但实际存于别处的数据并没有清除,此时GC就需要再专门顺藤摸瓜的找过去清除它们,这就多了一次内存操作,效率自然就比小整数低得多。
之所以要区分小整数和指针两种编码类型,当然就是为了提高程序在处理常见的整型数据时的速度,最大化利用八位字节的特点,让V8的执行效率再上一个台阶。
回到正题,在了解了这么多必备知识后,我们终于可以正视我在文章一开头抛出的问题——如何最快的清空一个数组?或者说,为什么list.length = 0的做法是最快速的?
如果问到这个问题,我相信很多人都会首先排除遍历法和Array.splice方法,因为这些做法的空间复杂度是O(n)甚至O(n*n),效率比较低。
而最后争议较大的,当然就是以下两种做法:
const list = [1,2,3,4,5]
// clear the array
list = []
// and
list.length = 0首先,让我纠正一个很多人可能会踏入的思维误区——我们要清空的是数组本身,而不是list这个变量,也就是说我们需要清空的不是这个变量所对应的信息,而是清空list引用的这个数组的内容。
因此,如果直接执行list = [],那么就相当于是将list重新引用到了一个空数组上,而原数组[1,2,3,4,5]需要等待GC下一次触发时才会被回收,而如果在别的地方还有其他变量引用了这个数组的话,它就不会被GC回收,因此在一些极端情况下,这个内存空间可能不会被及时的释放,理论上就不是最快速清空的方法了。
而当我们执行list.length = 0时,它调用的不是list变量本身含有的函数,而是位于Array.prototype原型链上的length属性,也就是我们跳过了list变量,直接操作了位于堆内存中的那个对象本身,并且当length被改变时,GC会被自动触发,list所占据的内存空间就会立刻被标记为”不可达“,并被回收。
因此,list.length = 0这个方式才是最快、最稳妥的清除一个数组的方式,它能百分百调用GC释放内存空间,并且使程序中所有引用了[1,2,3,4,5]这个数组的变量的引用同时失效,在概念上直接删除了这个数组的存在。
虽然这个问题看似非常无厘头,如果让我写”清除数组“这个需求,我也大概率会直接写list = [],这种对性能非常敏感的情况可能只会发生在面试中,但是这个问题确实也让我对V8的底层实现原理产生了更加深刻的理解,对于计算机的一些底层原理也有了更清晰的认知,该说不说,还是得感谢这个视频(笑