JavaScript性能优化-笔记

189 阅读19分钟

认识V8

V8是一款主流的JavaScript执行引擎

V8采用即时编译

它可以直接将源码翻译为可以直接执行的机器码,所以高效。

V8内存是设限制的

Node与其他语言不同的一个地方,就是其限制了JavaScript所能使用的内存(64位为1.4GB,32位为0.7GB)。V8之所以限制了内存的大小,表面上的原因是V8最初是作为浏览器的JavaScript引擎而设计,不太可能遇到大量内存的场景,而深层次的原因则是由于V8的垃圾回收机制的限制。

由于V8需要保证JavaScript应用逻辑与垃圾回收器所看到的不一样,V8在执行垃圾回收时会阻塞JavaScript应用逻辑,直到垃圾回收结束再重新执行JavaScript应用逻辑,这种行为被称为“全停顿”(stop-the-world)。若V8的堆内存为1.5GB,V8做一次小的垃圾回收需要50ms以上,做一次非增量式的垃圾回收甚至要1秒以上。这样浏览器将在1s内失去对用户的响应,造成假死现象。如果有动画效果的话,动画的展现也将显著受到影响。

当然这个限制是可以打开的,类似于JVM,我们通过在启动node时可以传递--max-old-space-size或--max-new-space-size来调整内存限制的大小,前者确定老生代的大小,单位为MB,后者确定新生代的大小,单位为KB。这些配置只在V8初始化时生效,一旦生效不能再改变。

V8的垃圾回收

采用分代回收的思想,将内存分为新生代和老生代,针对不同代采用不同的GC算法

V8中常用的GC算法

  1. 分代回收
  2. 空间复制
  3. 标记清除
  4. 标记整理
  5. 标记增量

V8的回收策略

自动垃圾回收算法的演变过程中出现了很多算法,但是由于不同对象的生存周期不同,没有一种算法适用于所有的情况。所以V8采用了一种分代回收的策略,将内存分为两个生代:新生代和老生代。

新生代的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象。分别对新生代和老生代使用不同的垃圾回收算法来提升垃圾回收的效率。对象起初都会被分配到新生代,当新生代中的对象满足某些条件(后面会有介绍)时,会被移动到老生代(晋升)。

V8的分代内存

默认情况下,64位环境下的V8引擎的新生代内存大小32MB、老生代内存大小为1400MB,而32位则减半,分别为16MB和700MB。V8内存的最大保留空间分别为1464MB(64位)和732MB(32位)。

具体的计算公式是4*reserved_semispace_space_ + max_old_generation_size_,新生代由两块reserved_semispace_space_组成,每块16MB(64位)或8MB(32位)。

V8如何回收新生代对象

新生代的特点: 大多数的对象被分配在这里,这个区域很小但是垃圾回特别频繁。在新生代分配内存非常容易,我们只需要保存一个指向内存区的指针,不断根据新对象的大小进行递增即可。当该指针到达了新生代内存区的末尾,就会有一次清理(仅仅是清理新生代)。

新生代使用Scavenge算法进行回收(复制算法+标记整理) 。在Scavenge算法的实现中,主要采用了Cheney算法。Cheney算法算法是一种采用复制的方式实现的垃圾回收算法。它将内存一分为二,每一部分空间称为semispace。在这两个semispace中,一个处于使用状态,另一个处于闲置状态。处于使用状态的semispace空间称为From空间,处于闲置状态的空间称为To空间,当我们分配对象时,先是在From空间中进行分配。

当开始进行垃圾回收算法时,会检查From空间中的存活对象,这些存活对象将会被复制到To空间中(复制完成后会进行紧缩),而非活跃对象占用的空间将会被释放。完成复制后,From空间和To空间的角色发生对换。

也就是说,在垃圾回收的过程中,就是通过将存活对象在两个semispace之间进行复制。可以很容易看出来,使用Cheney算法时,总有一半的内存是空的。但是由于新生代很小,所以浪费的内存空间并不大。而且由于新生代中的对象绝大部分都是非活跃对象,需要复制的活跃对象比例很小,所以其时间效率十分理想。复制的过程采用的是BFS(广度优先遍历)的思想,从根对象出发,广度优先遍历所有能到达的对象。

具体的执行过程大致是这样:

首先将From空间中所有能从根对象到达的对象复制到To区,然后维护两个To区的指针scanPtr和allocationPtr,分别指向即将扫描的活跃对象和即将为新对象分配内存的地方,开始循环。循环的每一轮会查找当前scanPtr所指向的对象,确定对象内部的每个指针指向哪里。

如果指向老生代我们就不必考虑它了。如果指向From区,我们就需要把这个所指向的对象从From区复制到To区,具体复制的位置就是allocationPtr所指向的位置。复制完成后将scanPtr所指对象内的指针修改为新复制对象存放的地址,并移动allocationPtr。如果一个对象内部的所有指针都被处理完,scanPtr就会向前移动,进入下一个循环。若scanPtr和allocationPtr相遇,则说明所有的对象都已被复制完,From区剩下的都可以被视为垃圾,可以进行清理了

对象的晋升

当一个对象经过多次新生代的清理依旧幸存,这说明它的生存周期较长,也就会被移动到老生代,这称为对象的晋升。具体移动的标准有两种:

  1. 对象从From空间复制到To空间时,会检查它的内存地址来判断这个对象是否已经经历过一个新生代的清理,如果是,则复制到老生代中,否则复制到To空间中。

  2. 对象从From空间复制到To空间时,如果To空间已经被使用了超过25%,那么这个对象直接被复制到老生代。

V8的老生代

老生代的特点: 老生代所保存的对象大多数是生存周期很长的甚至是常驻内存的对象(全局变量、闭包),而且老生代占用的内存较多。

V8老生代的垃圾回收算法

老生代占用内存较多(64位为1.4GB,32位为700MB),如果使用Scavenge算法,浪费一半空间不说,复制如此大块的内存消耗时间将会相当长。所以Scavenge算法显然不适合。V8在老生代中的垃圾回收策略采用Mark-Sweep和Mark-Compact相结合。

Mark-Sweep(标记清除)

标记清除分为标记和清除两个阶段。在标记阶段需要遍历堆中的所有对象,并标记那些活着的对象,然后进入清除阶段。在清除阶段总,只清除没有被标记的对象。由于标记清除只清除死亡对象,而死亡对象在老生代中占用的比例很小,所以效率较高。

标记清除有一个问题就是进行一次标记清楚后,内存空间往往是不连续的,会出现很多的内存碎片。如果后续需要分配一个需要内存空间较多的对象时,如果所有的内存碎片都不够用,将会使得V8无法完成这次分配,提前触发垃圾回收。

Mark-Compact(标记整理)

标记整理正是为了解决标记清除所带来的内存碎片的问题。标记整理在标记清除的基础进行修改,将其的清除阶段变为紧缩极端。在整理的过程中,将活着的对象向内存区的一段移动,移动完成后直接清理掉边界外的内存。紧缩过程涉及对象的移动,所以效率并不是太好,但是能保证不会生成内存碎片。

V8的老生代使用标记清除和标记整理结合的方式,主要采用标记清除算法,如果空间不足以分配从新生代晋升过来的对象时,才使用标记整理。这也是为了提高效率。

Incremental Marking(增量标记)

这是V8的一个优化,垃圾回收会阻塞JS程序执行,而这样的阻塞(全停顿)会造成了浏览器一段时间无响应。所以V8使用了一种增量标记的方式,将完整的标记拆分成很多部分,每做完一部分就停下来,让JS的应用逻辑执行一会,这样垃圾回收与应用逻辑交替完成。经过增量标记的改进后,垃圾回收的最大停顿时间可以减少到原来的1/6左右。

新生代老生代垃圾回收细节对比

新生代区域的垃圾回收使用空间换时间,因为它采用的是一个复制算法,意味着它空间内部每时每刻都有一个空闲空间的存在。但由于新生代空间本来就很小,所以分出一半来的空间的浪费,相对于它时间上的提升是微不足道的。

老生代区域垃圾回收不适合复制算法,如果采用复制算法首先意味着会有几百兆空间浪费,而且老生代存放对象数据比较多,复制消耗时间也会加长。

答题汇总

请详细说明var、let、const三种声明变量的方式之间的具体差别

1.var可以重复声明,let和const不能重复声明。

2.var声明的变量会挂载在globalthis上,而let和const声明的变量不会,后者会声明在Scripts块级作用域中

3.var声明变量存在变量提升,let和const不存在变量提升,后者还会产生暂时性死区。

4.let和const声明只在块级作用域中能访问到,而var在外部也能访问到。

5.const一旦声明必须赋值,不能使用null占位。声明后不能再修改。如果声明的是引用类型数据,可以修改其属性。

简述Symbol类型的用途

目前最主要作用就是为对象添加一个独一无二的属性名,并使用这个独一的属性名去实现对象的一些公共接口,例如iterator、toString。可以通过Object.getOwnPropertySymbols(obj)方法获取到一个对象内的所有Symbol属性。

说说什么是浅拷贝,什么是深拷贝?

深拷贝和浅拷贝最根本的区别在于是否真正获取一个对象的复制实体,而不是引用。浅拷贝只是增加了一个指针指向已存在的内存地址,深拷贝是增加了一个指针并且申请了一个新的内存,使这个增加的指针指向这个新的内存。    

浅拷贝仅仅是指向被复制的内存地址,如果原地址发生改变,那么浅拷贝出来的对象也会相应的改变。深拷贝是在计算机中开辟一块新的内存地址用于存放复制的对象。

请简述TypeScript与JavaScript之间的关系?

TypeScript是JavaScript的超集,包含了 JavaScript 的所有元素,在TypeScript中可以运行JavaScript代码。

请谈谈你所认为的typescript优缺点

之前的JS是弱类型、动态语言,随着前端应用日渐复杂,它已不够满足我们的开发要求,所以出现了TS。

  • TypeScript 是静态的,增加了代码的可读性和可维护性。首先使用函数时直接看类型定义就能知道如何调用;在编译阶段就可以发现大部分错误;增强了编辑器和 IDE 的功能,包括代码补全、接口提示、跳转到定义、重构等。
  • TypeScript 是渐进式的,对原本JS代码侵入性小,可以学多少用多少。
  • TypeScript 拥有活跃的社区。

TypeScript 当然也有着一些缺点:

  • 有一定的学习成本,需要理解接口(Interfaces)、泛型(Generics)、类(Classes)、枚举类型(Enums)等前端工程师可能不是很熟悉的概念。
  • 短期可能会增加一些开发成本,毕竟要多写一些类型的定义,不过对于一个需要长期维护的项目,TypeScript 能够减少其维护成本
  • 集成到构建流程需要一些工作量
  • 可能和一些库结合的不是很完美

 

描述引用计数的工作原理和优缺点

设置引用数,判断当前引用数是否为0 引用关系改变时修改引用数字,比如有一个对象指向它 他的引用计数+1 多个对象引用,引用计数累加引用计数累加,当没有对象引用时引用计数为0,GC立即进行回收。

优点:

  • 发现垃圾时会立即回收,根据当前引用数是否为0,来决定当前对象是否为垃圾,如果判断为垃圾则会立即进行释放。
  • 最大限度减少程序暂停,当内存要爆满时,会立即去寻找引用计数为0的对象空间,对其进行释放。
  • 减少程序卡顿时间

缺点:

  • 无法回收循环引用的对象。
  • 时间和资源开销大,因为它需要去维护一个数值的变化,去监控对象引用数值的修改,而这个对象空间的引用数大小未知,可能影响性能。
  • 空间碎片化。

 

描述标记整理算法的工作流程

首先说说标记清除算法:它分为标记和清除两个阶段完成,第一个阶段是从global递归遍历所有对象及其子对象并对活动对象进行标记,第二个阶段是遍历所有对象并对没有标记的对象进行清除,并将第一阶段设置的标记给抹除掉。通过两次遍历来回收相应的空间。并将回收的空间放入空闲链表上,方便程序后续申请空间使用。

优点:

  • 解决循环引用无法回收的问题。

缺点:

  • 空间碎片化。
  • 不会立即回收垃圾对象,要统一清除,并且主程序会停止工作。

标记整理算法是标记清除算法的一个增强,标记阶段和前者是一致的,但是在清除阶段标记整理算法会先执行整理,移动对象的位置,让它们在地址上产生连续。然后再将非活动对象进行回收。这就解决了空间碎片化的问题。同时它也不会立即回收垃圾对象。

导师解答: 垃圾回收可能在代码运行的任意时刻, 会影响性能, 因为它是同步的, 回收时主代码是不执行的, 所以说是同步的。因为同步, 所以是需要回收时, 在代码执行的任意位置, 挂起它。因为同步, 所以影响代码执行, 而卡顿, 所以说会影响性能, 当然垃圾回收又是为了代码有更好的运行空间。

性能优化

上节说的GC的目的是为了实现内存空间的良性循环,而良性循环的前提就是我们编写代码时,要对内存合理分配。而ECMAScript并没有API告诉我们是否使用合理, 都是由GC完成这个操作的。所以我们要想办法去关注内存的实时变化,进行监控。

内存出现问题可归纳为三种情况

  • 内存泄漏:内存使用持续升高
  • 内存膨胀:在多数设备上,都存在性能问题
  • 频繁的垃圾回收:通过内存变化图进行分析

监控内存的几种方式

浏览器任务管理器

主要关注内存和JavaScript内存两列,前者为原生内存,比如我们这个页面有很多dom节点,这个就代表dom节点所占据的内存。后者指的是JS的堆,我们主要关注小括号中的值,它代表页面中所有可达对象正在是用的内存大小。

如果小括号内存一直增大,说明有问题,但是这个工具并不能定位问题。

Timeline时序图记录

在performance面板进行录制,主要是分析timeline时序图的升高与下降,分析内存占用是否健康,可以根据异常节点看到是哪些页面操作造成了问题。

堆快照查找分离DOM

什么是堆快照呢?它简单说就是找到我们的JS堆,然后进行快照的留存,然后我们可以快照内的所有信息,依次进行相关监控。

分离DOM是什么呢?DOM有这三种状态:

  • 界面元素存活在DOM树上
  • 垃圾对象时的DOM节点(垃圾对象),该节点从当前DOM树脱离,并且JS没有引用。
  • 分离状态的DOM节点(分离DOM),该节点从当前DOM树脱离,但是在JS中还有引用,它在界面上是看不见的,但是在内存还占据了空间。这就是一种内存泄漏

所以可以使用堆快照功能进行查找,去清除问题代码。

判断是否存在频繁的垃圾回收

垃圾回收的问题:

  • GC工作时应用程序是停止的
  • 频繁且过长的GC会导致应用假死
  • 用户使用中感知应用卡顿

主要是通过时序图去判断,看TImeLine是否频繁的上升下降,任务管理器中的数据是否频繁增大减小。

JS代码级性能优化

慎用全局变量

为什么要慎用呢?有以下几点原因:

  1. 全局变量定义在全局执行上下文中,是所有作用域链的顶端。如果在局部作用域要使用某个变量,而我们将它定义在全局,那么它会顺着作用域链查询到顶端的全局作用域。这样的时间消耗是非常大的。
  2. 全局执行上下文一直存在于上下文执行栈,直到程序退出才会消失,这对GC工作是不利的,因为定义在全局作用域中的变量不会被垃圾回收。会降低程序运行中,内存的使用。
  3. 在局部作用域定义了全局变量,如果是同名变量则会污染或遮蔽全局。

缓存全局变量

做法是在局部作用域中用一个变量将全局作用域保存下来,并在后续操作中用此变量替换全局变量使用场景。

通过原型对象添加附加方法

使用原型对象添加方法,性能可能会慢些,因为查找方法时还需要去prototype上去查找,有两步骤。但是我们依然推荐使用原型对象添加方法的原因,是从内存空间角度考虑的,因为每个创建的实例他们都共享一个原型对象,减少了空间占用。

比如一个say方法,如果绑定在this上,每次创建实例都会在其身上重复绑定,导致空间变大。

思考:react组件是不是也应该考虑将一些方法,放在原型上,比如优化箭头函数方式定义方法。

避开闭包陷阱

闭包有这两个特点:外部具有指向内部的引用;在“外”部作用域访问“内”部作用域的数据。避免闭包占用内存空间的方式是收到的去将变量置为null去释放掉。

避免属性访问方法使用

真像脱裤子放屁,实际开发应该没人这样写。

For循环优化

将判断条件中的length变量提取出来,而不是每次都去查询一次。

一种新的效率更高的一种for循环书写方式,但是这是从尾到头遍历的吧,而且经过试验,其不会遍历到第一位。

for (var i = arrList.length; i; i--) {

console.log(arrList[i])

}

选择最优的循环方法

普通for循环、forEach、forin、map有各自的适用场景,不同场景性能也是不一样的。

从堆栈层面了解JS的执行

待补充

减少判断层级

整理判断条件的优先级,合理使用return终止代码,节省性能。合理使用switch case。

减少作用域链查找层级、减少声明及语句数

归根结底,要么是时间换空间,要么是空间换时间。根据是否重复大量使用某个变量等业务场景来决定用哪种方式。

减少声明是说,建议多个变量使用一个声明,中间用逗号隔开,例如:

var a = 1, b = 2, c = 3

这里涉及到JS编译时的词法分析、语法分析,少的代码又能提高些效率。

减少数据读取次数

提前用变量接收数据进行缓存。复杂对象都是储存在堆里,提前缓存,就减少了从堆查找变量的动作。主要还是看一个变量是否被大量重复使用,这样才有提取的意义。如果只是少量使用则没必要提取,因为这反倒增加了动作。

重要的是理解JS执行机制,它是如何查找变量的,再依次根据具体情况去选择最优方式去解决具体问题。

减少循环体中活动

尽可能的抽离循环体中的公共代码,放在外部进行声明。恰当的选择for、while循环。切记,时间不是衡量性能的唯一指标,它只是一项指标,还有内存占用等等。要具体情况具体分析。

react.lazy react.suspense