内存管理
- 内存:由可读写单元组成,表示一片可操作空间
- 管理:人为的去操作一片空间的申请、使用和释放
- 内存管理:开发者主动申请空间、使用空间、释放空间
- 管理流程:申请-使用-释放
JavaScript中的内存管理:JavaScript中没有专门的API来操作内存空间,那么它是怎么申请使用并且释放的呢?
垃圾回收
Javascript中的垃圾:
- JavaScript中内存管理是自动的
- 对象不再被引用时是垃圾
- 对象不能从根上访问到时是垃圾
JavaScript执行引擎把垃圾占据的对象空间进行回收,这个过程就是垃圾回收。
JavaScript中的可达对象:
- 可以访问到的对象就是可达对象(引用、作用域链)
- 可达的标准就是从根出发是否能够被找到
- JavaScript中的根就可以理解为是全局变量对象
GC算法
- GC是一种机制,垃圾回收器完成具体的工作
- 工作的内容就是查找垃圾释放空间、回收空间
- 算法就是工作时查找和回收所遵循的规则
常见的GC算法:
- 引用计数
- 标记清楚
- 标记整理
- 分代回收
引用计数
实现原理:在内部通过一个引用计数器来维护当前对象的引用数,判断当前引用数是否为0来决定它是不是一个垃圾对象。如果是,执行引擎就将它回收并释放。
优点:
- 发现垃圾时立即回收
- 最大限度减少程序暂停
缺点:
- 无法回收循环引用的对象
- 资源消耗较大
标记清除
实现原理:分标记和清除两个阶段。第一个阶段:遍历所有对象找到活动对象进行标记;第二阶段:遍历所有对象把没有标记的对象进行清除(在这个阶段会把第一阶段的标记给抹掉,便于GC下次正常工作)。
优点:
- 可以回收循环引用的对象
缺点:
- 容易产生碎片化空间,浪费空间
- 不会立即回收垃圾对象
标记整理
- 标记整理可以看作是标记清除的增强
- 标记阶段的操作和标记清除一致
- 清除阶段会先执行整理,移动对象位置
优点:
- 减少碎片化空间
缺点:
- 不会立即回收垃圾对象
V8
- V8 是一款主流的 JavaScript 执行引擎
- V8 采用即时编译
- V8 内存设限
V8 垃圾回收策略
V8 采用分代回收的思想,将内存空间一分为二,并针对不同对象采用不同算法。
- 左侧小空间为新生代,用于存储新生代对象,在64位系统中占32M,32位系统中占16M,新生代指的是存活时间较短的对象。
- 右侧大空间为老生代,用于存储老生代对象,在64位操作系统中占1.4G,32位操作系统中占700M。
V8常用的GC算法:
- 分代回收
- 空间复制
- 标记清除
- 标记整理
- 标记增量
回收新生代对象
新生代对象指的是存活时间较短的对象。
- 回收过程采用复制算法 + 标记整理
- 新生代内存区分为两个等大小的空间,分别为From,To
- From 为使用空间,To 为空闲空间,活动对象存储于From 空间
- 标记整理后将活动对象拷贝至To
- 将 From 和 To 交换空间完成释放
回收细节说明:
- 拷贝过程中可能出现晋升
- 晋升就是将新生代对象移动至老生代
- 一轮GC还存活的新生代需要晋升
- To 空间的使用率超过25% 需要晋升
回收老生代对象
老生代对象就是指存活时间较长的对象:全局变量,闭包中放置的变量数据。
- 主要采用标记清除、标记整理、增量标记算法
- 首先使用标记清除完成垃圾空间的回收
- 当新生代对象向老生代移动时,采用标记整理进行空间优化
- 最后采用增量标记进行效率优化
增量标记:
当垃圾回收的时候,是会阻塞 JavaScript 代码的执行,所以会有空档期。所谓的增量标记就是将一整段的垃圾回收操作拆分成多个小步,组合着完成整个回收,从而是程序执行和垃圾回收交替进行,这样带来的时间消耗更加合理。
老生代 vs. 新生代
- 新生代区域垃圾回收使用空间换时间:采用的复制算法就意味着有空闲空间的存在,但是由于新生代存储区域本身空间就很小,分出来的空间就更小,这一部分空间浪费对于时间上的提升是微不足道的。
- 老生代区域垃圾回收不适合复制算法:老生代空间区域比较大,如果采用一分为二,会有几百兆的空间是浪费不用的,这样太奢侈了。而且老生代存储的数据比较多,复制的话会占用很多时间。
Performance 工具介绍
为什么使用Performance?
- GC的目的是为了实现内存空间的良性循环
- 良性循环的基石是合理使用
- 时刻关注才能确定是否合理
- Performance 提供多种监控内存的方式
使用步骤:
- 打开浏览器输入目标网址
- 进入开发人员工具面板,选择性能
- 开启录制功能,访问具体界面
- 执行用户行为,一段时间后停止录制
- 分享界面中记录的内存信息
监控内存的几种方式
内存问题的外在表现:
- 页面出现延迟加载或经常性暂停
- 页面持续性出现糟糕的性能
- 页面拿到性能随时间延长越来越差
界定内存问题的标准:
- 内存泄漏:内存使用持续升高
- 内存膨胀:在多数设备上都存在性能问题
- 频繁垃圾回收:通过内存变化图进行分析
监控内存的几种方式:
- 浏览器任务管理器
- Timeline 时序图记录
- 堆快照查找分离 DOM
- 判断是否存在频繁的垃圾回收
任务管理器监控内存
shift + esc 调出任务管理器,操作页面后观察任务管理器的内存占用空间(DOM 节点所占用的内存)和JavaScript占用内存。
如果内存一直增长就是有问题的,但是任务管理器无法定位问题。
Timeline 记录内存
在性能选项录制停止后,可以在这里查看内存走势。
堆快照查找分离 DOM
什么是分离DOM?
界面元素都是DOM节点,存活在DOM树上,DOM 节点存在几种形态:
- 垃圾对象:节点从当前DOM树上脱离,js中也没有引用。
- 分离DOM:从DOM树上脱离但是在js中还有引用。
分离DOM在界面上是看不见的,但是在内存里占据了空间,这就是一种内存泄漏。可以通过堆快照的功能找到这些分离DOM。
判断是否存在频繁GC
确定频繁的垃圾回收:
- Timeline 中频繁的上升下降
- 任务管理器中数据频繁的增加减小
代码优化
如何精准测试 JavaScript 性能?
- 本质上就是采集大量的执行样本进行数学统计和分析
- 使用基于 Benchmark.js 的 https://jsbench.me/ 完成
慎用全局变量
为什么要慎用?
- 全局变量定义在全局执行上下文,是所有作用域链的最顶端
- 全局执行上下文一直存在于上下文执行栈,知道程序退出
- 如果某个局部作用域出现了同名变量则会遮蔽或污染全局
eg:
// 代码 1
var i, str = ''
for (i = 0; i < 1000; i++) {
str += i
}
// 代码 2
for (let i = 0; i < 1000; i++) {
let str = ''
str += i
}
将这段代码进行测试。
可以发现使用局部变量性能要好很多。
缓存全局变量
将使用中无法避免的全局变量缓存到局部。比如 document。
通过原型新增方法
在原型对象上新增实例对象需要的方法。
避开闭包陷阱
闭包的特点:
- 外部具有指向内部的引用
- 在“外”部作用域访问“内”部作用域的数据
// 闭包
function foo() {
var name = 'lg'
function fn() {
console.log(name)
}
return fn
}
var a = foo()
a()
我们根据一个例子来解释闭包陷阱
<button id="btn">Add</button>
<script>
// 代码 1
function foo() {
var el = document.getElementById('btn')
el.onclick = function () {
console.log(el.id)
}
}
foo()
// 代码 2
function foo() {
var el = document.getElementById('btn')
el.onclick = function () {
console.log(el.id)
}
el = null
}
foo()
</script>
上面例子中,foo 函数中将 document.getElementById('btn') 的引用给了 el,但是这个 btn 元素,本身就有一个dom元素的引用,在这里赋值,就变成了两个引用。在引用计数法中,如果dom元素被删除,这个dom元素的引用减一,但是这个dom元素还存在一个引用,这个内存就会无法释放。所以可以通过将 el 置为 null 的方法,手动释放这个内存,以防大量的这种错误使用造成内存泄漏。
避免属性访问方法使用
JavaScript中的面向对象
- JS不需要属性的访问方法,所有属性都是外部可见的
- 使用属性访问方法只会增加一层重定义,没有访问的控制力
For循环优化 - 减少循环体中活动
将每次循环都要用到的变量抽离到外边。
<p class="btn">add</p>
<p class="btn">add</p>
<p class="btn">add</p>
<p class="btn">add</p>
<p class="btn">add</p>
<p class="btn">add</p>
<p class="btn">add</p>
<p class="btn">add</p>
<p class="btn">add</p>
<p class="btn">add</p>
<p class="btn">add</p>
<script>
var aBtns = document.getElementsByClassName('btn')
for (var i = 0; i < aBtns.length; i++) {
console.log(i)
}
for (var i = 0, len = aBtns.length; i < len; i++) {
console.log(i)
}
</script>
上面两个for循环,一个将直接用 i < aBtns.length, 一个将 aBtns.length 存到变量 len, 再用 i<len,将两者进行对比,可以看出,将长度先存到变量再进行比较性能要好一些。
采用最优循环方式
可以看出如果只是简单的遍历,while在数组大小小于5000时是最快的,但是大于5000就特别慢;而for永远是最慢的;for...in在数组越大它执行的越快;forEach则比较稳定,相对速度都比较快。
数组较小时:while > forEach > for...in > for
数组较大时:for...in > forEach > while > for
使用 forEach 的性能最好,其次是 for...in,最后才是 for 循环。
文档碎片 优化节点添加
节点的添加操作必然会有回流和重绘,而这两个对性能的消耗是非常大的。那我们怎么进行一个最优操作呢?
// 使用普通方法创建节点 p, 将 p 节点加到文档末尾
for (var i = 0; i < 10; i++) {
var oP = document.createElement('p')
oP.innerHTML = i
document.body.appendChild(oP)
}
// 创建一个文档碎片容器,将 p 节点放入文档碎片容器的末尾
// 最后再将文档最偏容器放入 body 的末尾
const fragEle = document.createDocumentFragment()
for (var i = 0; i < 10; i++) {
var oP = document.createElement('p')
oP.innerHTML = i
fragEle.appendChild(oP)
}
document.body.appendChild(fragEle)
将两段代码进行性能对比后发现,通过“文档碎片容器”的方式添加节点性能更好。
克隆 优化节点操作
<!-- 页面中有一个 p 标签,要创建一个和它一样的标签 -->
<p id="box1">old</p>
<script>
// 使用创建节点并添加的方式
for (var i = 0; i < 3; i++) {
var oP = document.createElement('p')
oP.innerHTML = i
document.body.appendChild(oP)
}
// 使用克隆 p 标签的方式
var oldP = document.getElementById('box1')
for (var i = 0; i < 3; i++) {
var newP = oldP.cloneNode(false)
newP.innerHTML = i
document.body.appendChild(newP)
}
</script>
减少判断层级
function doSomeThing(part, chapter) {
const parts = ['ES2016', '工程化', 'Vue', 'React', 'Node']
if (part) {
if (parts.includes(part)) {
console.log('属于当前课程')
if (chapter > 5) {
console.log('您需要提供 VIP 身份')
}
}
} else {
console.log('请确认模块信息')
}
}
function doSomeThing(part, chapter) {
const parts = ['ES2016', '工程化', 'Vue', 'React', 'Node']
if (!part) {
console.log('请确认模块信息')
return
}
if (!parts.includes(part)) return
console.log('属于当前课程')
if (chapter > 5) {
console.log('您需要提供 VIP 身份')
}
}
以上两种方式,第一种if嵌套很多,第二种减少了嵌套层级,根据结果可以看出性能更好。
减少作用域链查找层级
var name = 'aaa'
function foo() {
name = 'bbb' // 这里的name是属于全局的
function baz() {
var age = 38
console.log(age)
console.log(name)
}
baz()
}
foo()
var name = 'aaa'
function foo() {
var name = 'bbb'
function baz() {
var age = 38
console.log(age)
console.log(name)
}
baz()
}
foo()
可以看出,变量在作用域中的位置离的越近,查找的越快。不过这只是从时间上考虑的,如果从空间上考虑,第一种方法的name只占用了一个内存空间,而第二种两个name占了两个空间,占用内存相对较大。具体项目中要根据需求判断考虑空间还是时间。
减少数据读取次数
<div id="skip" class="skip"></div>
<script>
var oBox = document.getElementById('skip')
// function hasEle(ele, cls) {
// return ele.className == cls
// }
function hasEle(ele, cls) {
var clsName = ele.className
return clsName == cls
}
console.log(hasEle(oBox, 'skip')) // true
</script>
使用 var clsName = ele.className 可以减少 className 的读取次数,提高性能。
字面量与构造式
可以看出,直接通过字面量定义的方式,比通过 new 的方式要快很多,尤其是在普通数据类型特别明显,Object 类型虽然相差不多,但也是字面量的方式要快的,因为 new 的方式实际上是去调用了函数。字面量的方式除了快,它书写也是很方便的。
减少生命及语句数
对于后续不需频繁使用的数据建议使用时获取,而不做缓存,减少对内存的消耗。