前言
-
JavaScript是一门自动垃圾回收的语言,”我们”是无法主动控制V8的垃圾回收器进行相应的垃圾回收工作
-
本文将从分为以下几部分来介绍V8的垃圾回收机制
- 介绍代际假说以及不同的垃圾回收算法
- 栈和堆的结构
再推荐几篇文章:
V8引擎详解(六)—— 内存结构
V8引擎的内存管理
v8.dev/blog/orinoc…- V8在堆中的不同区域分别采用哪些垃圾回收算法
- 如何理解程序 、进程 、线程 、并发 、并行 、高并发 ?
这个推荐一篇文章,就不详细介绍了:www.zhihu.com/question/30…
- 垃圾回收的优化策略有哪些? 而V8它又采取了哪些优化策略
代际假说(The Generational Hypothesis)
- 大部分对象都是”朝生夕死”的
- 不死的对象,会活的更久
垃圾回收算法
- 垃圾回收的流程
1. 以某种方式区分存活对象和垃圾数据
2. 回收垃圾数据
3. 内存碎片整理(可选)
复制代码
引用计数法
- 回收没有被引用的对象
遇到循环引用时会导致内存泄漏
function example () {
var obj1 = {
property1: {
subproperty: 20
}
};
var obj2 = obj1.property1;
obj2.property1 = obj1;
return 'some random text';
}
example();
复制代码
- example函数调用前
- example函数调用后,由于两个对象相互引用,无法将其作为垃圾数据回收掉,但实际上我们已经调用完函数,而此时就会造成内存泄漏
标记-清除(Mark-Sweep)
- 由于引用计数法可能会出现上述描述的问题,因此延申出了Mark-Sweep算法,该算法主要是一个【可达性】
即沿着GC Root(全局对象…)BFS遍历扫描可以访问到数据为存活对象,反之无法从GC root查询到的对象都将被清除掉
function marry (man,woman) {
woman.husband = man;
man.wife = woman;
return {
father: man;
mother: woman;
};
}
let family = marry({
name: "John"
},{
name: "Ann"
})
复制代码
delete family.father
delete family.mother.husband
复制代码
标记-整理(Mark-Compact)
标记部分操作与Mark-Sweep相同,而之后是将所有可达的对象调整到一端,然后清除这一端之外的内存
(保留调整后的一端,这里面都是可达的对象,而且现在内存变得连续了)
半空间收集器的变体Scavenge算法
半空间收集器:将区域划分为两个相等的区域 from space和to space,等from space区域满时将存活对象有序移动(Evacuation)到to space中(防止内存碎片的产生),角色交换,重复过程
Scavenge算法区分了young generation(nursery和intermediate) 、old generation,如下图,第二次GC还存活的对象【晋升】到老年代
第一次GC
第二次GC
栈和堆的数据结构
- 函数执行栈
function main() {
func1();
}
function func1() {
func2();
func3();
}
function func2() {};
function func3() {};
main();
复制代码
- 代码执行过程中栈内存和堆内存的使用情况如下
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);
复制代码
V8垃圾回收机制
阐述V8两个不同的垃圾回收器
- 垃圾数据是如何产生的 ?
window.text = new Object();
window.text.a = new Array(100);
window.text.a = new Object();
复制代码
下图中的Array就是产生的垃圾数据
垃圾数据是指堆中的数据,栈通过偏移指针就可以覆盖了,不需要专门的垃圾回收器来处理
- V8的垃圾回收分为了副垃圾回收器和主垃圾回收器
- 副垃圾回收器处理新生代,采用Scavenge垃圾回收算法
- 主垃圾回收器处理老年代,通过Mark-Sweep和Mark-Compact两种垃圾回收策略配合使用
内存碎片过多时需要通过Mark-Compact来消除内存碎片
案例
- 分析如下代码产生了哪些垃圾数据
function strToArray (str) {
const len = str.length; // 10
let arr = new Array(str.length);
for(let i = 0; i < len; ++i) {
arr[i] = str.charCodeAt(i); // 转为ASCII来存放
}
return arr; // 这个返回可有可无...
}
function foo () {
let i = 0;
let str = "test V8 GC";
while (i ++ < 1e5) {
strToArray(str);
}
}
foo();
复制代码
- 产生了哪些垃圾数据
执行foo函数后,每一次while循环都会产生一个垃圾数据
strToArray调用后根据str参数生成的数组arr
- V8的垃圾回收器是如何回收这些垃圾数据
1. 首先进入新生区的work
2. work快满时通过Scavenge算法将存活对象转入到idle
3. 然后work区域清除
注释:因为从GC Root触发无法达到上述产生的每一个垃圾数据,所以经过第一次Scavenge算法后久可以清除掉它们
复制代码
- 站在内存空间和主线程的角度来优化这段代码
1. 因为每次循环调用strToArray都会申请一个新的数组,因此我们可以在foo中先新建一个数组,然后调用strToArray将该数组传递进去
(js传递是传递值的),这样最终foo调用完后,仅会产生1个垃圾数据
2. 从优化执行栈的角度考虑,调用foo函数进入循环,每次调用strToArr函数,那么此时执行栈就会存在两个函数的上下文,而如果将strToArray函数的逻辑写入到foo函数的循环中,那么执行栈就可以减少一个函数上下文(虽然减少一个感觉没什么用,哈哈哈,但还是分析一下)
* 在递归函数的时候,采用尾调用优化执行栈可以有效防止`爆栈`,但现在浏览器好像不支持
复制代码
- 优化执行栈和堆后的代码
function foo () {
let i = 0;
let str = "test V8 GC";
const len = str.length; // 10
let arr = new Array(str.length);
while (i ++ < 1e5) {
for(let i = 0; i < len; ++i) {
arr[i] = str.charCodeAt(i); // 转为ASCII来存放
}
}
}
foo();
复制代码
垃圾回收的优化策略
全停顿(Stop The World)
JavaScript是单线程的,在原来执行JS基础上穿插垃圾回收的操作,如果垃圾回收的操作需要的时间比较久,那么用户就会很明显的感觉到页面在卡顿
如:页面正在执行某个JavaScript动画,而因为此时垃圾回收器正在工作,就会导致这个动画在200毫秒内无法执行
复制代码
并行回收(Parallel)
GC的时候【主线程和辅助线程同时】开始进行垃圾回收
增量回收(Incremental)
- 如果面对大对象(window 、DOM等),通过并行回收策略来执行老生代的垃圾回收,时间依然会很久,因此V8引入了增量回收策略。【每次执行一个完整的垃圾回收过程的一小部分工作】
实现难点
一 . 垃圾回收暂停时需要记录它执行到哪里,重启时才能继续从记录点开始执行
二 . 垃圾回收暂停时,执行的JavaScript代码改变了被标记好的垃圾数据,那么垃圾回收器重启时需要能正确处理它
-
在没有引入增量算法之前,V8在Mark-Sweep的Mark中使用黑色和白色来区分数据,而这样会面临一个问题:暂停后执行JavaScript若改变了已经被标记好的垃圾数据,垃圾回收器重启时无法正确处理它
- 初始所有的数据均为白色
- Mark阶段:从GC root出发,将所有能访问到的数据标记为黑色,黑色为存活数据
- Sweep阶段:垃圾回收器将回收白色的垃圾数据
-
因此实现 三色标记法 的变体来解决了这个问题
* 最初:黑色集为空,灰色集是直接从根引用的对象集,白色集包括其它对象
三色标记法算法流程:
1. 从灰色集中选择一个对象,然后将其移入黑色集
2. 将其引用的每个白色对象移至灰色组
3. 重复步骤1和2,直至灰色集为空
* 所有无法从根到达的对象都将被添加到白色集合中,并且对象只能从白色移动到灰色,从灰色移动到黑色,该算法可能会出现黑色对象引用灰色对象,灰色对象引用白色对象,而不可能会出现黑色对象引用白色对象(三色不变式)
复制代码
- V8采用强三色不变性:当使黑色节点指向白色节点时,就会通过
写屏障机制
来使得该白色节点变为灰色
- 灰色集为空 -> 主线程将完成垃圾回收 -> 主线程重新扫描GC root,尽可能发现更多的白色节点,而白色节点的标记工作由辅助线程并行完成
1. 如果断开F,且此时标记列表为空
2. 主线程将完成垃圾回收
3. 主线程重新扫描GC root,如果此时Black集中存在没有扫描到的节点,
那么就将其从Black集转入White集中(用同样的BFS来对比即可)
复制代码
并发回收(Concurrent)
该种方式可以使得主线程完全执行JavaScript代码,从而避免了全等待,导致用户体验不佳,但实现起来比较困难
- 垃圾回收的辅助线程和JS主线程同时修改一个对象,这时候就需要加入读/写的锁机制了
- 堆的内容随时可能会被改变,从而使得我们之前所做的工作无效
Idle-time GC
提供给开发者的API:requestIdleCallback
浏览器每16ms渲染一帧
副垃圾回收器采用的垃圾回收优化策略
- 并行回收
主垃圾回收器采用的垃圾回收优化策略
- 并行回收 、并发回收 、增量回收
1. 并发回收策略进行Mark(标记工作都是在辅助线程中完成的)
2. 完成标记后,再采用并行回收策略,主线程和辅助线程同时进行清理工作,而且该过程会配合增量回收策略,使得清理任务穿插各中JavaScript任务之间执行
复制代码
思考
- 在使用 JavaScript 时,如何避免内存泄漏?
- 挂载到全局对象上的需要避免循环引用
window.b = new Object();
window.a = new Object();
window.b.name = a;
window.a.name = b;
/*
b = null;
window.a.name = null;
此时垃圾回收器会将b对象标记为垃圾数据
a = null;
window.b.name = null;
此时垃圾回收器会将a对象标记为垃圾数据
*/
复制代码
- 静态作用域链保存的闭包所指向的对象比较大或多时,
而面对eval这些动态作用域链所形成的闭包比较大时,
也需要及时引用函数的变量及时设置为null,然垃圾回收器将其回收
参考文章
- 极客时间:图解GoogleV8
- MDN-内存管理
- 通过垃圾回收机制理解 JavaScript 内存管理
- …
- 有部分文章在上文中已提到就不再赘述了