啥是浏览器垃圾回收?

1,157 阅读11分钟

1. 内存管理

JS环境中分配的内存一般有如下生命周期:

  1. 内存分配:当我们申明变量、函数、对象的时候,系统会自动为他 们分配内存
  2. 内存使用:即读写内存,也就是使用变量、函数等
  3. 内存回收:使用完毕,由垃圾回收机制自动回收不再使用的内存 为了便于理解,我们使用一个简单的例子来解释这个周期。
var a = 20; // 在内存中给数值变量分配空间
alert(a + 100); // 使用内存
var a = null; // 使用完毕之后,释放内存空间

2. 垃圾回收

JavaScript有自动垃圾收集机制,那么这个自动垃圾收集机制的原理是什么呢?其实很简单,就是找出那些不再继续使用的值,然后释放其占用的内存。垃圾收集器会每隔固定的时间段就执行一次释放操作。 在JavaScript中,最常用的是通过标记清除的算法来找到哪些对象是不再继续使用的,因此 a = null 其实仅仅只是做了一个释放引用的操作,让 a 原本对应的值失去引用,脱离执行环境,这个值会在下一次垃圾收集器执行操作时被找到并释放。而在适当的时候解除引用,是为页面获得更好性能的一个重要方式。

2.1 JavaScript中的垃圾

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

2.2 JavaScript中的可达对象

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

2.3 js中的引用与可达

  • 引用
let obj = {name: 'lg'};
let a=obj;
obj=null
  • 可达
function group(obj1,obj2){
	obj1.next=obj2;
    obj2.prev=obj1;
    
    return {
    	o1: obj1,
        o2: obj2,
    }
}
let obj=group({name: 'obj1'},{name: 'obj2'})
console.log(obj);

请各位老铁see一下以下的代码,来分析一下垃圾回收。

function fun1() {
    var obj = {name: 'csa', age: 24};
}
function fun2() {
    var obj = {name: 'coder', age: 2}
    return obj;
}
var f1 = fun1();
var f2 = fun2();

在上述代码中,当执行var f1 = fun1();的时候,执行环境会创建一个{name:'csa', age:24}这个对象,当执行var f2 = fun2();的时候,执行环境会创建一个{name:'coder', age=2}这个对象,然后在下一次垃圾回收来临的时候,会释放{name:'csa', age:24}这个对象的内存,但并不会释放{name:'coder', age:2}这个对象的内存。这就是因为在fun2()函数中将{name:'coder, age:2'}这个对象返回,并且将其引用赋值给了f2变量,又由于f2这个对象属于全局变量,所以在页面没有卸载的情况下,f2所指向的对象{name:'coder', age:2}是不会被回收的。 由于JavaScript语言的特殊性(闭包...),导致如何判断一个对象是否会被回收的问题上变的异常艰难,各位老铁看看就行。

3.GC算法

3.1 GC定义与作用

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

3.2 GC里的垃圾是什么

  • 程序中不再需要使用的对象
function fn(){
	name = 'lg'
	return `${name} is a laji`
}
fn()

如上,在函数调用完成之后,可以看出是不再需要使用name,因此从程序需求角度看,此时它会当作垃圾。

  • 程序中不能再访问到的对象
function fn(){
	const name='lg'
	return `${name} is laji`
}
fn()

如上,在程序运行过程中,当函数调用完成之后,name在函数外部不能被访问到,此时name也会被当作垃圾。

3.3 GC算法

  • GC是一种机制,垃圾回收器完成具体的工作
  • 工作的内容就是查找垃圾、释放空间、回收空间
  • 算法就是垃圾回收器在工作时查找和回收所遵循的规则 常见的GC算法:
  • 引用计数
  • 标记清除

4. 引用计数算法

现代的浏览器已经不再使用引用计数算法了。

核心思想:通过引用计数器,来维护一个引用数,判断一个对象的引用数是否为0,来决定它是不是一个垃圾对象。当引用数为0时,GC就开始工作,把这个对象空间回收再使用。

// 创建一个对象person,他有两个指向属性age和name的引用
var person = {
    age: 12,
    name: 'aaaa'
};
person.name = null; // 虽然设置为null,但因为person对象还有指向name的引用,因此name不会回收
var p = person;
person = 1; //原来的person对象被赋值为1,但因为有新引用p指向原person对象,因此它不会被回收
p = null; //原person对象已经没有引用,很快会被回收

由上面可以看出,引用计数算法是个简单有效的算法。但它却存在一个致命的问题:循环引用。如果两个对象相互引用,尽管他们已不再使用,垃圾回收器不会进行回收,导致内存泄露。

function cycle() {
    var o1 = {};
    var o2 = {};
    o1.a = o2;
    o2.a = o1;
    return "Cycle reference!"
}
cycle();

上面我们申明了一个cycle方程,其中包含两个相互引用的对象。在调用函数结束后,对象o1和o2实际上已离开函数范围,因此不再需要了。但根据引用计数的原则,他们之间的相互引用依然存在,因此这部分内存不会被回收,内存泄露不可避免了。 正是因为有这个严重的缺点,这个算法在现代浏览器中已经被下面要介绍的标记清除算法所取代了。但绝不可认为该问题已经不再存在了,因为还占有大量市场的IE老祖宗们使用的正是这一算法。在需要照顾兼容性的时候,某些看起来非常普通的写法也可能造成意想不到的问题:

var div = document.createElement("div");
div.onclick = function() {
    console.log("click");
};

上面这种JS写法再普通不过了,创建一个DOM元素并绑定一个点击事件。那么这里有什么问题呢?请注意,变量div有事件处理函数的引用,同时事件处理函数也有div的引用!(div变量可在函数内被访问)。一个循序引用出现了,按上面所讲的算法,该部分内存无可避免地泄露哦了。 现在你明白为啥前端程序员都讨厌IE了吧?拥有超多BUG并依然占有大量市场的IE是前端开发一生之敌!亲,没有买卖就没有杀害。

4.2 优缺点

优点

  • 发现垃圾时立即回收
  • 减少程序卡顿时间 缺点
  • 无法回收循环引用的对象
  • 资源消耗较大(引用计数器)
function fn(){
  const obj1 = {}
  const obj2 = {}
  obj1.name=obj2;
  obj2.name=obj1;
  return 'hah'
}
fn()

在函数调用完成后,应该对局部作用域内的对象进行回收的,但是按照引用计数法,obj1和obj2还被相互引用着,那引用数不为0,则不会被GC回收。

  • 时间开销大:维护一个引用数,监控对象的引用数值是否需要修改。对象本身的数值的修改需要时间,如果需要修改的对象很多,那是需要的时间更多。

5. 标记清除算法

核心思想:分标记和清除2个阶段完成。

  1. 遍历所有对象找到活动对象,并标记。活动对象也是之前的可达对象。如果遇到了层次关系,则递归查找到所有的可达对象。
  2. 遍历所有对象清除没有标记的对象,并把第一个阶段的标记清除掉,下次正常工作。
  3. 回收相应空间,放在一个空闲链表中,下次优先分配。 优点:相对引用计数法,可以对象循环引用无法回收的情况。

缺点

  • 空间的碎片化。清除阶段回收非活动对象的空间,由于非活动对象地址不一定连续,会导致可使用的空间碎片化,浪费了空间。
  • 不会立即回收垃圾对象。

6. 标记整理算法

是标记清除的增强。

  1. 标记阶段的操作和标记清除一致。
  2. 清除阶段会先执行整理,移动对象位置。将非活动对象清除后,回收空间,移动活动对象的位置,整理空间,使回收的空间连续化,解决了标记清除后空间碎片化的问题。 优点: 减少碎片化空间

缺点: 不会立即回收垃圾对象

7. V8引擎垃圾回收

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

7.1 V8中的内存分配

在讲内存分配之前,先了解一下弱分代假说,V8 的垃圾回收主要建立在这个假说之上。

概念:

  • 绝大部分的对象生命周期都很短,即存活时间很短
  • 生命周期很长的对象,基本都是常驻对象 基于以上两个概念,将内存分为新生代 (new space)与老生代 (old space)两个区域。

7.2 新生代

32 位系统分配 16M 的内存空间,64 位系统翻倍 32M。

新生代对应存活时间很短的假说概念,这个空间的操作,非常频繁,绝大多数对象在这里经历一次生死轮回,基本消亡,没消亡的会晋升至老生代内。

新生代算法为 Scavenge 算法,典型牺牲空间换时间,怎么说呢?首先他将新生代分为两个相等的半空间( semispace ) from space 与 to space,他使用宽度优先算法,是宽度优先,记住了不。两个空间,同一时间内,只会有一个空间在工作( from space ),另一个在休息( to space )。

  1. 首先,V8 引擎中的垃圾回收器检测到 from space 空间快达到上限了,此时要进行一次垃圾回收了。
  2. 然后,从根部开始遍历,不可达对象(即无法遍历到的对象)将会被标记,并且复制未被标记的对象,放到 to space 中。
  3. 最后,清除 from space 中的数据,同时将 from space 置为空闲状态,即变成 to space,相应的 to space 变成 from space,俗称翻转。 晋升
  • 当经历一次 form => to 翻转之后,发现某些未被标记的对象居然还在,会将新生代对象晋升到老生代。
  • 在复制的时候,to space 空间使用率超过25%,本轮活动对象也会晋升。

7.3 老生代

老生代对象存放在右侧老生代区域,指代存活时间较长代对象(全局、闭包)。内存大小:32 位操作系统分配大约 700M 内存空间,64 位翻倍 1.4G。

老生代回收算法采用:标记清除、标记整理和增量标记。

在标记的过程中,引入了概念:三色标记法,三色为:

  • 白:未被标记的对象,即不可达对象(没有扫描到的对象),可回收
  • 灰:已被标记的对象(可达对象),但是对象还没有被扫描完,不可回收
  • 黑:已被扫描完(可达对象),不可回收 当然,既然要标记,就需要提供记录的坑位,在 V8 中分配的每一个内存页中创建了一个 marking bitmap 坑位。

大致的流程为:

  1. 首先将所有的非根部对象全部标记为白色,然后使用深度优先遍历,是深度优先哈,和新生代不一样哈,按深度优先搜索沿途遍历,将访问到的对象,直接压入栈中,同时将标记结果放在 marking bitmap (灰色) 中,一个对象遍历完成,直接出栈,同时在 marking bitmap 中记录为黑色,直到栈空为止。

  2. 标记完成后,接下来就是等待垃圾回收器来清除了,清除完了之后,会在原来的内存区域留下一大堆不连续的空间。

  3. 所以在清除完之后,新生代中对象,再一次分配到老生代并且内存不足的时候,会优先触发标记整理(mark-compact), 在标记结束后,他会将可达对象(黑色),移到内存的另一端,其他的内存空间就不会被占用,直接释放,等下次再有对象晋升的时候,轻松放下。

标记增量对垃圾回收对优化:只针对老生代,因为新生代本身内存不大,在垃圾回收时,时间消耗不大。

当垃圾回收工作的时候会阻塞程序是的执行,此时程序是停止的。标记增量就是将原来一口气去标记的事情,做成分步去做,每次内存占用达到一定的量时候,就暂时停止 JS 程序,做一次最多几十毫秒的标记 marking,当下次 GC 的时候,反正前面都标记好了,开始清除就行了。