垃圾回收算法种类很多,它是不断演进的各种垃圾回收器的理论基础。掌握垃圾回收算法,能帮助我们看清 JVM 中垃圾回收器的本质和演进趋势。
标记 - 清除算法(Mark-Sweep)
标记 - 清除算法是最早提出并实现的垃圾回收算法,虽然和现在越来越智能的垃圾回收算法相比,标记 - 清除算法显得非常简单,但是它却是后面这些垃圾回收算法的思想基础和发展之源。可以认为我们现在使用的各种垃圾回收算法都是基于标记 - 清除算法的思想不断改进、演化而来的,所以标记 - 清除算法是必须要掌握的。
在标记 - 清除算法中,我们把垃圾回收的过程划分成标记和清除两个阶段。
-
标记阶段
基于可达性分析算法,从根对象开始遍历所有的可达对象并标记它们。 -
清除阶段
清除所有未被标记的对象。这个地方需要注意的是清除对象的顺序并不影响算法的结果。最简单的清除算法就是逐一检查每个对象并释放未被标记的对象。
标记 - 清除算法的一个重要挑战在于如何有效地处理空间碎片,因为每次回收后内存中都会存在大量不连续的碎片,即使这些内存碎片的空间总和足够装下这个对象,也没办法有效地分配给大型对象,从而影响 JVM 的性能和可靠性。
标记 - 清除算法除了会引发碎片化问题,还存在执行效率的问题。因为标记对象和清理对象需要消耗一定的时间,如果遇到拥有大量对象的应用场景的话,可能会导致暂停时间过长。
复制算法(Copying)
复制算法在处理大量短生命周期的对象时,比如处理 Web 请求时的临时对象,复制算法简单高效。
和清除算法相比,复制算法效率更高,可以有效地处理大量的内存。在复制算法下,JVM 把可用的堆内存分成两个相等的部分,只在其中一部分(From 区)里分配对象。另一部分(To 区)暂时不使用。所有新创建的对象都会被分配到 From 区。
复制算法分成标记、复制、清理、交换四个阶段。
和标记 - 清除算法相比,复制算法可以有效地把内存空间划分成两部分,每部分都可以分配给不同的对象,这样就可以省去标记和清除的步骤,大大提高了执行效率。并且因为活动对象会被复制到新的内存区域,只要新的内存区域有足够的空间就不会产生碎片。垃圾收集的同时进行对象的移动和内存分配,没有了标记和清除的过程,应用运行不会被暂停太长时间,解决了垃圾收集的停顿问题。
虽然复制算法有很多优点,但是缺点也同样明显。最主要的就是内存利用率低的问题。复制算法需要把内存空间分为相等大小的两部分,但只使用一部分,这导致可用内存空间只有原来的一半。虽然可以省去标记和清理的步骤,但它还是会消耗大量的时间在对象的拷贝上,特别是在对象的生命周期比较长的时候,更加繁琐,会导致整个系统的运转速度变慢。所以在实际的生产实践中,一般是以复制算法为基础,使用它的衍生版本。
复制算法改进版(Copying Advanced)
在这个改进版中,它把内存空间分成一个比较大的 Eden 区和两个小一点的 Survivor 区,这个模型从 JDK 2.0 版本后开始使用。就像搬家一样,我们会把 Eden 区的东西分好类,该回收的回收,还存活的对象会搬到 Survivor To 区域,然后再把原来的 Survivor From 子区中还存活的对象也搬到 Survivor To 区域,Survivor From 子区清空。每次垃圾回收周期结束后, Survivor To 区就会成为 Survivor From 区,两者角色互换,开始新一轮的垃圾回收。
![](https://img.haomeiwen.com/i5638694/2489baa3db9c6655.png)
如果 Survivor 空间不够用了,我们就需要老年代(更大的内存资源)来帮忙。
总的来说,复制算法改进版解决了一般复制算法在内存利用上的问题,避免了将内存“一刀两断”的情况。分配对象内存更加简单和快捷,只需简单的指针判断和位移即可。但是,这个算法的垃圾回收过程需要暂停用户线程,所以如果 Survivor 空间突然间不够用了,就可能会导致提前进行 Full GC,这也就意味着扩大了回收范围,影响性能。这种模型适用于对象存活率比较低及多核 CPU 的环境。
但是,我们需要留意 Survivor 区域的使用情况,避免这个区域空间不足引发不必要的 Full GC。我们可以通过参数设置来调整 Eden 和 Survivor 区的大小来满足具体的需求。
标记 - 压缩算法(Mark-Compact)
标记 - 压缩算法就是把要留下的对象统一放到一边,然后一次性清除掉另一边所有的空间。这个算法是为解决标记 - 清除算法和复制算法带来的空间碎片化问题而出现的。它的特别之处在于,它很好地利用了压缩手段,就像积木一样,把没用的方块移走,留下可利用的空间。
这样就能有效地改进内存的使用,节省内存资源并提高使用效率。跟复制算法不一样的地方在于,这个算法不需要分割内存,所以它不会浪费内存。
从 JDK 3.0 开始,新生代开始用复制算法,而老年代就用标记 - 清除 - 压缩技术,这是为了提高编程效率和准确性。这里的清除只是标记 - 压缩算法的一部分功能,这样在回收后,“压缩”这一步就留给了用户自己来决定,可以通过设置 JVM 参数 -XX:-UseCMSCompactAtFullCollection 来决定是否在 Full GC 后进行压缩整理。
![](https://img.haomeiwen.com/i5638694/2aa25de88179438a.png)
它综合了标记清除和复制算法的优点,不仅减少了内存碎片,而且没有浪费太多内存空间,所以在许多 JVM 中都是 Full GC 的首选算法。
分代收集算法(Generational Collection)
最后,我们来聊下 JVM 中最经典的分代收集算法。在实际的生产环境中,每个 Object 都具备独特的生命周期,有的 Object 的生命周期很长,有的则很短。因此,在 GC 时考虑对象存活的年龄并进行相应地处理,有助于提高收集的效率。基于这个原理,Java 堆被划分成新生代和老年代两个区域。
分代收集算法主要分为 3 个阶段。
- 初始分配阶段
这个阶段新创建的对象首先在新生代的 Eden 区分配。当 Eden 区域达到极限的时候,Minor GC 会被激活,它会清除所有无用的对象,而那些能够持续存在的对象会被加入 Survivor 区域。一般这个阶段会采用复制算法进行。
- 对象晋升阶段
经过多次 Minor GC 后,仍然活跃的对象会被晋升到老年代。这个阶段采用标记 - 复制算法进行。Major GC 可以有效地处理 Java 堆里所有可能的变量。在这个过程中,我们可以通过标记 - 清除 - 整理的方式来实现对堆的有效管控。
- 内存调整
经过 Full GC 后,如果还是没办法满足内存需求,就会抛出 OutOfMemoryError。
分代收集算法可以对新创建的临时对象进行高效的内存回收。对于存活周期长的对象,由于发生 GC 的频率比较低,所以整体上可以提高内存回收的效率。但是如果老年代频繁执行 GC,应用的吞吐量会下降。在实际生产实践中,需要手动调整新生代和老年代的大小以及晋升阈值,才能达到理想的性能,但这也增加了使用复杂性。
![](https://img.haomeiwen.com/i5638694/eab1844c8573020e.png)
此文章为9月Day20学习笔记,内容来源于极客时间《云时代JVM实战 》,强烈推荐该课程
网友评论