JS垃圾回收详解

279 阅读12分钟

1. 内存管理介绍

● 内存: 由可读写单元组成,表示一片可操作空间
● 管理: 人为的去操作一片空间的申请、使用和释放
● 内存管理: 开发者主动申请空间、使用空间、释放空间
● 管理流程: 申请—使用—释放

1.1 JavaScript中的内存管理

● 申请内存空间
● 使用内存空间
● 释放内存空间

// 申请
let obj = {}
// 使用
obj.name = 'lg'
// 释放
obj = null

2. JavaScript中的垃圾

● JavaScript中内存管理是自动的
● 对象不再被引用时是垃圾
● 对象不能从根上访问到时是垃圾

每当我们创建一个函数,数组,对象的时候,JavaScript会自动的分配相应的内存空间,后续代码在执行过程中,如果通过一些引用关系无法再找到某些对象的时候,这些个对象就会被看作是垃圾或者由于代码中一些不合适的代码或错误而无法找到某个对象时,这种对象也会被看作垃圾

2.1 JavaScript中的可达对象

● 可以访问到的对象就是可达对象(引用、作用域链)
● 可达的标准就是从根出发是否能够被找到
● JavaScript中的根就可以理解为是全局变量对象

如:

  1. 本地函数的局部变量和参数
  2. 当前嵌套调用链上的其他函数的变量和参数
  3. 全局变量
  4. 还有一些其他的,内部的

这些值称为根。如果引用或引用链可以从根访问任何其他值,则认为该值是可访问的。

来看一段代码:

let obj = {
    name: 'Xm'
}

let ali = obj

obj = null // obj 到 {name: 'Xm'} 引用断掉,但是 ali 到 {name: 'Xm'} 引用依然存在,所以 {name: 'Xm'} 依然可达
console.log(obj) // null
console.log(ali) // { name: 'Xm' }

上面代码中,虽然 obj{name: 'Xm'} 引用断掉,但是 ali{name: 'Xm'} 的引用依然存在,所以 {name: 'Xm'} 依然可达

再来看一个例子:

function objGroup(obj1, obj2) {
    obj1.next = obj2
    obj2.prev = obj1
    return {
        o1: obj1,
        o2: obj2
    }
}

let obj = objGroup({
    name: 'obj1'
}, {
    name: 'obj2'
})
console.log(obj)

我们来对上述代码画一张流程图,如下:

可达对象图示
接下来我们对obj1做删除操作,

delete obj.o1
delete obj.o2.prev
console.log(obj)

此时再来看一下,所有到达 obj1 的链都断掉了,所以 obj1 就不可达

可达对象图示

3. GC定义与作用

● GC就是垃圾回收机制的简写
● GC可以找到内存中的垃圾、并释放和回收空间

来看一下GC中的垃圾是什么,如下面代码所示:

// 程序中不再需要使用 name 对象, 但是 name 透到了全局,还是可以被访问到
function func() {
    name = 'lg'
    return `${name} is a coder`
}
func()

// 程序中不再需要使用 name 对象, name 在 func内部, 不能再访问到
function func() {
    const name = 'lg'
    return `${name} is a coder`
}
func()

3.1 GC算法是什么

● GC是一种机制,垃圾回收器完成具体的工作
● 工作的内容就是查找垃圾释放空间、回收空间
● 算法就是工作时查找和回收所遵循的规则

3.2 常见GC算法

● 引用计数
● 标记清除
● 标记整理
● 分代回收

3.2.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() {
    const num1 = 1
    const num2 = 2
}
fn()

优点:

发现垃圾时立即回收
最大限度减少程序暂停(减少程序卡顿时间)(计数算法时刻监听着引用计数为0的对象,当内存即将爆满时,引用计数会立即对引用计数为0的内存对象释放,保证了不会栈满)

缺点:

无法回收循环引用的对象
时间开销大,资源消耗较大(需要维护数值变化,时刻监控对象引用数值是否需要修改)

循环引用demo:

function fn() {
    const obj1 = {}
    const obj2 = {}
    obj1.name = obj2
    obj2.name = obj1
}

fn()

如上面代码,虽然函数执行完成后,虽然在全局作用域内找不到obj1和obj2,但是在函数内部两个对象之间依然有指引关系,所以它们的引用计数器数值并不为0,这种情况下引用计数就失效了。
obj1 引用 obj2,obj2 引用obj1,obj1、obj2彼此引用对方,导致引用计数都不为0,所以GC无法回收它们

具体解释参考:简单聊一聊JS中的循环引用及问题

知乎高赞答案:

a = new A() // a.ref = 1
b = new B() // b.ref = 1
a.otherObj = b // b.ref = 2
  1. 如果没有循环引用
    a的引用减1 => a.ref = 0。销毁a,a.otherObj 被销毁,所以 b.ref = 1
    b的引用减1 => b.ref = 0,销毁b
  2. 如果有循环引用
b.otherObj = a // a.ref = 2 

a的引用减1 => a.ref = 1,因为a.otherObj还在,所以 b.ref = 2
b的引用减1 => b.ref = 1,因为b.otherObj还在,所以 a.ref = 1
所以,在有循环引用的情况下,AB不会被销毁,仅仅是引用计数减1。注意,退出函数,只会对引用计数减1,而不是直接销毁对象!引用计数为0时才销毁对象,这是引用计数的重点。

3.2.2 标记清除算法

● 核心思想: 分标记和清除两个阶段完成
● 遍历所有对象找标记活动对象
● 遍历所有对象清除没有标记对象
● 回收相应的空间

第一阶段:找到所有可达对象,如果对象之下还有子对象,那么会使用递归找到所有可达对象,将可达对象进行标记。如图中A、B、C、D、E。而a1、b1在局部作用域中,没有被标记。 第二阶段:删除没有被标记的,并将第一次所作的标记清除掉,然后把回收的空间放在空闲链表上。

标记清除算法图示

优点: 由于局部作用域根本不会被标记,所以解决了引用计数法无法回收循环引用对象的问题

缺点: 不会立即回收垃圾对象
空间碎片化(地址不连续),浪费空间

如下图所示:我们假设根作用域找到的所有可达对象为A,左右边B、C都为局部作用域对象,垃圾回收时,B、C被回收,并添加到空闲链表上,(空间有两部分组成,头用来存放空间源信息,如大小,地址等。域用来存放数据),但是由于隔着A,地址是不连续的。这样我们使用时如果被回收的内存大小不合适,就不适合使用。例如,我们需要申请一个1.5个字的空间,使用B会多半个字节,使用C明显不够

image.png

3.2.3 标记整理算法

● 标记整理可以看做是标记清除的增强
● 标记阶段的操作和标记清除一致
● 清除阶段会先执行整理,移动对象位置(使内存连续)

回收前

整理后

回收后

优点:

减少碎片化空间

缺点:

不会立即回收垃圾对象

4 v8中的垃圾回收

● V8是一款主流的JavaScript执行引擎
● V8采用即时编译
● V8内存设限

内部内存设有上线,64位系统不超过1.5G,32位系统不超过800M。

4.1 v8垃圾回收策略:

● 采用分代回收的思想
● 内存分为新生代、老生代
● 针对不同对象采用不同算法

v8回收策略图示

4.2 v8常用GC算法

● 分代回收
● 空间复制
● 标记清除
● 标记整理
● 标记增量

4.3 v8如何回收新生代对象:

v8内存分配

● V8内存空间一分为二
● 小空间用于存储新生代对象(32M(64位) | 16M(32位))
● 新生代指的是存活时间较短的对象(比如局部变量)

4.3.1 新生代对象回收实现

● 回收过程采用复制算法+标记整理
● 新生代内存区分为二个等大小空间
● 使用空间为From,空闲空间为To
● 活动对象存储于From空间
● 标记整理后将活动对象拷贝至To
● From与To交换空间完成释放

来看张图了解一下:

新生代对象回收实现

4.3.2 回收细节说明

● 拷贝过程中可能出现晋升
● 晋升就是将新生代对象移动到老生代

触发晋升操作条件

● 经历过一次Scavenging算法,且并未被标记清除的,也就是过一次翻转置换操作的对象。
● 在进行翻转置换时,被复制的对象大于to space空间的25%。(from space 和 to space 一定是一样大的)(回收操作时To和From会进行交换,如果To使用率达到80%,当它变为From时,新的活动对象就存不进去了)

4.4 v8如何回收老生代对象

老生代对象说明

● 老生代对象存放在右侧老生代区域 ● 64位操作系统1 4G,32操作系统700M ● 老年代对象就是指存活时间较长的对象(比如全局对象,闭包等)

4.4.1 老生代对象回收实现

● 主要采用标记清除、标记整理、增量标记算法
● 首先使用标记清除完成垃圾空间的回收
● 采用标记整理进行空间优化(当新生代内容向老生代移动,并且这个时候老生代存储空间又不足以存放新生代存储区所移过来的数据)
● 采用增量标记进行效率优化

4.4.2 与新生代细节对比:

● 新生代区域垃圾回收使用空间换时间(每时每刻都有一个空余空间存在。新生代存储区域很小,所以分出来空间更小,这种空间的浪费相对于时间的提升是微不足道的)
● 老生代区域垃圾回收不适合赋值算法(老生代存储区域空间大,如果一分为二对空间来说太奢侈,并且老生代区域存放数据较多,所以赋值过程中消耗时间比较多)

标记增量如何优化垃圾回收

4.5 垃圾回收工作时会堵塞js执行

标记增量是指将之前一整段的垃圾回收操作拆分为多个小部组合着去完成当前整个回收,从而替代之前一口气昨晚的垃圾回收操作,好处是让垃圾回收和程序去交替执行,避免之前垃圾回收时代码不能运行,或者代码运行时无法垃圾回收,这样带来的时间消耗更加合理一些。

标记增量是针对老生代,第一步遍历对象不一定标记所有,可能只标记第一层可达对象,后面再继续标记下一层可达对象。交替的去标记和执行。最后垃圾回收,代码暂停,垃圾回收完后,再执行代码

4.6 v8总结:

● V8是一款主流的JavaScript执行引擎
● V8内存设置上限
● V8采用基于分代回收思想实现垃圾回收
● V8内存分为新生代和老生代
● V8垃圾回收常见的GC算法

5. performance工具

● GC的目的是为了实现内存空间的良性循环
● 良性循环的基石是合理使用
● 时刻关注才能确定是否合理
● Performance 提供多种监控方式

5.1 通过performance时刻监控内存

用法:

● 打开浏览器输入目标网址
● 进入开发人员工具面板,选择性能
● 开启录制功能,访问具体界面
● 执行用户行为,一段时间后停止录制
● 分析界面中记录的内存信息
● 勾选Memory(内存)

5.2 内存问题体现

外在表现:网络正常下存在以下问题

● 页面出现延迟加载或经常性暂停(频繁出现了垃圾回收,程序代码中有一些代码使内存瞬间爆掉了)
● 页面持续性出现糟糕的性能(存在内存膨胀,申请的内存空间远超过设备所能提供的大小)
● 页面的性能随时间延长越来越差(内存泄漏)

界面内存问题的标准:

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

5.3 监控内存的几种方式:

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

5.3.1 任务管理器监控内存(无法具体定位问题)

打开任务管理器

image.png

image.png
如果没有JavaScript使用的内存,点击右键,选择即可。 第一个内存为dom节点所占用内存,如果一直在持续增大,说明页面中在不断创建新dom。 第二个内存为JavaScript堆,小括号中为界面中所有可达对象正在使用的大小,如果这个值一直在增大,就意味着当前页面中要么在创建新对象,要么就是当前现有对象在不断增长。

5.3.2 TimeLine记录内存

中文

英文

5.3.3 堆快照查找分离dom

堆快照留存js堆照片

什么是分离DOM

● 界面元素存活在DOM树上
● 垃圾对象时的DOM节点(节点从dom树上进行了脱离,而且在js代码中也没有再引用)
● 分离状态的DOM节点(节点从dom树上进行了脱离,但是在js代码中还有引用,这也是内存泄漏)

中文

英文

5.3.4 判断是否存在频繁GC

为什么确定频繁的垃圾回收

● GC工作时应用程序是停止的
● 频繁且过长的GC会导致应用假死
● 用户使用中感知应用卡顿

如何确定:

● Timeline中频繁的上升下降
● 任务管理器中数据频繁的增加减小

5.4 总结:

● Performance 使用流程
● 内存问题的相关分析
● Performance时序图监控内存变化
● 任务管理器监控内存变化
● 堆块照查找分离DOM

参考文章: 谈谈 JS 垃圾回收机制