扫描下方二维码或者微信搜索公众号
菜鸟飞呀飞
,即可关注微信公众号,阅读更多Spring源码分析
、Java并发编程
、Netty源码系列
和MySQL工作原理
文章。
前言
上一篇文章中介绍了标记阶段的算法,这篇文章将介绍清除阶段的算法。常见的大概有三种算法:标记-清除、复制、标记-压缩算法,下面将一一介绍这三种算法。
标记-清除(Mark-Sweep)算法
标记-清除算法是最早出现也是最基础的垃圾回收算法,它分为两个步骤:标记阶段和清除阶段。其中标记阶段的作用是标记出哪些对象是存活对象,如何判断对象是否存活,在上一篇文章垃圾回收之标记算法中已经介绍了。清除阶段就是从堆内存的起始位置开始遍历每一个对象,将未存活的对象所在的内存回收。
❝注意:这里的内存回收并不是指直接置空,而是通过维护一个内存空闲列表,将死亡对象所在的内存加入到空闲列表中,内存上的数据并没有清除。当下次新的对象来申请内存空间时,就从这个空闲列表中找出一块内存区域,然后将新的对象数据写入到这块内存中,假如这块内存中原先存放过垃圾对象,那么垃圾对象的数据此时就被覆盖了。
❞
这也是为什么在 windows 电脑下删除磁盘数据或者格式化磁盘后,数据还能恢复的原因。前提条件是磁盘数据被删除或者格式化后,还没有被重新写入新的数据,否则将无法恢复。
标记-清除算法可以用如下示意图表示。
标记-清除算法基本上没啥优点,唯一的优点可能就是实现思路比较简单,是人们很容易想到的一种算法。
但是它的缺点却不少,首先是效率不高。在标记阶段需要通过所有的根节点(GC Roots)遍历所有对象,判断对象是否存活,然后在清除阶段也需要从头到尾遍历整个堆空间,这相当于遍历了两遍堆空间,因此效率不高。
其次标记-清除算法会产生内存碎片,当回收完垃圾对象后,整个堆空间中存在很多小的可用的内存区域,这些小区域不是连续的,因此被称之为内存碎片。当为一个较大的对象分配内存空间时,可能会出现堆内存虽然充足,但是却没有一块完整的空闲内存来存放这个对象,这个时候就会再次触发 GC 操作,这对应用程序是十分不友好的。
复制算法
为了解决标记-清除算法导致的内存碎片的问题,复制算法出现了。
复制算法的实现思路是:将一块内存区域分为两个部分,每次使用时只使用其中一部分区域,另一部分区域空着,当发生垃圾回收时,就将存活的对象复制到空着的那部分区域,原先的那块内存区域一次性的全部清空。复制算法可以用如下示意图表示。
复制算法的优点就是效率高,它将标记和清除这两个过程合二为一了,在标记过程中如果发现对象是存活对象,就直接将对象复制到空闲区域了,因此它的效率会高于标记-清除算法。另外复制算法在回收完垃圾后不会产生内存碎片。
当然,复制算法的缺点也很明显,就是浪费了一半的内存空间。另外,因为复制对象到新的内存区域了,也就是对象的地址变了,因此复制完后,还需要修改对象的引用地址,这个过程中,也需要暂停用户线程,因此会产生 STW(Stop The World)。
如果垃圾回收的目标区域中,对象大部分都是存活对象,甚至在极端情况下,所有对象都是存活对象,那么采用复制算法就需要复制所有对象了,这效率肯定是很低了。因此复制算法适合用于每次垃圾回收时,大部分对象都是垃圾对象的区域。例如新生代区域,大部分对象都是”朝生夕灭“的对象,每次对新生代区域进行垃圾回收时,大部分都是可回收的。
事实上,针对新生代区域的垃圾回收器如:Serial、ParNew、Parallel Scavenge,它们都是采用复制算法来进行垃圾回收的。
❝在 HotSpot 虚拟机中,新生代又被细分为 Eden 区、Survivor0 区、Survivor1 区(后面简称 S0 和 S1),默认情况,「Eden:S0:S1=8:1:1」,在使用过程中 S0 和 S1 区始终会有一块区域是空闲的,占新生代的 10%,而复制算法要求一半的空闲区域,那么为什么针对新生代的垃圾回收算法还能使用复制算法呢?这是因为新生代中大部分都是垃圾对象(通常占 98%),每次回收时,存活的对象极少,因此用 Survivor 区域完全能存放下这些存活对象。如果出现了 Survivor 存不下存活对象,别担心,还有担保空间的存在。至于什么是担保空间,后面在分享 JVM 内存结构的文章中,会详细介绍。
❞
标记-压缩(Mark-Compact)算法
前面提到的标记-清除算法会产生内存碎片,复制算法会造成一半的内存区域浪费,且不适合回收大部分对象都是存活对象的区域,为了解决这两个算法的缺点,标记-压缩算法出现了。
标记-压缩算法的实现思路是:先根据标记算法判断每个对象是否是存活对象,然后再将存活的对象全部压缩到内存的一端,最后将边界外的内存区域全部清空。示意图如下。
标记-压缩算法和标记-清除算法比较相似,但是区别是:标记-压缩算法多了一个步骤,就是内存碎片的整理。标记-压缩算法回收垃圾后,不需要维护一个单独的空闲列表来标识可用内存,而只需要维护一个空闲地址的起始指针即可,这比维护一个空闲列表所耗费的开销小很多。当需要为新的对象分配内存地址时,只需要移动该指针即可。
标记-压缩的优点是不会产生内存碎片,同时也消除了复制算法中浪费一半内存区域的缺点。但是标记-压缩算法的缺点也很明显,它比标记-清除算法多一个整理内存空间的步骤,因此效率更低。同时标记-压缩算法在整理内存过程中,还会涉及到移动对象的过程,因此在此期间会暂停用户线程,修改变量的引用地址,也会造成 STW 的现象。
对比
最后,用一个表格,从三个方面来对比一下标记-清除、复制、标记-压缩算法。
标记-清除 | 复制 | 标记-压缩 | |
---|---|---|---|
效率 | 中等 | 最快 | 最慢 |
内存开销 | 小(但会产生内存碎片) | 浪费一半内存(无内存碎片) | 小(无内存碎片) |
移动对象 | 不需要 | 需要 | 需要 |
这三种垃圾回收算法,各有优缺点,没有谁是完美的。通常在实际使用过程中,都是搭配使用。后面会有文章介绍 7 种具体经典的垃圾回收器时,会进行具体举例。
分代收集算法
分代收集算法和前面介绍的三种算法不一样,它实际上并不是一种算法,它是基于这样一个事实:不同的对象,它的生命周期是不一样的,为了提高垃圾回收的效率,可以针对不同生命周期的对象采取不同的垃圾回收方式。通常在 Java 中,会将对象分为新生代和老年代,在垃圾回收时,分别采用不同的回收算法对它们进行回收。
目前在 Java 中的大部分垃圾回收,均是采用分代回收算法。
增量收集算法
在上述介绍的垃圾收集算法中,在垃圾回收阶段,用户线程都将处于 STW 状态,如果停顿时间过长,将对应用程序十分不友好,严重影响用户体验和系统稳定性。因此增量收集算法出现了。
增量收集算法的核心思路是:如果一次性进行垃圾回收时造成的停顿时间过长,那么就让垃圾回收线程和用户线程交替执行。垃圾回收线程先执行一段时间,只收集一小块区域,然后切换到用户线程,如此反复,直至垃圾回收完成。如果在单核 CPU 上,这种交替执行的现象被称之为并发。
总的来说,增量收集算法底层使用的仍然是前面介绍的基础算法:标记-清除或者复制算法。增量收集算法通过妥善处理垃圾收集线程和用户线程之间的冲突,让垃圾标记阶段和清除或者复制可以分开执行。
增量收集算法虽然可以降低系统的停顿时间,但是由于线程间上下文的频繁切换,额外给 CPU 造成了压力,最终会导致系统的吞吐量下降。
分区算法
一般情况下,相同条件下,堆空间越大,GC 所需要的时间就越长,那么每次 GC 造成的 STW 时间就越长。为了更好地控制 GC 产生的停顿时间,可以将一大块内存区域划分为许许多多小的内存区域,每次在进行垃圾回收时,根据期望的停顿时间,一次只回收若干个小区域,而不是整个堆空间,从而减少了一次 GC 所产生的停顿时间。
分代收集算法的思路是根据对象的生命周期不同将堆空间划分为两个不同区域,而分区算法的思路是将整个堆空间划分成连续的若干个小区域。每个小区域都是独立使用,独立回收,这种算法的好处是可以控制一次回收多个小区域。
目前采用分区算法的垃圾收集器的代表为 G1 收集器。
总结
本文主要介绍了三种基础算法:标记-清除算法、复制算法、标记-压缩算法,接着又介绍了另外三种算法:分代收集算法、增量收集算法、分区算法,严格来讲,这三种算法,我个人认为这更是 3 种思想,它们底层使用的仍然是前面介绍的三种基础算法。
在实际的垃圾回收器中,大部分都是基于这三种思想以及算法来进行工作的。
参考
-
周志明《深入理解 JavaJVM 虚拟机》第三版