1. JavaScript 内存管理
- 内存:由可读写的单元组成,表示一片可操作空间
- 管理:人为操作一片空间申请、使用、释放
- 内存管理:开发者主动申请空间、使用空间、释放空间
- 管理流程:申请——使用——释放
- 申请:遇到变量,自动分配空间
let obj = {} - 使用:
obj.name = 'zhangsan' - 释放:
obj = null
- 申请:遇到变量,自动分配空间
2. JavaScript 垃圾回收
什么是垃圾
- 对象不再被引用时是垃圾
- 对象不能从根上访问时是垃圾
JavaScript的根:全局变量对象
可达对象:能访问到的对象(引用、作用域链)
可达标准:从根出发是否能被找到
3. GC 算法
-
GC:垃圾回收机制的简写。
GC 可找到内存中的垃圾,并释放&回收空间,方便后续代码即迅速使用 -
GC 里的垃圾
-
程序中不再需要使用的对象
function func() { name = 'lg' return name } func() -
程序中不能再访问到的对象
function func() { const name ='lg' return name } func()
-
-
常见 GC 算法
- 引用计数:通过数字判断当前是否是垃圾
- 标记清除:通过标记为活动的对象标记是否是垃圾
- 标记整理:类似标记清楚,在清除阶段要先整理再清除
- 分代回收:V8 引擎将内存分为新生代区及老生代区
3.1 引用计数算法
核心:在内部通过引用计数器,维护当前对象的引用数,判断当前引用数是否为0
- 对象引用关系发生改变,引用计数器会主动修改当前对象对应的引用数字。
- 引用数字为0立即回收
const user1 = { age: 11 }
const user2 = { age: 22 }
const user3 = { age: 33 }
const nameList = [ user1.age, user2.age, user3.age ]
// function fn () {
// num1 = 1
// num2 = 2
// }
function fn () {
const num1 = 1
const num2 = 2
}
fn()
分析:
- 全局 window 下可以获取到 user1、user2、user3,fn 里由于 num1 和 num2 没有设置声明关键字,因此也在 window 下。当给 fn 中的两个变量加上关键字后,两个变量的作用域则在 fn 内,一旦 fn 调用结束后,全局不再能找到这两个变量,他们身上的引用计数变为0,GC 开始进行垃圾回收。
- nameList 内部有对 user1、user2、user3 的引用,及时执行完成,也不会被当做垃圾回收
优点:
- 发现垃圾立即回收
- 最大限度减少程序暂停,减少程序卡顿时间:当前执行平台内存有上限,此方法会时刻监听,内存爆满时会立即找到计数为0的对象进行回收,释放空间
缺点:
-
无法回收循环引用的对象
-
造成内存浪费
function fn1 () { const obj1 = {} const obj2 = {} // 对象之间互相指引,即使在方法调用之后,引用计数也依然无法将其置为0 obj1.name = obj2 obj2.name = obj1 return 'test code' } fn1() -
时间开销大:需要维护数值变化,时刻监控数值是否修改
3.2 标记清除算法
核心:将垃圾回收操作分为标记和清除两个阶段
- 阶段1:遍历所有对象,找到活动对象(可达对象)进行标记
- 阶段2:遍历所有对象,找到没有标记的对象清除掉;也会将阶段1中设置的标记抹除,便于 GC 下一次的操作
- 通过两次遍历,回收相应空间
找到所有可达对象,有递归则引用查找,将其标记。找到未标记的对象以及之前的标记,清除。把回收的空间放到【空闲链表】
优点:
- 相对标记清除,解决对象循环引用的无法回收的问题;不标记函数内部的变量,因为在外部不可找到
缺点:
- 空间碎片化:由于当前回收的垃圾对象在地址上不连续,回收后分散在各个地方,若后续从空闲链表中开辟区域,会有空间不够的情况
- 不会立即回收垃圾对象
3.3 标记整理算法
标记整理可看做是标记清除的增强:标记阶段同标记清除;清除阶段会先执行整理,移动对象的位置,让他们在地址上产生连续
优点:
- 减少碎片化空间
缺点:
- 不会立即回收垃圾对象
4. V8
V8 是一款主流的 JavaScript 执行引擎。
V8 采用及时编译,将字节码转为机器码。
V8 内存有上限:64位 不超过1.5G,32位 不超过800M
4.1 垃圾回收策略
采用分代回收的思想;内存分为新生代、老生代存储区;针对不同对象采用不同算法
4.2 内存分配
- 新生代内存空间一分为二
- 小空间存储新生代对象(32M (64位)| 16M(32位))
- 新生代:存活时间较短的对象
4.3 回收新生代对象
- 回收过程采用复制算法 + 标记整理
- 新生代内存区分为两个等大的空间
- 使用空间为 From——存储活动对象,空闲空间为 To
- 标记整理后将活动对象拷贝至 To
- From 空间的对象有了备份,From 与 To 交换空间,完成释放
回收细节:
- 若在新生代区拷贝的对象在老生代区也存在,则会发生晋升
- 晋升就是将新生代对象移动至老生代
- 一轮 GC 后,还有存活的新生代,则需要晋升
- To 空间的使用率若超过 25%,也需要晋升
4.4 回收老生代对象
- 老生代对象存放在右侧老生代区
- 空间大小:1.4G(64位),700M(32位)
- 老生代对象:存活时间较长的对象(全局变量、闭包等)
回收实现:
- 回收过程采用标记清除、标记整理、增量标记算法
- 首先使用标记清除完成垃圾空间的回收
- 把新生代移动至老生代,但是会发生晋升问题,采用标记整理进行空间优化
- 采用增量标记进行效率优化
| 新生代 | 老生代 |
|---|---|
| 回收使用空间换时间 | 不适合复制算法 |
增量标记如何优化垃圾回收:
回收过程会阻塞 JavaScript 执行,可将垃圾回收与程序执行交替执行,将回收拆分成小段。
5. Performance
- GC 的目的为了实现内存空间的良性循环
- 良性循环的基石是使用合理,时刻关注才能确定是否合理
- Performance 提供多种监控方式
5.1 内存问题的表现
- 内存泄露:会导致程序的使用性能随着时间增长而越来越差
- 内存膨胀:一个应用在绝大多数的设备上运行时都表现出很糟糕的性能
- 频繁的垃圾回收:一个应用在运行中存在频繁的卡顿
5.2 监控内存的方式
- 浏览器任务管理器
- Timeline 时序图记录
- 堆快照查找 分离Dom
- 判断是否存在频繁的垃圾回收
6. 代码优化
可使用以下网站进行测试
6.1 慎用全局变量
Q:为什么要慎用?
A: 全局变量定义在全局执行上下文,是所有作用域链的顶端
全局执行上下文一直存在于上下文执行栈,直到程序退出
在局部作用于定义同名变量,则会污染或遮蔽全局数据
// 使用全局变量
var i, str = ''
for (i = 0; i< 1000; i++) {
str += i
}
// 使用局部变量
for (let i = 0; i< 1000; i++) {
let str = ''
str += i
}
6.2 缓存全局变量
将无法避免的全局变量缓存到局部作用域
6.3 通过原型新增方法
在原型对象上新增实例对象需要的方法
var fn1 = function () {
this.foo = function () {
console.log(111)
}
}
const f1 = new fn1()
// 原型链新增方法
var fn2 = function () {}
fn2.prototype.foo = function () {
console.log(222)
}
const f2 = new fn2()
6.4 避开闭包陷阱
闭包特点:
- 外部具有指向内部的引用
- 在”外“部作用域访问”内“部作用于的数据
- 使用不当的话很容易出现内存泄漏
测试
<button id='btn'></button>
// 会出现内存泄漏
function foo() {
// el 是页面本身存在的一个元素,只是存储了一下而已,元素有 onclick 事件,是引用了foo函数内部的 function
var el = document.getElementById('btn')
// 调用时处在一个新的作用域,不是 foo 函数的作用域,这个函数是用户点击时才调用的,处于独立的作用域中,与 foo 不在同一作用域
// 在onclick函数内用到了 foo 函数的 el,存在跨作用域用数据的现象
el.onclick = function () {
console.log(el.id)
}
}
// 优化
function foo1() {
// btn 本身存在于 DOM 中,不论是否引用都存在,这里相当于又对他有一个引用,理解为 btn 元素被引用了两次
// 若未来 btn 元素被删除,此时代码中的引用还在,GC 不会回收
var el = document.getElementById('btn')
el.onclick = function () {
console.log(el.id)
}
// 可将元素置为 null
el = null
}
6.5 避免属性访问方法的使用
- JS 不需要属性的访问方法,所有属性外部可见
- 使用属性访问方法只会增加一层重定义,没有访问的控制力
// 提供成员属性的访问方法
function Person() {
this.name = 'aa'
this.age = 11
this.getAage = function () {
return this.age
}
}
const p1 = new Person()
const a = p1.getAage()
// 直接让成员属性访问
function Person2() {
this.name = 'aa'
this.age = 11
}
const p2 = new Person2()
const b = p2.age
6.6 For 循环优化
var btns = document.getElementsByClassName('btn')
for (var i = 0; i < btns.length; i++) {
console.log(i)
}
// 将 len 提前存储,避免后面再次计算
for (var i = 0, len = btns.length; i < len; i++) {
console.log(i)
}
6.7 采用最优循环方式
const arrList = [1, 2, 3, 4, 5]
// 最优
arrList.forEach(item => {
console.log(item)
})
// 次优
for (let i = arrList.length; i; i--) {
console.log(arrList[i])
}
// 较差
for (let i in arrList) {
console.log(arrList[i])
}
6.8 文档碎片优化节点添加
节点的添加操作必然引起回流 & 重绘——耗性能
for (let i = 0; i < 10; i++) {
let p = document.createElement('p')
p.innerHTML = i
document.body.appendChild(p)
}
// 优化 - 相比上一种形式略快
// createDocumentFragment:创建虚拟的节点对象
const fragEle = document.createDocumentFragment()
for (let i = 0; i < 10; i++) {
let p = document.createElement('p')
p.innerHTML = i
fragEle.appendChild(p)
}
document.body.appendChild(fragEle)
6.9 克隆优化节点操作
新增节点时,把与它类似的节点克隆一下,再把克隆之后的节点添加
<p id='oldP'>oldP</p>
for (let i = 0; i < 3; i++) {
let p = document.createElement('p')
p.innerHTML = i
document.body.appendChild(p)
}
// 略快
let oldP = document.getElementById('oldP')
for (let i = 0; i < 3; i++) {
// 该方法将复制并返回调用它的节点的副本。如果传递给它的参数是 true,它还将递归复制当前节点的所有子孙节点。否则,它只复制当前节点
let p = oldP.cloneNode(false)
p.innerHTML = i
document.body.appendChild(p)
}
6.10 直接量替换 new Object
let arr2 = new Array(3)
arr2[0] = 1
arr2[1] = 2
arr2[2] = 3
// 使用字面量-略快
let arr = [1, 2, 3]