JavaScript 性能及代码优化

1,292 阅读21分钟

V8 引擎

先聊聊 CPU

CPU 的基本结构包括

逻辑部件、寄存器、控制部件
更细一点,从实现的功能方面看,CPU大致可分为如下八个逻辑单元:

  1. 指令寄存器 :它是芯片上的指令仓库,有了它CPU就不必停下来查找计算机内存中的指令,从而大幅提高了CPU的运算速度。
  2. 指令译码器 :它负责将复杂的机器语言指令解译成运算逻辑单元(ALU)和寄存器能够理解的简单格式,就像一位外交官。
  3. 控制单元 :既然指令可以存入CPU,而且有相应指令来完成运算前的准备工作,背后自然有一个扮演推动作用的角色——它便是负责整个处理过程的操作控制器。根据来自译码单元的指令,它会生成控制信号,告诉运算逻辑单元(ALU)和寄存器如何运算、对什么进行运算以及对结果进行怎样的处理
  4. 寄存器 :它对于CPU来说非常的重要,除了存放程序的部分指令,它还负责存储指针跳转信息以及循环操作命令,是运算逻辑单元(ALU)为完成控制单元请求的任务所使用的数据的小型存储区域,其数据来源可以是高速缓存、内存、控制单元中的任何一个。
  5. 逻辑运算单元(ALU) :它是CPU芯片的智能部件,能够执行加、减、乘、除等各种命令。此外,它还知道如何读取逻辑命令,如或、与、非。来自控制单元的讯息将告诉运算逻辑单元应该做些什么,然后运算单元会从寄存器中间断或连续提取数据,完成最终的任务。
  6. 预取单元 :PU效能发挥对其依赖非常明显,预取命中率的高低直接关系到CPU核心利用率的高低,进而带来指令执行速度上的不同。根据命令或要执行任务所提出的要求,何时时候,预取单元都有可能从指令高速缓存或计算机内存中获取数据和指令。当指令到达时,预取单元最重要的任务就是确保所有指令均排列正确,然后发送给译码单元。
  7. 总线单元 :它就像一条高速公路,快速完成各个单元间的数据交换,也是数据从内存流进和流出CPU的地方。
  8. 数据高速缓存 :存储来自译码单元专门标记的数据,以备逻辑运算单元使用,同时还准备了分配到计算机不同部分的最终结果。

CPU 指令

我们编写的 JavaScript 代码,交由浏览器或 Node 执行时,都是需要在 CPU 进行处理的。不同类型 CPU 有对应指令集,常见的有:

  1. Reduced Instruction Set Computing (RISC) 精简指令集,比如ARM,MIPS
  2. Complex Instruction Set Computing (CISC) 复杂指令集,比如Intel的X86

指令类型

有效的指令操作须包含:

  • 数据处理与存储操作
    • 暂存器的值(在中央处理器作为高速缓存的存储空间)设为固定值;
    • 将数据从存储空间中传送至寄存器,反之亦然。用于将数据取出并执行计算,或者将计算结果予以保存;
    • 从硬体设备读取或写入数据。
  • 算术逻辑单元
    • 对两个储存于暂存器的数字进行add,subtract,multiply,divide,将结果放到一个暂存器内,一个或是更多的状态码可能被设置在状态暂存器中;
    • 执行位操作,藉对两组数字(为两串的数字,都由零与一构成,分别储存于两个暂存器内)执行逻辑与逻辑或,或者对寄存器的每一位执行逻辑非操作;
    • 比较两个寄存器中的数据(例如是大于或者相等);
  • 控制流
    • 分支,跳跃至程序某地址并执行相应指令;
    • 条件分支,假设某一条件成立,就跳到程序的另一个位置;
    • 间接分支,在跳到另一个位置之前,将现在所执行的指令的下一个指令的位置储存起来,作为子程式执行完返回的地址;

复杂指令

一些电脑在他们的指令集架构内包含复杂指令。复杂指令包含:

  • 将许多暂存器存成堆叠的形式。
  • 移动记忆体内大笔的资料。
  • 复杂或是浮点数运算(正弦,馀弦,平方根等等)
  • 执行test-and-set指令。
  • 执行数字存在记忆体而非暂存器的运算

解释与编译

编译相当于做好了一桌子菜,可以直接开吃了。而解释就相当于吃火锅,需要一边煮一边吃。

JavaScript 属于解释型语言,它需要在代码执行时,将代码编译为机器语言。 image.png

  • Interpreter 逐行读取代码并立即执行。
  • Compiler 读取您的整个代码,进行一些优化,然后生成优化后的代码。

让我们来看下面这个例子。

function add(a, b) { return a+b }
for(let i = 0; i < 1000; i++) { add(1 + 1)}

上面的示例循环调用了 add 函数1000次,该函数将两个数字相加并返回总和。

  1. Interpreter 接收上面的代码后,它将逐行读取并立即执行代码,直到循环结束。 它的工作仅仅是实时地将代码转换为我们的计算机可以理解的内容。
  2. 如果这段代码接受者是 Compiler,它会先完整地读取整个程序,对我们要执行的代码进行分析,并生成电脑可以读懂的机器语言。过程如同获取 X(我们的JS文件)并生成 Y(机器语言)一样。如果我们使用 Interpreter 执行 Y,则会获得与执行 X 相同的结果。 image.png 从上图中可以看出,ByteCode 只是中间码,计算机仍需要对其进行翻译才能执行。 但是 InterpreterCompiler 都将源代码转换为机器语言,它们唯一的区别在于转换的过程不尽相同。
  • Interpreter 逐行将源代码转换为等效的机器代码。
  • Compiler 在一开始就将所有源代码转换为机器代码。

JavaScript 引擎

JavaScript 其实有众多引擎,只不过 v8 是我们最为熟知的。

  • V8 (Google),用 C++编写,开放源代码,由 Google 丹麦开发,是 Google Chrome 的一部分,也用于 Node.js。
  • JavaScriptCore (Apple),开放源代码,用于 webkit 型浏览器,如 Safari ,2008 年实现了编译器和字节码解释器,升级为了 SquirrelFish。苹果内部代号为“Nitro”的 JavaScript 引擎也是基于 JavaScriptCore 引擎的。
  • Rhino,由 Mozilla 基金会管理,开放源代码,完全以 Java 编写,用于 HTMLUnit
  • SpiderMonkey (Mozilla),第一款 JavaScript 引擎,早期用于 Netscape Navigator,现时用于 Mozilla Firefox。

这里我们先整体看一下 Nodejs 整个架构 image.png 简单看一下 V8 的处理过程。

  1. 从网络中获取 JavaScript 代码。
  2. V8 解析源代码并将其转化为抽象语法树(AST)
  3. 基于该 AST,Ignition 解释器可以开始做它的事情,并产生字节码
  4. 在这一点上,引擎开始运行代码并收集类型反馈。
  5. 为了使它运行得更快,字节码可以和反馈数据一起被发送到优化编译器。优化编译器在此基础上做出某些假设,然后产生高度优化的机器代码。
  6. 如果在某些时候,其中一个假设被证明是不正确的,优化编译器就会取消优化,并回到解释器中。

垃圾回收

image.png 垃圾回收,又称为:GC(garbage collection)。

GC 即 Garbage Collection ,程序工作过程中会产生很多 垃圾,这些垃圾是程序不用的内存或者是之前用过了,以后不会再用的内存空间,而 GC 就是负责回收垃圾的,因为他工作在引擎内部,所以对于我们前端来说,GC 过程是相对比较无感的,这一套引擎执行而对我们又相对无感的操作也就是常说的 垃圾回收机制 了

为什么需要垃圾回收?
内存有限,节省内存。
以我们的电脑为例,可能我们打开的软件就已经超过了电脑本身的内存,但是却依旧可以运行。这是因为在执行过程中,部分内存是需要回收的

当然也不是所有语言都有 GC,一般的高级语言里面会自带 GC,比如 Java、Python、JavaScript 等,也有无 GC 的语言,比如 C、C++ 等,那这种就需要我们程序员手动管理内存了,相对比较麻烦

上面提到了内容存储的方式,变量会在栈中存储,对象在堆中存储,例如:

let a = {name: 'hi'};//a在栈里,{name:'hi'}在堆里
//此时 a -> {name: 'hi'}
a = [1, 2, 3, 4, 5];//[1,2,3,4,5]也在堆里
//此时 a -> {name: 'hi'} (这个指向会断掉);然后a -> [1,2,3,4,5]
//所以现在{name:'hi'}就是一个游离的垃圾

//所以当堆里游离的垃圾过多,内存就会满,就会卡

我们知道写代码时创建一个基本类型、对象、函数……都是需要占用内存的,但是我们并不关注这些,因为这是引擎为我们分配的,我们不需要显式手动的去分配内存,那么 JavaScript 引擎是如何发现并清理垃圾的呢?

如何定性垃圾,哪些是垃圾?哪些不是
如果一个在堆中的对象地址,未被引用,那就将它视为垃圾

怎么找垃圾
一般在程序中关于垃圾的标记有这样一些方法(算法):

  1. 引用计数算法
  2. 标记清除(mark-sweep)

引用计数算法

相信这个算法大家都很熟悉,也经常听说。 它的策略是跟踪记录每个变量值被使用的次数

  • 当声明了一个变量并且将一个引用类型赋值给该变量的时候这个值的引用次数就为 1
  • 如果同一个值又被赋给另一个变量,那么引用数加 1
  • 如果该变量的值被其他的值覆盖了,则引用次数减 1
  • 当这个值的引用次数变为 0 的时候,说明没有变量在使用,这个值没法被访问了,回收空间,垃圾回收器会在运行的时候清理掉引用次数为 0 的值占用的内存

这个算法最怕的就是循环引用,还有比如 JavaScript 中不恰当的闭包写法。
如果两个对象互相持有对方,每个对象都让对方一直存在,这种情况就是所谓的循环引用。 比如:

function test(){ 
    let A = new Object()//它在内存中会开辟一个新空间 
    let B = new Object() //它在内存中也会开辟一个新空间 
    A.b = B
    B.a = A
}

这个例子用引用计数算法时,过程大致为:A -> new Object(); A里有一个b属性,因为A.b=B,所以:
A ->new Object()(A) -> A.b(B) ->new Object()(B)
此时 new Object(A) 为1 new Object(B) 为1
同样的: B -> new Object()(B) -> B.a(A) -> new Object()(A)
此时 new Object()(B) 1+1=2 ; new Object()(A) 1+1=2
然后呢我们的A -> new Object()(A) 和 B -> new Object()(B) 都断掉
此时 new Object()(A) 和 new Object()(B) 计数都 2-1=1

所以通过计数的方法,这里的a、b的引用计数会一直保持1,就不会被清除
那要怎么做? 我们可以手动清除——将对象指定为null,这样一来引用就断掉了,下次GC时,就会被标记为0,清除掉。

functiontest(){
    let A=new Object();
    let B=new Object();
    
    A.b=B;
    B.a=A;
    
    //手动标记
    A.b=null;
    B.a=null;//null才表示不分配,undefined不是。
}

优点
引用计数算法的优点我们对比标记清除来看就会清晰很多,首先引用计数在引用值为 0 时,也就是在变成垃圾的那一刻就会被回收,所以它可以立即回收垃圾

而标记清除算法需要每隔一段时间进行一次,那在应用程序(JS脚本)运行过程中线程就必须要暂停去执行一段时间的 GC,另外,标记清除算法需要遍历堆里的活动以及非活动对象来清除,而引用计数则只需要在引用时计数就可以了

缺点
首先它需要一个计数器,而此计数器需要占很大的位置,因为我们也不知道被引用数量的上限,还有就是无法解决循环引用无法回收的问题,这也是最严重的

标记清除(mark-sweep)算法

标记清除(Mark-Sweep),目前在 JavaScript引擎 里这种算法是最常用的,到目前为止的大多数浏览器的 JavaScript引擎 都在采用标记清除算法,各大浏览器厂商还对此算法进行了优化加工,且不同浏览器的 JavaScript引擎 在运行垃圾回收的频率上有所差异。

此算法分为标记清除 两个阶段,标记阶段即为所有活动对象做上标记,清除阶段则把没有标记(也就是非活动对象)销毁

当变量进入执行环境时,反转某一位(通过一个二进制字符来表示标记),又或者可以维护进入环境变量和离开环境变量这样两个列表,可以自由的把变量从一个列表转移到另一个列表。

引擎在执行 GC(使用标记清除算法)时,需要从出发点去遍历内存中所有的对象去打标记,而这个出发点有很多,我们称之为一组 根 对象,而所谓的根对象,其实在浏览器环境中包括又不止于 全局Window对象、文档DOM树

整个标记清除算法大致过程就像下面这样

  1. 垃圾收集器在运行时会给内存中的所有变量都加上一个标记,假设内存中所有对象都是垃圾,全标记为0
  2. 然后从各个根对象开始遍历,把不是垃圾的节点改成1
  3. 清理所有标记为0的垃圾,销毁并回收它们所占用的内存空间
  4. 最后,把所有内存中对象标记修改为0,等待下一轮垃圾回收

标记清除算法只关注它们进来了有没有出去,然后当前里面有没有还在引用的。
并不像引用计数那样进来了+1,出去了-1

那我们同样用这个例子来理解一下标记清除的大致过程:

function test(){ 
    let A = new Object()
    let B = new Object() 
    
    A.b = B
    B.a = A
}

过程如下:

A -> new Object()(A); 将new Object()(A)标记为1
虽然A里有一个b属性,因为A.b=B,但是我们这里是间接指向,不会给new Object()(B) +1
同样的: B -> new Object()(B); new Object()(B)标记为1
此时 new Object()(A) 1 ; new Object()(B) 1
然后呢我们的A -> new Object()(A) 和 B -> new Object()(B) 都断掉
此时 new Object()(A) 和 new Object()(B) 都标记为0

优点
标记清除算法的优点只有一个,那就是实现比较简单,打标记也无非打与不打两种情况,这使得一位二进制位(0和1)就可以为其标记,非常简单

缺点
标记清除算法有一个很大的缺点,就是在清除之后,剩余的对象内存位置是不变的,也会导致空闲内存空间是不连续的,出现了 内存碎片 image.png 并且由于剩余空闲内存不是一整块,它是由不同大小内存组成的内存列表,这就牵扯出了内存分配的问题

假设我们新建对象分配内存时需要大小为 size,由于空闲内存是间断的、不连续的,则需要对空闲内存列表进行一次单向遍历找出大于等于 size 的块才能为其分配 image.png 那如何找到合适的块呢?我们可以采取下面三种分配策略:

  • First-fit,找到大于等于 size 的块立即返回
  • Best-fit,遍历整个空闲列表,返回大于等于 size 的最小分块
  • Worst-fit,遍历整个空闲列表,找到最大的分块,然后切成两部分,一部分 size 大小,并将该部分返回

这三种策略里面 Worst-fit 的空间利用率看起来是最合理,但实际上切分之后会造成更多的小块,形成内存碎片,所以不推荐使用,对于 First-fitBest-fit 来说,考虑到分配的速度和效率 First-fit 是更为明智的选择

综上所述,标记清除算法或者说策略就有两个很明显的缺点

  • 内存碎片化,空闲内存块是不连续的,容易出现很多空闲内存块,还可能会出现分配所需内存过大的对象时找不到合适的块
  • 分配速度慢,因为即便是使用 First-fit 策略,其操作仍是一个 O(n) 的操作,最坏情况是每次都要遍历到最后,同时因为碎片化,大对象的分配效率会更慢

标记清除算法的缺点在于清除之后剩余的对象位置不变而导致的空闲内存不连续,所以只要解决这一点,两个缺点都可以完美解决了

标记整理(Mark-Compact)算法 就可以有效地解决,它的标记阶段和标记清除算法没有什么不同,只是标记结束后,标记整理算法会将活着的对象(即不需要清理的对象)向内存的一端移动,最后清理掉边界的内存 image.png

内存管理

以上介绍了常用的一些垃圾回收算法,在不同语言中,对于垃圾回收的处理略有差异。

V8 引擎对内存这块做了深度优化,我们接下来详细介绍。

V8 的垃圾回收策略主要基于分代式垃圾回收机制,V8 中将堆内存分为新生代老生代两区域,采用不同的垃圾回收器也就是不同的策略管理垃圾回收

image.png

新生代

image.png 当新加入对象时,它们会被存储在使用区。然而,当使用区快要被写满时,垃圾清理操作就需要执行。在开始垃圾回收之前,新生代垃圾回收器会对使用区中的活动对象进行标记。标记完成后,活动对象将会被复制到空闲区并进行排序。然后,垃圾清理阶段开始,即将非活动对象占用的空间清理掉。最后,进行角色互换,将原来的使用区变成空闲区,将原来的空闲区变成使用区

如果一个对象经过多次复制后依然存活,那么它将被认为是生命周期较长的对象,且会被移动到老生代中进行管理。除此之外,还有一种情况,如果复制一个对象到空闲区时,空闲区的空间占用超过了25%,那么这个对象会被直接晋升到老生代空间中。25%比例的设置是为了避免影响后续内存分配,因为当按照 Scavenge 算法回收完成后,空闲区将翻转成使用区,继续进行对象内存分配。

老生代

不同于新生代,老生代中存储的内容是相对使用频繁并且短时间无需清理回收的内容。这部分我们可以使用标记整理进行处理。

从一组根元素开始,递归遍历这组根元素,遍历过程中能到达的元素称为活动对象,没有到达的元素就可以判断为非活动对象

清除阶段老生代垃圾回收器会直接将非活动对象进行清除。

并行回收

JS 单线程,所以在处理任务时,有可能会阻塞。 为了减少主线程阻塞,我们在进行 GC 处理时,使用辅助进程。

可以理解为:我们花两个人的钱请一个人干三个人的活儿

image.png

全停顿标记

这个概念看字眼好像不好理解,其实如果用前端开发的术语来解释,就是阻塞
虽然通过并行回收,我们的 GC 操作被放到了主进程与子进程中去处理,减少了GC时间,但最终的结果还是主进程被较长时间占用。

切片标记

增量就是将一次 GC 标记的过程,分成了很多小步,每执行完一小步就让应用逻辑执行一会儿,这样交替多次后完成一轮 GC 标记 image.png -> 如果我们在0.5s处发现一个垃圾(对象1),但是到1s处发现它又被赋值给了另一个(b),要怎么处理?
这个时候需要三色标记

三色标记

三色:白灰黑。相信大家都知道灰色地带吧?😄

我们这里的灰,表示的是一个中间状态,为什么会有这个中间状态?

  • 白色指的是未被标记的对象
  • 灰色指自身被标记,成员变量(该对象的引用对象)未被标记
  • 黑色指自身和成员变量皆被标记

整个标记过程如下: image.png 那我们前面的例子中有提到:

A.b=B;
B.a=A;

A.b=null;
B.a=null;

那这个例子其实可以用写屏障来描述情况

写屏障(增量中修改引用)

这一机制用于处理在增量标记进行时修改引用的处理,可自行修改为灰色 image.png

惰性清理

增量标记只是用于标记活动对象和非活动对象,真正的清理释放内存,则 V8 采用的是惰性清理(Lazy Sweeping)方案。

在增量标记完成后,进行清理。当增量标记完成后,假如当前的可用内存足以让我们快速的执行代码,其实我们是没必要立即清理内存的,可以将清理过程稍微延迟一下,让 JavaScript 脚本代码先执行,也无需一次性清理完所有非活动对象内存,可以按需逐一进行清理直到所有的非活动对象内存都清理完毕。

并发回收

并发回收其实是更进一步的切片,几乎完全不阻塞主进程。 image.png

总结

分代式机制把一些新、小、存活时间短的对象作为新生代,采用一小块内存频率较高的快速清理,而一些大、老、存活时间长的对象作为老生代,使其很少接受检查,新老生代的回收机制及频率是不同的,可以说此机制的出现很大程度提高了垃圾回收机制的效率

代码优化

  1. 及时“断掉”
  2. 闭包对于内存回收的影响
function fn () {
    let test = new Array (10000). fill('heyi');
    return function () {
    return test;
};
click.addEventListener('click', function () {
    arr.push(fn());
    arr.push(fn())
    arr.push(fn())
    arr.push(fn())
    arr.push(fn())
    arr.push(fn())
    arr.push(fn())
    arr.push(fn())
    arr.push(fn())
    arr.push(fn())
}

编码规范与约束

上面提到的是代码底层执行原理和垃圾回收机制。我们在编写代码时,需要经常注意:代码是否存在性能问题,并且除了本身性能问题外,代码编写规范也同样影响开发效率及系统运行速度。

Eslint

为什么要用 eslint

  1. 保持代码风格一致、减少代码出错几率 为了解决这类静态代码问题,每个团队都需要一个统一的 JS 代码规范,团队成员都遵守这份代码规范来编写代码。当然,靠人来保障代码规范是不可靠的,需要有对应的工具来保障,ESLint 就是这个工具

  2. Prettier 可以按照设置的规则对代码进行统一格式化,但是 Prettier只会格式化代码化,一些隐藏的代码质量问题Prettier 是无法发现的。

  3. 目标:开发时提示、保存时自动修复、提交时检测StyleLint

发展历程

  • JSLint 是一款早期的JavaScript语法检查工具,受C语言检查工具Lint启发而来,旨在扫描JS源代码文件以检测其中的错误。然而,它的问题很明显:所有配置项都内置且不可更改,其推崇传统且不提供自定义配置规则,因此很多人无法接受其规则。

  • JSHint 则由此而生,初衷是为了让开发者能够自定义规则,提供了丰富的配置项以给开发者更大的自由度。相比于JSLint而言,JSHint最大的特点是可配置,并允许在项目中使用.jshintrc配置文件来指定JSHint所用的规则进行代码分析。 然而,JSHint也继承了JSLint的一些问题,例如难以扩展以及不容易直接根据错误信息定位具体的配置规则等。

  • ESLint 则在此基础上进一步发展,灵感来自于PHP Linter,并使用AST解析源码,检测AST是否符合规则进行代码分析。作为一个可高度定制的Linter,ESLint最初使用esprima解析器将源码解析为AST,然后使用自定义规则检测。此外,Babel团队还为ESLint开发了babel-eslint解析器,以支持ES6规范采用的新语法。ESLint的高度可配置性和易于扩展性使其在ES6规范发布后迅速崭露头角。

基本使用

初始化与配置规则

eslint.org/docs/latest…

eslint.org/docs/latest…

原理

ESLint 是一个用于检查 JavaScript 代码风格和代码语法错误的工具,它具有高度的可配置性和扩展性,可以根据项目的不同需求,配置不同的规则和插件进行检查和修复。ESLint 主要的实现原理是通过 AST(Abstract Syntax Tree) 抽象语法树来解析 JavaScript 代码,然后根据预设的规则和插件对代码进行检查和修复。

ESLint 的核心源码都是基于 AST 抽象语法树来实现的。AST 抽象语法树是将 JavaScript 代码转化成语法树形式的数据结构,可以方便地遍历节点、访问属性和节点上的方法,以方便进行静态分析、代码检查和语法修复。接着,通过插件和规则定义,在 AST 抽象语法树上遍历节点,检查代码是否符合规范或存在问题,对存在问题的部分进行警告或错误提示,或者直接对其进行修复。

ESLint 提供了CLI 命令行接口和开发者工具,可以方便地进行代码静态分析和自动修复。开发者可以根据需求,选择不同的规则和插件,甚至可以自己编写插件和规则,以符合项目的需求。ESLint 在解析 JavaScript 代码时使用了 Esprima 解析器实现,同时也可以用其他解析器来进行解析。

简单地说,ESLint 通过将 JavaScript 代码转换成抽象语法树形式,使用预设的规则和插件对代码进行静态分析和语法错误检查,并在代码错误或不规范的情况下提供警告或错误提示,并支持用户进行自动修复。ESLint 的核心源码主要基于抽象语法树来实现,可以方便地进行代码分析、检查和修复。