前置知识

[1] 对象是否进行回收判断

内存空间的分代理论


根据经验,对大量 Java 的对象的生命周期进行分析总结后,提出了3个假说:
弱分代假说( Weak Generational Hypothesis ):绝大多数对象都是朝生夕灭,快速消亡的。
强分代假说( Strong Generational Hypothesis ):熬过越多次垃圾回收的对象,越难以消亡。
跨代引用假说( intergenerational Reference Hypothesis ):跨代引用对于同代引用来说仅占极少数。

基于这些假说,每次回收内存的时候,都要将内存里面的所有对象都扫描一遍,找出已经消亡的对象,然后进行清理。就会浪费大量的性能去扫描一些并不需要被回收的对象。例如,内存空间里面绝大多数对象都是要被清理的,还要去扫描大量需要被清理的对象就会造成性能浪费。如果只标记留存的对象,然后清理掉没有做标记的对象,就可以节省大量的性能。

基于这些想法,需要被回收的空间被划分成了不同的区域,一般都区分新生代( Young Generation )老年代( Old Generation )两个区域。在新生代里面,经常会有大量的对象消亡,只留下少量存活的对象,这样的空间就可以用较高的频率去扫描进行回收。经历过多次回收都没有消亡的对象,可以移动到老年代里面去。老年代的对象存活时间比较长,就可以用较低的回收频率。这样就整体减少了回收的次数和需要扫描的对象。

一般新生代进行回收的行为称作 Minor GC 或者 Young GC。老年代的回收称为 Major GCOld GC。对整个新生代和部分老年代进行收集的混合收集 Mixed GC , 目前只有 G1 收集器有这样的行为。老年代的 GC 非常慢,一般来说是新生代回收的 10~20 倍。

对整个方法区和整个堆进行垃圾回收,称为 Full GC。(还需要理解清楚,Full GC 究竟是=Old GC 还是新生代老年代一起进行 GC )

值得注意的是,不是所有的垃圾收集器都会进行这样的分代,目前的ZGC就是暂时不支持分代。

垃圾回收算法


有了内存空间的区域划分理论后,接下来就是对如何对空间进行回收的一些算法的分析。

标记-清除算法
首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

  • 优点:简单快捷。
  • 缺点:
    1. 效率低:标记和清除两个过程的效率都不高
    2. 空间碎片多:标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后再程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前出发另一次垃圾收集动作。

这个是最基础的算法,其他算法几乎都是基于这个算法进行优化衍生出来的。

标记-整理算法
标记过程与“标记-清除”算法一样,然后让所有的存活对象都向一端移动,然后直接清理掉端边界以外的内存。

  • 优点:简单快捷,不会造成内存碎片。
  • 缺点:标记和整理的效率都比较低。

复制算法
将可用内存按容量大小划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已经使用过的内存空间一次清理掉。

  • 优点:每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按序分配内存即可,实现简单,运行高效。
  • 缺点:内存利用率低,将可用内存缩小为原来的一半。

新生代一般采用这种算法,但是内存空间的分配一般不会是1:1。HotSopt 默认新生代和1个 Survivor 空间分配比是 8:1。如下:
image.png
新的对象会优先在 Eden 区,第1次进行垃圾回收的时候,将存活下来的对象放到 From Survivor 区,然后对 Eden 区进行清理。当 Eden 区再次满了的时候,就会将 Eden 区和 From Survivor 区存活的对象转移到 To Survivor 区,然后将 Eden 区和 From Survivor 区清理掉。如此循环清理,来提升内存的使用率。

分代收集思想
根据对象存活周期的不同将内存划分为几块。一般将Java堆分为新生代和老年代。根据各个年代的的特点采用最适合的收集算法。

  • 新生代每次都会回收大量的内存,采用复制算法。
  • 老年代存活率高则采用标记清除或标记整理算法。

HotSpot 的垃圾收集算法实现


虚拟机规范中,并没有规定垃圾收集器的具体实现。所以,各个产商实现了不同的垃圾收集器。以下几个是经过时间检验的经典垃圾收集器。

垃圾收集器的搭配如下如图示,如果两个收集器之间有连线,则可以搭配使用。
image.png

新生代收集器

Serial 收集器

算法:复制算法
搭配:Serial Old , CMS
适用场景:客户端类型的单机的应用程序。
特点:

  • 单线程收集,收集的时候,必须停止其他所有的工作线程。
  • 由于是单线程,回收效率高。

工作过程:停止所有用户的工作线程,然后单独执行垃圾回收线程,回收完毕后,再继续执行用户线程。
image.png

ParNew 收集器

这个收集器是 Serial 收集器的多线程版本。
算法:复制算法
搭配:Serial Old(JDK9),CMS
特点:

  • Serial的多线程版本,使用多条线程进行垃圾回收。
  • 注重缩短用户的停顿时间。

适用场景:与用户进行较多交互的程序。
工作过程图示:
image.png

Parallel Scavenge 收集器

和 ParNew 收集器有很多相似的特性,但是更加关注的是吞吐量,也就是说,能更快的完成整个计算任务。
吞吐量=用户代码运行的时间 /(用户代码运行的时间+垃圾收集器运行的时间)
算法:复制算法
搭配:Serial Old,Parallel Old
特点:

  • 并行的多线程收集
  • 注重增大吞吐量

适用场景:与用户交互不多的后台计算程序。因为可能用户线程的停顿时间比其他收集器长。
image.png

老年代收集器

Serial Old 收集器

算法:标记-整理
特点:Serial收集器的老年代版本,单线程收集。
适用场景:单机的应用程序。 如果服务器应用程序选择了这个收集器,原因有两个:一,在JDK1.5版本之前与Parallel Scavenge配合使用,二作为CMS的备选。

Parallel Old 收集器

算法:标记-整理
特点:多线程收集,注重吞吐量。
适用场景:与用户交互不多的后台计算程序。

CMS(Concurrent Mark Sweep) 收集器

算法:标记-清除 。 初始标记—>并发标记—>重新标记—>并发清除
特点:注重最短停顿时间。
适用场景:与用户交互的服务端程序。
执行过程:
image.png
初始标记:需要Stop the world ,但是时间很短。标记GC Root对象,速度很快。
并发标记:进行GC Tracing。
重新标记:需要Stop the world ,时间稍微长一些。修正并发标记期间程序继续运行产生的一些变动。
并发清除:和用户程序一起并发执行清除垃圾对象。

缺点:

  • 对CPU资源敏感,因为是并发的,所以在用户程序执行的时候,会占用一些CPU资源,导致用户程序变慢。默认回收线程数=(CPU数量+3)/4
  • 无法处理浮动垃圾,可能会出现Concurrent Mode Failure。
  • 空间碎片过多。可以设置参数进行碎片整理。

整代收集器

G1 收集器

将内存分为多个区域,然后跟踪各个区域的垃圾收集的价值大小。根据允许的收集时间,优先回收价值最大(回收时间短,垃圾多)的区域。
算法:年轻代用复制算法,老年代用标记-整理。
搭配:不用搭配
特点:

  • 关注最少停顿时间,可以设定停顿时间

适用场景:与用户交互频繁的服务端程序。
执行过程:
image.png

初始标记:标记 GC Roots 能直接关联到的对象,停顿时间很短,并且是在进行 Minor GC 的时候同步完成。所以这个阶段实际没有额外的停顿。
并发标记:进行GC Tracing,耗时较长,但是和用户线程并发执行,不需要停顿。
最终标记:需要短暂停顿 ,处理并发标记结束后遗留的少量垃圾。
筛选回收:根据 Region 的统计数据,评估回收价值大的区域,然后对各个 Region 进行回收。

更详细的 G1 收集器的内容看这篇文章:

低延时收集器

ZGC 收集器

版本:JDK 11 推出
将内XXXXXX
算法:复制算法
搭配:不用搭配
特点:

  • 不分代,

适用场景:大内存的服务器,低延迟低停顿的服务端程序。
执行过程:

初始标记:

更详细的ZGC收集器的内容看这篇文章:

目前长期更新的JDK版式是:
JDK8 :默认Parallel Scavenge
JDK11 :默认G1 ,推出 ZGC
JDK17 :默认G1

参考
[1] 老年代 CMS 详细工作过程