【JVM】GC垃圾回收机制

说真的我要是不懂GC我都不好意思说自己懂Java(坏笑)探秘Java最能打能秀的GC=͟͟͞͞( •̀д•́)

前言

GC垃圾回收机制简直要被HR问爆了,在哪都能刷到关于GC的问题,看来不懂是不行了。说到底GC的最终目的还是对Java程序的性能进行优化。Java和C++不同,C++是垃圾自动回收的,这使得一旦C++一旦搞丢的自己new的一个对象就会导致内存得不到释放而致使的内存泄露问题,看来垃圾自动回收还是不能打鸭,那就来看看Java的GC垃圾回收机制吧

关于GC的几个问题

怎么判断一个对象是不是垃圾

在上篇JVM内存模型种讲到了GC垃圾回收,一个对象是不是垃圾简单的回答就是看这么对象有没有被引用,如果某个对象从始至终都没有被其他对象或直接引用,那么它就变成了垃圾对象。或者说作用域发生未捕获异常程序在作用域正常执行完毕又或者程序执行了System.exit()以及程序发生意外终止(被杀线程等)

GC的工作区域在哪

GC主要是在Java堆和方法区中工作的,因为堆内存放着所有对象的数据,如果不了解可快速飞机去了解堆,所以对于内存中的栈来说,一旦存储的数据超出了作用域就将被JVM自动释放掉,所以GC并不管辖有关栈的区域。

GC什么时候会被执行

GC的触发主要是在新生代的Eden区满了的时候就会触发Minro GC,还有一个情况是当从新生代“升级”到老年代的数量要大于老年代剩余容量的时候会触发Full GC(调优主要需要控制的对象),为了减少Full GC的触发次数我们可以通过NewRatio控制新生代转老年代的比例,也可以通过控制年龄阀值的方式限制。

GC的主要任务都是什么

  • 分配内存;
  • 确保被引用对象的内存不被错误的回收;
  • 回收不再被引用的对象的内存空间.

按代GC的垃圾回收机制

关于新生代老年代问题的补充

  • 默认的新生代与老年代所占空间比例为 1 : 2 ;
  • 默认新生代空间的分配:Eden : Fron : To = 8 : 1 : 1;
  • 年龄阀值设定,默认15
  • 对于一些比较大的对象(需要分配一块比较大的连续内存空间)则直接进入到老年代。一般在Survivor 空间不足的情况下发生
  • Full GC(Major GC)发生的次数不会有Minor GC 那么频繁,并且做一次Major GC 的时间比Minor GC 要更长(约10倍)。

各个代出发GC的条件

我们了解了新生代与老年代的垃圾回收条件及机制了(内存模型章节),作为“持久代“的方法区怎样进行GC呢?方法区内存储的并不是从老年代中存活下来的对象数据,而是那些类的常量以及字符串常量等数据,但是根据存储的对象来看这个区域要被GC的概率是不高的,所以说这个区域GC的条件十分苛刻,必须符合下列三个条件后才能被回收

  • 所有的实例都被回收了
  • 加载该类的类加载器被回收了
  • class对象已经无法通过任何包括反射的途径访问了

一个性能问题的解决

在老年代中存在着一个card table,它是一个512byte大小的块。所有老年代的对象指向新生代对象的引用都会被记录在这个表中。当针对新生代执行GC的时候,只需要查询 card table 来决定是否可以被回收,而不用查询整个老年代。这个 card table 由一个write barrier 来管理。write barrier给GC带来了很大的性能提升,虽然由此可能带来一些开销,但完全是值得的。

GC判断算法

引用计数算法

当某个对象被引用时那么计数器就会+1,当这个对象的引用失效时计数器就会-1,当某个对象的计数值为0的时候就说明这个对象不可能再被引用的,是可被回收的对象。但是根据调用System.gc()情况来看,这种算法不能解决对象之间相互循环引用问题

可达性分析法(主流判断法)

2yRU2.png

根搜索算法是按照离散数学中的图演化而来的,也就是将一个结点看成GC Root,然后从Root结点出发搜寻它对应的引用结点,找到后再搜索引用它的结点的引用结点,一直搜索下去,然后将所有未被标记的对象即为可回收对象。

可以当作GC Root结点的有:

  • 虚拟机栈中线帧所包含的本地变量表中引用的对象
  • 方法区中静态属性引用的对象
  • 方法区中常量所引用的对象
  • 本地方法栈中所引用的Native对象

GC回收算法

标记-清除算法

2yesV.png

标记清除算法

  • 第一步:从根集合开始对对象进行扫描,被引用的即存活的就会被标记
  • 第二步:将未被标记的对象进行GC回收
    从图中可以看到,我们不需要对存活的对象进行移动,这样的情况下如果存活的对象非常多的话,那么这个方法的效率会比较高,但是缺点是这会造成很多不连续性的内存碎片,比如图中第二步中将中间结点回收的例子

复制算法

2ytU7.png

  • 第一步:将内存均等分为两份:空间A与空间B,并且所有动态分配的对象都只在其中的一个空间(图中空间A)另一个空间为空(图中空间B)
  • 第二步:将从根集合出发扫描对象并将引用的对象(存活的对象)复制到为空的空间内(图中空间B)
  • 第三步:将原本的活动空间(空间A)对象全部回收,此时活动空间就变成了空闲空间,原本的空闲空间(空间B)变成了活动空间

很明显,这种算法堆对于对象存活率低的情况比较高效,但是也很明显的是在动态分配内存中我们必须要牺牲一半的空间充当空闲区,所以我们得克服一半内存的浪费

标记-整理算法

2yHKe.png

标记-整理算法的前两步与标记-清理的方法相同,前两步图参考标记-清理算法,它完整的步骤为:

  • 第一步:从根集合开始对对象进行扫描,被引用的即存活的就会被标记
  • 第二步:将未被标记的对象进行GC回收
  • 第三步:将存活的对象进行统一向左移动

很明显,添加第三步这样的做法是避免了标记-清除算法中造成的大量内存碎片问题,但是这样需要再次更新对应结点的指针,这样做的成本也明显增高了

使用情况

JVM为了优化GC回收机制,使用了分代回收的方法,所以对应的不同代的区域也使用了不同的回收算法

  • 新生代内存的回收(Minor GC):主要采用复制算法
  • 老年代的内存回收(Major GC):主要采用标记-整理算法

垃圾收集器(GC)

相关参数的定义

停顿时间:垃圾收集器进行垃圾回收工作时需要暂停应用程序的时间,这个由参数 -XX:MaxGCPauseMillis决定

吞吐量:在垃圾收集上的耗时与在应用上的耗时的占比。由参数-XX:GCTimeRatio=决定,比如设置n为10,那么垃圾收集的时间的比重为 1/ (10+1)=1/11

stop-the-world:它会在任何一种GC算法中发生。stop-the-world 意味着JVM因为需要执行GC而停止了应用程序的执行。当stop-the-world 发生时,除GC所需的线程外,所有的线程都进入等待状态,直到GC任务完成。GC优化很多时候就是减少stop-the-world 的发生。对这两个参数的调整就是JVM优化经常要做的工作

串行垃圾收集器(Serial)(-XX:+UseSerialGC)

单线程一个GC,当JVM发现我们的内存不够时就会暂停应用程序的执行,然后开启一个垃圾回收线程来回收垃圾,这种一般只适用于很小的嵌入式设备。

Java虚拟机中最基本、历史最悠久的收集器,在JDK1.3之前是Java虚拟机新生代收集器的唯一选择。目前也是ClientVM下ServerVM 4核4GB以下机器默认垃圾回收器。Serial收集器虽然是最老的,但是它对于限定单个CPU的环境来说,由于没有线程交互的开销,专心做垃圾收集,所以它在这种情况下是相对于其他收集器中最高效的。

-XX:+UseSerialGC Young(新生代)选择使用串行垃圾收集器
-XX:+UseSerialOldGC Old(老年代)选择使用串行垃圾收集器,开启前面那个参数,该参数会默认开启,所以若想用串行垃圾收集器开启前面的参数即可,这个收集器目前主要用于Client模式下使用。如果在Server模式下,它主要还有两大用途:一个是在JDK1.5及之前的版本中与Parallel Scavenge收集器搭配使用,另外一个就是作为CMS收集器的后备预案,如果CMS出现Concurrent Mode Failure,则SerialOld将作为后备收集器。注意web应用场景基本不使用串行垃圾收集器

并行垃圾收集器(ParNew)(-XX:+UseParNewGC)

并行并不是说垃圾收集线程和用户程序并行,而是说多个垃圾收集线程的并行,也就是说当内存不足的时候仍然需要暂停的用户线程来启动多个垃圾收集线程进行垃圾收集工作,也就是多线程版本的Serial,*使用复制算法,除了Serial收集器外,只有它能与CMS收集器配合工作。ParNew是许多运行在Server模式下的JVM首选的新生代收集器。但是在单CPU的情况下,它的效率远远低于Serial收集器,所以一定要注意使用场景。

吞吐量优先收集器(ParallelScavenge)(-XX:+UseParallelGC)

和ParNew一样,ParalleScavenge也是一个新生代收集器使用复制算法,ParallelScavenge收集器的目标是达到一个可控件的吞吐量。

ParallelOld(-XX:+UseParallelOldGC)
ParallelOld是一个老年代收集器,是老年代吞吐量优先收集器,它使用标记-整理算法。这个收集器在JDK1.6之后才开始提供的,在此之前,ParallelScavenge只能选择SerialOld来作为其老年代的收集器,这严重拖累了ParallelScavenge整体的速度。而ParallelOld的出现后,才证明了吞吐量优先收集器的大名。

在注重吞吐量与CPU数量大于1的情况下,都可以优先考虑ParallelScavenge + ParalleloOld收集器。

CMS(Concurrent Mark-Sweep Collector)(-XX:+UseConcMarkSweepGC)

概述

一种老年代垃圾收集器,其特点是响应时间优先,低延迟,低停顿,是JDK1.4后期开始引用的新GC收集器,在JDK1.5、1.6中得到了进一步的改进。它是对于响应时间的重要性需求大于吞吐量要求的收集器。它用两次短暂的暂停来代替串行或并行标记整理算法时候的长暂停,使用标记-清理算法。对于要求服务器响应速度高的情况下,使用CMS非常合适。

CMS的垃圾回收过程(注意并发与并行的区别):

1.初始标记阶段*有暂停)(STW initial mark):在这个阶段,需要虚拟机停顿正在执行的应用线程,官方的叫法STW(Stop The World)。这个过程从根对象扫描直接关联的对象,并作标记,这个过程进行的非常快;
2.并发标记阶段(Concurrent marking):这个阶段紧随初始标记阶段,在初始标记的基础上继续向下追溯标记。注意这里是并发标记,表示用户线程可以和GC线程一起并发执行,不需要暂停用户线程;
3.并发预清理阶段(Concurrent precleaning):
JVM查找正在执行“并发标记”阶段时候进入老年代的对象。通过重新扫描,减少下一阶段的工作,提高了性能,因为下一阶段会STW影响用户进程;
4.重新标记阶段有暂停)(STW remark):这个阶段会再次暂停用户线程,重新从根对象开始查找并标记并发阶段结束后对象状态的更新导致遗漏的对象,并处理对象关联。这一次耗时会比“初始标记”更长,并且这个阶段可以并行标记;
5.并发清理阶段(Concurrent sweeping):应用线程和GC清除线程可以一起并发执行;
6.并发重置阶段(Concurrent reset):重置CMS收集器的数据结构,等待下一次垃圾回收。

CMS的缺陷

  • CPU敏感:在CMS的工作过程中大都是使用的并发方式,这样以来将会占用更多的CPU资源,也牺牲了一定的吞吐量;
  • 浮动垃圾:同样因为CMS的并发性,导致在进行垃圾回收的过程中仍然在进行着用户线程,同样需要不断的向堆中存入对象,这样也会出现新的垃圾,但是这样的垃圾只能等到下一次GC时才能清理,成为了浮动垃圾;
  • 内存碎片:由于CMS使用“标记-清理”算法,所以会有算法产生的缺陷就是内存碎片。不过CMS收集器做了一些小的优化,就是把未分配的空间汇总成一个列表,当有JVM需要分配内存空间的时候,会搜索这个列表找到符合条件的空间来存储这个对象。如果一个对象需要多块连续的空间来存储,但是因为内存碎片所以寻找不到这样的空间,就会导致Full GC,这样一来内存碎片的问题依然存在;
  • 堆空间要求更大:因为CMS是属于并发进行的,那么就会有堆空间继续分配的问题,为了保障CMS在回收堆空间之前还有空间分配给新加入的对象,必须预留一部分空间。CMS默认在老年代空间使用68%时候启动垃圾回收,可以通过-XX:CMSinitiatingOccupancyFraction=n来设置这个阀值。

G1(Garbage-First)垃圾收集器

概述

G1(Garbage First)垃圾收集器是当今垃圾回收技术最前沿的成果之一。早在JDK7就已加入JVM的收集器大家庭中,成为HotSpot重点发展的垃圾回收技术。同优秀的CMS垃圾回收器一样,G1也是关注最小时延的垃圾回收器,也同样适合大尺寸堆内存的垃圾收集,官方也推荐使用G1来代替选择CMS。G1最大的特点是引入分区的思路,弱化了分代的概念,合理利用垃圾收集各个周期的资源,解决了其他收集器甚至CMS的众多缺陷。

G1GC的相关术语

  • Region:G1垃圾收集器利用分而治之的思想将堆进行分区,划分为一个个的区域。每次收集的时候,只收集其中几个区域,以此来控制垃圾回收产生的STW,G1和其他GC算法最大的区别是弱化分代概念,引入分区思想,如果要另外选择分区的尺寸,可以通过命令行选项:-XX:G1HeapRegionSize=n中进行设置;
    2V22Y.png
  • RSet:G1垃圾收集器里每一个RSet对应的是一个Region中存活对象的指针。在标记存活对象的时候,G1使用RSet概念,将每个分区指向分区内的引用记录在该分区,避免对整个堆扫描,并行独立处理垃圾集合
    老年代对年轻代的引用,维护老年代分区指向年轻代分区的指针
    老年代对老年代的引用。在这里,老年代中不同分区的指针将被维护在老年代拥有分区的RSet中。
    2VMVm.png
    在图中,我们可以看到3各分区,x(年轻代分区)、y和z(老年代分区)。x有一个来自z的对内引用。这个引用记录在x的RSet中,分区z有2个对内引用,一个来自x一个来自y,因为年轻代分区作为一个整体回收的,所以只需记录来自y的对内引用,不用记录x的对内引用;
  • CSet:Collection Set,简称CSet。在垃圾收集过程中收集的Region集合可以称为收集集合(CSet),也就是在垃圾收集暂停过程中被回收的目标。GC时在CSet中的所有存活数据都会被转移,分区释放回空闲分区队列。
    2Vptd.png
    如图所示,左边的年轻代收集CSet代表年轻代的一部分分区,右边的混合收集CSet代表年轻代的一部分区和老年代的多个分区;
  • PLAB:Promotion Local Allocation Buffers,对象晋升到survivor分区或者老年代分区的过程是在GC线程的晋升本地分配缓冲区(PLAB)进行的,每个线程有独立的PLAB。作用是避免多线程竞争相同数据。和下面介绍的TLAB思想是一致的;
  • TLAB:Thread Local Allocation Buffers,线程本地分配缓存。JVM使用了TLAB这种线程专属的区间来避免多线程冲突(无锁方式),提高对象分配效率。TLAB本身占用了Eden空间,即JVM会为每一个线程都分配一块TLAB空间;
    2Vkl4.png
  • IHOP:InitiatingHeapOccupancyPercent,简称IHOP。缺省情况是Java堆内存的45%。当老年代的空间超过45%,G1会启动一次混合周期收集。这也是G1和CMS之间较大的区别,G1的百分比是相对于整个Java堆而言的,CMS(CMSInitiatingOccupancyFraction)仅仅是针对老年代空间的占比。这样设计的原因是因为G1没有固定物理上分割一块内存作为老年代,而是用了Region的思想,这些Region可能是eden,survivor、老年代或者巨型分区,所以获取针对老年代本身的占用百分比没有意义;
  • 巨型分区:巨型对象会以连续分区的形式来存放,这种就叫巨型分区。巨型对象无法利用年轻代里的TLAB和PLAB。在JDK 8u40之前,它只能在并发收集周期的清除阶段回收,但是在JDK 8u40之后,巨型分区可以在年轻代收集中和full GC被回收。

G1的设计

之所以会有G1是因为并发、并行和CMS垃圾收集器都有2个共同的问题:老年代收集器大部分操作都必须扫描整个老年代空间(标记,清除和压缩)。这就导致了GC随着Java堆空间而线性增加或减少年轻代和老年代是独立的连续内存块,所以要先决定年轻代和年老代放在虚拟地址空间的位置。

设计目标

G1的设计目标就是把必要的调整限定在以下2个:

  • 设置最大的Java堆空间
  • 设置指定GC暂停时间

G1会通过调整Java堆尺寸大小来满足设定的暂停时间目标,暂停时间目标越短,年轻代空间越小,老年代空间相对越大.

使用场景  

G1 GC切分堆内存为多个区间(Region),从而避免很多GC操作在整个Java堆或者整个年轻代进行。G1 GC只关注你有没有存货对象,都会被回收并放入可用的Region队列。G1 GC是基于Region的GC,适用于大内存机器。即使内存很大,Region扫描,性能还是很高的。如果现在采用的收集器没有问题,就不要选择G1,如果追求低停顿,那么G1已经是一个可尝试的选择,如果追求吞吐量,就不要选G1了

G1垃圾收集机制

G1的垃圾收集周期主要有4种类型:年轻代收集周期、多级并发标记周期、混合收集周期和full GC(转移失败的安全保护机制),这节以应用启动的时间顺序来讲,可以参照G1垃圾收集活动时序图:
2VCLA.png

年轻代收集

应用刚启动,慢慢流量进来,开始生成对象。G1会选一个分区并指定他为eden分区,当这块分区用满了之后,G1会选一个新的分区作为eden分区,这个操作会一直进行下去直到达到eden分区上限,也就是说eden分区已经被占满,那么会触发一次年轻代收集。年轻代收集首先做的就是迁移存活对象,它使用单eden,双survivor进行复制算法,它将存活的对象从eden分区转移到survivor分区,survivor分区内的某些对象达到了任期阈值之后,会晋升到老年代分区中。原有的年轻代分区会被整个回收掉。同时,年轻代收集还负责维护对象年龄,存活对象经历过年轻代收集总次数等信息。G1将晋升对象的尺寸总和和它们的年龄信息维护到年龄表中,结合年龄表、survivor占比(–XX:TargetSurvivorRatio 缺省50%)、最大任期阈值(–XX:MaxTenuringThreshold 缺省为15)来计算出一个合适的任期阈值。调优:我们可以通过–XX:MaxGCPauseMillis,调优年轻代收集,缩小暂停时间。

并发标记周期

随着时间推移,越来越多的对象晋升到老年代中,当老年代占比(相对于Java总堆而言)达到IHOP参数(上图的IHOP Trigger)之后,那么G1首先会触发并发标记周期(上图的Concurrent Marking Cycle),当完成后才会开始下一小节的混合垃圾收集周期
G1的并发标记循环分5个阶段:

  • 第一阶段:初始标记(上图Young Collection with Initial Mark),收集所有GC根(对象的起源指针,根引用),STW,在年轻代完成
  • 第二阶段:根区间扫描,标记所有幸存者区间的对象引用
  • 第三阶段:并发标记(上图Concurrent Marking),标记存活对象
  • 第四阶段:重新标记(上图Remark),是最后一个标记阶段,STW,很短,完成所有标记工作
  • 第五阶段:清除(上图Clean),回收没有存活对象的Region并加入可用Region队列

调优:我们可以通过–XX:InitiatingHeapOccupancyPercent,配置适合应用的IHOP值(过大会可能转移失败,过小可能过早引起并发标记周期)。我们也可以通过–XX:ConcGCThreads,增加并发线程数

混合收集周期

当达到IHOP参数并完成上一小节的并发标记周期之后,混合收集周期就启动了,一个周期里的单次STW的混合收集和年轻代收集是类似的,唯一区别就是在混合收集过程中会包含一部分老年分区,所以也叫混合收集
看上图的Mixed Collection Cycle,中间有好几段Mixed Collection,说明混合收集周期包含多次收集次数。那么什么影响收集次数呢?是固定的?还是?有两个参数比较重要:

  • XX:G1MixedGCCountTarget:缺省值为8,意思是能启动混合收集的数目设定一个物理限制。G1根据将回收的老年分区除以该参数值得到每次混合收集的老年代CSet最小数量
  • XX:G1HeapWastePercent:缺省值为5%,每次混合收集暂停,G1算出废物百分比,根据堆废物百分比,当收集达到参数时,不再启动新的混合收集

调优:当暂停时间和运行时间呈现指数级增长,可以通过-XX:G1HeapWastePercent,调高该参数会有所帮助,但这也导致更多碎片化

full GC

有2个条件同时满足则会触发full GC

  • 拷贝存活对象晋升(promotion)失败,无法找到可用的空闲分区,GC日志记录为to-space exhausted。或分配巨型对象无法在老年代找到连续足够的分区
  • 当发生第一个条件后,G1会尝试增加堆使用量,如果扩展失败,那么会触发安全措施机制同时发生full GC
    2VDiV.png
    full GC中,单个线程会对整个堆的所有代中所有分区做标记、清除以及压缩动作!!非常非常昂贵的操作!

整理总结

新生代收集器:

  • Serial (-XX:+UseSerialGC)
  • ParNew(-XX:+UseParNewGC)
  • ParallelScavenge(-XX:+UseParallelGC)
  • G1 收集器

老年代收集器:

  • SerialOld(-XX:+UseSerialOldGC)
  • ParallelOld(-XX:+UseParallelOldGC)
  • CMS(-XX:+UseConcMarkSweepGC)
  • G1 收集器

G1是一款非常优秀的垃圾收集器,不仅适合堆内存大的应用,同时也简化了调优的工作。通过主要的参数初始和最大堆空间、以及最大容忍的GC暂停目标,就能得到不错的性能;同时,我们也看到G1对内存空间的浪费较高,但通过首先收集尽可能多的垃圾(Garbage First)的设计原则,可以及时发现过期对象,从而让内存占用处于合理的水平。

参考:
https://zhuanlan.zhihu.com/p/25539690
https://blog.csdn.net/qq_36652619/article/details/90645422
https://www.cnblogs.com/GrimMjx/p/12234564.html (G1垃圾收集器)
https://blog.csdn.net/coderlius/article/details/79272773

如果觉得还不错的话,把它分享给朋友们吧(ง •̀_•́)ง