JVM垃圾回收器(详解)

引言

垃圾回收(GC,Garbage Collection)
在笔者上一篇文章中(JVM内存模型),介绍了JVM内存模型以及JVM运行时的数据区,堆是JVM内存区域里面最大的一块区域,用于存放实例数据,因此这一块区域是垃圾回收的重点区域,而堆为了提高垃圾回收效率,又被分为了年轻代和老年代,年轻代又被分为了eden区、survivor区。

基础概念

判断垃圾

接下来我们就讨论Jvm是怎么回收堆这部分内存的。在进行回收前垃圾收集器第一件事情就是确定哪些对象还存活,哪些已经死去。下面介绍两种基础的回收算法(找垃圾)。

引用计数法

给对象添加一个引用计数器,每当有一个地方引用它时计数器就+1,当引用失效时计数器就-1,。只要计数器等于0的对象就是不可能再被使用的。
此算法在大部分情况下都是一个不错的选择,也有一些著名的应用案例(据说python使用的是此算法),但是Java虚拟机中是没有使用的。
优点:实现简单、判断效率高。
缺点:当几个对象存在互相循环引用,但这几个对象组成了一个圆环,没有任何对象指向这个圆环了,所以这个整体应该被回收,但它的引用计算不等于0,造成无法进行回收

  Object a = new Object();
  Object b = new Object();
  a=b;
  b=a;
  a=b=null; //这样就导致gc无法回收他们。

根可达算法

虚拟机规定一个GC ROOT标准,当从这些GC ROOT往下引用查找的时候,能够引用得到,则不是垃圾,如果从任何GC ROOT都无法引用到某一个对象,则这个对象,就会标记为垃圾
主流的商用程序语言(Java、C#等)在主流的实现中,都是通过可达性分析来判定对象是否存活的。

图中Object1-5不是垃圾,Object6/7/8会被认为是垃圾

在Java语言中,可作为GC Roots 的对象包括下面几种

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象
  2. 方法区中静态变量引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈(即一般说的 Native 方法)中JNI引用的对象

GC分代

为了使 JVM 能够更好的管理堆内存中的对象,包括内存的分配以及回收。
堆的内存模型大致为:

Java 中的堆也是 GC 收集垃圾的主要区域。GC 分为两种:Minor GC(或称为 young GC)、Full GC ( 或称为 Major GC )。

新生代
新生代几乎是所有 Java 对象出生的地方,即 Java 对象申请的内存以及存放都是在这个地方。Java 中的大部分对象通常不需长久存活,具有朝生夕灭的性质。(对于大对象,直接进入放在老年代)
对象优先在新生代 Eden 区中分配,如果 Eden 区没有足够的空间时,就会触发一次 Minor GC 。

当对象在 Eden ( 包括一个 Survivor 区域,这里假设是 from 区域 ) 出生后,在经过一次 Minor GC 后,如果对象还存活,并且能够被另外一块 Survivor 区域所容纳( 上面已经假设为 from 区域,这里应为 to 区域,即 to 区域有足够的内存空间来存储 Eden 和 from 区域中存活的对象 ),则使用复制算法将这些仍然还存活的对象复制到另外一块 Survivor 区域 ( 即 to 区域 ) 中,然后清理所使用过的 Eden 以及 Survivor 区域 ( 即 from 区域 ),并且将这些对象的年龄设置为1,以后对象在 Survivor 区每熬过一次 Minor GC,就将对象的年龄 + 1( 对象的当前GC年龄存在对象的headr中的),当对象的年龄达到某个值时 (默认是 15 岁,可以通过参数 -XX:MaxTenuringThreshold 来设定 ),这些对象就会成为老年代。

老生代
当对象经历过规定次数的Minor GC后,如果还有幸存活,则晋升至老年代(或者一些比较大的对象,一出生就会在老年代)。

现实的生活中,老年代的人通常会比新生代的人 "早死"。堆内存中的老年代(Old)不同于这个,老年代里面的对象几乎个个都是在 Survivor 区域中熬过来的,它们是不会那么容易就 "死掉" 了的。因此,Full GC 发生的次数不会有 Minor GC 那么频繁,并且做一次 Full GC 要比进行一次 Minor GC 的时间更长。FULL GC采用的是标记清除(或整理)算法

新生代与老年代的关系图

垃圾回收算法

复制(Copy)

将内存按容量划分为两块,每次只使用其中一块。当这一块内存用完了,就将存活的对象复制到另一块上,然后再把已使用的内存空间一次清理掉。这样使得每次都是对半个内存区回收,也不用考虑内存碎片问题,简单高效。缺点需要两倍的内存空间。
应用场景:回收新生代;如Serial收集器、ParNew收集器、Parallel Scavenge收集器、G1(从局部看)
复制算法执行过程如下:

  • 优点:实现简单,效率高。解决了标记-清除算法导致的内存碎片问题。
  • 缺点:代价太大,将内存缩小了一半。效率随对象的存活率升高而降低。

标记—清除(Mark-Sweep)

GC分为两个阶段,标记和清除。首先标记所有可回收的对象,在标记完成后统一回收所有被标记的对象。同时会产生不连续的内存碎片。碎片过多会导致以后程序运行时需要分配较大对象时,无法找到足够的连续内存,而不得已再次触发GC。
应用场景:针对老年代的CMS收集器
1. 标记
一次标记:在经过可达性分析算法后,对象没有与GC Root相关的引用链,那么则被第一次标记。并且进行一次筛选:当对象有必要执行finalize()方法时,则把该对象放入F-Queue队列中。
二次标记:对F-Queue队列中的对象进行二次标记。在执行finalize()方法时,如果对象重新与GC Root引用链上的任意对象建立了关联,则把他移除出“ 即将回收 ”集合。否则就等着被回收吧!!!
对被第一次标记切被第二次标记的,就可以判定位可回收对象了
2. 清除
两次标记后,还在“ 即将回收 ”集合的对象进行回收。
执行过程如下:

  • 优点:基础最基础的可达性算法,后续的收集算法都是基于这种思想实现的
  • 缺点:标记和清除效率不高,产生大量不连续的内存碎片,导致创建大对象时找不到连续的空间,不得不提前触发另一次的垃圾回收。

标记—整理(Mark-Compact)

标记-整理算法是根据老年代的特点应运而生。
也分为两个阶段,首先标记可回收的对象,再将存活的对象都向一端移动,然后清理掉边界以外的内存。此方法避免标记-清除算法的碎片问题,同时也避免了复制算法的空间问题。
应用场景:很多垃圾收集器采用这种算法来回收老年代,如Serial Old收集器、G1(从整体看)

  • 优点:不会像复制算法那样随着存活对象的升高而降低效率,不像标记-清除算法那样产生不连续的内存碎片
  • 缺点:效率问题,除了像标记-清除算法的标记过程外,还多了一步整理过程,效率更低。

一般年轻代中执行GC后,会有少量的对象存活,就会选用复制算法,只要付出少量的存活对象复制成本就可以完成收集。而老年代中因为对象存活率高,没有额外过多内存空间分配,就需要使用标记-清理或者标记-整理算法来进行回收。

垃圾回收器

根据堆中不同分代的特征,在JVM的历史长河中,诞生了各种各样的收集器,下面我们就以常见的几种做一些基本的认识

在很早以前,计算机内在只有几十M的时候,串行收集器基本上能满足使用,但随着硬件性能不断提高,内存大小和CPU运行速度的提升,在JVM发展的不同时期,诞生了针对当时计算机性能的垃圾回收器
在G1以前,物理和逻辑上都进行了分代,即将堆分为年轻代和老年代,直到G1的出现,这种分代概念就愈发模糊了,因为G1收集器针对的是整堆的收集。
基本概念
STW:Stop The World,当垃圾回收线程工作时,需要暂停当前的用户(业务)线程,这个过程称它为STW;
串行收集:单线程收集器,简单高效,因为是单线程的原因,也就不会产生用户态和内核态切换所带来的开销;(会产生 STW )
并行收集:随着内存地不断增大,单CPU实现了多核的技术,通过多线程的方式收集垃圾可以极大地提升效率;(会产生 STW )
并发收集:指用户线程与垃圾收集线程同时工作(不一定是并行的可能会是交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个CPU上(多核CPU)(并发标记 和 并发清除 阶段不会产生STW)

Serial 收集器

串行收集器,它是最早诞生的垃圾回收器,以单线程的方式进行垃圾收集,在JVM刚出来的情况下,计算机内存与现在相比特别地小,即便是串行回收,它的速度依然很快。
特点:单线程、简单高效(与其他收集器的单线程相比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程手机效率。收集器进行垃圾回收时,必须暂停其他所有的工作线程,直到它结束(Stop The World)。
应用场景:小内存、单核CPU情况下的垃圾收集
Serial / Serial Old收集器运行示意图

ParNew 收集器

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程外其余行为均和Serial收集器一模一样(参数控制、收集算法、Stop The World、对象分配规则、回收策略等)
特点:多线程、ParNew收集器默认开启的收集线程数与CPU的数量相同,在CPU非常多的环境中,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。
和Serial收集器一样存在Stop The World问题
应用场景:ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器,因为它是除了Serial收集器外,唯一一个能与CMS收集器配合工作的。
ParNew 收集器运行示意图

Parallel Scavenge 收集器

收集与吞吐量关系密切,故也称为吞吐量优先收集器。
特点:属于新生代收集器也是采用复制算法的收集器,又是并行的多线程收集器(与ParNew收集器类似)。
该收集器的目标是达到一个可控制的吞吐量。还有一个值得关注的点是:GC自适应调节策略(与ParNew收集器最重要的一个区别)

GC自适应调节策略:Parallel Scavenge收集器可设置-XX:+UseAdptiveSizePolicy参数。当开关打开时不需要手动指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等,虚拟机会根据系统的运行状况收集性能监控信息,动态设置这些参数以提供最优的停顿时间和最高的吞吐量,这种调节方式称为GC的自适应调节策略。

Parallel Scavenge收集器使用两个参数控制吞吐量:
XX:MaxGCPauseMillis 控制最大的垃圾收集停顿时间
XX:GCRatio 直接设置吞吐量的大小。

Serial Old 收集器

Serial Old是Serial收集器的老年代版本。
特点:同样是单线程收集器,采用标记-整理算法。
应用场景:主要也是使用在Client模式下的虚拟机中。也可在Server模式下使用。
它在Server模式下主要的两大用途:

  • 在JDK1.5以及以前的版本中与Parallel Scavenge收集器搭配使用。
  • 作为CMS收集器的后备方案,在并发收集Concurent Mode Failure时使用。
    Serial / Serial Old收集器工作过程图(Serial收集器图示相同):

Parallel Old 收集器

它是Parallel Scavenge收集器的老年代版本。
特点:多线程,采用标记-整理算法。
应用场景:注重高吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge+Parallel Old (PS + PO,JDK1.8默认) 收集器。
Parallel Scavenge/Parallel Old收集器工作过程图:

CMS 收集器

一种以获取最短回收停顿时间为目标的收集器,用于老年代的垃圾回收
特点:基于标记—清除算法实现,与用户线程并发收集、并发清除,低停顿、低延时
应用场景:适用于注重服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验等场景下。如web程序、b/s服务。
CMS收集器的运行过程可以大致分为以下四个阶段
初始标记
标记老年代中的所有GC Roots对象
标记年轻代中活着的对象引用到老年代的对象(指的是年轻带中还存活的引用类型对象,引用指向老年代中的对象)

并发标记
进行GC Roots Tracing 的过程,找出存活对象且与用户线程可并发执行。
从“初始标记”阶段标记的对象开始找出所有存活的对象
因为是并发执行,在用户线程运行的时候,会发生新生代对象晋升到老年代、或者是更新老年代对象的引用关系等等,对于这些新生成或改变的引用关系,可能会存在漏标,所有就必须要进行下一阶段的“重新标记”,为了提高下了阶段重新标记的效率,该阶段会把上述对象所在的Card标识为Dirty,下一阶段只需扫描这些Dirty Card的对象,避免扫描整个老年代;
并发标记阶段只负责将引用发生改变的Card标记为Dirty状态,不负责处理。
由于这个阶段是和用户线程并发执行的,可能会导致concurrent mode failure

重新标记
为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。仍然存在Stop The World问题。
由于之前的预处理阶段是与用户线程并发执行的,这时候可能年轻带的对象对老年代的引用已经发生了很多改变,这个时候,remark阶段要花很多时间处理这些改变,会导致很长stop the word,所以通常CMS尽量运行Final Remark阶段在年轻代是足够干净的时候。
另外,还可以开启并行收集:-XX:+CMSParallelRemarkEnabled,提高reMark效率

并发清理
对标记的对象进行清除回收。
通过以上5个阶段的标记,老年代所有存活的对象已经被标记并且现在要通过Garbage Collector采用清扫的方式回收那些不能用的对象了。
这个阶段主要是清除那些没有标记的对象并且回收空间;
由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”。

CMS收集器的工作流程图

G1 收集器

G1是一款面向服务端应用的垃圾收集器
它是在CMS的基础上改进而来,现已被JDK1.9作为默认的垃圾回收器
特点如下:
并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop-The-World停顿时间。部分收集器原本需要停顿Java线程来执行GC动作,G1收集器仍然可以通过并发的方式让Java程序继续运行。
分代收集:G1能够独自管理整个Java堆,并且采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。
空间整合:G1运作期间不会产生空间碎片,收集后能提供规整的可用内存。
可预测的停顿:G1除了追求低停顿外,还能建立可预测的停顿时间模型。能让使用者明确指定在一个长度为M毫秒的时间段内,消耗在垃圾收集上的时间不得超过N毫秒。、
G1为什么能建立可预测的停顿时间模型?
因为它有计划的避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的大小,在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。这样就保证了在有限的时间内可以获取尽可能高的收集效率。

G1与其他收集器的区别:
其他收集器的工作范围是整个新生代或者老年代、G1收集器的工作范围是整个Java堆。在使用G1收集器时,它将整个Java堆划分为多个大小相等的独立区域(Region)。虽然也保留了新生代、老年代的概念,但新生代和老年代不再是相互隔离的,他们都是一部分Region(不需要连续)的集合。

G1收集器存在的问题:
Region不可能是孤立的,分配在Region中的对象可以与Java堆中的任意对象发生引用关系。在采用可达性分析算法来判断对象是否存活时,得扫描整个Java堆才能保证准确性。其他收集器也存在这种问题(G1更加突出而已)。会导致Minor GC效率下降。

G1收集器是如何解决上述问题的?
采用Remembered Set来避免整堆扫描。G1中每个Region都有一个与之对应的Remembered Set,虚拟机发现程序在对Reference类型进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用对象是否处于多个Region中(即检查老年代中是否引用了新生代中的对象),如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set中。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆进行扫描也不会有遗漏。

如果不计算维护 Remembered Set 的操作,G1收集器大致可分为如下步骤:
初始标记
仅标记GC Roots能直接到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象。(需要线程停顿,但耗时很短。)

并发标记
从GC Roots开始对堆中对象进行可达性分析,找出存活对象。(耗时较长,但可与用户程序并发执行)

最终标记
为了修正在并发标记期间因用户程序执行而导致标记产生变化的那一部分标记记录。且对象的变化记录在线程Remembered Set Logs里面,把Remembered Set Logs里面的数据合并到Remembered Set中。(需要线程停顿,但可并行执行。)

筛选回收
对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。(可并发执行)

G1收集器运行流程图

posted @ 2021-05-13 22:04  心若向阳花自开  阅读(1825)  评论(0编辑  收藏  举报