JavaScript性能优化

178 阅读11分钟

JavaScript性能优化

内存管理

什么是内存管理?

  • 内存:由可读可写的单员组成,表示一片可操作性空间
  • 管理:人为的去操作一片空间的申请、使用和释放
  • 内存管理:开发者主动申请空间、使用空间和释放空间
  • 管理流程:申请-使用-释放

Javascript中的垃圾

  • js中的内存管理是自动的(创建变量或对象时自动分配空间,不使用时自动回收)
  • 对象不再被引用时是垃圾
  • 一些不适合的语法或结构性的错误导致对象不能从上访问到时是垃圾

什么是根?

  • 可以认为全局变量对象就是根

Javascript中的可达对象

  • 可访问到的对象就是可达对象(引用、作用域链)
  • 可达的标准就是从根上出发是否能够被找到

示例:根和可达

    // obj在全局执行上下文中,是从根上能够找到的,所以obj是一个可达对象
    // 间接的xm所对应的对象空间也是可达的
    let obj = { name: 'xm' }
    
    // xm对应的空间又多了一次引用,也就存在了引用数值变化(引用计数变化)
    let ali = obj
    
    // 此处xm对象引用被断掉了,但是此时xm还是可达的,因为xm还被ali引用
    obj = null

复杂一点示例:

    function objGroup(obj1, obj2) {
      obj1.next = obj2
      obj2.prev = obj1
    
      return {
        o1: obj1,
        o2: obj2
      }
    }
    
    let obj = objGroup({name: 'obj1'}, {name: 'obj2'})
    console.log(obj)
    // 输出:
    // { o1: { name: 'obj1', next: { name: 'obj2', prev: [Circular] } },
    // { name: 'obj2', prev: { name: 'obj1', next: [Circular] } } }

以上示例代码用一张图来表示:

avatar

  • 我们通过全局可达对象obj可以找到o1和o2
  • 通过o1可以找到obj1
  • 通过o2可以找到obj2
  • obj1通过next指向obj2
  • obj2通过prev指向obj1

此时我们通过某种操作(delete)把与obj1的联系通道删除掉,即o1执行obj1的和obj2通过prev指向obj1的。此时红色框中的obj1就会被浏览器认为是垃圾,js引擎的垃圾回收就会对其进行回收。

上图想说明的就是内存中的对象可以从根处可达,也就是存在对象引用的关系,一旦我们把这些对象引用的关系删除掉(从根处不可达),那浏览器会认为对象为垃圾,从而js引擎进行清除。

GC算法介绍

GC(Garbage Collection):垃圾回收

  • GC就是垃圾回收机制的缩写
  • GC可以找到内存中的垃圾、并释放和回收空间,方便后续使用

GC里的垃圾是什么

  • 程序中不在需要使用的对象
  • 程序中不能再访问到的对象

示例:

    function func() {
        const name = 'abc'
        return `${name} is a man`
    }
    
    func()
    // func在被调用后就没有被使用了,此时func算是垃圾
    // name在调用完后不能被其他地方访问到,此时name算是垃圾

GC算法是什么

  • GC是一种机制,垃圾回收器完成具体的工作所对应的方法
  • GC工作的内容就是查找垃圾释放空间、回收空间
  • GC算法就是工作时查找和回收所遵循的规则

常见的GC算法

  • 引用计数
  • 标记清除
  • 标记整理
  • 分代回收

引用计数算法

  • 核心思想
    • 设置引用计数,判断当前引用数是否为0
  • 引用计数器,用于记录引用的个数
  • 引用关系改变时修改引用数字(+1或-1)
  • 引用数字为0时立即回收

示例:

    const user1 = { age: 11 }
    const user2 = { age: 22 }
    const user3 = { age: 33 }
    
    const ageList = [user1.age, user2.age, user3.age]
    // 分析:
    // user1、user2、user3、ageList是被定义在全局对象上的
    // 当ageList执行完之后,由于ageList还引用着user1、user2、user3,也就是计数不为0
    // 所以user1、user2、user3不会被释放
    
    function fn() {
        const num1 = 1
        const num2 = 2
    }
    
    fn()
    // 分析:
    // num1、num2在fn函数内,当fn被调用时,num1和num2被引用,此时计数不为0,不会被释放
    // 当执行完fn后,num1、num2没有其他引用此时计数-1变为0,会被GC回收
    

引用计数算法的优缺点:

  • 优点

    • 发现垃圾时立即回收
    • 最大限度减少程序的暂停(发现引用计数0立即释放,保证内存有可用空间)
  • 缺点

    • 无法回收循环引用的对象(有引用便不会被释放)
    • 时间开销大(需要去维护一个引用数值的变化,时刻监测引用对象的数值是否为0)

标记清除算法

  • 核心思想

    • 分标记和清除两个阶段
      • 标记:遍历所有活动对象并标记活动对象(可达对象),遍历会用递归的方式一直遍历到结束
      • 清除:遍历所有对象,清除没有被标记的对象进行清除(并抹掉上一次的标记)
    • 回收相应的空间(回收的空间交给空闲列表维护,以备后续使用)
  • 标记清除算法优缺点

    • 优点
      • 能够回收循环引用的不可达对象
    • 缺点
      • 空间碎片(回收的空间在地址上是不连续的,后续使用时会造成碎片空间不满足当前所需空间大小)

标记整理算法(标记清除算法的增强)

  • 标记整理可以看做是标记清除的增强

  • 标记阶段的操作和标记清除一致

  • 清除阶段会先整理,移动对象位置,在空间上变成连续的

  • 标记整理算法优缺点

    • 优点
      • 减少碎片化空间
    • 缺点
      • 不会立即回收垃圾对象

认识V8

  • V8是一款主流的JavaScript执行引擎,内部有优秀的垃圾回收机制
  • V8采用即时(不需要将源代码转成字节码,V8直接将源代码转成机器码)
  • V8内存设置上限(64位不超过1.5G,32位不超过800M)

V8的垃圾回收策略

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

图示:

avatar

  • V8中常用的GC算法
    • 分代回收
    • 空间复制
    • 标记清除
    • 标记整理
    • 标记增量

V8如何回收新生代对象

avatar

上图展示了V8内存空间的分配,其中

  • 内存空间一分为二
  • 小的空间(左侧)用于存储新生代对象(64位 32M,32位 16M)
  • 新生代指的是存活时间较短的对象(如:局部作用域中的变量)

新生代对象回收实现

  • 回收过程采用复制算法+标记整理
  • 新生代内存区分为两个等大小空间
  • 使用空间为From,空闲空间为To
  • 活动对象存储于From空间
  • 当From空间存储内容达到一定程度时,触发GC操作,标记整理后将活动对象拷贝至To(其实就是From空间的活动对象有了备份)
  • From与To交换空间完成释放

回收细节说明

  • 拷贝过程中可能出现晋升
  • 晋升就是将新生代对象移动至老生代,通常有两种情况需要晋升
    • 一轮GC还存活的新生代需要晋升
    • To空间的使用率唱过25%

V8如何回收老生代(老年代)对象

  • 上图中大的那块区域(右侧)就是存放老生代对象的区域
  • 64位操作系统为1.4G,32位操作系统为700M
  • 老生代对象就是指存活时间较长的对象(全局变量,闭包中的变量数据)

老年代对象回收实现

  • 主要采用标记清除、标记整理、增量标记算法
  • 首先使用标记清除完成垃圾空间的回收
  • 采用标记整理进行空间优化(当新生代中对象往老生代中移动的时候,也就是产生晋升时)
  • 采用增量标记进行效率优化

新老生代细节对比

  • 新生代区域垃圾回收使用空间换时间(始终有一块空闲空间)
  • 老年代区域垃圾回收不适合复制算法
    • 老年代存储空间比较大,使用空间换时间占用内存比较大,太奢侈
    • 老年代存储区域存放的对象比较多,复制比较浪费时间

增量标记算法:

avatar

分析:

  • 程序执行
  • 触发GC操作
  • 遍历对象进行标记(可能不是一次遍历全部,找到可达对象后可能会停止遍历去执行程序)
  • 在执行程序过程中中间可能会暂停,进行标记
  • 后续都会进行执行、标记交替进行
  • 标记完成后执行清除操作

程序执行和标记交替进行不会影响程序的执行效率,因为在V8中即时是一次性清除全部内存(1.5G)也只需要10ms时间。

监控内存的几种方式

界定内存问题的标准

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

监控内存的几种方式

  • 浏览器任务管理器
  • Timeline时序图记录
  • 堆快照朝招分离DOM
  • 判断是否存在频繁的垃圾回收

什么是分离DOM

DOM的几种状态:

  • 存活状态:界面元素存活在DOM树上
  • 垃圾对象状态:已从DOM树上脱离,js代码中也没有引用
  • 分离状态:已从DOM树上分离,但是js代码中还有引用

判断是否存在频繁的GC

为什么要确定频繁的垃圾回收

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

确定频繁的垃圾回收

  • Timeline中频繁的上升下降
  • 任务管理器中数据频繁的增加减小

优化代码介绍

如何精准测试JavaScript性能

  • 本质是哪个就是采集大量的执行样本进行数学统计和分析(比较麻烦)
  • 可以使用基于Benchmark.js完成

Jsperf使用流程

  • 使用GitHub账号登录
  • 填写个人信息(非必须)
  • 填写详细的测试用例信息(title、slug(唯一的),生成短连接,再别的地方访问)
  • 填写准备代码(DOM操作时经常使用)
  • 填写必要的setup(前置准备工作)与teardown代码(所有操作执行完成之后的销毁操作)
  • 填写具体的测试代码片段

慎用全局变量

为什么要慎用

  • 全局变量定义在全局执行上下文,是所有作用域的顶端(在查找变量时,局部作用域找不到时会去全局作用域查找,时间消耗比较大)
  • 全局变量一直存在于全局上下文执行栈中,直到程序退出(对于GC处理也不友好)
  • 如果某个同名变量则会产生遮蔽和污染全局

缓存全局变量

将使用中无法薄面的全局变量缓存到局部 示例:

    // 不使用缓存查找dom元素
    <script>
        function getBtn() {
            let oBtn1 = document.getElementById('btn1')
            let oBtn3 = document.getElementById('btn3')
            let oBtn5 = document.getElementById('btn5')
            let oBtn7 = document.getElementById('btn7')
        }
    </script>
    
    // 使用缓存
    <script>
        let obj = document
        function getBtn() {
            let oBtn1 = obj.getElementById('btn1')
            let oBtn3 = obj.getElementById('btn3')
            let oBtn5 = obj.getElementById('btn5')
            let oBtn7 = obj.getElementById('btn7')
        }
    </script>
    
    // 将以上代码在Jsperf中执行后可以看出做全局变量缓存执行效率要高一些

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

示例:

    // 直接在函数内通过this添加方法
    var fn1 = function() {
        this.foo = function() {
            console.log(111)
        }
    }
    let f1 = new fn1()
    
    // 通过原型链添加对象方法添加方法
    var fn2 = function() {}
    fn2.prototype.foo = function() {
            console.log(111)
        }
    let f2 = new fn2()
    
    // 通过Jsperf对比以上两种方式,使用原型链添加要快于通过this在函数内部添加

避开闭包陷阱

闭包的特点:

  • 外部具有指向内部的引用
  • 在“外部”作用域访问“内部”作用域的数据

示例:

    function foo() {
        var name = 'zs'
        return function () {
            console.log(name)
        }
    }
    var a = foo()
    a()

关于闭包:

  • 闭包是一种强大的语法
  • 闭包使用不当很容易出现内存泄漏
  • 不要为了闭包而闭包

示例:如何去消除闭包带来的内存泄漏

    <body>
        <button id="btn">add</button>
        <script>
            // 不处理闭包的引用
            function foo() {
                // btn本身在dom节点中被引用,此处又被el引用
                // 如果在某个时间点btn本从dom中移除,但是此时btn还被el引用,引用计数不为0
                // 此时btn不会被释放,也就造成了内存泄漏
                var el = document.getElementById('btn')
                el.onclick = function() {
                    console.log(el.id)
                }
            }
            foo()
            
            // 处理闭包的引用
            function foo() {
                var el = document.getElementById('btn')
                el.onclick = function() {
                    console.log(el.id)
                }
                // 将el对btn的引用去掉
                // el在使用完成后置空,也就是引用计数-1
                // 如果btn被从dom中移除,此时btn会被释放
                el = null
            }
            foo()
        </script>
    </body>

for循环优化

循环长度值提前获取

示例:

    let arr = [...] // 一个很大的数组
    // 通过len = arr.length提前获取数组长度,能节省每次遍历重新获取的开销
    for (let i = 0, len = arr.length; i < len; i++) {}

通过大数据量的比较,循环中forEach的效率最高

其他优化

  • 文档碎片化节点添加(要添加多个节点时,生成节点后批量添加)
  • 克隆优化节点操作(添加新节点时使用cloneNode代替createElement)
  • 字面量替换new Object操作