JVM(3)之垃圾回收(GC垃圾收集器+垃圾回收算法+安全点+记忆集与卡表+并发可达性分析......)

《深入理解java虚拟机》+宋红康老师+阳哥大厂面试题2总结整理

一、堆的结构组成

堆位于运行时数据区中是线程共享的。一个进程对应一个jvm实例。一个jvm实例对应一个运行时数据区。一个运行时数据区有一个堆空间。

java堆区在jvm启动的时候就被创建了,其空间大小也就被确定了(堆是jvm管理的最大的内存空间)java堆的大小是可以调节的。

1.堆的组成

jdk1.7的时候将永久代中的字符串常量池和静态变量移到了堆中;jdk1.8的时候jvm有很大的改进:使用元空间(mate space)取代了永久代,并把方法区移到了本地内存。

java 8及之后堆内存逻辑上分为三个部分:新生区养老区元空间

新生区Young Generation Space (Young/New)

年轻代又可以划分为Eden(伊甸园区)、Survivor0Survivor1(幸存者0/1区有时候也叫做from区和to区),默认比例是8:1:1。

养老区Tenure generation space ( Old/Tenure)

元空间 Meta Space (Meta)

占比情况

-XX:NewRatio 默认-XX:NewRatio=2 表示新生代占1老年代占2即新生代占整个堆的1/3 -XX:NewRatio=4表示新生代:老年代=1:4

-XX:SurvivorRatio 默认是伊甸园区:幸存者0区:幸存者1区=8:1:1(因为自适应的原因,实际上为6:1:1) 如果要修改的话 -XX:SurvivorRatio 8 伊甸园区:幸存者0区:幸存者1区=8:1:1

二、垃圾回收

1.什么是Minor GC、Major GC、Full GC

JVM在进行GC时,并非每次对三个内存(新生代、老年代、方法区)区域一起回收,大部分时候回收的都是新生代。

针对Hotspot VM 的实现,它里面的GC按照回收区域分为两种大类:一种是部分收集(Partial GC),另一种是整堆收集(Full GC)。

1)部分收集(Partial GC):只针对部分区域进行垃圾收集。其中又分为:

①新生代收集(Minor GC/Young GC):只针对新生代的垃圾收集(Eden\s0、s1)。

②老年代收集(Major GC/Old GC):只针对 老年代的垃圾收集。

目前,只有CMS收集器会有单独收集老年代的行为。

注意,很多时候,Major GC 会和Full GC混淆使用,需要具体分辨是老年代的回收还是整堆回收。

③混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。

目前只有G1收集器会有这种行为。

2)整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

2.不同区域GC的触发机制

  • 年轻代GC(Minor GC)

    当年轻代空间不足时,就会触发Minor GC,这里的年轻代满指的是 Eden区满。Survivor 区满不会触发GC 。

    因为Java对象大多具有朝生夕死的特性,所以Minor GC非常频繁,一般回收的速度也比较快

    Minor GC会引发STW(stop the world),暂停其他用户线程

  • 老年代GC(Major GC)

    当老年代空间不足时,会先尝试触发Minor GC,如果之后空间还不足,则触发Major GC。但是也并不是绝对的,CMS是唯一个老年代空间不足时只回收老年代,不触发Minor GC的并发垃圾收集器

    如果进行Major GC后,老年代空间还是不足,就会报OOM了

    Major GC的速度一般会比Minor GC慢10倍以上

  • 整堆GC(Full GC)

    (1)调用System.gc时,系统建议执行Full GC,但是不一定会执行 。 (2)老年代空间不足 (3)方法区空间不足(主要收集常量池中废弃的常量和不再使用的类型) (4)通过 Minor GC 后进入老年代的空间大于老年代的可用内存 (5)由Eden区、survivor space0(From Space)区向survivor space1(To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小 。

3.垃圾回收流程

频繁收集新生代,较少收集老年代,几乎不动永久代

*以下提到的移动实际上是复制

1.当我们new了一个对象一般直接放入Eden区(大对象直接放入老年代)

2.Eden区满时,程序再次创建对象,将会触发Minor GC,将Eden区不再引用的对象销毁,并将Eden区剩余还在引用的对象移到survivor0区,将移过去的对象的年龄计数器+1,

3.继续new对象,放入Eden区,当Eden区再次空间不足时,又会触发Minor GC,对Eden区不再引用的对象进行垃圾回收,将仍然存活的对象放入survivor1区(to区),并将survivor0区仍然存活的对象(不再引用的回收掉)移到survivor1区,将这些对象的年龄计数器+1,将s0区变成to区,s1区变成from区(移动完成交换from和to,谁空谁是to)

4.当Eden区又满了的时候,再次触发Minor GC,将Eden区中仍然存活的对象移到to区,将from区的仍存在引用对象移到to区,这时,发现from区部分对象的年龄已经达到了最大分代年龄(默认15,可以通过参数-XX:MaxTenuringThreshold=N进行修改),于是将这些对象晋升至老年代,将移动对象的年龄计数器+1。

或者,当从Eden区/from区向to区移动对象时,发现to区满了,也会将这些对象直接晋升到老年代。

5.当刚刚经历过Minor GC后,Eden区为空(不用的对象被回收,有用的对象移到to区),再次new对象时,发现Eden区放不下,也就是这是个大对象,就将其直接放入老年代

6.如果在5的情况下,老年代仍然放不下的话,触发Major GC(Full GC),如果回收后,仍然放不下就报oom

图示:

5.方法区的垃圾收集

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。

1.判断一个常量是否废弃,就是看虚拟机中是否有其他地方引用这个常量。

2.判定一个类型是否属于“不再被使用的类”的条件就 比较苛刻了。需要同时满足下面三个条件:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。

  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如 OSGi、JSP的重加载等,否则通常是很难达成的。

  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方 法。

在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载 器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压 力。

《Java虚 拟机规范》中提到过可以不要求虚拟机在方法区中实现垃圾收集,事实上也确实有未实现或未能完整 实现方法区类型卸载的收集器存在(如JDK 11时期的ZGC收集器就不支持类卸载)

三、如何判定对象是否存活

1.引用计数算法

描述:

在对象中添加一个引用计数器,每当有一个地方 引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可 能再被使用的。

图示:

应用:

微软COM(Component Object Model)技术、、使用ActionScript 3的FlashPlayer、Python语 言、Squirrel

缺点:

每次对对象进行赋值时均要维护引用计数器,增加了时间开销;

需要占用一些额外的内存空间进行计数;

较难解决循环引用的问题(JVM的实现一般不采用这种方式)

比如:对象objA和objB都有字段instance,赋值令 objA.instance=objB及objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已 经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也 就无法回收它们。

优点:

原理简单,效率比较高

2.可达性分析算法

描述:

也叫根搜索算法,通过 一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过 程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连(从GC Roots到这个对象不可达)时,则证明此对象是不可能再被使用的。

GC Roots集合就是一组必须活跃的引用

所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的

图示:

如图3-1所示,对象object 5、object 6、object 7虽然互有关联,但是它们到GC Roots是不可达的, 因此它们将会被判定为可回收的对象。

应用:

Java、C#、古老的Lisp

可作为GC Roots的对象

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的 参数、局部变量、临时变量等。

  • 在堆中类静态属性引用的对象,譬如Java类的引用类型静态变量。

  • 方法区中常量引用的对象。

  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。

  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。

  • 所有被同步锁(synchronized关键字)持有的对象。

  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等

除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不 同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。

谈谈强引用、软引用、弱引用、虚引用、引用队列、WeakHashMap

谈谈强引用、软引用、弱引用、虚引用、引用队列、WeakHashMap_hanna22的博客-CSDN博客

四、垃圾回收算法

1.标记-清除算法

描述:

该算法分为“标记”“清除”两个阶段:首先标记出所有需要回 收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回 收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程(可达性分析算法)。

缺点:

1.需要经过两次扫描,执行效率不稳定

如果Java堆中包含大量对 象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过 程的执行效率都随对象数量增长而降低

2.会产生内存碎片

标记、清除之后会产生大 量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找 到足够的连续内存而不得不提前触发另一次垃圾收集动作

2.标记-复制算法

新生代的Minor GC常使用标记-复制算法

描述:

1969年Fenichel提出了一种称为“半区复制”(Semispace Copying)的垃圾收集算法,它将可用 内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着 的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

 Hospot虚拟机,将新生代划分为Eden区、s0区、s1区三部分,针对这三部分来进行标记复制清除。

(在前面“垃圾回收流程”中已经谈过了,忘记了可以划上去看看)

优点:

1.解决了标记-清除算法面对大量可回收对象时执行效率低 的问题

每次都是针对整个半区进行内存回收

2.不会产生内存碎片

只要移动堆顶指针,按顺序分配即可,实现简单,运行高效。

缺点:

1.如果内存中多数对象都是存 活的,这种算法将会产生大量的内存间复制的开销

2.会浪费内存空间

内存空间被分成了几份,可用的内存空间缩小了

3.标记-整理算法

老年代的Major GC常使用标记-整理算法

描述:

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

优点:

1.不会导致空间碎片化

2.能够提高程序的吞吐量

缺点:

1.移动对象,更新引用,需要成本,影响性能

2.移动对象的操作需要全程暂停用户线程(STW),增加耗时

为什么老年代不使用标记-复制算法?

标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低,并且还会浪费内存空间。老年代中的对象一般存活时间都比较长,不易被回收,那就需要进行大量的复制操作,并且老年代中可能存在大对象,需要比较大的内存空间。

标记-清除算法与标记-整理的对比

1.本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。

2.标记-清除算法也是需要停顿用户线程来标记、清理可回收对象的,只是停顿时间相对而言要短;而标记-整理算法因为还要移动对象,用户线程的停顿时间更长。

3.相比标记-清除算法,标记-整理算法的程序总吞吐量更高的。(吞吐量一般指使用垃圾收集的用户程序和收集器的效率总和)因为内存分配和访问相比垃圾收集频率要 高得多,这部分的耗时增加,总吞吐量仍然是下降的。

总之,不管是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会 更复杂(产生内存碎片,需要依赖更为复杂的内存分配器和内存访问器来解决)。

HotSpot虚拟机里面关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟的CMS收集器则是基于标记-清除算法的

还有一种做法是让虚 拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经 大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。前面提到的基于标 记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。

五、前置知识

JVM客户端与服务端

关于JVM的类型和模式 - CSniper - 博客园

六、HotSpot的算法细节实现

OopMap

1.所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,因为若根节点集合的对象引用关系不断变化,分析结果的准确性是无法保证的。

2.目前主流Java虚拟机使用的都是准确式垃圾收集,准确式内存管理是指虚拟机可以知道内存中某个位 置的数据具体是什么类型。在HotSpot 的解决方案里,是使用一组称为OopMap的数据结构来达到这个目的。一旦类加载动作完成的时候, HotSpot就会把对象内存偏移量上是什么类型的数据计算出来,在即时编译过程中,也 会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信 息了,并不需要真正一个不漏地从方法区等GC Roots开始查找。

3.在OopMap的协助下,HotSpot可以快速准确地完成GC Roots枚举

看一下OopMap这个数据结构:

可 以看到在0x026eb7a9处的call指令有OopMap记录,它指明了EBX寄存器和栈中偏移量为16的内存区域 中各有一个普通对象指针(Ordinary Object Pointer,OOP)的引用,有效范围为从call指令开始直到 0x026eb730(指令流的起始位置)+142(OopMap记录的偏移量)=0x026eb7be,即hlt指令为止。

安全点

1.什么是安全点?

在OopMap的协助下,HotSpot可以快速准确地完成GC Roots枚举,但是,导致OopMap内容变化的指令非常多,HotSpot并没有为每条指令都生成OopMap,只是在“特定的位置”记录 了这些信息,这些位置被称为安全点(Safepoint)。

是不是不太明白?看一张图

2.安全点的选择?

有了安全点的设定,也就决定了用户程序执行时 并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才 能够暂停。因此,安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过 分增大运行时的内存负荷。

安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准 进行选定的,"长时间执行”的最明显特征就是指令序列的复用,例如方法调用循环跳转异常跳转 等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。

3.如何在垃圾收集发生时让所有线程都跑到最近的安全点,然后停顿下来?

  • 抢先式中断 (Preemptive Suspension)(现在没有虚拟机使用)

    抢先式中断不需要线程的执行代码 主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地 方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。

  • 主动式中断(Voluntary Suspension)

    是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一 个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最 近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的。

    举例:

    下面代码清单3-4中的test指令就是HotSpot生成的轮询指 令,当需要暂停用户线程时,虚拟机把0x160100的内存页设置为不可读,那线程执行到test指令时就会 产生一个自陷异常信号,然后在预先注册的异常处理器中挂起线程实现等待,这样仅通过一条汇编指 令便完成安全点轮询和触发线程中断了。

     总结来说就是:会在某些安全点后设置一条指令(当然不是所有安全点),当需要暂停用户线程进行垃圾收集时,将该指定所要访问的页面置为不可读。

安全区域

1.为什么出现安全区域?

如果用户线程处于Sleep状态或者Blocked状态,这时候线程无法响应虚拟机的中断请求,不能再走 到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。对于 这种情况,就必须引入安全区域(Safe Region)来解决。

2.什么是安全区域?

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任 意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。

当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时 间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全 区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的 阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以 离开安全区域的信号为止。

比如说:

用户执行到sleep时,会标识自己已经进入安全区域,那之后如果要进行垃圾收集,不用管这些阻塞或挂起的线程,只需要让其他不在安全区域的线程,走到安全点挂起自己即可,等sleep的线程被唤醒后,检查虚拟机是否已经不需要用户线程暂停了,如果是,继续执行;不是,挂起等待。

记忆集与卡表

1.为什么会出现记忆集?

为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建 立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进GC Roots扫描范围。

2.什么是记忆集?

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。

3.什么是卡表?(仔细阅读理解一下)

“卡表”(Card Table)“是记忆集的一种实现方式,”卡表“中每个记录精确到一块内存区域,该区域内有对象含有跨代指针

HotSpot虚拟机中的”卡表“是一个字节数组,字节数组中的每一个元素都存储着一个标识(0或1),这个标识是用来标志:内存区域中一块特定大小的内存块是否变脏(只要这块内存区域中有一个对象存在着跨代指针,就标识为1,称这个元素变脏;没有就标识为0),也就是说,数组中的每个元素都会对应标识一个内存块,这个内存块被称作“卡页”(Card Page)。

4.那么,我们怎么知道数组中的每个元素,与哪个”卡页“相对应呢?(数组中的每个元素标识着那块内存区域呢?)

我们可以看到this address >> 9 ,就是将内存块的起始地址右移9位(除以512)就得到了:这个内存块的标识所在的索引位置,那么我们来进行反推,标识所在的索引位置 * 512 字节= 内存块的起始地址,那么,索引为0,1,2的元素所标识的内存块的起始地址分别为 0,512,1024,所以每个内存块(”卡表“)的大小为512字节。

当然前提是,卡表标识的内存区域的起始地址是0,如果卡表标识的内存区域起始地址为N字节,那么计算内存块的起始地址就需要:N + 标识所在的索引位置 * 512 字节= 内存块的起始地址

5.图示:

写屏障

1.卡表中的元素何时变脏?

有其他分代区域中对象引用了本区域对象时,其对应的 卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。

2.卡表元素如何变脏(如何在对象赋值的那一刻去更新维护卡表)?

在HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。(注:不是内存屏障)

3.什么是写屏障?(注:与内存屏障无关)

写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切 面(通过预编译方式和运行期动态代 理实现程序功能的统一维护的一种技术),在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的 前后都在写屏障的覆盖范畴内。

简单说就是:通过写屏障,能在引用类型字段赋值前后,做一些其他的事情

在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值 后的则叫作写后屏障(Post-Write Barrier)。

虽然,每次只要对引用进行更新,就会产生额外 的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的。

并发的可达性分析

当前主流编程语言的垃圾收集器基本上都是依靠可达性分析算法来判定对象 是否存活的,可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析, 这意味着必须全程冻结用户线程的运行-->GC Root根节点枚举。

由于GC Roots相比 起整个Java堆中全部的对象毕竟还算是极少数,且在各种优化技巧(如OopMap)的加持下,它带来 的停顿已经是非常短暂且相对固定(不随堆容量而增长)的了。

但是,从GC Roots再继续往下遍历对象 图,这一步骤的停顿时间就必定会与Java堆容量直接成正比例关系了:堆越大,存储的对象越多,对 象图结构越复杂,要标记更多对象而产生的停顿时间自然就更长,所以有些追求停顿时间短的垃圾收集器,需要标记对象与用户线程并发执行。

我们先来看一下:

可达性分析的扫描过程:

我们引入三色标记(Tri-color Marking)作为工具来辅 助推导,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:

  • 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是 白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。

  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代 表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对 象不可能直接(不经过灰色对象)指向某个白色对象。

  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

    前面提到了,要想缩短用户线程停顿时间,需要用户线程与收集器是并发工作,那就会存在问题:

    一种是把原本消亡的对象错误标记为存活

    另一种是把原本存活的对象错误标记为已消亡,这就是非常致命的后果了

    如图:

Wilson于1994年在理论上证明了,当且仅当以下两个条件同时满足时,会产生“对象消失”的问 题,即原本应该是黑色的对象被误标为白色:

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用(在已经扫描过的黑色对象上插入了新的引用对象)

  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用(从还没有扫描完成的灰色对象上删除了没扫描到的白色对象)

因此,我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可。由此分别 产生了两种解决方案:增量更新(Incremental Update)原始快照(Snapshot At The Beginning, SATB)。

  • 增量更新(Incremental Update)

    增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新 插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫 描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象 了。

  • 原始快照(Snapshot At The Beginning, SATB)

    原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删 除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描 一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来 进行搜索。

    以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。在 HotSpot虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS是基于增量更新 来做并发标记的,G1、Shenandoah则是用原始快照来实现。

七、垃圾收集器

垃圾收集器就是GC算法的落地实现。到目前为止,还没有完美的收集器出现,更加没有万能的收集器,只是针对程序,应用最合适的收集器,进行分代收集

针对新生代和老年代的垃圾收集器,有些可以搭配使用;一般,确定了年轻代的GC老年代的GC也随之确定

常用参数说明:

DefNew:Default New Generation

Tenured: Old

ParNew:Parallel New Generation

PSYoungGen:Parallel Scavenge

ParOldGen:Parallel Old Generation

1.Serial收集器(串行垃圾收集器)

这个收集器是一个单线程工作的收集器,它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束(“Stop The World”)。

Serial垃圾收集器是最古老,但是简单而高效(与其他收集器的单线程相比),对于内 存资源受限的环境,它是所有收集器里额外内存消耗(Memory Footprint)最小的;对于单核处理 器或处理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以 获得最高的单线程收集效率,它是HotSpot虚拟机运行在客户端模式下的默认新生 代收集器。

对应的JVM option:-XX:+UseSerialGC

开启后会使用:Serial(Young区) + Serial Old(Old区)收集器组合

新生代和老年代都会使用串行垃圾回收器,新生代使用复制算法,老年代采用标记-整理算法

举例:

/**
 * @author smileha
 * @create 2022-03-06 20:26
 * @description 串行GC
 * -Xms10m -Xmx10m -XX:+PrintGCDetails -         XX:+PrintCommandLineFlags -XX:+UseSerialGC
 */

2.ParNew收集器

ParNew收集器采用多线程并行的垃圾回收方式,在垃圾收集时,会STW暂停其他所有的工作线程直到它收集结束。

ParNew收集器实质上是Serial收集器的多线程并行版本。除了同时使用多条线程进行垃圾收集之 外,其余的行为包括Serial收集器可用的所有控制参数(例如:-XX:SurvivorRatio、-XX: PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规 则、回收策略等都与Serial收集器完全一致,在实现上这两种收集器也共用了相当多的代码。

JDK1.8及之前,对应的JVM option:-XX:+UseParNewGC,启用ParNew收集器,只影响新生代的收集,不影响老年代。

开启后会使用:ParNew(Young区) + Serial Old(Old区)收集器组合,但是,这样的组合在JDK8中已经不推荐了。

新生代使用复制算法,老年代采用标记-整理算法

它默认开启的收集线程数与处理器核心数量相同,可以使用参数-XX:ParallelGCThreads=N,来限制垃圾收集的线程数

ParNew收集器是激活CMS后(使用-XX:+UseConcMarkSweepGC选项)的默 认新生代收集器,也可以使用-XX:+/-UseParNewGC选项来强制指定或者禁用它。

除了Serial收集器外,目前只有它能与CMS 收集器配合工作。但是,JDK 9开始,Serial+CMS这个组合也被取消了,并直接取消了-XX:+UseParNewGC参数,可以理解为:ParNew合并入CMS。

缺点

在单核处理器的环境中,会存在线程交互的开销

演示:

/**
 * @author smileha
 * @create 2022-03-06 20:43
 * @description并行GC
 * -Xms10m -Xmx10m -XX:+PrintGCDetails -XX:+PrintCommandLineFlags -XX:+UseParNewGC
 */

3.Parallel Scavenge收集器

Parallel Scavenge收集器也是一款基于标记-复制算法实现的新生代收集器,也是 能够并行收集的多线程收集器,它和ParNew非常相似。

它也是需要暂停用户线程的。

Parallel Scavenge收集器的特点是:它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能 地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐 量(Throughput)

比如:用户代码加上垃圾收集总共耗费了100分钟,其中垃圾收集花掉1分 钟,那吞吐量就是99%。

控制吞吐量的参数

-XX:MaxGCPauseMillis:控制最大垃圾收集停顿时间

允许的值是一个大于0的毫秒数,收集器将尽力保证内存回收花费的 时间不超过用户设定值。

停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良 好的响应速度能提升用户体验;

不过时间不是设置的越小越好,垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的:系统把新生代调得小一些,收集300MB新生代肯定比收集500MB快,但这也直接导致垃圾收集发生得 更频繁,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间 的确在下降,但吞吐量也降下来了。

-XX:GCTimeRatio:直接设置吞吐量大小

-XX:GCTimeRatio参数的值则应当是一个大于0小于100的整数,也就是运行用户代码时间/垃圾收集时间。比如:设置为19,也就是19/1,允许的最大垃圾收集时间为:1/(19+1)=5%。

高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算 任务,主要适合在后台运算而不需要太多交互的分析任务。

-XX:+UseAdaptiveSizePolicy:自适应的调节策略(GC Ergonomics)

这是一 个开关参数,当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden与Survivor区 的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数 了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时 间或者最大的吞吐量。

自适应调节策略也是Parallel Scavenge收集器区别于ParNew收集器的一个重要特性。

对应的JVM参数:-XX:+UseParallelGC或-XX:+UseParallelOldGC(可互相激活)

开启后会使用:Parallel Scavenge(Young区) + Parallel Old(Old区)收集器组合

新生代使用复制算法,老年代使用标记-整理算法

演示:

/**
 * @author smileha
 * @create 2022-03-06 22:02
 * @description -Xms10m -Xmx10m -XX:+PrintGCDetails -XX:+PrintCommandLineFlags -XX:+UseParallelGC/-XX:+UseParallelOldGC
 */

4.Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收 集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。

它也是需要暂停用户线程的。

只要年轻代开启Serial/PerNew,老年代的Serial Old会自动开启,但是不能单独配置。

举例:

/**
 * @author smileha
 * @create 2022-03-06 22:18
 * @description -Xms10m -Xmx10m -XX:+PrintGCDetails -XX:+PrintCommandLineFlags -XX:+UseSerialOldGC
 *
 */

5.Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实 现,它也是需要暂停用户线程的。

这个收集器是直到JDK 6时才开始提供的。

在JDK1.6之前,如果新生代选择了Parallel Scavenge收集器,老年代只能选择Serial Old(PS MarkSweep)收集器,由于单线程的老年代收集中无法充分利用服务器多处理器的并行处 理能力,在老年代内存空间很大而且硬件规格比较高级的运行环境中,这种组合的总吞吐量甚至不一 定比ParNew加CMS的组合来得优秀。

在注重 吞吐量或者处理器资源较为稀缺的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组 合

对应的JVM参数:-XX:+UseParallelGC或-XX:+UseParallelOldGC(可互相激活),

开启后会使用:Parallel Scavenge(Young区) + Parallel Old(Old区)收集器组合,

如果不加参数,默认参数:-XX:+UseParallelGC,年轻代和老年代使用这两个垃圾收集器,也就是它们是服务端模式下的默认垃圾收集器

新生代使用复制算法,老年代使用标记-整理算法

演示:

/**
 * @author smileha
 * @create 2022-03-06 22:11
 * @description -Xms10m -Xmx10m -XX:+PrintGCDetails -XX:+PrintCommandLineFlags -XX:+UseParallelOldGC
 */

/**
 * @author smileha
 * @create 2022-03-06 22:11
 * @description -Xms10m -Xmx10m -XX:+PrintGCDetails -XX:+PrintCommandLineFlags
 **/

6.CMS收集器

CMS(Concurrent Mark Sweep并发标记清除)收集器是一种以获取最短回收停顿时间为目标的收集器

CMS收集器适合用在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为 关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。

它是基于标记-清除算法实现的。

它的运作过程分为四个步骤:

1)初始标记(CMS initial mark)

初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,仍然需要“Stop The World”

2)并发标记(CMS concurrent mark)

并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对 象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行

3)重新标记(CMS remark)

重 新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的 标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一 些,但也远比并发标记阶段的时间短。

4)并发清除(CMS concurrent sweep)

最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的 对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一 起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

优点:

并发收集、低停顿

缺点:

1.并发执行对CPU资源的压力比较大

在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计 算能力)而导致应用程序变慢,降低总吞吐量。

CMS默认启动的回收线程数是(处理器核心数量 +3)/4,也就是说,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过25%的 处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个时, CMS对用户程序的影响就可能变得很大。如果应用本来的处理器负载就很高,还要分出一半的运算能 力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅降低。

了解一下:

为了缓解这种情况,虚拟机提 供了一种称为“增量式并发收集器”(Incremental Concurrent Mark Sweep/i-CMS)的CMS收集器变种, 所做的事情是在并发标记、清理的时候让收集器线程、用户线程交替运行,尽量减少垃圾收集线程的独占资源的 时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得较少一些,直观感受是速度变 慢的时间更多了,但速度下降幅度就没有那么明显。实践证明增量式的CMS收集器效果很一般,从 JDK 7开始,i-CMS模式已经被声明为“deprecated”,即已过时不再提倡用户使用,到JDK 9发布后i-CMS模式被完全废弃。

2.可能出现并发失败进而导致另一次完全STW的Full GC的产生

由于并发执行,在垃圾收集阶段用户线程还需要持续运 行,那就还需要预留足够内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待 到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用。

要是CMS运行期间预留的内存无法满 足程序分配新对象的需要,就会出现一次“并发失败”(Concurrent Mode Failure),这时候虚拟机将触发担保机制暂停用户线程临时启用Serial Old收集器来重新进行老年代的垃圾收集

在JDK5中,默认当老年代使用了68%的空间后CMS收集器就会被激活,可以通过参数-XX:CMSInitiatingOccupancyFraction的值 来提高CMS的触发百分比,降低内存回收频率,获取更好的性能;但是,参数设置的太高将会很容易导致 大量的并发失败产生,性能反而降低,用户应在生产环境中根据实际应用情况来权衡设置。

到了JDK6,CMS收集器的启动 阈值就已经默认提升至92%。

3.收集结束时会产生大量空间碎片

CMS是一款基于“标记-清除”算法实现的收集器,收集结束时会有大量空间碎片产生。空间碎片过多,当有大对象时,即使老年代还有很多剩余空间,但是,无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC。

CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数(默认是开启的,此参数从 JDK 9开始废弃),用于在CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程,对内存碎片进行整理,(在Shenandoah和ZGC出现前)是无法并发的,需要暂停用户线程,但是,停顿时间又会变长。所以又提供了-XX:CMSFullGCsBeforeCompaction=N(此参数从JDK 9开始废弃)参数,该参数的作用就是,前N次Full GC不整理空间碎片,下一次进入Full GC前会先进行碎片整理(默认值为0,表 示每次进入Full GC时都进行碎片整理)。

对应的jvm option:-XX:+UseConcMarkSweepGC

开启后:ParNew(Young区) + CMS(Old区)收集器组合,但是ParNew开启后,老年代会启用Serial Old

年轻代使用标记-复制算法,老年代使用标记-清除算法

举例:

/**
 * @author smileha
 * @create 2022-03-06 22:38
 * @description 并发标记清除
 * -Xms10m -Xmx10m -XX:+PrintGCDetails -XX:+PrintCommandLineFlags -XX:+UseConcMarkSweepGC
 */

7.Garbage First(G1)收集器

G1是一款主要面向服务端应用的垃圾收集器。应用在多处理器大容量的内存环境中,在实现高吞吐量的同时,尽可能满足垃圾收集暂停时间的要求(目标是在延迟可控的情况下获得尽可能高的吞吐 量)。

G1垃圾回收器的特点:

  • G1可以面向堆内存任 何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而 是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。

  • G1仍是遵循分代收集理 论设计的,但是它“化整为零”,把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以 根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的 Region采用不同的策略去处理

  • 虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区 域(不需要连续)的动态集合

  • Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个 Region容量一半的对象即可判定为大对象。

  • 每个Region的大小可以通过参数-XX:G1HeapRegionSize设 定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象, 将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代 的一部分来进行看待。

    (默认将整堆划分为2048个区域,也即能够支持的最大内存为32M*2048=65536MB=64GB)

  • G1收集器可以跟踪各个Region里面的垃 圾堆积的“价值”大小,然后在后台维护一 个优先级列表,每次根据用户设定允许的收集停顿时间(用户可以通过-XX:MaxGCPauseMillis参数指定期望停顿时间,默 认值是200毫秒) ,优先处理回收价值收益最大的那些Region。

G1收集器的 运作过程大致可划分为以下四个步骤

  • 初始标记(Initial Marking):

    只是标记一下GC Roots能直接关联到的对象,并且修改TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要 停顿线程,但耗时很短。

  • 并发标记(Concurrent Marking):

    从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆 里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以 后,还要重新处理SATB(原始快照)记录下的在并发时有引用变动的对象。

  • 最终标记(Final Marking):

    对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留 下来的最后那少量的SATB记录。

  • 筛选回收(Live Data Counting and Evacuation):

    负责更新Region的统计数据,对各个Region的回 收价值成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region 构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧 Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行 完成的。

以年轻代为例,看G1是如何进行Young GC的:

 (G1收集器将整个的内存区都混合在一起了,但其本身依然在小范围内要进行年轻代和老年代的划分,依然会采用不同的GC方式处理不同的区域

针对Eden区进行收集,Eden区耗尽后会被触发,主要是小区域收集+形成连续的内存块

  • 把Eden区的存活对象复制到某些空闲区域(这些空闲区域可连续,可不连续,主要目的是减少内存碎片),把这些空闲区域作为新的survivor区,如果空闲区域放不下了(新的survivor区),将这部分对象晋升至老年代,然后清除整个旧的Region。

  • 原survivor区的存活对象复制到新的survivor区(可以看出G1中不再有明确的to space区了,而是选取空闲的区域作为to区),放不下的对象晋升至老年代,然后清除整个旧的Region。

  • 最后清理结束,用户线程继续执行。

G1与CMS进行对比:

  • G1可以指定最大停顿时间、物理上采用了Region的内存布局、可以按收益动态确定回收集

  • CMS采用的是“标记-清除”算法,G1从整体上看是基于“标记-整理”算法实现的,但从局部上看,又是基于“标记-复制”算法实现的,基于这两种算法,G1运行期间不会产生内存碎片

  • G1为了垃圾收集产生的内存占用(Footprint)比CMS高

    虽然G1和CMS都使用卡表来处理跨代指针,但G1的卡表实现更为复杂,而且 堆中每个Region,都必须有一份卡表,这导致G1的记忆集(和 其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间;相比起来CMS的卡表就相当简单, 只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要,由于新生代的对象具有朝 生夕灭的不稳定性,引用变化频繁,能省下这个区域的维护开销是很划算的

  • G1程序运行时的额外执行负载 (Overload)都要比CMS要高。

    譬如它们都使用到写屏障,CMS用写后屏障来更新维护卡表;而G1除了使用写后屏障来进行 同样的(由于G1的卡表结构复杂,其实是更烦琐的)卡表维护操作外,为了实现原始快照搜索 (SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况。

    相比起增量更新算法,原始快照 搜索能够减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的缺点, 但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。由于G1对写屏障的复杂操作 要比CMS消耗更多的运算资源,所以CMS的写屏障实现是直接的同步操作,而G1就不得不将其实现 为类似于消息队列的结构,把写前屏障和写后屏障中要做的事情都放到队列里,然后再异步处理。

对应的vm option:

-XX:UseG1GC 使用G1垃圾收集器

-XX:G1HeapRegionSize=n 设置Region的大小,值是2的幂次方,范围1-32MB

-XX:MaxGCPauseMillis=100 设置期望GC最大停顿时间,单位毫秒,默认200ms,这是个软目标,JVM将尽可能(但不保证)停顿小于这个目标。

-XX:InitiatingHeapOccupancyPercent=n 堆占用多少的时候触发GC,默认45

-XX:ConcGCThreads=n 并发GC使用的线程数

-XX:G1ReservePercent=n 设置作为空闲的预留内存百分比,以降低目标空间溢出的风险,默认10%

整体上采用标记整理算法,局部采用标记复制算法

举例:

/**
 * @author smileha
 * @create 2022-03-07 8:48
 * @description
 * -Xms10m -Xmx10m -XX:+PrintGCDetails -XX:+PrintCommandLineFlags
 * -XX:+UseG1GC -XX:MaxGCPauseMillis=150
 */

JDK 9发布之 日,G1宣告取代Parallel Scavenge加Parallel Old组合,成为服务端模式下默认垃圾收集器,而CMS则 沦落至被声明为不推荐使用(Deprecate)的收集器[1]。如果对JDK 9及以上版本的HotSpot虚拟机使用 参数-XX:+UseConcMarkSweepGC来开启CMS收集器的话,用户会收到一个警告信息,提示CMS未 来将会被废弃:

目前在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其 优势,这个优劣势的Java堆容量平衡点通常在6GB至8GB之间,当然,以上这些也仅是经验之谈,不 同应用需要量体裁衣地实际测试才能得出最合适的结论

8.关于垃圾收集器的总结

1.如果两个GC是默认的搭配组合,没必要全写上,写一个即可

2.如果两个GC没有组合关系,一起使用,会报错

 3.垃圾收集器的选择

  • 单CPU或小内存,单机程序

    -XX:+UseSerialGC

  • 多CPU,需要最大的吞吐量,如:后台计算型应用

    -XX:+UseParallelGC或者-XX:+UseParallelOldGC

  • 多CPU,追求低停顿时间,需要快速响应如互联网应用

    -XX:+UseConcMarkSweepGC

    -XX:+ParNewGC

  • 多CPU,大内存,追求停顿时间可控

    -XX:+UseG1GC

JVM两种运行模式的区别 - Alfie014 - 博客园

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值