一、学习目标
1、v8内存空间是如何划分的?
2、js为什么要进行垃圾回收?
3、垃圾回收和js执行代码是在同一个线程进行还是在各自线程上执行?
4、引用计数存在什么问题?
5、目前主流的垃圾回收算法是什么?
6、如何通过工具查看垃圾回收事件?
二、V8内存分配
2.1、栈
-
小而连续,数组结构是由系统自动分配且相对固定大小的内存空间并由系统自动释放。
-
遵循LIFO后进先出的规则,主要职责是javascript中存储局部变量及管理函数调用。
-
栈上分配资源和销毁资源的速度非常快,分配空间和销毁空间只需要移动下指针就可以了
基础数据类型的变量都是直接存储在栈中,复杂类型数据会将对象的引用(实际存储的指针地址)存储在栈中,数据本身存储在堆中
。
每个函数的调用时,解释器都会在栈中创建一个调用栈(call stack)来存储函数的调用流程顺序。然后把该函数添加进调用栈,解释器会为被添加进的函数再创建一个栈帧(Stack Frame)并立即执行。如果正在执行的函数还调用了其它函数,那么新函数也将会被添加进调用栈并执行。直到这个函数执行结束,对应的栈帧也会被立即销毁。栈帧中一般会存放信息包括:
-
函数的返回地址和参数
-
临时变量:函数局部变量+编译器自动生成的其他临时变量
-
函数调用的上下文
例如:
const a = () => {
console.log("a");
};
const b = () => {
a();
console.log("b");
};
const c = () => {
b();
console.log("c");
};
c();
图解如下:
2.2、堆
堆是一种特殊的数据结构,通常被视为一棵完全二叉树,在堆中,每个节点都满足特定的条件,即父节点的键值总是大于或等于(或小于或等于)任何一个子节点的键值,这被称为堆性质。堆通常用于实现优先队列等应用程序。栈空间是有上限的,一旦函数循环嵌套次数过多,或者分配的数据过大,就会造成栈溢出问题。
如下:
let num = 999999;
const foo = () => {
num--;
if (num > 0) {
foo();
} else {
console.log(num);
}
};
foo();
报错:
引用数据类型存储在堆内存中,因为引用数据类型占据空间大、大小不固定。如果存储在栈中,将会影响程序运行的性能;引用数据类型在栈中存储了指针,该指针指向堆中该实体的起始地址。当解释器寻找引用值时,会首先检索其在栈中的地址,取得地址后从堆中获得实体。
2.3、内存结构
a)、代码->栈->堆地址对应图
解释:var a 指向栈的地址为00x1,而栈中的00x1是00000001(即为:1,也就是代码中的1)这块内存的地址。即为:var a = 00x1;00x1 = 00000001;
b)、计算机内存结构
c)、变量生命周期:分配内存大小 > 使用内存(读 or 写)> 不需要时进行释放
d)、堆栈地址分配情况
1)、堆空间分配
2)、堆栈对应情况
解释说明:
i)、Heap (堆)内存
-
V8 引擎存储对象和动态数据的地方。
-
程序在内存区域中最大的一块地方。
-
新空间(New Space)和旧空间(Old Space)被垃圾回收管理。
-
发生**垃圾回收( GC )**的地方。
整个堆内存划分如下:
空间名称 | 解释说明 |
---|---|
新生代空间(New space) | (1). 新(小)对象存活的地方,对象的生命周期很短。 (2). 空间很小,其大小由--min_semi_space_size(Initial)和 -- max_semi_space_size(Max)两个标志来控制,64位和32位操作系统中最大值分别为:64MB和32MB。 (3). 由两个Semi-Space组成,一个是from space,一个是to space。 (4). 新生代空间主要是用于新对象的存储。 // 后面配合垃圾回收再深入讲下gc的过程。 |
老生代空间(Old space) | (1). 在新生代中经过两次minor GC周期后迁移到此的对象。 (2). 空间的大小由--initial_old_space_size(Initial)和--max_old_space_size(Max)两个标志来控制。 (3). 然而老生代(Old Space)被分为两部分: |
(i). 旧指针空间(Old pointer space):存放含有指针的对象。 (ii). 旧数据空间(Old data space):存放不含任何指针的对象,仅有数据的对象,例如:字符串等数据。 | |
大对象空间(Large object space) | (1). 存放大于其他空间(即新生代,老生代)限制的对象。 (2). 避免大对象的频繁拷贝导致性能变差。 (3). 每个对象都有自己的内存区域,这里的对象不会被垃圾回收器移动。 |
代码空间(Code Space) | (1). 存储即时编译器(JIT)已经编译的代码块。 (2). 唯一可执行代码的空间(尽管代码被分配到大对象空间也可执行)。 |
单元空间(Cell Space) 属性单元空间(Property Cell Space) 映射空间(Map Space) | (1). 分别存放 Cell,PropertyCell 和 Map。这些空间包含的对象大小相同,并且对对象类型有些限制,可以简化回收工作。 (2). Map空间存放的Map对象是一个隐藏类(Hiden Class) ,其最大限制为8MB。然而每一个Map对象大小固定,为了快速定位,因此将该空间单独出来。 |
每个空间(除了大对象空间)都由一组 Page 组成。一个 page 是由操作系统分配的一个连续内存块,大小为 1MB。
区域 | 64bit系统(最大值) | 32bit系统(最大值) |
---|---|---|
V8引擎内存大小 | 1.4GB | 0.7GB |
新生代区域 | 64MB | 32MB |
老生代区域 | 1400MB | 700MB |
node | 2GB(可修改大小) | 2GB(可修改大小) |
const a = [1,2,3,4,5]; // 放到 Old data space
const b = {
a: 1,
b: a
} // b对象引用了a数组对象,这类就是引用类型 放到 Old Pointer space
ii)、Stack(栈)
每个 V8 进程都有一个栈(Stack),这里保存静态数据的地方,比如:方法/函数框架,原型对象的值(Primitive value)和指针。栈(Stack)内存的大小由 V8 的标志位来设置:stack_size。
3)、V8 内存的使用(Stack VS Heap)
现在我们已经清楚了内存的组织情况,让我们来看看一个程序被执行时,是如何使用内存的。
如下代码且代码没有被优化,可以忽略不必要的情况,比如:中间变量等。而且可动态演示 Stack 和 Heap 内存的使用情况。.
class Employee {
constructor(name, salary, sales) {
this.name = name;
this.salary = salary;
this.sales = sales;
}
}
const BONUS_PERCENTAGE = 10;
function getBonusPercentage(salary) {
const percentage = (salary * BONUS_PERCENTAGE) / 100;
return percentage;
}
function findEmployeeBonus(salary, noOfSales) {
const bonusPercentage = getBonusPercentage(salary);
const bonus = bonusPercentage * noOfSales;
return bonus;
}
let john = new Employee("John", 5000, 5);
john.bonus = findEmployeeBonus(john.salary, john.sales);
console.log(john.bonus);
2.4、内存的可达性
V8采用可达性管理内存,从字面意思上看,可达的意思是可以到达,也就是指程序可以通过某种方式访问、使用的变量和对象,这些变量所占用的内存是不可以被释放的
例如:
const person = {
boys: {
boy1: { name: "小廖" },
boy2: { name: "小张" },
},
girls: {
girl1: { name: "小红" },
girl2: { name: "小花" },
},
}
i)、可达性案列
ii)、不可达性案列
三、垃圾回收机制
3.1、变量内存连转流程
3.2、什么是GC( Garbage Collection)
在内存空间进行垃圾回收的过程。
- GC 是一种机制,垃圾回收器完成垃圾回收的具体工作
- 工作的内容就是查找垃圾释放空间、回收空间
- 算法就是工作时查找和回收所遵循的规则
Javascript的标准ECMAScript并没有对GC做相关的要求,GC完全依赖底层引擎的能力。
3.3、哪些数据会被回收
堆内存中存储着动态数据,随着代码的运行,这些数据随时都可能会发生变化,而且这部分数据可能会相互引用,引擎需要不断地遍历找到这些数据相互之间的关系,从而发现哪些数据是非活动对象并对其进行gc操作,所以gc的算法及策略的好坏,直接影响着整个引擎执行代码的性能。
数据变化,导致引用及其之间的关系变化,V8采用某种算法或策略,找出非活动对象并对其gc操作。
3.4、如何判断非活跃对象
-
判断是否为活跃对象的方法:引用计数法和可访问性分析法。
-
引用计数法
- V8 中并没有使用这种方法,因为每当有引用对象的地方,就加 1,去掉引用的地方就减 1,这种方式无法解决 A 与 B 循环引用的情况,引用计数都无法为 0,导致无法完成 gc。
-
可访问性分析法
- V8 中采用了这种方法,将一个称为GC Roots的对象(在浏览器环境中,GC Roots 可以包括:全局的 window 对象、所有原生 dom 节点集合等等)作为所有初始存活的对象集合,从这个对象出发,进行遍历,遍历到的就认为是可访问的,为活跃对象,需要保留;如果没有遍历到的对象,就是不可访问的,这些就是非活跃对象,可能就会被垃圾回收。
GC过程
3.5、垃圾回收(GC)算法
V8中常用GC算法是:标记清除、标记整理、标记增量、分代回收、空间复制。
3.5.1 标记清除算法
标记清除算法实现原理
- 核心思想:分标记和清除两个阶段完成
- 遍历所有对象,找到、标记活动对象(可达对象)
- 遍历所有对象,清除没有标记的对象,同时将设置的标记抹掉,便于 GC 下次还能正常工作
- 回收相应的空间到空闲链表
标记清除算法优点
- 相对于引用计数算法,解决对象循环引用的回收操作
标记清除算法缺点
- 回收到空闲链表的地址不连续,浪费空间(空间碎片化)
- 不会立即回收垃圾对象(清除时会阻塞程序的执行)
图解如下:
解释:变量两侧虚线的内存被回收,一共回收了4个空间,但我们需要申请一个3块空间的连续内存时,却无法将这两块进行完全分配完,如果需要申请2.5个空间时,分配左侧分配完,分配右侧的内存会造成剩下1.5个空间。
3.5.2 标记整理算法
标记整理算法实现原理
- 标记整理可以看做是标记清除的增强操作
- 标记阶段的操作和标记清除一致
- 清除阶段会先执行整理,移动对象位置
标记整理算法优点
- 回收到的空间基本连续,减少碎片化空间
标记整理算法缺点
- 不会立即回收垃圾对象(清除时会阻塞程序的执行)
图解如下:
3.5.3 引用计数算法
引用计数算法实现原理
其思路是对每个值都记录它被引用的次数。声明变量并给它赋一个引用值时,这个值的引用数为 1。如果同一个值又被赋给另一个变量,那么引用数加 1。类似地,如果保存对该值引用的变量被其他值给覆盖了,那么引用数减 1。当一个值的引用数为 0 时,就说明没办法再访问到这个值了,因此可以安全地收回其内存了。垃圾回收程序下次运行的时候就会释放引用数为 0 的值的内存
- 核心思想:设置引用数,判断当前引用数是否为 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
}
fn()
解释:
- 从全局的角度考虑,Window 下面可以直接找到 user1、user2、user3 以及 nameList。
- 从变量的角度出发,在 fn() 函数里面定义的 num1 和 num2 没有设置关键字,它们同样被挂载在 Window 下。
- 此时对于上述变量而言,它们的引用计数都不是 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()
解释:
- 加了关键字的声明后就意味着 num1 和 num2 只能在作用域内起效果。
- 一旦 fn() 函数调用执行结束之后,从外部全局的角度出发就不能再找到 num1 和 num2,此时它们身上的引用计数就会回到 0。
- 只要是 0 的情况下,GC 就会立即开始工作,将它们当作垃圾进行对象回收。
- nameList 里面都指向 user1、user2、user3 三个对象空间,即使脚本执行完后,它们还被引用,其引用计数器就不为 0,此时就不会被当做垃圾回收。
引用计数算法优点
- 发现垃圾时立即回收(可以即时回收垃圾对象)
- 最大限度减少程序暂停(应用程序在执行过程中,必然会对内存进行一个消耗,而当前的执行平台内存是有上限的,所以内存肯定有占满的时候。而引用计数算法是时刻监控引用数值为 0 的对象,当它发现内存即将爆满时,引用计数就会立马去找到数值为 0 的对象空间,然后对其进行释放,保证内存不会有占满的时候)
引用计数算法缺点
- 无法回收循环引用的对象
- 时间开销大(资源消耗较大)
循环引用就是对象A有一个指针指向对B ,而对象B也引用了对象A,此时它们的计数都是2,它们的计数永远不会为0,会导致大量的内存一直不会被释放。
function fn() {
const obj1 = {} // 虽然在全局无法找到
const obj2 = {}
obj1.name = obj2 // 但是此作用域内还互相引用,引用计数不为 0
obj2.name = obj1
return 'lg is a coder'
}
fn()
3.5.4 新生代和老生代的算法
变量的存储路径:变量 --> 新生代 --> 老生代
新生代简单来说就是复制(copy),使用Scavenge算法(新生代互换)
老生代就是标记整理清除:早期用Mark-Sweep(标记清除),现在用Mark-Compact(标记整理)
i、新生代垃圾回收算法
- 回收过程采用复制算法 + 标记整理
- 新生代内存区分为两个等大小空间
- 使用空间 From,空闲空间 To
- 活动对象存储于 From 空间
- 标记整理后将活动对象拷贝至 To
- From 与 To 交换空间完成释放
图解如下:
ii、老生代垃圾回收算法
- 主要采用标记清除、标记整理、增量标记算法
- 首先使用标记清除完成垃圾空间的回收
- 采用标记整理进行空间优化(老生代空间不足以支持新生代晋升时会触发)
- 采用增量标记进行效率优化
图解如下:
iii、Mark-Sweep & Mark-Compact 算法
是老生代内存中的垃圾回收算法,标记-清除 & 标记-整理,老生代里面的对象一般占用空间大,而且存活时间长,如果也用Scavenger算法,复制会花费大量时间,而且还需要浪费一半的空间。
- 标记-清除过程:与之前讲过的可访问性分析一致,从GC Root开始遍历,标记完成后,就直接进行垃圾数据的清理工作。
-
标记-整理过程:清除算法后会产生大量不连续的内存碎片,碎片过多会导致后面大对象无法分配到足够的空间,所以需要进行整理,第一步的标记是一样的,但标记完成活跃对象后,并不是进行清理,而是将所有存活的对象向一端移动,然后清理掉这端之外的内存。
iv、优化策略
由于 JavaScript 是运行在主线程之上的,因此一旦执行垃圾回收算法,必须将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。这种行为叫做全停顿(Stop-The-World)。
-
STW 会造成系统周期性的卡顿,对实时性高的和与时间相关的任务执行成功率会有非常大的影响。例如:js 逻辑需要执行动画,刚好碰到 gc 的过程,会导致整个动画卡顿,用户体验极差。
-
为了降低这种 STW 导致的卡顿和性能不佳,V8 引擎中目前的垃圾回收器名为 Orinoco,经过多年的不断精细化打磨和优化,已经具备了多种优化手段,极大地提升了 GC 整个过程的性能及体验。
a、并行回收
简单来讲,就是主线程执行一次完整的垃圾回收时间比较长,开启多个辅助线程来并行处理,整体的耗时会变少,所有线程执行 gc 的时间点是一致的,js 代码也不会有影响,不同线程只需要一点同步的时间,在新生代里面执行的就是并行策略。
b、增量回收
并行策略说到底还是 STW 的机制,如果老生代里面存放一些大对象,处理这些依然很耗时,Orinoco 又增加了增量回收的策略。将标记工作分解成小块,插在主线程不同的任务之间执行,类似于 React fiber 的分片机制,等待空闲时间分配。这里需要满足两个实现条件:
- 随时可以暂停和启动,暂停要保存当前的结果,等下一次空闲时机来才能启动。
- 暂停时间内,如果已经标记好的数据被 js 代码修改了,回收器要能正确地处理。
c、并发回收
虽说三色标记法和写屏障保证了增量回收的机制可以实现,但依然改变不了需要占用主线程的情况,一旦主线程繁忙,垃圾回收依然会影响性能。所以增加了并发回收的机制。V8里面的并发机制相对复杂,简化来看,当主线程运行代码时,辅助线程并发进行标记,当标记完成后,主线程执行清理的过程时,辅助线程也并行执行。
d、三色标记法
三色标记法来解决随时启动或者暂停且不丢之前标记结果的问题
,规则如下:
- 最开始所有对象都是白色状态。
- 从GC Root遍历所有可到达的对象,标记为灰色,放入待处理队列。
- 从待处理队列中取出灰色对象,将其引用的对象标记为灰色放入待处理队列,自身标记为黑色。
- 重复3中动作,直到灰色对象队列为空,此时白色对象就是垃圾,进行回收。
垃圾回收器可以依据当前内存中有没有灰色节点,来判断整个标记是否完成,如果没有灰色节点了,就可以进行清理工作了。如果还有灰色标记,当下次恢复垃圾回收器时,便从灰色的节点开始继续执行。
e、写屏障
解决由于js代码导致对象引用发生变化的情况。
1)、一旦对象发生变化时,如何精确地更新标记的结果,我们可以分析下一般js执行过程中带来对象的变化有哪些,其实主要有2种:
- 标记过的黑色或者灰色的对象不再被其他对象所引用
- 引入新的对象,新的对象可能是白色的,面临随时被清除的危险,导致代码异常
第一种问题不大,在下次执行gc的过程中会被再次标记为白色,最后会被清空掉。
第二种就使用到了写屏障策略,一旦有黑色对象引用到了白色对象,系统会强制将白色对象标记成为灰色对象,从而保证了下次gc执行时状态的正确,这种模式也称为强三色原则。
四、如何通过工具查看垃圾回收事件
4.1 Performance 工具介绍
通过 Performance 时刻监控程序运行过程中内存的变化。在内存出现问题时,可以帮助我们定位到出现问题的地方
- GC 的目的是为了实现内存空间的良性循环
- 良性循环的基石是合理使用
- 而 ECMAScript 没有提供操作内存空间的 API
- 时刻关注才能确定是否合理
- Performance 提供多种监控方式
4.2 Performance 使用步骤
- 打开浏览器输入目标网址
- 进入开发人员工具面板,选择性能(Performance)
- 开启录制功能,访问具体界面
- 执行用户行为,一段时间后停止录制
- 分析界面中记录的内存信息
4.3 内存问题的体现(外在表现)
网络正常的前提下:
- 页面出现延迟加载或经常性暂停
- 页面持续性出现糟糕的性能
- 页面的性能随时间延长越来越差
4.4 监控内存的几种方式
4.4.1 界定内存问题的标准
- 内存泄露:内存使用持续升高
- 内存膨胀:在多数设备上都存在性能优化
- 当前应用本身为了达到最优的效果需要很大的内存空间
- 有可能是由于当前设备本身硬件不支持
- 频繁垃圾回收:通过内存变化图进行分析
4.4.2 监控内存的几种方式
- 浏览器任务管理器
- Timeline 时序图记录
- 堆快照查找分离 DOM
- 判断是否存在频繁的垃圾回收(获取内存走势图进行分析)
4.4.3 任务管理器监控内存
- 通过快捷键 Shift + Esc 调出当前浏览器自带的任务管理器(Mac OS 环境需要在浏览器工具选项中打开)
- 定位到当前正在执行的脚本
- 可以对其右击打开 JavaScript 内存选项(默认关闭,Mac OS 环境需要在最顶部的列标题栏右击打开)
- 选项内存表示原生内存(DOM 节点所占据的)
- 选项 JavaScript 内存表示 JS 堆,实时内存表示界面所有可达对象正在使用的内存大小
- 如果 JS 实时内存一直增大就意味着内存是有问题的(只能发现问题,无法定位问题)
4.4.4 Timeline 时序图记录内存
Timeline 是 Google的 chrome 浏览器中的一个开发者工具,它有助于前端开发者来分析页面的解析、脚本运行以及渲染、布局的情况,从而帮助开发者去优化页面的性能
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>时间线记录内存变化</title>
</head>
<body>
<button id="btn">Add</button>
<script>
const arrList = [];
function test() {
for (let i = 0; i < 100000; i++) {
document.body.appendChild(document.createElement("p"));
}
arrList.push(new Array(100000).join("x"));
}
document.getElementById("btn").addEventListener("click", test);
</script>
</body>
</html>
4.4.5 堆快照查找分离 DOM
什么是分离DOM
- 界面元素存活在 DOM 树上
- 垃圾对象时的 DOM 节点
- 如果一个节点从当前 DOM 树脱离,而且 JS 代码中没有引用此节点,就称其为垃圾 DOM
- 分离状态的 DOM 节点
- 如果一个节点从当前 DOM 树脱离,但是 JS 代码还有引用此节点,就称其为分离 DOM
- 通过分析用户行为执行前后,对比所拍摄快照中是否存在 detached 来确定脚本中是否存在分离 DOM
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>时间线记录内存变化</title>
</head>
<body>
<button id="btn">Add</button>
<script>
const arrList = [];
function test() {
for (let i = 0; i < 100000; i++) {
document.body.appendChild(document.createElement("p"));
}
arrList.push(new Array(100000).join("x"));
}
document.getElementById("btn").addEventListener("click", test);
</script>
</body>
</html>