0%

JavaScript性能优化

性能优化一直是代码开发阶段不可避免也十分重要的环节,本文将从内存管理这一方面来阐述JavaScript的优化方法,具体包括:内存管理概述、垃圾回收与常见的GC算法、V8引擎的GC算法、js语言内存优化实例。

performance

内存管理

类似C这样偏底层的语言,一般都有内存管理接口,比如申请内存malloc()和释放内存free()。而像javaScript这样的高级语言则是在创建变量时自动分配内存,在不使用时自动回收内存资源。其中,自动回收资源也称为垃圾回收(Garbage Collection),简称GC

这种自动化的内存管理常常让corder难以感知和控制内存的变化,提高了编程体验的同时会让不规范或者未加控制的代码设计问题造成内存泄漏。内存泄漏会导致程序无法继续执行乃至崩溃,所以学习如果观察内存变化,以及合理的代码编写规范是值得关注的。

内存的生命周期

内存是一片由可读写单元组成的可操作空间,内存一般的生命周期为:

  • 申请内存
  • 操作内存(读写)
  • 释放内存(归还)

js中,内存的申请和释放是自动的,我们只是在使用内存。当我们申明一个变量后,js就会自动申请内存空间,而变量的使用就是在操作内存,至于释放,也就是js引擎的自动垃圾回收。

垃圾回收

内存泄漏的本质就是内存无法被释放,未使用的内存资源也就是垃圾无法被正常回收,了解垃圾的回收机制可以帮助我们在编写代码时能让垃圾回收正常运行,避免内存溢出。

js中的垃圾(无用内存空间)一般有以下特征:

  • 对象不再被引用时为垃圾
  • 对象无法从根上访问时为垃圾(根就是全局作用域)

我们观察这样一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let obj = {
left: {
name: 'left',
next: null,
},
right: {
name: 'right',
next: null,
}
}
obj.left.next = obj.right
obj.right.next = obj.left

console.log(obj)
obj = null

代码片段中变量obj指向了一个对象,对象内部的left对象和right对象存在相互引用,当执行obj = null或者其他赋值语句后,该对象的唯一引用被切断,也就是说原来obj所指向的对象无法再访问到,属于垃圾的定义,需要被回收,而该对象的left和right对象虽然存在引用,但无法从全局作用域被访问到,同样也属于垃圾。

GC算法

从垃圾的定义出发,不再使用的对象或者说无法访问的对象就归属为垃圾,而GC算法的主要的目标就是寻找到这些无用的存储。常见的GC算法如下文所示。

引用计数算法

引用计数算法的原理很简单,当某个对象的被引用数目为0时,说明该对象无法再被访问,则判断为垃圾进行回收即可。

引用计数算法逻辑简单,发现垃圾时能立即回收,但是有一个限制:无法回收循环引用的对象。类似上文实例中的obj.leftobj.right一样。而且引用计数算法由于需要实时修改对象的引用数目,时间开销也是相对较大的。

标记清除

由于引用计数算法的循环引用限制和时间开销大的缺点,标记清除算法通过标记活动对象和清除标记两个阶段对象垃圾进行回收:

  • 思想:分为标记活动对和清除未标记对象两个阶段
  • 步骤:遍历所有对象并标记可达(活动)对象;遍历所有对象清除没有标记的对象;回收内存空间。

标记清除算法解决了引用计数算法中循环引用不能被清除的问题,但标记清除算法也存在一定缺陷,在清除阶段结束后,回收的空间会放到空闲列表中以供下次申请使用,但是这些回收的空间往往是内存地址不连续的,也就是空间碎片化的问题,当我们下次再申请空间地址时,如果空闲列表中回收的某块空间大小刚好满足就能正常使用,如果小于申请的空间,就需要重新申请新的地址空间,也就造成了空闲列表的浪费,这样回收到的空间如果不能再次被使用也就失去了意义。

标记整理

标记整理算法是标记清除算法的一种增强算法,由于标记清除算法存在空间碎片化的问题,标记整理算法同样是分为标记和清除阶段,只不过,标记整理算法再清除阶段增加了移动对象位置的操作,所以清除阶段我们称为整理阶段,这样,碎片化的地址通过位置移动整理:移动活动对象地址,让内存地址变得连续,这样回收之后的地址也就变得连续起来。

V8引擎

V8是用C ++编写的Google开源高性能JavaScript和WebAssembly引擎。它用于Chrome和Node.js等。它实现ECMAScriptWebAssembly,并在Windows 7或更高版本,macOS 10.12+和使用x64,IA-32,ARM或MIPS处理器的Linux系统上运行。V8可以独立运行,也可以嵌入到任何C ++应用程序中。

V8垃圾回收策略

V8的采用分带回收的的思想:将内存分为新生代和老生代,针对不同分类采取不同的垃圾回收算法。即V8会将内存一份为二,一部分存储新生代对象,一部分存储老生代对象,而不同的存储区采用不同的GC算法进行针对性回收。

一般新生代对象指存活时间较短的对象,一般存储在小空间中,采用复制算法和标记整理算法:新生代内存区会分为两个等大的内存空间,使用空间为From,空闲空间为To,活动对象存储在From中,当进行标记整理后将活动对象从From拷贝至To空间,然后就可以直接释放From中拷贝的标记对象,以此达到空间释放的效果。

值得注意的是,如果一些新生代对象在进过一轮GC操作之后还存活着,或者TO 使用率已经达到了25%,那么新生代对象将不再拷贝至To空间,而是直接拷贝到老生代空间,这种现象称为晋升

而老生代区域存在内存限制,比如64位操作系统中为1.4G,32为操作系统中为700M左右。老生代对象主要采用标记清除、标记整理和增量标记算法进行垃圾回收:使用标记清除算法进行高效率的垃圾空间回收,适当采用标记整理算法进行空间优化,当内存空间出现不足时,再使用增量标记算法进行效率优化。

可以发现,新生代区域的垃圾回收主要采用空间换时间的思维,由于新生代区域存货时间段且占用内存小,直接拷贝再清除能提回收效率,而老生代就不再适用复制算法。

注:上面提到的增量标记算法其实是在程序执行中插入片段化的标记算法,而不是一次性的GC算法,即标记操作和程序执行短暂交替执行,这样就避免整个GC回收过程过长的问题,而拆分为片段化的执行。

Peformance工具

GC的回收目的其实就是为了实现内存空间的良性循环,但GC都是隐式执行的,程序员并不能感知到内存的变化,而Chrome的Perfomance工具就能为我们监控内存的变化,让我们更好地分析程序执行时内存变化是否异常。

performance_tool

存在内存问题的表现

  • 页面出现延迟加载或经常性暂停
  • 页面持续性出现糟糕的性能
  • 页面性能随时间延长越来越差

监控内存的方法

界定内存问题的标准:

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

监控内存变化的方式一般有:

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

使用任务管理器监控内存

浏览器开发者工具中,可以找到任务管理器这一工具,打开是这样的界面:

这是按照打开的标签页进行记录的,如果不存在jsvascript内存列,可以右键显示。

任务管理器需要关注两列数据:内存和JavaScript内存,前者是界面的原生内存,而后者就是JavaScript代码执行占用的内存,我们需要关注的是括号里的实时内存变化,观察jsvaScript实时内存是否存在持续增高而不会出现下降的内存变化。

使用Timeline记录内存变化

使用timeLine可以更准确地记录内存变化的时序图,我们打开perfomance工具进行界面录制即可展示录制期间的内存变化。

我们先模拟一个内存开销的界面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8'>
<meta http-equiv='X-UA-Compatible' content='IE=edge'>
<title></title>
<meta name='viewport' content='width=device-width, initial-scale=1'>
</head>
<body>
<button id="add">添加</button>
</body>
<script>
const addBtn = document.getElementById('add')
const arr = [];
addBtn.addEventListener('click', (e) => {
// 大批量创建dom
for(let i = 0; i < 1e5; i++) {
document.body.appendChild(document.createElement('p'));
}
// 大数组的使用
arr.push(new Array(1e5).join('-'));
});
</script>
</html>

我们打开网页并点击录制按钮,然后正常点击界面的添加按钮,让内存开始变化,测试完成后停止录制就得到下面的时序图:(我们只需要勾选需要展示的内容,让界面更简洁):

如果时序图内存变化有升有降,就是正常的,如果只升不降或持续升高说明代码可能存在内存泄漏,需要调整测试阶段的代码执行逻辑。

堆快照查找分离DOM

堆快照也是performance工具中的一个功能,可以截取某个时间点的js堆内存快照。使用堆快照我们可以用于分析js堆中是否存在不合理的内存数据。

比如分离DOM就是一种内存的浪费,DOM一般分为三种状态:

  • 存活在DOM树上的界面元素
  • 垃圾对象时的DOM节点:脱离了DOM树,又未引用到的垃圾
  • 分离状态的DOM节点:不存在DOM树,但有引用

分离dom由于被js引用而不能当做垃圾回收,这时,我们可以通过堆快照来分析是否存在分离DOM,如果存在,我们就可以优化相应产生分离DOM的代码。

现在我们简单的模拟产生分离DOM的代码:

1
const temp = document.createElement('p')

我们打开工具栏的内存选项卡,再点击左侧的录制按钮即可拍下当前堆内存快照:

使用detached可以快速检索到对应的分离内存。

判断是否存在频繁GC

GC工作时程序是停止状态的,频发的垃圾回收会导致应用假死,用户体验不佳。

我们可以通过观察Timeline是否存在频繁的内存变化(上升下降),或者通过观察任务管理器中的数据是否存在频繁的增加减小来判断是否存在频繁的GC操作。

代码优化

使用jsperf测试js性能

jsper-github

慎用全局变量

  • 全局变量定义在全局执行上下文,是所有作用域的顶端
  • 全局执行上下文会一直存在上下文执行栈中,直到程序退出
  • 如果某个局部作用域出现的同名变量会很容易污染全局变量

缓存全局变量

如果某些地方不得不使用全局变量,我们可以使用局部缓存全局变量的方式进行性能优化。比如:

1
2
3
4
5
6
7
8
9
10
11
12
// 全局变量
const config = {
name: 'jinx',
age: 23,
//
}

function getName(){
const name = config.name; // 在频繁调用的地方,缓存全局变量
//....
return;
}

通过原型链新增方法

测试表明,通过原型链新增方法比内部构造新增速度要更快。

1
2
3
4
5
6
7
8
9
10
11
12
var fn1 = function(){
this.foo = function(){
console.log('内部构造的方式新增函数')
}
}

// 推荐
var fn2 = function(){
}
fn2.prototype.foo = function(){
console.log('通过原型链增加新函数')
}

for循环的优化

1
2
3
4
5
6
7
const arr = [];
arr[10000] = '1111'
// 一般
for(let i = 0; i < arr.length; i++) console.log(i)

// 优化写法
for(let i = arr.length - 1; i; i --) console.log(i)

参考

  1. JavaScript内部管理 - MDN
  2. 认识 V8 引擎 - 知乎
谢谢你请我吃糖!