0%

深入理解垃圾回收原理


前一阵子由于网站拿去ICP的备案,所以停了一段时间,现在备案下来了,我们来学习一下垃圾回收

什么是垃圾回收

垃圾回收是一种自动的内存管理机制。当计算机上的动态内存不再需要时,就应该予以释放,以让出内存。

直白点讲,就是程序是运行在内存里的,当声明一个变量、定义一个函数时都会占用内存。内存的容量是有限的,如果变量、函数等只有产生没有消亡的过程,那迟早内存有被完全占用的时候。

这个时候,不仅自己的程序无法正常运行,连其他程序也会受到影响。

所以,在计算机中,我们需要垃圾回收。需要注意的是,定义中的“自动”的意思是语言可以帮助我们回收内存垃圾,但并不代表我们不用关心内存管理,如果操作失当,JavaScript 中依旧会出现内存溢出的情况。

垃圾回收原理

垃圾回收基于两个原理:

  • 考虑某个变量或对象在未来的程序运行中将不会被访问
  • 向这些对象要求归还内存

而这两个原理中,最主要的也是最艰难的部分就是找到“所分配的内存确实已经不再需要了”。

在解释这些之前,我们要先了解一下名词-GC

什么是GC

GC可以理解为在追踪仍然使用的所有对象,并将其余对象标记为垃圾然后进行回收,这样的一个过程称之为GC,所有的GC系统可以从如下几个方面进行实现

  • GC判断策略(例如引用计数,对象可达)
  • GC收集算法(标记清除法,标记清除整理法,标记复制清除法,分带法)
  • GC收集器(例如Serial,Parallel,CMS,G1)

V8垃圾回收策略

V8的垃圾回收策略基于分代回收机制,该机制又基于世代假说。该假说有两个特点:

  • 大部分新生对象倾向于早死;
  • 不死的对象,会活得更久。

基于这个理论,现代垃圾回收算法根据对象的存活时间将内存进行了分代,并对不同分代的内存采用不同的高效算法进行垃圾回收。

V8的内存分代

在V8中,将内存分为了新生代(new space)和老生代(old space)。它们特点如下:

  • 新生代:对象的存活时间较短。新生对象或只经过一次垃圾回收的对象。
  • 老生代:对象存活时间较长。经历过一次或多次垃圾回收的对象。

V8堆的空间等于新生代空间加上老生代空间,默认设置下,64位系统的老生代大小为1400M,32位系统为700M。

垃圾回收算法

Stop The World (全停顿)

在介绍垃圾回收算法之前,我们先了解一下「全停顿」。垃圾回收算法在执行前,需要将应用逻辑暂停,执行完垃圾回收后再执行应用逻辑,这种行为称为 「全停顿」(Stop The World)

全停顿的目的,是为了解决应用逻辑与垃圾回收器看到的情况不一致的问题。举个例子,在自助餐厅吃饭,高高兴兴地取完食物回来时,结果发现自己餐具被服务员收走了。这里,服务员好比垃圾回收器,餐具就像是分配的对象,我们就是应用逻辑。在我们看来,只是将餐具临时放在桌上,但是服务员看来觉得你已经不需要使用了,因此就收走了。你与服务员对于同一个事物看到的情况是不一致,导致服务员做了与我们不期望的事情。因此,为避免应用逻辑与垃圾回收器看到的情况不一致,垃圾回收算法在执行时,需要停止应用逻辑。

引用计数(reference counting)

在V8回收之前,低版本 IE大多数用的是这种的方法

在内存管理环境中,对象 A 如果有访问对象 B 的权限,叫做对象 A 引用对象 B。引用计数的策略是将“对象是否不再需要”简化成“对象有没有其他对象引用到它”,如果没有对象引用这个对象,那么这个对象将会被回收。上例子:

1
2
3
4
5
let obj1 = { a: 1 }; // 一个对象(称之为 A)被创建,赋值给 obj1,A 的引用个数为 1 
let obj2 = obj1; // A 的引用个数变为 2

obj1 = 0; // A 的引用个数变为 1
obj2 = 0; // A 的引用个数变为 0,此时对象 A 就可以被垃圾回收了

但是引用计数有个最大的问题: 循环引用。

1
2
3
4
5
6
7
function func() {
let obj1 = {};
let obj2 = {};

obj1.a = obj2; // obj1 引用 obj2
obj2.a = obj1; // obj2 引用 obj1
}

当函数 func 执行结束后,返回值为 undefined,所以整个函数以及内部的变量都应该被回收,但根据引用计数方法,obj1 和 obj2 的引用次数都不为 0,所以他们不会被回收。

要解决循环引用的问题,最好是在不使用它们的时候手工将它们设为空。上面的例子可以这么做:

1
2
obj1 = null;
obj2 = null;

这个方法被称为“解除引用”。

Scavenge算法

在分代的基础上,新生代中的对象主要通过Scavenge算法进行垃圾回收,在Scavenge的具体 实现中,主要采用了Cheney算法

Cheney算法采用复制的方式进行垃圾回收。它将堆内存一分为二,每一部分空间称为 semispace。这两个空间,只有一个空间处于使用中,另一个则处于闲置。使用中的 semispace 称为 「From 空间」,闲置的 semispace 称为 「To 空间」。

过程如下:

  1. 从 From 空间分配对象,若 semispace 被分配满,则执行 Scavenge 算法进行垃圾回收。
  2. 检查 From 空间的存活对象,若对象存活,则检查对象是否符合晋升条件,若符合条件则晋升到老生代,否则将对象从 From 空间复制到 To 空间。
  3. 若对象不存活,则释放不存活对象的空间。
  4. 完成复制后,将 From 空间与 To 空间进行角色翻转(flip)。

算法示意图

对象晋升

第二点提到的对象晋升,条件有两个:

  • 对象是否经历过Scavenge回收。
  • To 空间的内存使用占比是否超过限制(25%)。

Scavenge 算法的缺点是,它的算法机制决定了只能利用一半的内存空间。但是新生代中的对象生存周期短、存活对象少,进行对象复制的成本不是很高,因而非常适合这种场景。

标记-清除(mark and sweep)

这是 JavaScript 中最常见的垃圾回收方式

从 2012 年起,所有现代浏览器都使用了标记-清除的垃圾回收方法,除了低版本 IE…它们采用的是引用计数方法。

那什么叫标记清除呢?JavaScript 中有个全局对象,浏览器中是 window。定期的,垃圾回收期将从这个全局对象开始,找所有从这个全局对象开始引用的对象,再找这些对象引用的对象…对这些活着的对象进行标记,这是标记阶段。清除阶段会对标记的对象会与内存中的对象进行比较,然后清除内存中那些没有标记的对象。

标记-清除法的一个问题就是不那么有效率,因为在标记-清除阶段,整个程序将会等待,所以如果程序出现卡顿的情况,那有可能是收集垃圾的过程。

算法示意图

增量标记

在新生代中,由于存活对象少,垃圾回收效率高,全停顿时间短,造成的影响小。但是老生代中,存活对象多,垃圾回收时间长,全停顿造成的影响大。为了减少全停顿的时间,V8对标记进行了优化,将一次停顿进行的标记过程,分成了很多小步。每执行完一小步就让应用逻辑执行一会儿,这样交替多次后完成标记。

长时间的GC,会导致应用暂停和无响应,将会导致糟糕的用户体验。从2011年起,v8就将「全暂停」标记换成了增量标记。改进后的标记方式,最大停顿时间减少到原来的1/6。

以上就是我对垃圾回收的一些理解,如果文章由于我学识浅薄,导致您发现有严重谬误的地方,请一定在评论中指出,我会在第一时间修正我的博文,以避免误人子弟。

-------------本文结束感谢您的阅读-------------
没办法,总要恰饭的嘛~~