聊聊垃圾回收算法

语言: CN / TW / HK

其实,对于写代码来说,也有垃圾回收这个问题,这里所说的垃圾,指的是程序中不再需要的内存空间,垃圾回收指的是回收这些不再需要的内存空间,让程序可以重新利用这些释放的内存空间。

 

那么垃圾回收是怎么,是不采用算法来实现呢?本次课时,我们就一起来探讨 Java 的垃圾回收算法。

 

标记-清除算法(Mark-Sweep)

 

标记-清除,顾名思义,先标记垃圾,再清除。它是垃圾回收最基础的算法,后续很多算法都是基于它上面去改进的。标记-清除算法主要分成两个阶段:

 

标记阶段:需要回收的对象。那么这个过程其实就是使用可达性分析去判断一个对象是不是垃圾的过程。

 

清除阶段:标记完成之后,就会统一清理掉要回收的对象。

 

用图表示出来大致如下图所示:

image.png

 

 

先去标记哪些对象是存活的,哪些对象是可以回收的。标记完成之后把它回收的对象直接删掉。从这张图可以看出来。标记清除它存在一定的缺点,标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致程序在运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。比如现在假设我们想在内存里面分配一段连续三个单位的内存空间,要分配一个超大的字节数组,在这样的内存结构下就没有办法做到了。

 

标记-整理算法(Mark-compact)

 

下面来看一下做标记-整理算法,也有一些文章会把它翻译成标记-压缩,本课程里面统一称作是标记-整理算法,那么标记-整理算法是怎玩的。

 

首先也是标记要回收的对象,这个过程和标记清除是一样的,但是在标记完成之后并不是直接清除掉要回收的对象,而是把所有的存活对象都压缩到内存的一端,最后在清理掉边界之外的所有空间,所以不会产生内存碎片,提高了内存的利用率,这种算法适用于老年代。用图表示出来大概如下图所示:

 

image.png  

先去标记哪些对象是存活的,哪些对象可以回收,然后把存活的对象往内存的一端压缩,最后再把可以回收的对象除清除。这样就可以避免内存碎片的问题,但是这种方式在标记和整理移动的过程中也是耗时的。

 

复制算法(Copy)

 

复制算法大致上是这样玩的,把内存分成两块,每次只使用其中的一块,然后把正在使用的这块内存里面的存活对象复制到不使用的内存里面去,然后再清理掉正在使用的这块内存里面的所有对象,接着交换两块内存的角色,等待下一次回收。用图表示大概如下图所示:

image.png

 

把一块内存分成了两块,每次只使用其中的一块,在做垃圾回收的时候,把存活的对象移动到另外一端内存里面去,然后清除掉这块内存里面的所有对象。那么在下一次回收的时候也是一样,把存活的对象移动回来,然后清除掉这一块内存里面的所有对象。

 

三种算法对比

 

以上是三种比较基础的垃圾回收算法,下面来对比一下这三种算法。

 

标记-清除算法的优点:实现起来比较简单。

 

缺点:存在内存碎片。另外使用标记清除算法的时候,分配内存的速度也会受到影响,这是为什么呢?你想,假设现在有一个比较大的对象,因为现在有很多碎片化的内存空间。那么想找到一块连续空间的话,就需要去便利空闲链表,从而值查找哪一块内存可以存放这样的对象。那么在极端场景下,需要把整个链表全部遍历完,才能知道这个对象该分配到哪里去,又或者根本没有办法分配。那么遍历空闲链表是需要时间的,所以分配内存的速度会受到影响。

 

标记-整理算法的优点:没有碎片。

 

缺点:整理存在开销。因为标记整理需要把对象集中起来,放到内存的一端,这个过程需要计算以及时间,并且对象越多,占的内存越大,整理的开销也会越大。

 

 

复制算法的优点是性能好,没有碎片。一般来讲复制算法比标记-清除算法以及标记-整理算法性能要好一些,因为它不像标记-清除算法或者标记-整理算法那样,需要标记哪些对象是存活的,哪些对象可以回收,而只需要找到存活的对象。然后移动这个存活的对象。所以性能上会有一些优势。

 

缺点:内存使用率低。我们一次只能使用整个内存的一半,内存使用率最多只会达到 50%。

 

两种综合的算法

 

好,探讨完三种基础的垃圾回收算法之后,再来探讨 Java 里面两种相对综合的垃圾回收算法。

 

第一种是分代收集算法。分代收集算法的思路是把一个内存分成多个区域,不同的区域使用不同的回收算法去回收。代收集算法比较复杂,而且细节极其之多。我们将在下面详细讨论。

 

 

第二种是增量算法。你想,如果你的内存非常的大,如果一次收集所有垃圾,那么需要耗费的时间就会比较的长,有可能会造成系统长时间的停顿。那么增量算法的思想是每次只收集一小片区域内存空间的垃圾,这样就可以减少系统的停顿。

 

分代收集算法

 

目前各种商业虚拟机,它的堆内存的回收基本上都采用了分代收集的方式。所以可想而知,分代收集算法有多么重要。

 

现在设计算法的思想是根据对象的存活周期,把内存分成多个区域,然后不同的区域使用不同的垃圾回收算法去回收对象。Java 把堆分成了新生代和老年代。下图前面探讨 Java 内存结构的时候已经详细介绍过了。

image.png

 

那么经过分代之后啊,垃圾回收可以分成以下几类:

 

一是新生代的回收(Minor GC 或者 Young GC)。

 

二是老年代的回收(Major GC)。

 

三是整个堆的回收(Full GC)。

 

那么由于执行 Major GC 的时候,一般也会伴随着一次 Minor GC,所以可以认为 Major GC ≈ Full GC 。那么很多时候,程序员在聊 Major GC 以及 Full GC 的时候,聊的就是一件事儿。

 

下面来看一下对象是怎么样分配到堆内存的,我们还是对照堆内存的结构去讲解。对象在创建的时候会先存放到 Eden。当 Eden 满了之后就会触发垃圾回收,这个回收的过程是把 Eden 里面存活的对象拷贝的存活区里面的 From Survivor 或者是 To Survivor 里面去。

 

比如第一次回收 From Survivor 里面去了,那么下一次回收就会把 From Survivor 里面存活的对象拷贝到 To Survivor 里面去,再下一次就会把 From Survivor 面里面存活的对象拷贝的 From Survivor 里面去,周而复始。

 

不难发现这个回收的过程使用了复制算法,这也是为什么新生代要有两个 Survivor 的原因。因为复制算法需要把一个内存分成两块。那么对象每经历一次垃圾回收之后,如果还存活的话,它的年龄就会增加 +1。当对象的年龄达到阈值的话,默认情况下是 15,就会晋升到老年代。

 

老年代里面的对象存活率一般是比较高的,因为你想,都回收 15 次了,还是没能回收得了,所以后面继续存活的可能性依然是比较大的。

 

那么老年代的对象一般会使用标记-清除算法或者是标记-整理算法去进行回收。这里需要说明一下,这个对象分配的过程只是一个典型的分配流程,实际情况是存在例外的:

 

一是,一个新建对象,并利率总是会分配到 Eden,也可能会直接进入到老年代。主要有两种场景。第一,如果你的对象大小,大于这个阈值就会直接分配到老年代。当然了,默认情况下这个参数的值是零,也就是说不做限制,所有的对象都会优先在 Eden 里面分配。第二,如果你的对象非常的大,比方说一个超大的数组,新生代的空间根本都不够,那么这个时候也会直接把这个对象放到老年代去担保。之所以要允许对象直接分配到老年代,主要是因为新生代采用的是复制算法,在 Eden 里面分配大对象的话,将会导致 Eden 和两个 Survivor 区之间大量的内存拷贝。

 

二是,对象也不一定要达到年龄阈值,才会进入到老年代。虚拟机有一个动态年龄的概念,就是说如果存活区里面,所有相同年龄对象的大小的总和已经大于 Survivor 空间的一半了。这个时候,如果某个对象的年龄大于这个年龄的话,会直接进入老年代,比如有一堆的对象,它的年龄值是 9,年龄都是 9 的所有对象它的总和以及大于 Survivor 空间的一半了,那么年龄大于 9 的对象就会直接进入老年代。

 

触发垃圾回收的条件

 

下面我们来看一下不同区域触发垃圾回收的条件:

 

先来看一下新生代回收的触发条件,Eden 空间不足就会进行 Minor GC 回收新生代。

 

再来看一下 Full GC 的触发条件,Full GC 的触发条件要复杂一些,主要有这么几种场景:

 

一是,老年代空间不足,老年代代空间不足又分为两种情况:第一是空间真的不足了。第二是内存碎片,导致没有连续的内存去分配对象,触发 Full GC。

 

二是,元空间不足。

 

三是,在某一次新生代回收之后,要晋升到老年代的对象所占用的空间大于了老年代的剩余空间,这个时候也会触发 Full GG。

 

四是,显式调用了 System.gc()。System.gc() 的作用是建议垃圾回收容器直径垃圾回收,这个代码是会触发 Full GC 的,你也可以使用这个参数:-XX:DisableExplicitGC 去忽略掉 System.gc() 的调用。

 

好,介绍到这里可以简单总结一下,分代收集算法是根据对象的生命周期,把内存做的分代。然后在分配对象的时候,把不同生命周期的对象放在不同代里面,不同的代上选用合适的回收算法进行回收。

 

比如,新生代里面的对象存活周期一般都比较的短,每次垃圾回收的时候都会发现有大量的对象死去。那么 IBM 有做过研究,98% 以上的对象都是会很快消亡的,只有少量的对象能够存活,所以新生代可以使用复制算法来完成垃圾收集。而老年代里面的对象存活率比较高,所以就采用标记-清除算法或者是标记-整理算法进行回收。

 

那么相比单纯的标记-清除算法、标记-整理算方法以及复制算法三代带来了什么好处呢?

 

首先,分代可以更有效的清除不需要的对象,对于生命周期比较短的对象,对象还处于新生代的时候就会被回收掉了。

 

其次,分代提升的垃圾回收的效率。如果不做分代的话,那么需要扫描整个堆里面的对象。而现在的话只要扫描新生代或者老年代就可以了。

 

总结

 

好,简单总结一下,本课时课我们探讨了五种垃圾口的算法。基础的垃圾回收算法有标记-清除算法、标记-整理以及复制算法。另外还探讨了两种综合性的垃圾回收算法,即分代收集算法以及增量算法,同时详细探讨分代收集算法。

 

最后来总结一下分代收集算法的调优原则:

 

第一,要合理的设置 Survivor 区的大小,因为 Survivor 区对内存的利用率不高。如果配置的过大的话,内存浪费就会比较严重。

 

第二,要让 GC 尽量的发生在新生代,也就是让 GC 停留在 Minor GC 的级别。

尽量减少 Full GC 的发生。

 

另外,总结有关堆内存的 JVM 参数。

 

参数 作用 默认
-XX:NewRatio=n 老年代:新生代内存大小比值 2
-XX:SurvivorRatio=n 伊甸园:survivor区内存大小比值 8
-XX:PretenureSizeThreshold=n 对象大小该值就在老年代分配,0表示不做限制 0
-Xms 需小堆内存 -
-Xmx 最大堆内存 -
-Xmn 新生代大小 -
-XX:+DisableExplicitGC 忽略掉 System.gc() 的调用 启用
-XX:NewSize=n 新生代初始内存大小 -
-XX:MaxNewSize=n 新生代最大内存 -