文章内容输出来源:拉勾教育前端高薪训练营
何为内存管理
- 内存:由可读写单元组成,表示一片可操作空间
- 管理:人为的去操作一片空间的申请、使用和释放
- 内存管理:开发者主动申请空间、使用空间、释放空间
- 管理流程:申请-使用-释放
// 申请
let foo
// 使用
foo = 1
// 释放
foo = null
内存问题的外在表现
- 页面出现延迟加载或经常性暂停,此时底层存在频繁的垃圾回收,有程序可能瞬间让内存爆掉
- 页面持续性出现糟糕的性能表现,底层可能出现了内存膨胀,为了达到使用速度,可能会去申请一定的内存使用空间,但是这个内存大小远远超过了设备可提供大小
- 页面的性能随时间延长越来越差,可能出现了内存泄露
JS中的垃圾回收
首先需要明确几个概念
js中的垃圾是什么
js中内存管理是自动的,当内存中的对象不再被引用时,就变成了垃圾,或者是对象不能从根上访问到时就变成了垃圾
js中的可达对象是什么
- 可以访问到的对象就是可达对象(引用、作用域链)
- 可达的标准就是从根出发是否能够被找到
- js中的根就可以理解为是全局变量对象
GC垃圾回收
GC是一种机制,是垃圾回收机制Garbage Collector的简写,GC的工作内容就是找到内存中的垃圾、并释放和回收空间
gc里的垃圾是什么
- 程序中不再需要使用的对象
// func1不再被调用,下一轮会被回收
function func1 () {
name = 'zc'
return `my name is ${name}`
}
- 程序中不能再访问到的对象,例如下面的const
function func2 () {
const name = 'zc'
return `my name is ${name}`
}
常见的GC算法
引用计数算法
引用计数算法的核心思想是设置引用数,判断当前引用数是否为0,引用关系改变时修改引用数字,当引用数字为0时立即回收
let obj = { name: 'xm' } // obj引用了内存中的对象
let ali = obj // ali也引用了内存中的对象
obj = null // obj被释放,但是ali还引用者对象
console.log(ali)
const user1 = { age: 11 }
const user2 = { age: 22 }
const user3 = { age: 33 }
const nameList = [ user1.age, user2.age, user3.age ] // 引用三个user里面的内存空间,user内部计数不为0
function fn() {
num1 = 1 // fn执行完毕后,此时变量由于声明在全局上,全局对其还由引用,计数不为0
num2 = 2 // fn执行完毕后,此时变量由于声明在全局上,全局对其还由引用,计数不为0
const num3 = 3 // fn执行完毕后,此时变量作用域由于在函数内部,没有其他引用了,计数为0
}
fn()
引用计数算法的优点
- 发现垃圾时立即回收,可以即时回收垃圾对象
- 最大限度减少程序暂停,减少程序卡顿
引用计数算法的缺点
- 无法回收循环引用的对象
function loopFn () {
const obj1 = {}
const obj2 = {}
obj1.name = obj2
obj2.name = obj1
return 'nothing'
}
loopFn() // 当函数执行完毕后,obj1和obj2应该立即被删除,但是函数内部obj1和obj2形成了循环引用,造成引用计数都不为0无法被回收
- 资源消耗大,时间开销大,需要随时监听数据是否被修改,随时修改数据的引用数
标记清除算法
标记清除算法分为标记和清除两个阶段,在标记阶段遍历所有对象找到并标记活动对象,然后再遍历所有对象清除没有标记的对象,并把第一阶段被标记的对象的标记抹掉,便于下一轮重新做标记,最后回收相应的空间(回收的空间会放在一个叫空闲列表的地方,方便后续程序直接在这里申请空间使用)
标记清除算法的优点是可以解决引用计数中的循环引用无法被回收的情况,但是也有一些缺点,此算法不能立即回收垃圾对象,会等到最后才执行清除,且执行清除时程序是暂停的,而且可能会造成内存地址的碎片化。
例如:根下有三对象,a:占据2个空间,b:占据3个空间,c:占据1个空间,当a和c被回收到空闲列表中时,由于中间隔了一个b,所以a和c的内存地址不会连续,会形成分散的碎片化地址,如果新对象是1.5个空间,则不会利用刚刚被回收的地址,而是新创建一个内存地址,如果新对象刚好是2个或1个空间,则会利用刚才被回收的a或者c内存地址
标记整理算法
标记整理可以看做是标记清除的增强,在v8引擎中配合标记清除算法使用,标记阶段的操作和标记清除一致,但是在清除阶段解决了标记清除算法可能造成内存地址碎片化的缺点,清除阶段会先执行整理,先移动对象位置,将活动对象放在一起,地址上形成连续,回收剩余的非活动地址,这样就不会形成碎片化的地址
标记整理算法的优点是减少了碎片化空间,缺点同样是不会立即回收垃圾对象。
V8引擎及V8中的垃圾回收
V8是一款主流的JavaScript执行引擎,其采用即时编译,可以直接将当前的源码翻译成机器语言执行,而之前很多JavaScript引擎都需要将源代码转成字节码,然后才去执行。
V8的内存设有上限,64位操作系统不超过1.5GB,32位操作系统不超过800MB(为什么设置上限:表面上的原因是V8最初是作为浏览器的JavaScript引擎而设计,不太可能遇到大量内存的场景,而深层次原因则是因为V8的垃圾回收机制造成的,V8在执行垃圾回收时会阻塞程序继续执行,直到垃圾回收结束再重新执行,回收1.5GB大小内存时,当采用增量标记算法进行垃圾回收时只需要50ms,而采用非增量标记的方式回收需要1s,1s对于浏览器应用而言,卡顿感非常明显)
V8引擎工作流程
渲染引擎=>(utf-8 chunks)=>Stream(utf-16 code utils)=>Scanner扫描器(tokens)=>PreParser预解析器=>Parser解析器或者Scanner扫描器(tokens)=>Parser解析器(AST)=>Ignition解释器(bytecode)=>TurboFan编译器
- Scanner扫描器,对纯文本的js代码进行词法分析,把代码分析成不同的tokens
const username = 'tom'
// Scanner后的词法tokens
// [
// {
// type: 'Keyword',
// value: 'const',
// },
// {
// type: 'Identifier',
// value: 'username',
// },
// {
// type: 'Punctuator',
// value: '=',
// },
// {
// type: 'String',
// value: 'tom',
// },
// ]
- -PreParser预解析,其优点是跳过未被使用的代码,不生成AST语法树,创建无变量引用和声明的scopes,解析速度更快
// 只有func2被调用使用,func1只会被预解析,但是仍然会生成func1的作用域
function func1 () {
console.log('func1')
}
function func2 () {
console.log('func2')
}
func2()
- Parser,一个全量解析器,会把词法分析里的tokens转换成抽象的语法树(AST),解析过程中会进行语法校验,有错误会直接抛出
// {
// type: 'Program',
// body: [
// {
// type: 'VariableDeclaration',
// declarations: [
// {
// type: 'VariableDeclarator',
// id: {
// type: 'Identifier',
// name: 'username',
// },
// init: {
// type: 'Literal',
// value: 'tom',
// raw: 'tom',
// },
// },
// ],
// kind: 'const',
// },
// ],
// sourceType: 'script'
// }
// 声明时未调用,因此会被认为是不执行的代码,进行预解析
function foo (params) {
console.log('foo')
}
// 声明时未调用,因此会被认为是不执行的代码,进行预解析
function fn () {}
// 函数立即执行,只进行一次全量解析
(function bar (params) {})()
// 执行foo,那么需要重新对foo函数进行全量解析,此时foo函数被解析了两次,所以当嵌套函数时就经常出现这种情况,应该避免过多过深的函数嵌套
foo()
- Ignition,V8提供的一个解释器,作用是将AST语法树转为字节码base code,同时收集下一个编译阶段所需要的信息(此过程也可以看作是一个预编译过程)
- TurboFan,V8提供的编译器模块,会将解释器的字节码转化为具体的汇编代码,然后开始代码执行(即堆栈过程)
V8垃圾回收策略
采用分代回收的思想,将内存空间分为新生代对象和老生代对象,针对不同对象采用不同算法,同时V8将内存空间一分为二(我们暂且把内存看作分为左右两侧),内存左侧用于存储新生代对象,右侧用于存储老生代对象,V8中常用GC算法有:分代回收、空间复制、标记清除、标记整理、标记增量
- 新生代指的是存活时间较短的对象(比如局部作用域中的对象,当前函数执行完后就要回收),老生代指的是存活时间较长的对象(比如全局作用域中的对象,或者闭包里的对象,程序关闭后才回收)
- 存储新生代的内存大小:64位系统为32MB,32位系统为16MB
- 存储老生代的内存大小:64位系统为1.4GB,32位系统为700MB
V8如何回收新生代对象
- 首先回收过程采用空间复制算法+标记整理
- 新生代内存分为两个等大小的空间,使用空间我们暂且叫From空间,空闲空间我们暂且叫To空间,然后将活动对象存储于From空间
- 标记整理后将活动对象拷贝至To空间
- 最后对From空间进行释放,From与To交换空间并完成释放,相当于From变成了To,To变成了From
V8新生代回收注意的一些细节
- 拷贝过程中可能出现晋升(晋升就是将新生代对象移动至老生代),某一个变量使用空间在老生代里面也会出现,当一轮GC后还存活的新生代就需要晋升
- 活动对象拷贝到To的过程中,发现To空间的使用率超过25%(为什么要指定一个使用率,因为From和To是一种交换机制,From拷贝到To后,To空间就变成了From空间,From空间随后清除后就变成了To空间)如果新的From空间使用率过大,那么新产生的新生代对象就无法存储进From空间了,所以此时就需要晋升来保持新生代的内存空间够用
V8如何回收老生代对象
- 主要采用标记清除、标记整理、增量标记算法
- 首先使用标记清除完成垃圾空间的回收,此时是可能存在空间碎片化的情况
- 当新生代区域对象要晋升到老生代时,如果正好老生代剩余空间不足以来存放新生代对象时,就会采用标记整理进行碎片空间优化
- 最后采用增量标记进行效率优化(增量标记操作分成很多步完成,而不是一次性标记完),程序执行和垃圾回收交替运行来回收
新老生代回收细节对比
- 新生代区域由于本身存储空间较小,即使剩余一部分空间不使用也不会太浪费,所以垃圾回收采用复制算法,使用空间换时间
- 老生代区域垃圾回收不适合复制算法,首先老生代空间较大,放着不用会很浪费,其次,老生代存储的数据量较大,复制所消耗的时间也比较大