js性能优化-各种GC、新老生代GC、性能优化code tips
前情提要
1. 内存管理
2. 垃圾回收与GC算法
3. V8引擎的垃圾回收
4. Performance工具
内存管理
内存:由可读写单元组成,表示一片可操作的空间
管理:人为的去操作一片空间的申请、使用和释放
内存管理:开发者主动申请空间、使用空间、释放空间
管理流程:申请-使用-释放
Js 中的内存管理
// 申请
let obj = {}
// 使用
obj.name = 'ssss'
// 释放
obj = null
JS 中的垃圾回收
js 中的内存管理时自动的
对象不再被引用时是垃圾
对象不能从根上访问到时是垃圾
js 中的可达对象
可以访问到的对象就是可达对象(引用、作用域链)
可达的标准就是从根出发是否能够被找到
js 中的根就可以理解为全局变量对象
GC 算法
GC 可以找到内存中的垃圾、并释放和回收空间。
GC 中的垃圾是什么
比如:
1. 程序中不再需要使用的对象
function func() {
name = 'lh'
return `hi!${name}`
}
func()
2. 程序中不再被访问到的对象
function func() {
const name = 'lh'
return `hi!${name}`
}
func()
GC 是一种机制,垃圾回收器完成具体的工作,工作内容就是查找垃圾释放空间、回收空间,算法就是工作时查找和回收所遵循的规则。
常见的 GC 算法
- 引用计数:通过数字判断当前对象是否为垃圾
- 标记清除:工作时给活动对象添加标记来判断是否为垃圾
- 标记整理:与标记清除类似,后续回收的过程与标记清除不同
- 分代回收:V8 中的回收机制
引用计数算法实现原理
设置引用数,判断当前引用数是否为 0。
引用计数器
引用关系改变时,修改引用数值。如果代码中出现新的引用+1,减少 1 个引用-1.
引用数字为 0 时立即回收。
const user1 = { age: 1 }
const user2 = { age: 2 }
const user3 = { age: 3 }
const nameList = [user1.age, user2.age, user3.age] // user1,user2,user3不是0,并不会被立即回收
function fn() {
const num1 = 1
const num2 = 2
} // 一旦执行到该位置,num1、num2计数为0,立即被回收
fn()
引用计数算法优缺点
优点:发现垃圾时立即回收,最大限度减少程序暂停
缺点:无法回收循环引用的对象,时间开销大(引用计数需要维护一个数值变化,在当前情况下要时刻监控当前对象的引用数值是否需要修改,如果当前环境有很多对象需要修改,那么这个时间开销就会显得更大一些)
function fn() {
const obj1 = {}
const obj2 = {}
obj1.name = obj2
obj2.name = obj1
return 'jjj'
}
fn()
如上,obj1-><-obj2 两个引用计数都为 1,所以即使运行到函数结束也不会被回收。
标记清除算法实现原理
分为标记和清除两个阶段。
标记阶段:会遍历所有对象找到并标记活动对象
清除阶段:清除没有标记的对象,并将第一次所标记的对象清除掉。
通过两次遍历行为将回收相应的空间
标记清除算法优缺点
优点:相对于引用计数,可以解决循环引用的回收操作。
缺点:标记清除算法中,把回收的空间放入空闲列表中,可能会使当前地址不连续,回收后的空间分散到各个角落,造成空间碎片化,如果后续想要申请一片空间与这些空间不匹配,那么就不适合,不能让空间最大化的使用。不会立即回收垃圾对象。
标记整理算法的原理
标记整理可以看作是标记清除的增强,标记阶段与标记清除一致。
在清除阶段会先执行整理,移动对象的位置,使其回收后的空间连续。
优点:减少碎片化空间。
缺点:不会立即回收垃圾对象。
V8
认识 V8
V8 是一款主流的 js 执行引擎,采用即时编译,V8 内存设限(64 位不超过 1.5G,32 位不超过 800M)。
V8 垃圾回收策略
采用分代回收思想,内存分为新生代和老生代,针对不同代采用不同算法。V8 的内存空间,一部分针对新生代对象存储(采用具体的 GC 算法),另一部分针对老生代存储(采用具体的算法)。
V8 中常用的 GC 算法
- 分代回收
- 空间复制
- 标记清除
- 标记整理
- 标记增量
V8 如何回收新生代对象
V8 内存一分为 2,小空间用于存储新生代对象(32M|16M),新生代指的是存活时间较短的对象。
新生代对象回收过程采用复制算法+标记整理。新生代内存区分为两个等大小的空间,From 和 to。from 为使用空间,to 为空闲空间。活动对象存储于 from 空间,标记整理后将活动对象拷贝至 to。from 与 to 交换空间完成释放。
回收细节说明
拷贝过程中可能会出现晋升,即将新生代对象移动至老生代。如果一轮 GC 中还存活的新生代则需要晋升,拷贝至老生代中。若 To 空间的使用率超过 25%,也将拷贝至老生代中。
V8 如何回收老生代对象
老生代对象存放在右侧老生代区域,64 位 1.4G,32 位 700M。老年代对象就是指存活时间较长的对象(全局变量、闭包)。
老年代主要采取标记清除、标记整理、增量标记算法。首先使用标记清除完成垃圾空间的回收,如果想要将新生代的内容往老生代中去移动并且老生代空间不足以存储的时候,将会采用标记整理进行空间优化。采用增量标记进行效率优化。
新生代区域 GC 使用空间换时间(复制),老年代 GC 不适合复制算法
标记增量如何优化垃圾回收
垃圾回收工作时会阻塞程序执行。当程序执行之后,一旦出发垃圾回收,会遍历对象进行标记(增量),将当前一整段的 GC 操作进行分段,让垃圾回收与程序交替执行,直至完成垃圾回收。
监控内存
界定内存问题的标准
内存泄漏: 内存持续升高
内存膨胀:设备硬件不支持
频繁垃圾回收:通过内存变化图
监控内存的几种方式
浏览器任务管理器
timeline 时序图记录
堆快照查找分离 dom
判断是否存在频繁的垃圾回收
堆快照分离 dom
什么是分离 dom
脱离 dom 树,但被 js 引用的 dom 节点,称为分离 dom。
在界面上看不见,但会在内存上看到,通过堆快照找到,处理使内存释放。
如下例,点击按钮创建了分离 dom
var tmpEle
function fn() {
const ul = document.createElement('ul')
for (let i = 0; i < 10; i++) {
const li = document.createElement('li')
ul.appendChild(li)
}
tmpEle = ul
}
document.getElementById('btn').addEventListener('click', fn)
我们在 chrome->f12->memory,在点击 add 按钮前后拍下快照,键索“detached”,会发现控制台中显示 Detached HTMLUListElement 和 Detached HTMLLIElement×10。
那么我们将引用那行注释掉,再次拍照就不会产生分离 dom
function fn() {
const ul = document.createElement('ul')
for (let i = 0; i < 10; i++) {
const li = document.createElement('li')
ul.appendChild(li)
}
//tmpEle=ul
}
判断是否频繁 GC
GC 工作时应用程序是停止的,GC 频繁工作且过长会导致应用假死,用户就会感觉到卡顿。
1. 通过 timeline 中的走势判断(timeline 中蓝色显示条)
2. 通过任务管理器中数据频繁的增加减少
如何精准测试 js 性能
通过大量的执行样本进行数学统计和分析。
使用基于 benchmark.js 的https://jsbench.me 来测试。
一些优化性能的 tips
慎用全局变量
- 全局变量一直存在于全局执行上下文,是所有作用域链的顶端。如果在局部找不到变量,就会沿着作用域链一层一层查找直到作用域顶端,非常消耗时间。
- 全局执行上下文一直存在于上下文执行栈中,直至退出,由于一直处于上下文执行栈全局变量一直处于活跃状态,会降低程序对内存的使用。
- 局部作用域定义了同名变量,则会遮蔽或者污染全局。
缓存全局变量
将使用中无法避免的全局变量缓存到局部,如下:
<button id="Add1">Add1</button>
<button id="Add2">Add2</button>
<button id="Add3">Add3</button>
<button id="Add4">Add4</button>
<button id="Add5">Add5</button>
<button id="Add6">Add6</button>
<button id="Add7">Add7</button>
case1 为:
function getBtn1() {
const btn1 = document.getElementById('Add1')
const btn4 = document.getElementById('Add4')
const btn2 = document.getElementById('Add2')
const btn3 = document.getElementById('Add3')
const btn5 = document.getElementById('Add5')
}
case 2 缓存 document;
function Btn2() {
const doc = document
const btn1 = doc.getElementById('Add1')
const btn4 = doc.getElementById('Add4')
const btn2 = doc.getElementById('Add2')
const btn3 = doc.getElementById('Add3')
const btn5 = doc.getElementById('Add5')
}
打开 jsbench 进行性能测试,case2 比 case1 快了 2%。
通过原型对象添加附加方法
在原型对象上添加方法,要比在构造函数中添加方法性能更高。
//case1
const func1 = function () {}
func1.prototype.foo = function () {
console.log(111)
}
const obj1 = new func1()
// case2
const func2 = function () {
this.foo = function () {
console.log(111)
}
}
const obj2 = new func2()
同样放到 jsbench 上去测试,case1 要比 case 快 18%!!
避开闭包陷阱
闭包很强大,但使用不当会造成内存泄漏,不要为了使用闭包而使用闭包。
一个很常见的例子:
function bindEvent() {
const el = document.getElementById('btn')
el.onclick = function () {
console.log(el.id)
}
}
bindEvent()
当执行 bindEvent 后,即使函数结束 el.onclick 中还是存在对 el 的引用,造成内存泄漏。可以修改为:
function bindEvent() {
const el = document.getElementById('btn')
const str = el.id
el.onclick = function () {
console.log(str)
}
el = null // 事件已经绑定到dom上了,清除该变量将清除所有引用标记
}
bindEvent()
避免属性访问方法使用
- js 不需要属性的访问方法,因为所有的属性外部都可见
- 使用属性访问方法只会增加一层重定义,没有访问的控制力
for 循环优化
比如:
const arr=[.....]
for(let i=0;i<arr.length;i++){....}
// 写成
for(let i=0,len=arr.length;i<len;i++){....}
采用最优循环
效率高到低:foreach>for>for..in..
节点添加优化
节点的添加必然会带来回流和重绘。在插入文档的操作中我们要尽量使用文档碎片的操作,如下:
// case1
for (let i = 0; i < 10; i++) {
const el = document.createElement('p')
el.innerHTML = i
document.body.appendChild(el)
}
// case2
const wrapper = document.createDocumentFragment()
for (let i = 0; i < 10; i++) {
const el = document.createElement('p')
el.innerHTML = i
wrapper.appendChild(el)
}
document.body.appendChild(wrapper)
进入 jsbench 测试性能,case2 要比 case1 快 10%
用直接量替换 new Object
const arr = new Array(1, 2, 3)
const arr = [1, 2, 3] // better!!