GC

GC的基本概念

GC会发生在堆和元空间当中,常见在堆中,主要是为了清除没有引用(使用)的对象以腾出内存,甚至包括堆内存中的碎片整理。

STW(stop the world)是指在GC时要停下所有进程和线程的工作以保证对象的内容和引用状态不会被修改。STW是在GC中必然会发生的情况,不同GC算法都要旨在减少STW的时间以免影响系统工作和用户体验。减少STW有更新算法、减少拷贝、并行化等思路。

对象存活判断

在垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有对死亡对象的标记才会被GC释放空间,因此这个过程可以称为垃圾标记阶段。

简单来说,当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。

判断对象的存活一般有两种方式:引用计数算法(python等) 和 可达性分析算法(java golang等)。

1.1 引用计数

对每个对象维护一个引用计数,当引用对象的对象被销毁时,引用计数-1,如果引用计数为0,则进行垃圾回收

  • 优点:对象可以很快的被回收,不会出现内存耗尽或达到某个阀值时才回收。
  • 缺点:不能很好的处理循环引用,而且实时维护引用计数,有也一定的代价。
  • 代表语言:Python、PHP、Swift

1.2 标记-清除

从根变量开始遍历所有引用的对象,引用的对象标记为"被引用",没有被标记的进行回收。

  • 优点:解决了引用计数的缺点。
  • 缺点:需要STW,即要暂时停掉程序运行。
  • 代表语言:Golang(其采用三色标记法)

1.3 分代收集

按照对象生命周期长短划分不同的代空间,生命周期长的放入老年代,而短的放入新生代,不同代有不能的回收算法和回收频率。

  • 优点:回收性能好
  • 缺点:算法复杂
  • 代表语言: JAVA

Java 的GC

四种引用类型

强引用(StrongReference)

Java中默认声明的就是强引用,:只要强引用存在,垃圾回收器将永远不会回收被引用的对象,哪怕内存不足时,JVM也会直接抛出OutOfMemoryError(OOM)

软引用(SoftReference):

在内存足够的时候,软引用对象不会被回收,只有在内存不足时,系统则会回收软引用对象,实现缓存技术,比如网页缓存,图片缓存等。

弱引用(SoftReference):

无论内存是否足够,只要 JVM 开始进行垃圾回收,那些被弱引用关联的对象都会被回收。

虚引用(PhantomReference):

通过一个虚引用申明。那么它就和没有任何引用一样,它随时可能会被回收仅用来处理资源的清理问题,比Object里面的finalize机制更灵活。get方法返回的永远是null,Java虚拟机不负责清理虚引用,但是它会把虚引用放到引用队列里面。

引用队列

引用队列可以与软引用、弱引用以及虚引用一起配合使用,当垃圾回收器准备回收一个对象时,如果发现它还有引用,那么就会在回收对象之前,把这个引用加入到与之关联的引用队列中去。

JAVA中使用可达性分析法标记对象是否需要被清理。

JAVA中GC常用的算法

GC常用的算法:标记-清除算法,标记-压缩算法,复制算法,分代收集算法。

目前主流的JVM(HotSpot)采用的时分代收集算法。

1、标记-清除算法

为每个对象存储一个标记位,记录对象的状态(活着或是死亡)。分为两个阶段,一个是标记阶段,这个阶段内,为每个对象更新标记位,检查对象是否死亡;第二个阶段是清除阶段,该阶段对死亡的对象进行清除,执行GC操作

优点

最大的优点是,标记-清除算法中每个活着的对象的引用只需要找到一个即可,找到一个就可以判断它为活的。此外,更重的是,这个算法并不移动对象的位置。

缺点

他的缺点就是效率比较低(递归与全堆对象遍历)。每个活着的对象都要在标记阶段遍历一遍;所有对象都要在清除阶段扫描一遍,因此算法复杂度较高。没有移动对象,导致可能出现很多碎片空间无法利用的情况

2、 标记-压缩算法(标记-整理)

标记压缩法是标记-清除法的一个改进版。同样,在标记阶段,该算法也将所有的对象标记为存活和死亡两种状态;不同的是,在第二个阶段,该算法并没有直接对死亡的对象进行清理,而是将所有的存活的对象整理一下,放到另一处空间,然后把剩下的所有对象全部清除,这样就达到了标记-整理的目的。

优点

该算法不会像标记清除算法那样产生大量的碎片空间。

缺点

如果存活的对象过多,整理阶段将会执行较多的复制操作,导致算法效率降低,该算法不会产生大量碎片内存空间。

3、复制算法

该算法将内存平均分成两部分,然后每次只使用其中的一部分,当这部分内存满的时候,将内存中所有存活的对象复制到另一个内存中,然后将之前的内存清空,只使用这部分内存,循环下去。

注意

这个算法与标记-整理算法的区别在于,该算法不是在同一个区域复制,而是将所有存活的对象复制到另一个区域内

优点

实现简单;不产生内存碎片

缺点

每次运行,总有一半内存是空的,导致可使用的内存空间只有原来的一半

4、分代收集算法

现在的虚拟机垃圾收集大多采用这种方式,它根据对象的生存周期,将堆分为新生代(Young)和老年代(Tenure)。在新生代中,由于对象生存期短,每次回收都会有大量对象死去,那么这时就采用复制算法。老年代里的对象存活率较高,没有额外的空间进行分配担保,所以可以使用标记-整理 或者 标记-清除。

具体过程:新生代(Young)分为Eden区,From区与To区

当系统创建一个对象的时候,总是在Eden区操作,当这个区满了,那么就会触发一次YoungGC,也就是年轻代的垃圾回收。一般来说这时候不是所有的对象都没用了,所以就会把还能用的对象复制到From区。

这样整个Eden区就被清理干净了,可以继续创建新的对象,当Eden区再次被用完,就再触发一次YoungGC,然后呢,注意,这个时候跟刚才稍稍有点区别。这次触发YoungGC后,会将Eden区与From区还在被使用的对象复制到To区,

再下一次YoungGC的时候,则是将Eden区与To区中的还在被使用的对象复制到From区。

经过若干次YoungGC后,有些对象在From与To之间来回游荡,这时候From区与To区亮出了底线(阈值),这些家伙要是到现在还没挂掉,对不起,一起滚到(复制)老年代吧。

老年代经过这么几次折腾,也就扛不住了(空间被用完),好,那就来次集体大扫除(Full GC),也就是全量回收。如果Full GC使用太频繁的话,无疑会对系统性能产生很大的影响。所以要合理设置年轻代与老年代的大小,尽量减少Full GC的操作。

G1收集器

G1是目前技术发展的最前沿成果之一,HotSpot开发团队赋予它的使命是未来可以替换掉JDK1.5中发布的CMS收集器。与其他GC收集器相比,G1收集器有以下特点:

(1). 并行和并发。使用多个CPU来缩短Stop The World停顿时间,与用户线程并发执行。

(2). 分代收集。独立管理整个堆,但是能够采用不同的方式去处理新创建对象和已经存活了一段时间、熬过多次GC的旧对象,以获取更好的收集效果。

(3). 空间整合。基于标记 - 整理算法,无内存碎片产生。

(4). 可预测的停顿。能简历可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

在G1之前的垃圾收集器,收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分(可以不连续)Region的集合。

Golang的GC

Golang通过gcmarkBits位图标记span的块是否被引用。对应内存分配中的bitmap区。

Golang的对象标记采用三色标记法。

三色标记法

  • 灰色:对象已被标记,但这个对象包含的子对象未标记
  • 黑色:对象已被标记,且这个对象包含的子对象也已标记,gcmarkBits对应的位为1(该对象不会在本次GC中被清理)
  • 白色:对象未被标记,gcmarkBits对应的位为0(该对象将会在本次GC中被清理)

例如,当前内存中有A~F一共6个对象,根对象a,b本身为栈上分配的局部变量,根对象a、b分别引用了对象A、B, 而B对象又引用了对象D,则GC开始前各对象的流程如下:

  1. 初始状态下所有对象都是白色的。
  2. 接着开始扫描根对象a、b; 由于根对象引用了对象A、B,那么A、B变为灰色对象,接下来就开始分析灰色对象,分析A时,A没有引用其他对象很快就转入黑色,B引用了D,则B转入黑色的同时还需要将D转为灰色,进行接下来的分析。
  3. 灰色对象只有D,由于D没有引用其他对象,所以D转入黑色。标记过程结束
  4. 最终,黑色的对象会被保留下来,白色对象会被回收掉。

三色标记法中最后只剩下的黑白两种对象,黑色对象是程序恢复后接着使用的对象,如果不碰触黑色对象,只清除白色的对象,肯定不会影响程序逻辑。所以: 清除操作和用户逻辑可以并发。

标记操作和用户逻辑也是并发的,用户逻辑会时常生成对象或者改变对象的引用,golang利用写屏障进行标记操作和用户逻辑的并发。

写屏障

因为go支持并行GC, GC的扫描和go代码可以同时运行,这样带来的问题是GC扫描的过程中go代码有可能改变了对象的依赖树。

例如开始扫描时发现根对象A和B,B拥有C的指针。

  1. GC先扫描A,A放入黑色
  2. B把C的指针交给A
  3. GC再扫描B,B放入黑色
  4. C在白色,会回收;但是A其实引用了C。

为了避免这个问题, go在GC的标记阶段会启用写屏障(Write Barrier).

启用了写屏障(Write Barrier)后,

  1. GC先扫描A,A放入黑色
  2. B把C的指针交给A
  3. 由于A在黑色,所以C放入灰色
  4. C没有子对象,放入黑色
  5. 扫描B,B没有子对象,放入黑色

即使A可能会在稍后丢掉C, 那么C就在下一轮回收。

开启写屏障之后,当指针发生改变, GC会认为在这一轮的扫描中这个指针是存活的, 所以放入灰色