Java虚拟机 - JVM垃圾回收

本次分享主要从Java内存模型、Java内存结构和垃圾回收三个方面讲解Java虚拟机
本章是第三节:垃圾回收



垃圾回收

上一章我们将了Java的内存结构,本章开始讲解Java垃圾回收。垃圾回收分为五个小部分讲


一、什么是垃圾回收

垃圾回收(Garbage Collection,GC),顾名思义就是释放垃圾占用的空间,防止内存泄露。有效的使用可以使用的内存,对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收。

Java 语言出来之前,大家都在拼命的写 C 或者 C++ 的程序,而此时存在一个很大的矛盾,C++ 等语言创建对象要不断的去开辟空间,不用的时候又需要不断的去释放控件,既要写构造函数,又要写析构函数,很多时候都在重复的构造,然后不停的析构。于是,有人就提出,能不能写一段程序实现这块功能,每次创建,释放控件的时候复用这段代码,而无需重复的书写呢?
1960年就提出了垃圾回收的概念,而这时 Java 还没有出世呢!所以实际上 GC 并不是Java的专利,GC 的历史远远大于 Java 的历史!

二、怎么定义垃圾

既然我们要做垃圾回收,首先我们得搞清楚垃圾的定义是什么,哪些内存是需要回收的。我们来看看两种定义垃圾的算法:引用计数法和可达性分析法
下面我来分别讲一讲这两种定义垃圾算法

1.引用计数法

引用计数算法(Reachability Counting)是通过在对象头中分配一个空间来保存该对象被引用的次数(Reference Count)。如果该对象被其它对象引用,则它的引用计数加1,如果删除对该对象的引用,那么它的引用计数就减1,当该对象的引用计数为0时,那么该对象就会被回收。

1.先创建一个对象soc,将对象soc赋值给变量m。这时候"soc"有一个引用,就是 m
2.然后将 m 设置为 null,这时候对象soc的引用次数就等于0了
3.在引用计数算法中,意味着这块内容就需要被回收了

Java虚拟机在进行垃圾收集时要挂起整个应用的运行,直到对堆中所有对象的垃圾收集处理都结束。我们将这种挂起的行为称之为“Stop-The-World”。由于STW挂起了整个应用,会严重的影响应用程序的性能和整体的体验。而引用计数算法是将垃圾回收分摊到整个应用程序的运行当中了,而不是在进行垃圾收集时。因此,采用引用计数的垃圾收集不属于严格意义上的"Stop-The-World"的垃圾收集机制。

看似很美好,但引用计数法存在一个致命的问题,最终导致我们放弃了引用计数算法。

我们先上一段代码,我们结合图来说明问题

public class ReferenceCounting {
	public Object instance;
	
	public ReferenceCounting(String name) {
	}
}

public class Test {
	public static void testGC() {
		ReferenceCounting a = new ReferenceCounting("objA");
		ReferenceCounting b = new ReferenceCounting("objB");
		
		a.instance = b;
		b.instance = a;
		
		a = null;
		b = null;
	}
}

结合代码,我们来看下这个例子
首先我们定义2个对象objA和objB,分别由变量a和b引用。此时objA和objB的RC都为1
然后我们让objA与objB相互引用。此时objA和objB的RC都增加1,为2
最后置空各自的声明引用。此时objA和objB的RC都减少1,但仍然不为0
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

我们可以看到,最后这2个对象已经不可能再被访问了,但由于他们相互引用着对方,导致它们的引用计数永远都不会为0,通过引用计数算法,也就永远无法通知GC收集器回收它们。如果这样的对象多了,最终会占满导致内存泄漏

正是因为循环依赖的原因导致放弃了引用技术算法。我们需要一种新的算法来解决这个问题,于是就出现了可达性分析算法

2.可达性分析算法

可达性分析算法(Reachability Analysis)的基本思路是,通过一些被称为引用链(GC Roots)的对象作为起点,从这些节点开始向下搜索,搜索走过的路径被称为(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时(即从 GC Roots 节点到该节点不可达),则证明该对象是不可用的。
可达性分析算法是如何解决循环依赖问题的呢?
我们还是通过图直观的看一下可达性分析算法是如何定义垃圾的。
在这里插入图片描述
在这里插入图片描述
以GC Root对象为起点向下搜索,可找到Object1到4四个对象与引用链相连,而Object5到7没有与任何引用链相连。说明Object1到4对象是可达的,而Object5到7是不可达的。Object5到7被定义为垃圾对象,即使Object5到7之间循环依赖,只要他们无法与引用链相连,那么他们依然会被定义为垃圾对象,需要被回收。

在可达性分析算法中,在 Java 语言中可作为 GC Root 的对象包括的四种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 元空间中类静态变量引用的对象
  • 元空间中常量引用的对象
  • 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象

1)虚拟机栈(栈帧中的本地变量表)中引用的对象

public class Demo {
	private Demo(String name){
	}
	
	public static void main(String[] args) {
		Demo root = new Demo("stackLocalParamter");
		root = null;
	}
}

此时的 root,即为 GC Root,当root置空时,Demo 对象也断掉了与 GC Root 的引用链,将被回收

2) 元空间中类静态属性引用的对象

public class Demo {
	public static Demo d;
	private Demo(String name){
	}
	
	public static void main(String[] args) {
		Demo root = new Demo("stackLocalParamter");
		root.d = new Demo("staticProperty");
		root = null;
	}
}

root 为 GC Root,root 置为 null,经过 GC 后,root 所指向的 loaclParameter 对象由于无法与 GC Root 建立关系被回收。
而 d 作为类的静态属性,也属于 GC Root,staticProperty 对象依然与 GC root 建立着连接,所以此时 staticProperty 对象并不会被回收。

3) 元空间中常量引用的对象

public class Demo {
	public static final Demo d = new Demo("final");
	private Demo(String name){
	}
	
	public static void main(String[] args) {
		Demo root = new Demo("stackLocalParamter");
		root = null;
	}
}

d 即为元空间中的常量引用,也为 GC Root,root 置为 null 后,final 对象也不会因没有与 GC Root 建立联系而被回收。

4) 本地方法栈中引用的对象

任何 native 接口都会使用某种本地方法栈,实现的本地方法接口是使用 C 连接模型的话,那么它的本地方法栈就是 C 栈。当线程调用 Java 方法时,虚拟机会创建一个新的栈帧并压入 Java 栈。然而当它调用的是本地方法时,虚拟机会保持 Java 栈不变,不再在线程的 Java 栈中压入新的栈帧,虚拟机只是简单地动态连接并直接调用指定的本地方法。
结合之前说的线程类native函数,线程执行结束,那么对象将被回收

三、怎么回收垃圾

在确定了哪些垃圾可以被回收后,垃圾收集器要做的事情就是开始进行垃圾回收,但是这里面涉及到一个问题是:如何高效地进行垃圾回收。由于Java虚拟机规范并没有对如何实现垃圾收集器做出明确的规定,因此各个厂商的虚拟机可以采用不同的方式来实现垃圾收集器,这里我们讨论四种常见的垃圾收集算法的核心思想

1.标记清除算法

标记清除算法(Mark-Sweep)是最基础的一种垃圾回收算法,它分为2部分,先把内存区域中的这些对象进行标记,哪些属于可回收标记出来,然后把这些垃圾拎出来清理掉。清理掉的垃圾就变成未使用的内存区域,等待被再次使用。
在这里插入图片描述
标记清除算法的逻辑非常清晰,并且也很好操作。
1.首先在一段内存中我们创建多个对象
2.然后标记出哪些对象是可回收的,哪些对象是存活的
3.执行清除,释放内存空间
但它存在一个很大的问题,那就是内存碎片。从上图中我们可以看出垃圾回收以后,内存就会被切割成多段。
我们假设上图中小的对象大小为1M,中等的为2M,大的为4M。开辟内存空间时,需要的是连续内存,这时我们需要一个2M的内存区域时,其中3个1M的区域是无法使用的。这样就导致了我们释放了很多内存空间,但实际上却使用不了

2.复制算法

复制算法(Copying)是在标记清除算法上演化而来,解决标记清除算法的内存碎片问题。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

为了解决内存碎片化的问题,就出现了复制算法
在这里插入图片描述
1.首先将内存等分为2份
2.当其中一份内存耗尽时标记垃圾对象
3.将存活对象移动到另一份内存上,然后将使用过的内存空间一次性清理掉

复制算法保证了内存的连续可用,内存分配时也就不用考虑内存碎片等复杂情况,逻辑清晰,运行高效。
但也暴露了另一个问题,降低了内存的使用率。合着我们原本有个140平的大房子,但只能当70平的两套小房子来使。

3.标记整理算法

标记整理算法(Mark-Compact)标记过程仍然与标记清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,再清理掉端边界以外的内存区域。

为了解决内存碎片化和内存使用率不高的问题,就出现了标记整理算法
在这里插入图片描述
标记清除算法的逻辑非常清晰,并且也很好操作。
1.首先在一段内存中我们创建多个对象
2.然后标记出哪些对象是可回收的,哪些对象是存活的
3.让所有存活的对象都向一端移动
4.执行清除,清理掉端边界以外的内存区域,释放内存空间

标记整理算法一方面在标记清除算法上做了升级,解决了内存碎片的问题,也规避了复制算法只能利用一半内存区域的弊端。看起来很美好,但从上图可以看到,它对内存变动更频繁,需要整理所有存活对象的引用地址,在效率上比复制算法要差很多。

4.分代算法

分代收集算法(Generational Collection)严格来说并不是一种思想或理论,而是融合上述3种基础的算法思想,而产生的针对不同情况所采用不同算法的一套组合拳。

以上三种算法都不是完美的,但能不能每种都取其长处,扬长避短呢?于是乎就出现了分代收集算法

对象存活周期的不同将内存划分为几块。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行内存分配担保,就必须使用标记清除算法或者标记整理算法来进行回收。

5.垃圾收集算法对比

名称执行效率空间使用可能问题
标记清除算法内存碎片多碎片多,不利于后续分配内存
复制算法一半使用率,可以优化成90%使用率优化后可能预留的内存空间不足,需要其他内存做担保
标记整理算法高/低充分使用移动对象太多,会导致效率低下

在Java中内存区域到底被分为哪几块,每一块又有什么特别适合什么算法呢?我们结合之前讲到的Java内存结构来讲解

四、内存结构与回收策略

我们把之前讲的Java堆的结构图请回来。
在这里插入图片描述

Java堆是JVM所管理的内存中最大的一块,堆又是垃圾收集器管理的主要区域。我们之前讲了,堆主要分为年轻代和老年代两个区域,年轻代又分Eden去和Survivor区,而Survivor区又分为From区和To区。可能之前讲到这里的时候大家就会有疑问,为什么堆要划分出这么多个区域。
我们从内存角度再次来看看对象到底是怎么来的,而它又是怎么没的

1.Eden区

IBM 公司的专业研究表明,有将近98%的对象是朝生夕死,所以针对这一现状,大多数情况下,对象会在年轻代 Eden 区中进行分配,当 Eden 区没有足够空间进行分配时,虚拟机会发起一次Minor GC,Minor GC相比Major GC 更频繁,回收速度也更快。
通过Minor GC 之后,Eden区会被清空,Eden区中绝大部分对象会被回收,而那些无需回收的存活对象,将会进到Survivor区的From区(若From区不够,则直接进入老年代)。

2.Survivor区

Survivor区相当于是Eden区和老年代的一个缓冲,类似于我们交通灯中的黄灯。Survivor区又分为2个区,一个是From区,一个是To区。每次执行Minor GC,会将Eden区和From区或To区存活的对象放到Survivor区的To区或From区(如果To区不够,则直接进入老年代)。
说白了不就是从新生代到老年代嘛,直接从Eden区进入老年代不就完了嘛,为啥还需要个Survivor区呢?为啥还需要俩Survivor区?

为啥需要?
想想如果没有Survivor区,Eden区每进行一次Minor GC,存活的对象就会被送到老年代,老年代很快就会被填满。而有很多对象虽然一次 Minor GC 没有消灭,但其实也并不会蹦跶多久,或许第二次,第三次就需要被清除。这时候移入老年区,很明显不是一个明智的决定。所以,Survivor区存在的意义就是减少被送到老年代的对象,进而减少Major GC 的发生。Survivor区的预筛选保证,只有经历15次Minor GC 还能在新生代中存活的对象,才会被送到老年代

为啥需要俩呢?
我们先假设一下,Survivor区如果只有一个区域会怎样。Minor GC 执行后,Eden区被清空了,存活的对象放到了Survivor区,而之前Survivor 区中的对象,可能也有一些是需要被清除的。问题来了,这时候我们怎么清除它们?在这种场景下,我们只能标记清除,而我们知道标记清除最大的问题就是内存碎片,在新生代这种经常会消亡的区域,采用标记清除必然会让内存产生严重的碎片化。因为Survivor有2个区域,所以每次 Minor GC,会将之前Eden区和From区中的存活对象复制到To区域。第二次Minor GC 时,From区与To区职责兑换,这时候会将Eden区和To 区中的存活对象再复制到From区域,以此反复。
这种机制最大的好处就是,整个过程中,永远有一个Survivor区是空的,另一个非空的Survivor区是无碎片的。那么,Survivor区为什么不分更多块呢?比方说分成三个、四个、五个?显然,如果Survivor区再细分下去,每一块的空间就会比较小,容易导致Survivor区满,两块Survivor区可能是经过权衡之后的最佳方案。

由此可见,在年轻代中采用了复制算法来实现GC

3.老年代

老年代占据着2/3的堆内存空间,只有在Major GC 的时候才会进行清理,每次GC都会触发“Stop-The-World”。内存越大,STW的时间也越长,所以内存也不仅仅是越大就越好。由于复制算法在对象存活率较高的老年代会进行很多次的复制操作,效率很低,所以老年代这里采用的是标记整理算法。

除了上述所说,在内存担保机制下,无法安置的对象会直接进到老年代,以下三种情况也会进入老年代。

  • 大对象 大对象指需要大量连续内存空间的对象,这部分对象不管是不是“朝生夕死”,都会直接进到老年代。这样做主要是为了避免在Eden区及2个Survivor区之间发生大量的内存复制。当系统有非常多“朝生夕死”的大对象时就得注意了。
  • 长期存活对象 虚拟机给每个对象定义了一个对象年龄计数器。正常情况下对象会不断的在Survivor区的From区与To区之间移动,对象在Survivor区中每经历一次Minor
    GC,年龄就增加1岁。当年龄增加到15岁时,这时候就会被转移到老年代。
  • 动态对象年龄 虚拟机并不重视要求对象年龄必须到15岁,才会放入老年区,如果Survivor区中相同年龄所有对象大小的总合大于Survivor区的一半,年龄大于等于该年龄的对象就可以直接进去老年区,无需等到“成年”。

五、垃圾收集器

说完了垃圾收集算法,我们再来讲讲基于垃圾收集算法实现的垃圾收集器

目前Java11版本之前有七个非实验性的垃圾收集器。七个垃圾收集器又可分为四大种类,分别是串行收集器、并行收集器、并发收集器和分区并行并发收集器。下面我们分别讲解下这七种收集器,着重讲解CMS收集器和划时代的G1收集器

1.Serial收集器

年轻代串行收集器,顾名思义该收集器作用于年轻代

  • 作用于年轻代
  • 使用单线程进行垃圾回收
  • 它是独占式的垃圾回收
  • 进行垃圾回收时,Java应用程序中的线程都需要暂停(Stop-The-World)
  • 使用复制算法
  • 适合CPU等硬件不是很好的场景

2.SerialOld收集器

老年代串行收集器

  • 作用于老年代
  • 同serial收集器一样, 单线程, 独占式的垃圾回收器
  • 使用标记整理算法
  • 通常老年代垃圾回收比新生代回收要更长时间, 所以可能会使应用程序停顿较长时间
    在这里插入图片描述
    串行收集器运行示意图。每次执行GC线程都是Stop-The-World

为了减少GC线程STW的时间,充分利用CPU的计算能力,因此又出现了并发收集器的。我们来看看jdk1.3之后加入的并发收集器

3.ParNew收集器

新生代ParNew收集器。并行收集器。它就是Serial收集器的并行版本

  • 作用于年轻代
  • 将串行回收多线程化
  • 使用复制算法
  • 垃圾回收时, 应用程序仍会暂停, 只不过由于是多线程回收, 在多核CPU上,回收效率会高于串行回收器, 反之在单核CPU, 效率会不如串行收集器

4.ParallelScavenge收集器

ParallelScavenge收集器与其他收集器的关注点不同。其他收集器的关注点是尽可能的缩短垃圾收集时应用程序线程的暂停时间,而这个收集器的目标是达到一个可控的吞吐量,因此也被称为“吞吐量优化”收集器

  • 作用于年轻代
  • 特点基本与ParNew收集器相同
  • 使用复制算法
  • 关注系统的吞吐量

5.ParallelOld收集器

就是ParallelScavenge收集器的老年代版本

  • 作用于老年代
  • 特点与ParallelScavenge收集器相同
  • 使用标记整理算法
  • 关注系统的吞吐量

在这里插入图片描述
并发收集器运行示意图,每次执行GC线程仍然会Stop-The-World,但由于启动了多线程并发执行垃圾回收,会缩短STW的时间。

虽然使用并行收集器可以缩短STW的时间,但人们追求完美的脚步是不会停止的,于是乎又提出了一种新的垃圾收集器:并发收集器。下面我们再来看看JDK1.5之后加入的并发收集器

6.CMS收集器(Concurrent Mark-Sweep)

并发标记清除收集器,它是一种以获取最短回收停顿时间为目标的收集器。它实现了并发垃圾回收的机制,进一步减少了STW的时间。下面我们来看看CMS究竟是如何实现并发垃圾回收的

  • 作用于老年代
  • 是并发回收, 非独占式的回收器, 大部分时候应用程序不会停止运行
  • 使用标记清除算法
  • 关注系统的停顿时间
  • 是一种预处理垃圾收集器,它不等到老年代内存耗尽才回收。需要在内存耗尽前完成回收操作,否则会导致并发回收失败。所以CMS收集器有一个触发回收的阈值,默认是老年代内存使用率达到92%

CMS收集器处理步骤

  1. 初始标记(CMS-initial-mark)?
  2. 并发标记(CMS-concurrent-mark)
  3. 预清理(CMS-concurrent-preclean)【可选】
  4. 可终止的预清理(CMS-concurrent-abortable-preclean)【可选】
  5. 重新标记(CMS-remark)
  6. 并发清除(CMS-concurrent-sweep)
  7. 并发重置状态等待下次CMS的触发(CMS-concurrent-reset)
    在这里插入图片描述
    首先应用线程执行
    初始标记:这个阶段会触发第一次STW,但STW的时间很短。它主要做两件事
    1.GCRoot可直达的老年代对象
    2.遍历新生代直达的老年代对象。这里的直达是指直接关联到GCRoot的一级对象。

并发标记:主要做两件事:
1.对初始标记中标记的存活对象进行追踪,标记这些对象为可达对象。例如A->B,A在初始标记被识别,而B就是在并发标记阶段被识别。
2.将在并发阶段新生代晋升到老年代的对象、直接在老年代分配的对象以及老年代引用关系发生变化的对象所在的card标记为dirty,避免在重新标记阶段扫描整个老年代。
并发标记是与应用程序一起执行的,因此会出现之前A->B->C变成A->C的情况,这种情况下C对象时无法在并发标记阶段被标记的。在标记阶段会使用三色标记算法结合增量更新,将变化记录下来。此处三色标记算法不做扩展讲解,大家可以自己学习一下

预清理:可以通过参数选择关闭该阶段,默认启用。前一个阶段已经说明,不能标记出老年代全部的存活对象,是因为标记的同时应用程序会改变一些对象引用,这个阶段就是用来处理前一个阶段因为引用关系改变导致没有标记到的存活对象的,它会扫描所有并发标记阶段的Dirty Card,重新标记那些在并发标记阶段引用被更新的对象

可终止的预清理:该阶段存在的目的是减轻重新标记的工作量,减少STW时间。这个阶段持续的时间依赖好多的因素,由于这个阶段是重复的做相同的事情直到发生终止的条件(比如:重复的次数、持续的时间等等)之一才会停止。该阶段是希望能发生一次Young GC,这样就可以减少Eden区对象的数量,降低重新标记的工作量

重新标记:这个阶段会触发第二次STW,它是可以设置并发执行的,该阶段会重新扫描Eden区、Dirty Card和GC Root,完成标记整个年老代的所有的存活对象。由于该阶段遍历的区域比较多,因此可能耗时较长。我们可以通过参数设置强制在重新标记阶段之前执行一次Young GC

并发清理:该阶段主要就是清除那些没有被标记的对象释放内存空间。并发清理阶段是与应用线程并发执行的,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们。这部分垃圾被成为“浮动垃圾”,只能留给下一次GC的时候再清理

重置并发:这个阶段也是并发执行,重置CMS算法内部的数据结构,准备下一个CMS生命周期的使用

通过之前的讲解,我们可以了解到CMS收集器是一个并发执行、低停顿的收集器,但CMS收集器也并不是完美的,它还存在三个非常明显的缺点

  • 对CPU资源非常敏感:并发收集虽然不会暂停用户线程,但因为占用一部分CPU资源,还是会导致应用程序变慢,总吞吐量降低。CMS的默认收集线程数量是=(CPU数量+3)/4。当CPU数量多于4个,收集线程占用的CPU资源多于25%,对用户程序影响可能较大;不足4个时,影响更大,可能无法接受。
  • 产生浮动垃圾导致并发模式失败:我们之前说过浮动垃圾无法在本次GC中回收,这使得并发清除时需要预留一部分内存空间,不能像其他收集器那样在老年代几乎填满的时候才进行收集。如果预留空间无法满足程序需求时,就会出现并发模式失败。这样就会导致JVM启用后备预案,触发Full GC,使用SerialOld收集器进行垃圾回收。这意味着更长的STW时间
  • 产生内存碎片:由于CMS使用的使标记清除算法,清除后不会进行内存压缩操作,所以会产生大量的内存碎片,这就可能会导致提前触发Full GC。所以需要通过参数设置主动开启内存压缩。内存压缩的过程是无法并发的,所以消除内存碎片就不得不增加停顿时间

7.G1收集器(Garbage First)

我们程序员都是不停的追求完美,所以当我们意识到CMS收集器还存在缺点的时候,就需要推出更好的垃圾收集器。于是乎划时代的G1收集器就横空出世了。
我们先来看看G1收集器的特点

  • 并行性: 回收期间, 可由多个线程同时工作, 有效利用多核CPU资源
  • 并发性:G1收集器可以通过并发方式让Java程序继续执行
  • 分代收集:G1可以独立管理整个GC堆
  • 空间整合:基于标记整理算法实现,不会产生内存空间碎片
  • 可预测的停顿:G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒

为什么说G1是一个划时代的收集器呢?我们先回顾一下之前的收集器
在这里插入图片描述
因为每种收集器都是对 “标记清除算法”、“复制算法”、 “标记整理算法”其中一种算法的具体实现,所以基本上之前的收集器要么适用于年轻代的要么适用于老年代。而G1由于独特的Java堆划分方式实现了一种算法适用整个Java堆。
G1收集器是如何实现一种算法适用整个Java堆的呢?它又是如何执行垃圾回收的呢?
我们需要先了解G1收集器的一些重要概念

1)概念:Region(分区)

G1采用了分区(Region)的思路,将整个堆空间分成若干个大小相等的内存区域,每次分配对象空间将逐段地使用内存。因此,在堆的使用上,G1并不要求对象的存储一定是物理上连续的,只要逻辑上连续即可;每个分区也不会确定地为某个代服务,可以按需在年轻代和老年代之间切换。启动时可以通过参数可指定分区大小(1MB~32MB,且必须是2的幂),默认将整堆划分为2048个分区。
我们在通过图来看看使用了分区思路的G1与其他收集器在内存区域划分上的不同之处
在这里插入图片描述
其他收集器都是基于分代将堆内存进行划分的,而G1是基于分区对堆内存进行划分的
上图G1的每个Region都有一个身份,每个Region有可能是Eden、Survivor、Old、Humongous,但是他们的身份仅仅是逻辑上的,是可以变化的,G1可以根据情况动态的调整各种Region的数量,通过控制回收的Region数量来控制STW的时间,以达到STW时间的可控制。
G1保留了分代的概念,但年轻代和老年代不再是物理上的隔离了,他们都是多个Region的集合,每个Region都可能随G1的运行在不同代之间切换。

上图种出现了一个新的名词Humongous,它被称为巨型对象。当一个对象大于Region大小的50%时,就被定义为巨型对象,它会独占一个或多个Region。巨型对象会被直接分配到老年代。巨型对象所占用的Region被成为巨型分区

2)概念:Card(卡片)

G1将每个分区内部又被分成了若干个大小相等的区域,这些小区域(一般大小在128~512byte之间)称为Card(卡片),是堆内存最小可用粒度。所有分区的Card将会记录在Card Table (卡表)中,分配的对象时会占用物理上连续的若干个卡片,当查找对分区内对象的引用时便可通过Card来查找该引用对象。

我们还需要知道一个概念Dirty Card。Card中存储了对象,此Card就称为Dirty Card。G1对内存的使用以分区(Region)为单位,而对对象的分配则以卡片(Card)为单位。若Card中存储了对象,这个Card就被脏化了,称为Dirty Card。

3)概念:Remember Set(已记忆集合)

Remember Set在每个分区中都存在,并且每个分区只有一个RSet。其中存储着其他分区中的对象对本分区对象的引用。

在串行和并行收集器中,GC通过整堆扫描,来确定对象是否处于可达路径中。然而G1为了避免STW式的整堆扫描,在每个分区记录了一个RSet,内部类似一个反向指针,记录引用分区内对象的卡片索引。当要回收该分区时,通过扫描分区的RSet,来确定引用本分区内的对象是否存活,进而确定本分区内的对象存活情况。

事实上,并非所有的引用都需要记录在RSet中,如果一个分区确定需要扫描,那么无需RSet也可以无遗漏的得到引用关系。那么引用源自本分区的对象,当然不用落入RSet中;同时,G1每次GC都会对年轻代进行整体收集,因此引用源自年轻代的对象,也不需要在RSet中记录。最后只有老年代的分区可能会有RSet记录,这些分区称为拥有RSet分区。

4)概念: Collection Set(收集集合)

GC中待回收的Region的集合。CSet中可能存放着各个分代的Region。CSet中的存活对象会在GC过程中被移动(复制)到另一个可用Region。GC后CSet中的Region会成为可用分区。
由上述可知,G1的收集都是根据CSet进行操作的。
CSet分为两种,分别为年轻代收集集合和混合收集集合,这两种收集集合对应着G1收集器的两种收集模式。Young GC(年轻代收集)和Mixed GC(混合收集)。两种收集模式对应收集两种集合中的Region。

下面我们再来讲讲G1的两种收集模式

5)收集模式:Young GC

Young GC主要是对Eden区进行GC,它在Eden区空间耗尽时会被触发
Young GC分为5个阶段
根扫描:目的时发现那些没有加入到CSet中的对象
扫描RSet:在收集当前CSet之前,考虑到分区外的引用,必须扫描CSet分区的RSet。

  • 根扫描:扫描GC Roots。与CMS类似,触发STW
  • 更新RSet:处理Dirty Card队列更新RSet
  • 扫描RSet:检测从年轻代指向老年代的对象
  • 拷贝对象:拷贝存活的对象到Survivor区或老年代
  • 处理引用队列,软引用,弱引用,虚引用

我们通过图看一下Young GC的内部情况

  1. 首先,我们有一块堆内存
    在这里插入图片描述

  2. 当触发了Young GC时,会对年轻代进行收集。经过Young GC的几个阶段以后,G1将存活的对象移动到可分配Region中
    在这里插入图片描述

  3. Eden分区存活的对象将被拷贝到Survivor分区。原有Survivor分区存活的对象,将根据对象年龄晋升到新的Survivor区和老年代分区中。而原有的年轻代分区将被整体回收掉。
    在这里插入图片描述

6)收集模式:Mixed GC

年轻代收集不断活动后,老年代的空间也会被逐渐填充。当老年代占用空间超过参数配置的阈值 (默认45%)时,G1就会启动一次Mixed GC。为了满足暂停目标,G1可能不能一口气将所有的候选分区收集掉,因此G1可能会产生连续多次的Mixed GC与应用线程交替执行,每次STW的Mixed GC与Young GC过程相类似。
Mixed GC针对所有属于年轻代的Region加上在全局并发标记阶段标记出来的收益高的老年代Region进行收集。

在Mixed GC的执行阶段中,有一个全局并发标记阶段。我们再来看看全局并发标记是什么

全局并发标记分为5个步骤

  • 初始标记(initial mark,STW):标记了从GCRoot开始直接可达的对象。
  • 根区域扫描(root region scan):G1在初始标记的存活区扫描对老年代的引用,并标记被引用的对象。该阶段与应用程序(非 STW)同时运行,并且只有完成该阶段后,才能开始下一次 STW 年轻代垃圾回收。
  • 并发标记(Concurrent Marking):G1在整个堆中查找可访问的(存活的)对象。该阶段与应用程序同时运行,可以被 STW 年轻代垃圾回收中断。
  • 重新标记(Remark,STW):该阶段是 STW 回收,帮助完成标记周期。G1清空 SATB 缓冲区,跟踪未被访问的存活对象,并执行引用处理。
  • 清除垃圾(Cleanup):在这个最后阶段,G1执行统计和 RSet 净化的 STW 操作。在统计期间,G1会识别完全空闲的区域和可供进行混合垃圾回收的区域。清理阶段在将空白区域重置并返回到空闲列表时为部分并发。
    在这里插入图片描述
    G1的GC过程会在Young GC和Mixed GC之间不断地切换运行,同时定期地做全局并发标记,在实在赶不上对象创建速度的情况下使用Full GC。

最终梳理G1的流程

  1. 首先G1把Java分成多个Region,每个Region中存放着RSet,G1收集的时候扫描其他区域的GC Roots(比如方法栈中的局部变量表)
  2. 然后由GC Roots找到直连的对象
  3. 接着找到RSet中引用的对象,以这两类对象进行堆的引用标记
  4. 标记完成后把新生代中所有的Region放到CSet,有时会触发全局标记然后选出部分收集效率高的老年代Region加入到CSet中
  5. 最后清理Cset中的Region,完成垃圾回收

最后我们看看G1收集器的运行示意图
在这里插入图片描述

  • 初始标记阶段只是标记一下GC Roots能直接关联到的对象,这个阶段停顿线程,但耗时很短
  • 并发标记阶段是从GC Root开始对堆中对象进行可达性分析,找出存活对象,这阶段耗时较长,可与用户程序并发执行
  • 重新标记阶段是为了修改并发标记期间发生的变动
  • 筛选回收阶段可以根据用户所期望的GC停顿时间来制定回收计划
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值