之前一篇文章(jvm-垃圾回收之垃圾标记算法)中介绍了标记阶段的算法,这篇文章将介绍清除阶段的算法。常见的大概有三种算法:标记-清除、复制、标记-压缩算法,下面将一一介绍这三种算法。
参考自: 微信公众号 "菜鸟飞呀飞"
1.标记-清除(Mark-Sweep)算法
标记-清除算法是最早出现也是最基础的垃圾回收算法,它分为两个步骤:标记阶段和清除阶段。其中标记阶段的作用是标记出哪些对象是存活对象,如何判断对象是否存活,在上一篇文章垃圾回收之标记算法中已经介绍了。清除阶段就是从堆内存的起始位置开始遍历每一个对象,将未存活的对象所在的内存回收。
注意:这里的内存回收并不是指直接置空,而是通过维护一个内存空闲列表,将死亡对象所在的内存加入到空闲列表中,内存上的数据并没有清除。当下次新的对象来申请内存空间时,就从这个空闲列表中找出一块内存区域,然后将新的对象数据写入到这块内存中,假如这块内存中原先存放过垃圾对象,那么垃圾对象的数据此时就被覆盖了。这也是为什么在 windows 电脑下删除磁盘数据或者格式化磁盘后,数据还能恢复的原因。前提条件是磁盘数据被删除或者格式化后,还没有被重新写入新的数据,否则将无法恢复。
标记-清除算法可以用如下示意图表示。
标记-清除算法基本上没啥优点,唯一的优点可能就是实现思路比较简单,是人们很容易想到的一种算法。
但是它的缺点却不少,首先是效率不高。在标记阶段需要通过所有的根节点(GC Roots)遍历所有对象,判断对象是否存活,然后在清除阶段也需要从头到尾遍历整个堆空间,这相当于遍历了两遍堆空间,因此效率不高。
其次标记-清除算法会产生内存碎片,当回收完垃圾对象后,整个堆空间中存在很多小的可用的内存区域,这些小区域不是连续的,因此被称之为内存碎片。当为一个较大的对象分配内存空间时,可能会出现堆内存虽然充足,但是却没有一块完整的空闲内存来存放这个对象,这个时候就会再次触发 GC 操作,这对应用程序是十分不友好的。
2.复制算法
为了解决标记-清除算法导致的内存碎片的问题,复制算法出现了。
复制算法的实现思路是:将一块内存区域分为两个部分,每次使用时只使用其中一部分区域,另一部分区域空着,当发生垃圾回收时,就将存活的对象复制到空着的那部分区域,原先的那块内存区域一次性的全部清空。复制算法可以用如下示意图表示。
复制算法的优点就是效率高,它将标记和清除这两个过程合二为一了,在标记过程中如果发现对象是存活对象,就直接将对象复制到空闲区域了,因此它的效率会高于标记-清除算法。另外复制算法在回收完垃圾后不会产生内存碎片。
当然,复制算法的缺点也很明显,就是浪费了一半的内存空间。另外,因为复制对象到新的内存区域了,也就是对象的地址变了,因此复制完后,还需要修改对象的引用地址,这个过程中,也需要暂停用户线程,因此会产生 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 内存结构的文章中,会详细介绍。
3.标记-压缩(Mark-Compact)算法
前面提到的标记-清除算法会产生内存碎片,复制算法会造成一半的内存区域浪费,且不适合回收大部分对象都是存活对象的区域,为了解决这两个算法的缺点,标记-压缩算法出现了。
标记-压缩算法的实现思路是:先根据标记算法判断每个对象是否是存活对象,然后再将存活的对象全部压缩到内存的一端,最后将边界外的内存区域全部清空。示意图如下。
标记-清除 | 复制 | 标记-压缩 | |
效率 | 中等 | 最快 | 最慢 |
内存开销 | 小(但会产生内存碎片) | 浪费一半内存(无内存碎片) | 小(无内存碎片) |
移动对象 | 不需要 | 需要 |
需要 |