JVM垃圾回收

对象分配过程概述

为新对象分配内存是件复杂的事,不仅需要考虑内存如何分配、在哪里分配,并且由于内存分配算法和内存回收算法密切相关,还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片。

一般情况

新创建的对象放在Eden区,该区域内存大小有限制。随着新对象的不断创建,该区域的内存会被填满。这时,JVM的垃圾回收器会执行YGC(Minor GC),将Eden区和不空闲的Survivor区中被视为垃圾的对象进行销毁,其他对象会被放入空闲的Survivor区,可能是Survivor0区也可能是Survivor1区。放入空闲Survivor区的对象的 age属性会加1。执行完YGC后,Eden区又是空闲状态,可以继续放新的对象直至Eden区满。就这样反复执行,但是放入空闲Survivor区的对象不会无休止的换区存放,会有一个阈值,默认15,如果对象的age超过15,就会被放入老年代。可以通过设置参数调整阈值:-XX:MaxTenuringThreshold=10

例如:A对象首先在Eden区,等到执行YGC时,如果该对象是垃圾对象,则进行销毁操作;如果不是垃圾对象,会放入空闲的Survivor0区,此时它的age为1,因为第一次进入Survivor区。然后等到第二次YGC时,扫描Eden区和不空闲的Survivor0区,此时A对象是在不空闲的Survivor0区,如果A对象变成垃圾对象,则进行销毁操作,如果不是,则放入空闲的Survivor1区,此时它的age会在原来age基础上+1为2,Survivor0变成空闲,Survivor1变成不空闲。第二次YGC时,如果A对象变成垃圾对象,则进行销毁操作,如果不是,则放入空闲的Survivor0区,此时它的age会在原来age基础上+1为3,Survivor1变成空闲,Survivor0变成不空闲。就这样循环,直至age为16时,将A对象放入老年代。

特殊情况

分配大内存对象

分配大对象内存时,先判断Eden区是否放的下,如果放不下,先执行YGC,在判断。如果还是放不下,再判断Old区是否放的下,如果Old区也放不下,执行FGC,再判断。如果还是放不下,就抛出OOM异常。

YGC时,先判断Survivor区是否放的下,如果放不下,就直接进入老年代。

内存分配和回收策略

  • 优先分配到Eden
  • 大对象直接分配到老年代,尽量避免程序中出现过多的大对象。
  • 长期存活的对象分配到老年代
  • 动态对象年龄判断:
  • 空间分配担保

大对象直接在老年代分配

超出某个大小的对象将直接在老年代分配。这个值是通过参数 -XX:PretenureSizeThreshold 进行配置的。默认为 0,意思是全部首选 Eden 区进行分配。

动态对象年龄判断

虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代。首先它会根据 -XX:TargetSurvivorRatio (默认 50,也就是 50%) 指定的比例,乘以 Survivor 一个区的大小,得出目标晋升空间大小。  如果 Survivor 区中小于或等于某一年龄的所有对象大小的总和大于目标晋升空间大小,那么年龄大于或等于该年龄的对象可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄,这么做的意义是为了避免-XX:MaxTenuringThreshold 设置过大导致大量对象无法晋升。主要是由 TargetSurvivorRatio 这个参数来控制。算的是年龄从小到大的累加和,而不是某个年龄段对象的大小。总体表征就是,年龄从小到大进行累加,当加入某个年龄段后,累加和超过survivor一个区的空间*TargetSurvivorRatio的时候,就从这个年龄段往上的年龄的对象进行晋升。注意:这里和《深入理解Java虚拟机》中的描述有出入。《深入理解Java虚拟机》中的描述是“如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。”

空间分配担保

每次存活的对象,都会放入其中一个幸存区,这个区域默认的比例是 10%。但是我们无法保证每次存活的对象都小于 10%,当 Survivor 空间不够,就需要依赖其他内存(指老年代)进行分配担保。这个时候,对象也会直接在老年代上分配。

在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于年轻代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。如果不成立,则虚拟机会先查看 

-XX:HandlePromotionFailure 参数的设置值是否允许担保失败;如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者 -XX:HandlePromotionFailure 设置不允许冒险,那这时就要改为进行一次 Full GC。

在JDK 6 Update 24 之后,-XX:HandlePromotionFailure 参数不再影响到虚拟机的空间分配担保策略,观察OpenJDK中的源码变化,虽然源码中还定义了 -XX:HandlePromotionFailure 参数,但是在实际虚拟机中已经不会在使用它。JDK 6 Update 24 之后的规则变为只要老年代的连续空间大于年轻代对象总大小或者历次晋升的平均大小,就会进行Minor GC,否则进行Full GC。

Minor GC、Major GC、Full GC

JVM 进行GC时,并非每次都对上面三个内存(年轻代、老年代、方法区(元空间/永久代))区域一起回收的,大部分时候回收的都是指年轻代。

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

  • 部分收集:不是完整收集整个Java堆的垃圾收集。其中又分为:
    • 年轻代收集(Minor GC/Young GC):只是年轻代(Eden、S0/S1)的垃圾收集
    • 老年代收集(Major GC/Old GC):只是老年代的垃圾收集。目前,只有CMS GC会有单独收集老年代的行为。注意,很多时候Major GC会和Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收。
    • 混合收集(Mixed GC):收集整个年轻代以及部分老年代的垃圾收集。目前,只有G1 GC会有这种行为。
  •  整堆收集(Full GC):收集整个java 堆和方法区的垃圾收集。

 年轻代GC(Minor GC/Young GC)触发机制:

当Eden区空间不足时,就会触发Minor GC,Survivor满不会引发GC,Survivor满只会让对象晋升为老年代。(每次Minor GC 会清理年轻代(Eden区 + S0/S1)的内存)。

Minor GC 会引发STW(Stop The World),暂停其他用户的线程,等垃圾回收结束,用户线程才恢复运行。因为Java对象大多都是朝生夕灭的,所以Minor GC非常频繁,一般回收速度也比较快。

 老年代GC(Major GC/Old GC)触发机制:

指发生在老年代的GC,对象从老年代消失时,我们说“Majot GC”或“Old GC”发生了。

出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC 的策略选择过程)。也就是说在老年代空间不足时,会先尝试触发Minor GC。如果之后空间还不足,则触发Major GC。如果Major GC以后,内存还不足,就报OOM了。

Major GC的速度一般会比Minor GC慢10倍以上,STW的时间更长。

Full GC 触发机制:

  • 调用System.gc()时,系统建议执行Full GC,但是不必然执行。
  • 老年代空间不足
  • 方法区空间不足

Full GC是开发或调优中尽量避免的。因为Full GC暂停服务的时候比较长。

垃圾回收相关算法

垃圾标记阶段

在堆里存放着几乎所有的Java对象实例,在GC执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是死亡对象。只有被标记为已经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,这个过程我们称为垃圾标记阶段。如何在JVM中标记一个死亡对象呢?简单来说,当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。

判断一个对象是否存活一般有两种方式:引用计数算法和可达性分析算法。

引用计数算法

引用计数算法(Reference Counting)比较简单,对每个对象保存一个整形的引用计数器属性。用于记录对象被引用的情况。

对于对象A,只要有任何一个对象引用了A,则A的引用计数器加1;当引用失效时,计数器减1。只要对象A的引用计数器值为0,即表示对象A不可能再被使用,可进行回收。

优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。

缺点:

  • 需要单独的字段存储计数器,增加存储空间的开销。
  • 每次赋值都需要更新计数器,伴随着加法和减法操作,增加时间的开销。
  • 引用计数器无法处理循环引用的情况,所以Java的垃圾回收器没有使用这类算法。

引用计数算法是很多语言的资源回收选择,例如Python,它同时支持引用计数和垃圾收集机制。那它是如何解决循环引用的呢?

  • 手动解除:在合适的时机,人为解除引用关系
  • 使用弱引用weakref,weakref是Python提供的标准库,旨在解决循环引用。

可达性分析算法

相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决引用计数算法中循环引用的问题,防止内存泄漏的发生。这种类型的垃圾收集通常也叫作追踪性垃圾收集(Tracing Garbage Collection)。Java、C#选择的就是这类算法。

基本思路:

可达性分析算法是以根对象集合(GC Roots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(reference Chain)。如果对象没有任何引用链相连,则是不可达的,就意味着该对象已经死亡,可以标记为垃圾对象。在可达性分析算法中,只有能够被根对象集合直接或间接连接的对象才是存活对象。所谓“GC Roots”根集合是一组必须活跃的引用。

GC Roots包含以下几类:

  1. 虚拟机栈中引用的对象。比如:各个线程被调用的方法中使用到的引用类型参数、局部变量、临时值等。也就是与我们栈帧相关的各种引用。
  2. 本地方法栈内JNI(通常说的本地方法)引用的对象,包括 global handles 和 local handles。
  3. 所有当前被加载的 Java 类。
  4. 方法区中类静态属性引用的对象。比如:Java类的引用类型静态变量
  5. 方法区中常量引用的对象。比如:字符串常量池(String Table)里的引用
  6. 用于同步的监控对象,比如所有被同步锁synchronized持有的对象,调用了对象的 wait() 方法
  7. Java虚拟机内部的引用。比如:基本数据类型对应的Class对象,一些常驻的异常对象(如:NullPointerException、OutOfMemoryError),系统类加载器。
  8. 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
  9. 除了以上固定的GC Roots集合以外,根据用户所选择的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整的GC Roots集合。比如:分代收集和局部回收(Partial GC)。如果只针对Java堆中的某一块区域进行垃圾回收(比如:典型的只针对新生代),必须考虑到内存区域是虚拟机自己的实现细节,更不是孤立封闭的,这个区域的对象完全有可能被其他区域的对象所引用,这时候就需要一并将关联的区域对象也加入GC Roots集合中去考虑,才能保证可达性分析的准确性。

这些 GC Roots 大体可以分为三大类,下面这种说法更加好记一些:

  • 活动线程相关的各种引用。
  • 类的静态变量的引用。
  • JNI 引用。

判定Root方法:由于Root 采用栈方式存放变量和指针,所以如果一个指针,他保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那他就是一个GC Root。

如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证了。这点也是导致GC进行时必须“STW(Stop The World)”的一个重要原因。即使号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。

注意:

  • 我们这里说的是活跃的引用,而不是对象,对象是不能作为 GC Roots 的。
  • GC 过程是找出所有活对象,并把其余空间认定为“无用”;而不是找出所有死掉的对象,并回收它们占用的空间。所以,哪怕 JVM 的堆非常的大,基于 tracing 的 GC 方式,回收速度也会非常快。

三色标记算法

迄今为止,所有垃圾收集器在根节点枚举这一步骤时都是必须暂停用户线程的,因此毫无疑问会面临 ”Stop The World“ 的困扰,也就是我们平时说的 STW。其实就是根节点枚举过程中必须在一个能保障一致性的快照中进行,说白了就相当于持久化的快照一样,在某个时间点这个过程像被冻结了。如果根节点枚举过程中整个根节点集合对象引用关系还在变化,那垃圾回收分析的结果也不会准确,所以这就导致垃圾收集过程中必须停顿所有用户线程。这样做的后果就是降低服务的响应时间,影响用户的体验。想要解决或者降低用户线程的停顿,三色标记算法就登场了。

三色标记法将对象分为三类:

  • 白色对象:表示尚未被回收器访问到的对象。在可达性分析刚刚开始的阶段,所有对象均为白色,当分析结束后,仍然是白色的对象,代表不可达。
  • 灰色对象:表示已被回收器访问到的对象,但回收器需要对其中的一个或多个指针进行扫描,因为他们可能还指向白色对象。即这个对象上至少存在一个引用还没有被扫描过。
  • 黑色对象:表示已被回收器访问到的对象,其中所有字段都已被扫描,黑色对象中任何一个指针都不可能直接指向白色对象。

标记过程:

1.初始状态,所有的对象都是白色的。

2.初始标记阶段,GC Roots 标记直接关联对象置为灰色。

3.并发标记阶段,扫描整个引用链。没有子节点的话,将本节点变为黑色。有子节点的话,则当前节点变为黑色,子节点变为灰色。

 

4.重复并发标记阶段,直至灰色对象没有其它子节点引用时结束。

 

6.扫描完成。此时黑色对象就是存活的对象,白色对象就是已消亡可回收的对象。即(A、D、E、F、G)可达也就是存活对象,(B、C、H)不可达可回收的对象。

缺点:

三色标记算法也存在缺陷,在并发标记阶段的时候,因为用户线程与 GC 线程同时运行,有可能会产生多标或者漏标。

多标:

假设已经遍历到 E(变为灰色了),此时应用执行了 objD.fieldE = null (D > E 的引用断开)

 

D > E 的引用断开之后,E、F、G 三个对象不可达,应该要被回收的。然而因为 E 已经变为灰色了,其仍会被当作存活对象继续遍历下去。最终的结果是:这部分对象仍会被标记为存活,即本轮 GC 不会回收这部分内存。

这部分本应该回收但是没有回收到的内存,被称之为浮动垃圾。浮动垃圾并不会影响应用程序的正确性,只是需要等到下一轮垃圾回收中才被清除。

另外,针对并发标记开始后的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能会变为垃圾,这也算是浮动垃圾的一部分。

实际上,这个问题可以通过“写屏障”来解决,只要在D写E的时候加入写屏障,记录下E被切断的记录,重新标记时可以再把他们标为白色即可。

漏标:

假设 GC 线程已经遍历到 E(变为灰色了),此时应用线程先执行了:

Object G = objE.fieldG; 
objE.fieldG = null;  // 灰色E 断开引用 白色G 
objD.fieldG = G;  // 黑色D 引用 白色G

此时切回到 GC 线程,因为 E 已经没有对 G 的引用了,所以不会将 G 置为灰色;尽管因为 D 重新引用了 G,但因为 D 已经是黑色了,不会再重新做遍历处理。

最终导致的结果是:G 会一直是白色,最后被当作垃圾进行清除。这直接影响到了应用程序的正确性,是不可接受的。

由此可知,漏标只有同时满足以下两个条件时才会发生:

  • 一个或者多个黑色对象重新引用了白色对象;即黑色对象成员变量增加了新的引用。
  • 灰色对象断开了白色对象的引用(直接或间接的引用);即灰色对象原来成员变量的引用发生了变化。

如下代码:

Object G = objE.fieldG; // 1.读
objE.fieldG = null;  // 2.写
objD.fieldG = G;     // 3.写

我们只需在上面三个步骤中任意一个中,将对象 G 记录起来,然后作为灰色对象再进行遍历即可。比如放到一个特定的集合,等初始的 GC Roots 遍历完(并发标记),该集合的对象遍历即可(重新标记)。

重新标记是需要 STW 的,因为应用程序一直在跑的话,该集合可能会一直增加新的对象,导致永远都跑不完。当然,并发标记期间也可以将该集合中的大部分先跑了,从而缩短重新标记 STW 的时间,这个是优化问题了。三色标记算法也并不能完全解决 STW 的问题,只能尽可能缩短 STW 的时间,尽可能达到停顿时间最少。

针对于漏标问题,JVM 团队采用了读屏障与写屏障的方案。读屏障是拦截第一步;而写屏障用于拦截第二和第三步。它们拦截的目的很简单:就是在读写前后,将对象 G 给记录下来。

读屏障

读屏障是直接针对第一步:Object G = objE.fieldG;,当读取成员变量之前,先记录下来。这种做法是保守的,但也是安全的。因为条件一中【一个或者多个黑色对象重新引用了白色对象】,重新引用的前提是:得获取到该白色对象,此时读屏障就发挥作用了。

写屏障

写屏障是针对第二、三步的写操作,所谓的写屏障,其实就是指给某个对象的成员变量赋值操作前后,加入一些处理(类似 Spring AOP 的概念)。

增量更新(Incremental Update)与原始快照(Snapshot At The Beginning,SATB)

增量更新

当对象 D 的成员变量的引用发生变化时(objD.fieldG = G;),我们可以利用写屏障,将 D 新的成员变量引用对象 G 记录下来。这种做法的思路是:不要求保留原始快照,而是当黑色指向白色的引用被建立时,针对新增的引用,通过写屏障将其记录下来,待扫描结束之后,再以这些记录中的黑色对象为根重新扫描一次。相当于黑色对象一旦建立了指向白色对象的引用,就会变为灰色对象。增量更新破坏了漏标的条件一:【 一个或者多个黑色对象重新引用了白色对象】,从而保证了不会漏标。

伪代码:

class D{
    private G g;

    public void setD(G g) {
        writeBarrier(g);// 插入一条写屏障
        this.g = g;
    }

    private void writeBarrier(G g){
        // 将D -> G的引用关系记录下来,后续重新扫描
    }
}

 

原始快照

当对象 E 的成员变量的引用发生变化时(objE.fieldG = null;),我们可以利用写屏障,将 E 原来成员变量的引用对象 G 记录下来。这种做法的思路是:尝试保留开始时的对象图,即原始快照(Snapshot At The Beginning,SATB),当某个时刻的 GC Roots 确定后,当时的对象图就已经确定了。比如当时 E 是引用着 G 的,那后续的标记也应该是按照这个时刻的对象图走(E 引用着 G)。如果期间发生变化,则可以记录起来。扫描结束之后,以这些灰色对象为根重新扫描一次。所以就像是快照一样,不管删没删,保证标记依然按照原本的视图来。SATB 破坏了漏标的条件二:【灰色对象断开了白色对象的引用(直接或间接的引用)】,从而保证了不会漏标。SATB也是有副作用的,如果被替换的白对象就是要被收集的垃圾,这次的标记会让它躲过GC,这就是float garbage。因为SATB的做法精度比较低,所以造成的float garbage也会比较多。

对于读写屏障,以 Java HotSpot VM 为例,其并发标记时对漏标的处理方案如下:

  • CMS:写屏障 + 增量更新
  • G1、Shenandoah:写屏障 + 原始快照
  • ZGC:读屏障

为什么会选择这样的方案呢?

  • 原始快照相对增量更新来说效率更高(当然原始快照可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描被删除引用对象。
  • 而 CMS 对增量引用的根对象会做深度扫描,G1 因为很多对象都位于不同的 region,CMS 就一块老年代区域,重新深度扫描对象的话 G1 的代价会比 CMS 高,所以 G1 选择原始快照不深度扫描对象,只是简单标记,等到下一轮 GC 再深度扫描。
  • 而 ZGC 有一个标志性的设计是它采用的染色指针技术,染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的目的通常是为了记录对象引用的变动情况,如果讲这些信息直接维护在指针中,显然可以省去一些专门的记录操作。而 ZGC 没有使用写屏障,只使用了读屏障,显然对性能大有裨益的。

对象的finalization机制

Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。当垃圾回收器发现没有引用指向一个对象,即:垃圾回收器回收此对象之前,总会先调用这个对象的finalize()方法。finalize()方法允许子类重写,用于在对象被回收时进行资源释放。通常这个方法会进行一些资源释放和清理工作,比如关闭文件、套接字和数据库连接等。

程序开发时,要避免主动调用某个对象的finalize()方法,应该交给垃圾回收机制调用。因为:

  1. 执行finalize()时可能会导致对象复活。
  2. finalize()方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生GC,finalize()方法将没有执行机会。
  3. 一个糟糕的finalize()方法会严重影响GC的性能。

从功能上来说,finalize()方法与C++中的析构函数比较相似,但是Java采用的是基于垃圾回收器的自动内存管理机制,所以finalize()方法在本质上不同于C++中的析构函数。

由于finalize()方法的存在,虚拟机中的对象一般处于三种状态。

如果从所有的根节点都无法访问到某个对象,说明该对象已经不再使用了。一般来说,此对象需要被回收。但事实上,也并不是一下子就立马回收的,这时候它们暂时处于“待回收”阶段。一个无法触及的对象有可能在某个条件下成为有用对象,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态如下:

  1. 可触及的:从根节点开始,可以到达这个对象。
  2. 可复活的:对象的所有引用都被释放,但是对象有可能在finalize()方法中重新成为有用对象。
  3. 不可触及的:对象的finalize()方法被调用,并且没有成为可用对象,那么就会进入不可触及状态。不可触及的对象不会重新成为有用对象,因为finalize()方法只会被调用一次。只有在对象不可触及的状态下,才可以被回收。

具体过程:

判定一个对象是否可回收,至少要经历两次标记过程:

  1. 如果对象到GC Roots没有引用链,则进行第一次标记。
  2. 进行筛选,判断此对象是否有必要执行finalize()方法
    1. 如果对象没有重写finalize()方法,或者finalize()方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,此对象被判定为不可触及的。
    2. 如果对象重写了finalize()方法,且未被执行,那么此对象会被插入到F-Queue队列中,由一个虚拟机自动创建、低优先级的的Finalizer线程触发其finalize()方法执行。
    3. finalize()方法是对象逃脱死亡的最后机会,稍后GC会对F-Queue队列中的对象进行第二次标记,如果该对象在finalize()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,该对象会被移出即将回收集合。之后,如果对象再次出现没有引用存在的情况。这时,finalize()方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的finalize()方法只会被调用一次。 

垃圾清除阶段

当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾回收,释放掉无用对象所占的内存空间,以便有足够的可用内存空间供新对象分配。目前在JVM中比较常见的三种垃圾收集算法是标记-清除算法(Mark-Sweep)、复制算法(Copying)、标记-压缩算法(Mark-Compact)。

标记-清除算法(Mark-Sweep)

当堆中的有效内存空间被耗尽时,就会停止整个程序(即STW),然后进行两项工作,第一项是标记,第二项则是清除。

标记:Collector从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的Header中记录为可达对象。

清除:Collector对堆内存从头到尾进行线性遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。

 缺点:效率不算高;清理出来的空闲内存是不连续的,容易产生内存碎片,需要维护一个空闲列表。

注意:这里的清除不是真的置空,只是吧需要清除的对象地址保存在空闲列表中。下次需要申请内存时,直接从空闲列表中取,然后覆盖原来的对象。

复制算法(Copying)

将可用内存空间分为两块,每次只使用一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。回收前和回收后的内存空间是两个。

优点:没有标记和清除过程,实现简单,运行高效;不会出现碎片问题。

缺点:需要的空间变成了两倍;对于G1这种分拆成大量region的GC,复制而不是移动,意味着GC需要维护region之间对象的引用关系,不管是内存占用还是时间开销都不太友好。

应用场景:在年轻代中,对常规应用的垃圾回收,一次通常可以回收70%-99%的内存空间。回收性价比高。所以现在的商业虚拟机都是用这种收集算法回收年轻代,即之前所说的S0和S1区。复制算法的高效性是建立在存活对象少、垃圾对象多的前提下。这种情况在新生代经常发生,但是在老年代,大部分对象都是存活对象。如果使用复制算法,成本会很高。因此,基于老年代垃圾回收特性,需要使用其他算法。

标记-压缩算法(Mark-Compact)

执行过程:

第一阶段和标记-清除算法一样,从根节点开始标记所有被引用的对象,第二阶段将所有的存活对象压缩到内存的一端,按顺序排列。之后,清理边界外所有空间。回收前和回收后的内存空间是同一个。

 

标记-压缩算法的最终效果等同于标记-清除算法执行完以后,在执行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩(Mark-Sweep-Compact)算法。二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策,有好处也有坏处。

标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。这样一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了很多开销。

优点:解决了标记-清除算法的碎片问题,解决了复制算法的内存利用率不高的问题。

缺点:从效率上来说,标记-整理算法要低于复制算法。移动对象的同时,如果对象被其他对象引用,还需要调整引用的地址。

分代收集算法

前面所讲的算法,都是单一算法,没有一种算法可以完全替代其他算法,他们都具有自己的优势和特点。实际使用时,不会单一选择某种算法,而是具体问题具体分析,根据场景选择合适的算法。

分代收集算法是基于不同对象的生命周期不一样这一实际情况来说的。因为不同生命周期的对象可以采取不同的收集方式,以提高回收效率。之前说过,Java堆分为年轻代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。

在Java程序运行过程中,会产生大量的对象,其中某些对象是与业务信息相关,比如Http请求中的session对象、线程、Socket链接,这类对象跟业务直接挂钩,因此生命周期长。但是有一些对象,是程序运行过程中生成的临时变量,这些对象的生命周期短,比如:String对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。

目前几乎所有的GC都是采用分代收集算法执行垃圾回收的。

在HotSpot中,基于分代的概念,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点。

  • 年轻代(Young Gen)
    • 特点:区域相对于老年代较小,对象生命周期短,存活率低,回收频繁。这种情况下适合使用复制算法的回收整理,它的速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适合年轻代的回收。而复制算法内存利用率不高的问题,可以通过HotSpot中的两个survivor解决。
  • 老年代(Tenured Gen)
    • 特点:区域大,对象生命周期长、存活率高,回收不及年轻代频繁。这种情况下存在大量存活率高的对象,不适合复制算法,一般是由标记-清除或者是标记-清除与标记-整理的混合实现。
    • Mark阶段的开销与存活对象的数量成正比。
    • Sweep阶段的开销与所管理区域的大小成正比。
    • Compact阶段的开销与存活对象的数据成正比。    

以HotSpot中的CMS回收器为例,CMS是基于Mark-Sweep实现的,对于对象的回收效率很高。而对于碎片问题,CMS采用基于Mark-Compact算法的Serial Old回收器作为补偿措施:当内存回收不佳时(碎片导致的Concurrent Mode Failure),将采用Serial Old执行Full GC以达到对老年代内存的整理。

分代收集算法会有一个问题,那就是我们进行MinorGC时,由于部分对象有可能被老年代引用着,利用可达性分析算法判断对象的存活,不光要管GC Roots,还要去遍历老年代,性能就会有问题。

跨代引用相对于同代引用来说仅占极少数,由此产生了一个新的解决方案,我们不用去扫描整个老年代了,只要在年轻代建立一个数据结构,叫做记忆集Remembered Set,他把老年代划分为N个区域,标志出哪个区域会存在跨代引用。以后在进行MinorGC的时候,只要把这些包含了跨代引用的内存区域加入GC Roots一起扫描就行了。

卡表

卡表实际上就是记忆集的一种实现方式,如果说记忆集是接口的话,那么卡表就是他的实现类。

对于HotSpot虚拟机来说,卡表的实现方式就是一个字节数组。

CARD_TABLE [this address >> 9] = 0;

这段代码代表着卡表标记的的逻辑。实际上卡表就是映射了一块块的内存地址,这些内存地址块称为卡页,从代码可以看出每个卡页的大小就是2^9=512字节。如果转换为16进制,数组的0,1号元素就映射为0x0000~0x01FF(0-511)、0x0200~0x03FF(512-1023)内存地址的卡页。

只要一个卡页内的对象存在一个或者多个跨代对象指针,就将该位置的卡表数组元素修改为1,表示这个位置为脏,没有则为0。在GC的时候,就直接把值为1对应的卡页对象指针加入GC Roots一起扫描即可。有了卡表,我们就不需要去在发生MinorGC的时候扫描整个老年代了,性能得到了极大的提升。当完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。

由于 Minor GC 伴随着存活对象的复制,而复制需要更新指向该对象的引用。因此,在更新引用的同时,我们又会设置引用所在的卡的标识位。这个时候,我们可以确保脏卡中必定包含指向新生代对象的引用。

在 Minor GC 之前,我们并不能确保脏卡中包含指向新生代对象的引用。其原因和如何设置卡的标识位有关。

卡表的问题:

1.写屏障

如果想要保证每个可能有指向新生代对象引用的卡都被标记为脏卡,那么 Java 虚拟机需要截获每个引用型实例变量的写操作,并作出对应的写标识位操作。即卡表的数组元素修改成1,也就是脏的状态。这个操作在解释执行器中比较容易实现。但是在即时编译器生成的机器码中,则需要插入额外的逻辑。也就是通过写屏障来实现的,实际上就是在其他分代引用了当前分代的对象时候,在对引用进行赋值的时候进行更新,更新的方式类似AOP的切面思想。虽然写屏障不可避免地带来一些开销,但是它能够加大 Minor GC 的吞吐率( 应用运行时间 /(应用运行时间 + 垃圾回收时间) )。总的来说还是值得的。不过,在高并发环境下,写屏障又带来了虚共享(false sharing)问题 。

void oop_field_store(oop* field, oop new_value) { 
//写前屏障
pre_write_barrier(field);
// 引用字段赋值操作 *field = new_value; // 写后屏障,在这里完成卡表状态更新 post_write_barrier(field, new_value); }

2.伪共享

这里的伪共享不是几个 volatile 字段出现在同一缓存行里造成的虚共享。这里的虚共享则是卡表中不同卡的标识位之间的虚共享问题。

缓存行通常来说都是64字节,一个卡表元素1个字节,占用的卡页内存大小就是64*512=32KB的大小。如果多线程刚好更新刚好处于这32KB范围内的对象,那么也将造成存储卡表的同一部分的缓存行的写回、无效化或者同步操作,因而间接影响程序性能。

如何解决

JDK7之后新增了一个参数-XX:+UseCondCardMark,他代表是否开启卡表更新的判断,没有被标记过才标记为脏。

if (CARD_TABLE [this address >> 9] != 0) 
   CARD_TABLE [this address >> 9] = 0;

卡表主要解决跨代收集和根节点枚举的性能问题。有了这些措施实际上枚举根节点这个过程造成的STW停顿已经属于可控范围。

增量收集算法

上述现有的算法,在垃圾回收过程中,Java程序会处于一种Stop The World的状态。此状态下,程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,程序会被挂起很久,严重影响用户的体验或系统的稳定性。于是,增量收集算法应运而生。

基本思想:如果一次性将所有的垃圾进行回收,会造成系统长时间的停顿。所以我们可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,然后切换到应用程序线程,反复执行,直至垃圾收集完成。总的来说,增量收集算法的基础仍是传统的标记-清除和复制算法,增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。

缺点:使用这种方式,由于垃圾回收过程中,间断性地执行应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量下降。

分区收集算法

一般来说,在相同条件下,堆空间越大,一次GC所需要的时间就越长,有关GC产生的停顿也越长。为了更好地控制GC产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次GC所产生的停顿。

分代算法按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间。每个小区间独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。

垃圾回收相关概念

System.gc()

默认情况下,通过System.gc()或者Runtime.getRunTime().gc()的调用,会显式触发Full GC,同时对老年代和年轻代进行回收,尝试释放被丢弃对象占用的内存。然而,System.gc()方法无法保证对垃圾收集器的调用。可以这样理解,System.gc()只是负责通知垃圾收集器需要垃圾回收,但垃圾收集器是否立刻进行垃圾回收是它自己决定的。

JVM实现者可以通过System.gc()调用来决定JVM的GC行为。而这一般情况下,垃圾回收应该是自动进行的,无须手动触发,否则太过麻烦了。在一些特殊情况下,如我们正在编写一个性能基准,我们可以在运行时调用System.gc()。

System.runFinalization(); 会强制调用失去引用的对象的 finalize() 方法。

案例demo

启动参数加上:-XX:+PrintGCDetails

public class SystemGCTest {
    public void SystemGC1() {
        byte[] buffer = new byte[10 * 1024 * 1024];
        System.gc();
    }

    public void SystemGC2() {
        byte[] buffer = new byte[10 * 1024 * 1024];
        buffer = null;
        System.gc();
    }

    public void SystemGC3() {
        {
            byte[] buffer = new byte[10 * 1024 * 1024];
        }
        System.gc();
    }

    public void SystemGC4() {
        {
            byte[] buffer = new byte[10 * 1024 * 1024];
        }
        int value = 10;
        System.gc();
    }

    public void SystemGC5() {
        SystemGC1();
        System.gc();
    }

    public static void main(String[] args) {
        SystemGCTest systemGCTest = new SystemGCTest();
        systemGCTest.SystemGC1();
        systemGCTest.SystemGC2();
        systemGCTest.SystemGC3();
        systemGCTest.SystemGC4();
        systemGCTest.SystemGC5();
    }
}

方法 SystemGC1() 执行结果:

[GC (System.gc()) [PSYoungGen: 16744K->10736K(75776K)] 16744K->11064K(249344K), 0.0060282 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
[Full GC (System.gc()) [PSYoungGen: 10736K->0K(75776K)] [ParOldGen: 328K->11007K(173568K)] 11064K->11007K(249344K), [Metaspace: 3270K->3270K(1056768K)], 0.0049958 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
Heap
 PSYoungGen      total 75776K, used 433K [0x000000076bd80000, 0x0000000771200000, 0x00000007c0000000)
  eden space 65024K, 0% used [0x000000076bd80000,0x000000076bdec770,0x000000076fd00000)
  from space 10752K, 0% used [0x000000076fd00000,0x000000076fd00000,0x0000000770780000)
  to   space 10752K, 0% used [0x0000000770780000,0x0000000770780000,0x0000000771200000)
 ParOldGen       total 173568K, used 11007K [0x00000006c3800000, 0x00000006ce180000, 0x000000076bd80000)
  object space 173568K, 6% used [0x00000006c3800000,0x00000006c42bfdc0,0x00000006ce180000)
 Metaspace       used 3277K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 357K, capacity 388K, committed 512K, reserved 1048576K

第一次GC是YGC,执行后,年轻代的空间占用10M多,第二次是Full GC,执行后,年轻代空间清空,老年代空间占用10M多。说明GC 没有回收 buffer对象。

方法 SystemGC2() 执行结果:

[GC (System.gc()) [PSYoungGen: 16744K->904K(75776K)] 16744K->912K(249344K), 0.0010207 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 904K->0K(75776K)] [ParOldGen: 8K->767K(173568K)] 912K->767K(249344K), [Metaspace: 3271K->3271K(1056768K)], 0.0036451 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
Heap
 PSYoungGen      total 75776K, used 433K [0x000000076bd80000, 0x0000000771200000, 0x00000007c0000000)
  eden space 65024K, 0% used [0x000000076bd80000,0x000000076bdec770,0x000000076fd00000)
  from space 10752K, 0% used [0x000000076fd00000,0x000000076fd00000,0x0000000770780000)
  to   space 10752K, 0% used [0x0000000770780000,0x0000000770780000,0x0000000771200000)
 ParOldGen       total 173568K, used 767K [0x00000006c3800000, 0x00000006ce180000, 0x000000076bd80000)
  object space 173568K, 0% used [0x00000006c3800000,0x00000006c38bfdb0,0x00000006ce180000)
 Metaspace       used 3278K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 357K, capacity 388K, committed 512K, reserved 1048576K

第一次GC后,年轻代被占用的空间就已经没有10M了,说明GC回收了 buffer 对象。

方法 SystemGC3() 执行结果:

[GC (System.gc()) [PSYoungGen: 16744K->10728K(75776K)] 16744K->11128K(249344K), 0.0061685 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
[Full GC (System.gc()) [PSYoungGen: 10728K->0K(75776K)] [ParOldGen: 400K->11007K(173568K)] 11128K->11007K(249344K), [Metaspace: 3270K->3270K(1056768K)], 0.0053899 secs] [Times: user=0.05 sys=0.00, real=0.01 secs] 
Heap
 PSYoungGen      total 75776K, used 433K [0x000000076bd80000, 0x0000000771200000, 0x00000007c0000000)
  eden space 65024K, 0% used [0x000000076bd80000,0x000000076bdec770,0x000000076fd00000)
  from space 10752K, 0% used [0x000000076fd00000,0x000000076fd00000,0x0000000770780000)
  to   space 10752K, 0% used [0x0000000770780000,0x0000000770780000,0x0000000771200000)
 ParOldGen       total 173568K, used 11007K [0x00000006c3800000, 0x00000006ce180000, 0x000000076bd80000)
  object space 173568K, 6% used [0x00000006c3800000,0x00000006c42bfdc0,0x00000006ce180000)
 Metaspace       used 3277K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 357K, capacity 388K, committed 512K, reserved 1048576K

和SystemGC1() 执行结果一样,也未被回收。因为buffer 对象仍然占用了局部变量表的槽。

 SystemGC3() 的局部变量表槽数为2

当前局部变量表,this占用了第一个,第二个虽然没显示,但我们可从代码中得知占用第二个位置的是buffer 对象。此时,还存在引用,不会回收。

 方法 SystemGC4() 执行结果:

[GC (System.gc()) [PSYoungGen: 16744K->904K(75776K)] 16744K->912K(249344K), 0.0025389 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 904K->0K(75776K)] [ParOldGen: 8K->767K(173568K)] 912K->767K(249344K), [Metaspace: 3270K->3270K(1056768K)], 0.0075384 secs] [Times: user=0.03 sys=0.00, real=0.01 secs] 
Heap
 PSYoungGen      total 75776K, used 433K [0x000000076bd80000, 0x0000000771200000, 0x00000007c0000000)
  eden space 65024K, 0% used [0x000000076bd80000,0x000000076bdec770,0x000000076fd00000)
  from space 10752K, 0% used [0x000000076fd00000,0x000000076fd00000,0x0000000770780000)
  to   space 10752K, 0% used [0x0000000770780000,0x0000000770780000,0x0000000771200000)
 ParOldGen       total 173568K, used 767K [0x00000006c3800000, 0x00000006ce180000, 0x000000076bd80000)
  object space 173568K, 0% used [0x00000006c3800000,0x00000006c38bfe88,0x00000006ce180000)
 Metaspace       used 3277K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 357K, capacity 388K, committed 512K, reserved 1048576K

通过执行结果可知,对象被回收了。SystemGC4()  和SystemGC3() 的区别在于代码块后面还执行了代码,导致占用本地变量表第二个槽的变量发生了变更。buffer 对象被移除出本地变量表,失去了引用,被回收。

 方法 SystemGC5() 执行结果:

[GC (System.gc()) [PSYoungGen: 16744K->10736K(75776K)] 16744K->11104K(249344K), 0.0055142 secs] [Times: user=0.01 sys=0.03, real=0.01 secs] 
[Full GC (System.gc()) [PSYoungGen: 10736K->0K(75776K)] [ParOldGen: 368K->10983K(173568K)] 11104K->10983K(249344K), [Metaspace: 3263K->3263K(1056768K)], 0.0065093 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
[GC (System.gc()) [PSYoungGen: 0K->0K(75776K)] 10983K->10983K(249344K), 0.0002812 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 0K->0K(75776K)] [ParOldGen: 10983K->743K(173568K)] 10983K->743K(249344K), [Metaspace: 3263K->3263K(1056768K)], 0.0042803 secs] [Times: user=0.08 sys=0.00, real=0.00 secs] 
Heap
 PSYoungGen      total 75776K, used 1734K [0x000000076bd80000, 0x0000000771200000, 0x00000007c0000000)
  eden space 65024K, 2% used [0x000000076bd80000,0x000000076bf31970,0x000000076fd00000)
  from space 10752K, 0% used [0x0000000770780000,0x0000000770780000,0x0000000771200000)
  to   space 10752K, 0% used [0x000000076fd00000,0x000000076fd00000,0x0000000770780000)
 ParOldGen       total 173568K, used 743K [0x00000006c3800000, 0x00000006ce180000, 0x000000076bd80000)
  object space 173568K, 0% used [0x00000006c3800000,0x00000006c38b9c40,0x00000006ce180000)
 Metaspace       used 3275K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 357K, capacity 388K, committed 512K, reserved 1048576K

前两个GC是SystemGC3()的GC 结果,未进行回收,后两个GC 是SystemGC5() 的GC 结果,显示已回收。因为SystemGC1()执行完后,栈帧退出栈,对象失去引用。

内存溢出(OOM)

没有空闲内存,并且垃圾收集器执行完垃圾回收后还是无法提供更多内存,就会出现内存溢出(Out Of Memory)。

内存区域有哪些会发生 OOM 呢?可以从内存区域划分图上,看一下彩色部分。

可以看到除了程序计数器,其他区域都有OOM溢出的可能。但是最常见的还是发生在堆上。

OOM 到底是什么引起的呢?有几个原因:

  • JVM的堆内存设置不够。
  • 存在大量大对象,并且长时间不能被垃圾收集器收集。
  • 错误的引用方式,发生了内存泄漏。没有及时的切断与 GC Roots 的关系。比如线程池里的线程,在复用的情况下忘记清理 ThreadLocal 的内容。
  • 接口没有进行范围校验,外部传参超出范围。比如数据库查询时的每页条数等。
  • 对堆外内存无限制的使用。这种情况一旦发生更加严重,会造成操作系统内存耗尽。

内存泄漏(Memory Leak)

对象不会再被程序使用,且GC不能回收他们,这种情况称为内存泄漏。实际上,很多时候一些不太好的操作会导致对象的生命周期变得很长甚至导致OOM,这种情况也可以叫做宽泛意义上的内存泄漏。

例子:

  • 单例模式:单例的生命周期和应用程序一样长,所以在单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,会导致内存泄漏。
  • 一些提供close的资源未关闭导致内存泄漏:数据库连接(DataSource Connection),网络连接(Socket)和IO连接必须手动close,否则是不能被回收的。
  • 对象没有及时的释放自己的引用:一个局部变量,被外部的静态集合引用。类A的a方法定义了一个集合,这个集合被类B的静态集合引用。

Stop The World

Stop-The-World,简称STW,指的是GC事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为STW。

Java 虚拟机中的 Stop-the-world 是通过安全点(safepoint)机制来实现的。当 Java 虚拟机收到 Stop-the-world 请求,它便会等待所有的线程都到达安全点,才允许请求 Stop-the-world 的线程进行独占的工作。

可达性分析算法中枚举GC Roots 会导致所有Java执行线程停顿。分析工作必须在一个能确保一致性的快照中进行,一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上。如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证。

被STW中断的应用程序线程会在完成GC之后恢复,频繁中断会让用户感觉像是网速不快造成电影卡带一样,所以我们要尽量避免STW的发生。

垃圾回收的并发和并行

  • 并行(Parallel):指多条垃圾收集线程并行工作,此时用户线程处于等待状态。如 ParNew、Parallel Scavenge、Parallel Old;
  • 串行(Serial):相较于并行概念,单线程执行。
  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行,可能是交替执行)。用户程序在继续运行,而垃圾收集程序线程运行于另一个CPU上,如:CMS、G1。

安全点和安全区域

安全点(SafePoint)

程序执行时并非在任何地方都能停顿下来开始GC,只有在特定的位置才能停顿下来开始GC,这些位置称为“安全点”。

举个例子,当 Java 程序通过 JNI 执行本地代码时,如果这段代码不访问 Java 对象、调用 Java 方法或者返回至原 Java 方法,那么 Java 虚拟机的堆栈不会发生改变,也就代表着这段本地代码可以作为同一个安全点。

只要不离开这个安全点,Java 虚拟机便能够在垃圾回收的同时,继续运行这段本地代码。

由于本地代码需要通过 JNI 的 API 来完成上述三个操作,因此 Java 虚拟机仅需在 API 的入口处进行安全点检测(safepoint poll),测试是否有其他线程请求停留在安全点里,便可以在必要的时候挂起当前线程。

除了执行 JNI 本地代码外,Java 线程还有其他几种状态:解释执行字节码、执行即时编译器生成的机器码和线程阻塞。阻塞的线程由于处于 Java 虚拟机线程调度器的掌控之下,因此属于安全点。

其他几种状态则是运行状态,需要虚拟机保证在可预见的时间内进入安全点。否则,垃圾回收线程可能长期处于等待所有线程进入安全点的状态,从而变相地提高了垃圾回收的暂停时间。

对于解释执行来说,字节码与字节码之间皆可作为安全点。Java 虚拟机采取的做法是,当有安全点请求时,执行一条字节码便进行一次安全点检测。

执行即时编译器生成的机器码则比较复杂。由于这些代码直接运行在底层硬件之上,不受 Java 虚拟机掌控,因此在生成机器码时,即时编译器需要插入安全点检测,以避免机器码长时间没有安全点检测的情况。HotSpot 虚拟机的做法便是在生成代码的方法出口以及非计数循环的循环回边(back-edge)处插入安全点检测。

那么为什么不在每一条机器码或者每一个机器码基本块处插入安全点检测呢?原因主要有两个。

第一,安全点检测本身也有一定的开销。不过 HotSpot 虚拟机已经将机器码中安全点检测简化为一个内存访问操作。在有安全点请求的情况下,Java 虚拟机会将安全点检测访问的内存所在的页设置为不可读,并且定义一个 segfault 处理器,来截获因访问该不可读内存而触发 segfault 的线程,并将它们挂起。

第二,即时编译器生成的机器码打乱了原本栈桢上的对象分布状况。在进入安全点时,机器码还需提供一些额外的信息,来表明哪些寄存器,或者当前栈帧上的哪些内存空间存放着指向对象的引用,以便垃圾回收器能够枚举 GC Roots。

由于这些信息需要不少空间来存储,因此即时编译器会尽量避免过多的安全点检测。

不过,不同的即时编译器插入安全点检测的位置也可能不同。以 Graal 为例,除了上述位置外,它还会在计数循环的循环回边处插入安全点检测。其他的虚拟机也可能选取方法入口而非方法出口来插入安全点检测。

不管如何,其目的都是在可接受的性能开销以及内存开销之内,避免机器码长时间不进入安全点的情况,间接地减少垃圾回收的暂停时间。

除了垃圾回收之外,Java 虚拟机其他一些对堆栈内容的一致性有要求的操作也会用到安全点这一机制。

安全点的选择很重要,如果太少会导致GC等待的时间太长,如果太多会导致运行时存在性能问题。大部分指令的执行时间都非常短暂,通常会根据“是否具有让程序长时间执行的特征”为标准。比如:选择一些执行时间较长的指令作为安全点,如方法调用、循环跳转和异常跳转等。

如何在GC发生时,检查所有线程都跑到最近的安全点停顿下来呢?

  • 抢先式中断(目前没有虚拟机采用):首先中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安全点。
  • 主动式中断:设置一个中断标志,各个线程运行到安全点的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂机。

安全区域(SafeRegion)

安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的安全点。但是,程序不执行的时候呢?例如线程处于Sleep状态或者Blocked状态,这时候线程无法响应JVM的中断请求,运行到安全点去中断挂起,JVM也不太可能等待线程被唤醒。对于这种情况,就需要安全区域来解决。

安全区域是指一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的。我们也可以吧SafeRegion看做是扩展的SafePoint。

实际执行时:

当线程运行到安全区域的代码时,首先标识已经进入了安全区域,如果这段时间内发生了GC,JVM会忽略标识为安全区域状态的线程,因为安全区域中的对象引用关系不会发生变化,没必要GC。当线程即将离开安全区域时,会检查JVM是否已经完成GC,如果完成了,则继续运行,否则线程必须等待直到收到可以安全离开安全区域的信号为止。就好像,收拾房子的时候,让你先在一个不会有垃圾的屋子待着,等其他屋子收拾好了,你才可以走出屋子,如果还没收拾好,需要等待直到收到收拾好了的消息才可以离开屋子。而你待着的这个屋子是不会产生垃圾的,所以不用收拾。

引用类型

在JDK1.2 之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。除强引用外,其它三种引用都可以在java.lang.ref包中找到它们。如下图所示:

 Reference 子类中只有终结器引用(FinalReference)是包内可见的,其它3种引用类型均为public,可以在应用程序中直接使用。

  • 强引用:最传统的引用定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object object = new Object();”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收被引用的对象。
  • 软引用:在系统将要发生内存溢出之前,将会把这部分对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。
  • 弱引用:被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象。
  • 虚引用:一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

强引用

在java程序中,最常见的引用类型就是强引用(普通系统99%以上都是强引用),也就是我们最常见的普通对象引用,也是默认的引用类型。

当使用new操作符创建一个对象,并将其赋值给一个变量时,这个变量就成为指向该对象的一个强引用。强引用的对象是可触及的,垃圾收集器就永远不会回收被引用的对象。对于一个普通对象,如果没有其他引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为null,就可以当做垃圾回收了,当然具体回收时机还是要看垃圾收集策略。相对的,软引用、弱引用和虚引用的对象是软可触及、弱可触及和虚可触及的,在一定条件下,都是可以被回收的。所以,强引用是造成Java内存泄漏的主要原因之一。

举个例子:

假如你的系统被大量用户(User)访问,你需要记录这个 User 访问的时间。可惜的是,User 对象里并没有这个字段,所以我们决定将这些信息额外开辟一个空间进行存放。

static Map<User,Long> userVisitMap = new HashMap<>();

...

userVisitMap.put(user, time);

当你用完了 User 对象,其实你是期望它被回收掉的。但是,由于它被 userVisitMap 引用,我们没有其他手段 remove 掉它。这个时候,就发生了内存泄漏(memory leak)。

这种情况还通常发生在一个没有设定上限的 Cache 系统,由于设置了不正确的引用方式,加上不正确的容量,很容易造成 OOM。

软引用

软引用是用来描述一些还有用,但非必需的对象。软引用通常用来实现内存敏感的缓存。比如:高速缓存就有用到软引用。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就可以保证使用缓存的同时,不会耗尽内存。

Guava 的 CacheBuilder,就提供了软引用和弱引用的设置方式。在这种场景中,软引用比强引用安全的多。

垃圾回收器在某个时刻决定回收软可达对象时,会清理软引用,并可选地把引用存放到一个引用队列。Java虚拟机会尽量让软引用的存活时间长一些,迫不得已才清理。

软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,Java 虚拟机就会把这个软引用加入到与之关联的引用队列中。

实现软引用:

Object object = new Object();
SoftReference<Object> softReference = new SoftReference<Object>(object);
object = null;
softReference.get();

这里有一个相关的 JVM 参数。它的意思是:每 MB 堆空闲空间中 SoftReference 的存活时间。这个值的默认时间是1秒(1000)。

-XX:SoftRefLRUPolicyMSPerMB=<N>

弱引用

弱引用也是用来描述那些非必需对象,只被弱引用关联的对象只能生存到下一次垃圾收集发生为止。在系统GC时,只要发现弱引用,不管系统堆空间是否充足,都会回收掉只被弱引用关联的对象。但是,由于垃圾回收器的线程通常优先级很低,因此,并不一定能很快地发现持有弱引用的对象。这种情况下,弱引用对象可以存在较长的时间。弱引用和软引用一样,在构造弱引用时,也可以指定一个引用队列,当弱引用对象被回收时,就会加入指定的引用队列,通过这个队列可以追踪对象的回收情况。

软引用和弱引用都非常适合保存那些可有可无的缓存数据。如果这么做,当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出。而当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用。

实现弱引用

Object object = new Object();
WeakReference<Object> weakReference = new WeakReference<Object>(object);
object = null;
weakReference.get();

弱引用对象和软引用对象最大的不同在于,当GC进行回收时,需要通过算法检查是否回收软引用对象,而对于弱引用对象,GC总是进行回收。弱引用对象更容易、更快被回收。

虚引用

虚引用也称为“幽灵引用”或者“幻影引用”,是所有引用类型中最弱的一个。一个对象是否有虚引用的存在,完全不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它和没有引用几乎是一样的,随时都可能被垃圾收集器回收。它不能单独使用,也无法通过虚引用来获取被引用的对象。当试图通过虚引用的get()方法获取对象时,总是返回null。

为一个对象设置虚引用关联的唯一目的在于跟踪垃圾回收过程。比如:能在这个对象被收集器回收时收到一个系统通知。

虚引用必须和引用队列一起使用。虚引用在创建时必须提供一个引用队列作为参数。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象后,将这个虚引用加入引用队列。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动,以通知应用程序对象的回收情况。

由于虚引用可以跟踪对象的回收时间,因此,也可以将一些资源释放操作放置在虚引用中执行和记录。

实现虚引用

Object object = new Object();
ReferenceQueue referenceQueue = new ReferenceQueue();
PhantomReference<Object> phantomReference = new PhantomReference<Object>(object,referenceQueue);
object = null;
System.out.println(phantomReference.get());

虚引用使用案例

public class PhantomReferenceTest {
    public static PhantomReferenceTest obj;
    static ReferenceQueue<PhantomReferenceTest> queue = null;

    public static class CheckRefQueue extends Thread {
        @Override
        public void run() {
            while (true) {
                if (queue != null) {
                    PhantomReference<PhantomReferenceTest> phantomReference = null;
                    try {
                        phantomReference = (PhantomReference<PhantomReferenceTest>) queue.remove();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    if (phantomReference != null) {
                        System.out.println("追踪垃圾回收过程,PhantomReferenceTest实例被GC了");
                    }
                }
            }
        }

    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("调用当前类的finalize()方法");
        obj = this;
    }

    public static void main(String[] args) {
        Thread thread = new CheckRefQueue();
        thread.setDaemon(true);
        thread.start();
        queue = new ReferenceQueue<>();
        obj = new PhantomReferenceTest();
        PhantomReference<PhantomReferenceTest> phantomReference = new PhantomReference<>(obj, queue);

        try {
            System.out.println(phantomReference.get());
            obj = null;
            System.gc();
            Thread.sleep(1000);
            if (obj == null) {
                System.out.println("obj is null");
            } else {
                System.out.println("obj is not null");
            }
            System.out.println("第二次 GC");

            obj = null;
            System.gc();//一旦将obj对象回收,就会将此虚引用存放到引用队列中。
            Thread.sleep(1000);
            if (obj == null) {
                System.out.println("obj is null");
            } else {
                System.out.println("obj is not null");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

基于虚引用,有一个更加优雅的实现方式,那就是 Java 9 以后新加入的 Cleaner,用来替代 Object 类的 finalizer 方法。

终结器引用

它用以实现对象的finalize()方法,也可以称为终结器引用。无需手动编码,其内部配合引用队列使用。在GC时,终结器引用入队。由Finalizer线程通过终结器引用找到被引用对象并调用它的finalize()方法,第二次GC时才能回收被引用对象。

垃圾回收器

垃圾收集器分类

按照线程数分

可以分为串行垃圾回收器和并行垃圾回收器。

串行回收指的是在同一时间段内只允许有一个CPU用于执行垃圾回收操作,此时工作线程被暂停,只在垃圾收集工作结束。在诸如单CPU处理器或者较小的应用内存等硬件平台不是特别优越的场合,串行回收器的性能表现可以超过并行回收器和并发回收器。所以,串行回收默认被应用在客户端的Client模式下的JVM中,在并发能力比较强的CPU上,并行回收器产生的停顿时间要短于串行回收器。

和串行回收相反,并行收集可以运用多个CPU同时执行垃圾回收,因此提升了应用的吞吐量,不过并行回收仍然与串行回收一样,采用独占式,使用STW机制。

按照工作模式分

可以分为并发式垃圾回收器和独占式垃圾回收器。

并发式垃圾回收器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间。

独占式垃圾回收器一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束。

按照碎片处理方式分

可以分为压缩式垃圾回收器和非压缩式垃圾回收器。

压缩式垃圾回收器会在回收完成后,对存活对象进行压缩整理,清除回收后的碎片。非压缩式的垃圾回收器不进行这步操作。

按照工作的内存区间分

可以分为年轻代垃圾回收器和老年代垃圾回收器

评估GC的性能指标

吞吐量:运行用户代码的时间占总运行时间的比例(总运行时间=程序的运行时间+内存回收时间)

垃圾收集开销:吞吐量的补数,垃圾收集所用时间与总运行时间的比例。

暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间。

收集频率:相对于应用程序的执行,收集操作发生的频率。

内存占用:Java堆区所占的内存大小。

吞吐量、暂停时间和内存占用共同构成一个三角。三者不可能同时满足,一款优秀的收集器通常最多同时满足其中的两项。

主要考虑两点:吞吐量和暂停时间

评估GC的性能指标:吞吐量(Throughput)

吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间)。比如:虚拟机总共运行了100分钟,其中垃圾收集花掉了1分钟,那吞吐量就是99%。高吞吐量的应用程序有更长的时间基准,快速响应是不必考虑的,应用程序能容忍较高的暂停时间。吞吐量优先,意味着在单位时间内,STW的时间最短。

注重吞吐量:

评估GC的性能指标:暂停时间(pause time)

“暂停时间”是指一个时间段内应用程序线程暂停,让GC线程执行的状态。例如:GC期间100毫秒的暂停时间意味着在这100毫秒期间内没有应用程序线程是活动的。暂停时间优先,意味着尽可能让单词STW的时间最短。

 注重低延迟:

 

吞吐量和暂停时间比较

高吞吐量较好因为这会让应用程序的最终用户感觉只有应用程序线程在做事。直觉上,吞吐量越高程序运行越快。

低暂停时间(低延迟)较好因为从最终用户的角度来看不管是GC还是其他原因导致一个应用被挂起始终是不好的。这取决于应用程序的类型,有时候甚至短暂的200毫秒暂停都可能打断终端用户体验。因此,具有低的较大暂停时间是非常重要的,特别是对于一个交互式应用程序。

但是,“高吞吐量”和“低暂停时间”是一对相互竞争的目标(矛盾)。因为如果选择以吞吐量优先,那么必然需要降低内存回收的执行频率,但是这样会导致GC需要更长的暂停时间来执行内存回收。相反,如果选择以低延迟优先为原则,那么为了降低每次执行内存回收时的暂停时间,也只能频繁地执行内存回收,这又引起了年轻代内存的缩减和导致程序吞吐量的下降。

在设计GC算法时,我们必须确定我们的目标:一个GC算法只可能针对两个目标之一(即只专注于较大吞吐量或最小暂停时间),或尝试找到一个折中方案。现在标准:在最大吞吐量优先的情况下,降低停顿时间。

垃圾收集器发展史

  1. 1999年随JDK 1.3.1 一起来的是串行方法的Serial GC,它是第一款GC。ParNew 垃圾收集器是Serial 收集器的多线程版本
  2. 2002年2月26日,Parallel GC和Concurrent Mark Sweep GC跟随JDK 1.4.2 一起发布
  3. Parallel GC在JDK6之后成为HotSpot 默认GC。
  4. 2012年,在JDK1.7u4版本中,G1可用。
  5. 2017年,JDK9中G1变成默认的垃圾收集器,以替代CMS。
  6. 2018年3月,JDK10中G1垃圾回收器的并行完整垃圾回收,实现并行性来改善最坏情况下的延迟。
  7. 2018年9月,JDK11发布。引入Epsilon垃圾回收器,又被称为“No-Op(无操作)”回收器。同时,引入ZGC:可伸缩的低延迟垃圾回收器。
  8. 2019年3月,JDK12发布。增强G1,自动返回未用堆内存给操作系统。同时,引入shenandoah GC:低停顿时间的GC。
  9. 2019年9月,JDK13发布。增强ZGC,自动返回未用堆内存给操作系统。
  10. 2020年3月,JDK14发布。删除CMS垃圾回收器。扩展ZGC在MacOS和windows上的应用。

7款经典的垃圾收集器

  • 串行回收器:Serial、Serial Old
  • 并行回收器:ParNew、Parallel Scavenge、Parallel Old
  • 并发回收器:CMS、G1

  • 年轻代收集器:Serial、ParNew、Parallel Scavenge
  • 老年代收集器:Serial Old、Parallel Old、CMS
  • 整堆收集器:G1

垃圾收集器的组合关系

 

  1.  两个收集器间有连线,表明它们可以搭配使用:Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;
  2. CMS与Serial Old有连线,是因为Serial Old作为CMS出现“Concurrent Mode Failure”失败的后备预案。
  3. (黑色虚线)由于维护和兼容性测试的成本,在JDK 8 时将Serial+CMS、ParNew+Serial Old这两个组合声明为废弃(JEP 173),并在JDK 9中完全取消了这些组合的支持(JEP214),即移除。
  4. (蓝色虚线)JDK 14中,弃用Parallel Scavenge+Serial Old组合(JEP 366),删除CMS垃圾回收器(JEP363)。

Serial回收器:串行回收

Serial收集器是最基本、历史最悠久的垃圾收集器。JDK1.3之前回收年轻代唯一的选择。Serial收集器在HotSpot的Client模式下是默认的年轻代垃圾收集器。Serial收集器采用复制算法、串行回收和STW机制的方式执行内存回收。除了年轻代之外,Serial收集器还提供用于执行老年代垃圾收集的Serial Old收集器。Serial Old收集器同样也采用了串行回收和STW机制,只不过内存回收算法使用的是标记-压缩算法。

Serial Old收集器在Client模式下是默认的老年代垃圾回收器,在Server模式下主要有两个用途:1.与年轻代的Parallel Scavenge配合使用,2.作为老年代CMS收集器的后备垃圾收集方案。

 

优势:简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率,Client模式下的虚拟机使用Serial收集器是个不错的选择。

在用户的桌面应用场景中,可用内存一般不大(几十MB至一两百MB),可以在较短时间内完成垃圾收集(几十ms至一百多ms),只要不频繁发生GC,使用串行回收器是可以接受的。

在HotSpot虚拟机中,使用 -XX:+UseSerialGC参数可以指定年轻代和老年代都使用串行回收器。等价于年轻代使用Serial GC,老年代使用Serial Old GC。

ParNew回收器:并行回收

如果说Serial GC是年轻代中的单线程垃圾收集器,那么ParNew收集器则是Serial收集器的多线程版本。Par是Parallel的缩写,New:只能处理年轻代。

ParNew 收集器除了采用并行回收的方式执行内存回收外,两款垃圾收集器之间几乎没有任何区别。在多 CPU 环境下性能比 Serial 会有一定程度的提升;但线程切换需要额外的开销,因此在单 CPU 环境中表现不如 Serial。ParNew 收集器在年轻代中同样也是采用复制算法、STW机制。ParNew 是很多JVM运行在Server模式下年轻代的默认垃圾收集器。

在HotSpot虚拟机中,使用 -XX:+UseParNewGC 参数手动指定使用ParNew收集器执行内存回收任务。它表示年轻代使用并行收集器,老年代仍然使用Serial Old。-XX:ParallelGCThreads 限制线程数量,默认开启和CPU核心数相同的线程数。

Parallel回收器:吞吐量优先

HotSpot的年轻代中除了ParNew收集器是基于并行回收的以外,Parallel Scavenge收集器同样采用复制算法、并行回收和STW机制。和ParNew收集器不同,Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量,它也被称为吞吐量优先的垃圾收集器。自适应调节策略也是Parallel Scavenge与ParNew一个重要区别。

高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运行而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序。而ParNew,追求降低用户停顿时间,适合交互式应用。

Parallel 收集器在 JDK 1.6 时提供了用于执行老年代垃圾收集的Parallel Old收集器,用于代替老年代的Serial Old收集器。Parallel Old 收集器采用了标记-压缩算法,但同样也是基于并行回收和STW机制。

Java 8 中,默认使用Parallel回收器。

参数设置:

-XX:+UseParallelGC 手动指定年轻代使用Parallel并行收集器执行内存回收任务。

-XX:+UseParallelOldGC 手动指定老年代使用并行回收收集器。分别适用于年轻代和老年代。默认JDK8是开启的。它和-XX:+UseParallelGC 互相激活,开启一个,另外一个也会被开启。

-XX:ParallelGCThreads 设置年轻代并行收集器的线程数。一般地,最好与CPU核心数量相等,以避免过多的线程数影响垃圾收集性能。在默认情况下,当CPU核心的数量小于8个,ParallelGCThreads的值等于CPU核心数量。当CPU核心数量大于8个,ParallelGCThreads的值等于3+[5*core_count]/8

-XX:MaxGCPauseMillis 设置垃圾收集器最大停顿时间(即STW的时间)。单位为毫秒。为了尽可能地把停顿时间控制在MaxGCpauseMillis以内,收集器在工作时会调整Java堆大小或者其他一些参数。对于用户来讲,停顿时间越短体验越好。但是在服务器端,我们注重高并发,整体的吞吐量。所以服务器端适合Parallel,进行控制。

-XX:GCTimeRatio 垃圾收集时间占总时间的比例(= 1/(N+1))。用于衡量吞吐量的大小。取值范围(0,100)。默认99,也就是垃圾回收时间不超过1%。与前一个 -XX:MaxGCPauseMillis 参数有一定矛盾性。暂停时间越长,Ratio参数就容易超过设定的比例。

-XX:UseAdaptiveSizePolicy 设置Parallel Scavenge 收集器具有自适应调节策略。在这种模式下,年轻代的大小、Eden和Survivor的比例、晋升老年代的对象年龄等参数会被自动调整,已达到在堆大小、吞吐量和停顿时间之间的平衡点。在手动调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量(GCTimeRatio )和停顿时间(MaxGCPauseMillis),让虚拟机自己完成调优工作。因为它和 CMS 不兼容,所以 CMS 下默认为 false,但 G1 下默认为 true。

 CMS回收器

在JDK 1.5 时期,HotSpot推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器:CMS(Concurrent-Mark-Sweep)收集器,这款收集器是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作。

CMS收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务器上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。

CMS的垃圾收集算法采用标记-清除算法,也是会有STW的。

CMS作为老年代的收集器,却无法与JDK 1.4中已存在的年轻代收集器Parallel Scavenge 配合工作,所以在JDK1.5 中使用CMS来收集老年代时,年轻代只能选择ParNew或Serial收集器中的一个。

 CMS整个过程分为四个主要阶段,即初始标记阶段、并发标记阶段、重新标记阶段和并发清除阶段

  1. 初始标记阶段(Initial-Mark):在这个阶段中,程序中所有的工作线程都将会因为STW机制而出现短暂的暂停,这个阶段的主要任务仅仅只是标记出所有的GC Roots、GC Roots能直接关联到的对象,以及被年轻代中所有存活对象所引用的对象(老年代单独回收)。这也是 CMS 老年代回收,依然要扫描新生代的原因。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于最耗时的在 tracing 阶段,直接关联对象比较小,所有这里的速度非常快。
  2. 并发标记阶段(Concurrent-Mark):从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时长但不需要停顿用户线程,可以与垃圾收集器线程一起并发执行,这个阶段就可以使用三色标记法。
  3. 重新标记阶段(Remark):由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,因此为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
  4. 并发清除阶段(Concurrent-Sweep):此阶段清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。由于 CMS 并发清理阶段用户线程还在运行中,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法在当次 GC 中处理掉它们,只好留待下一次 GC 时再清理掉。这一部分垃圾就称为“浮动垃圾”。
  5. 并发重置(Concurrent Reset):此阶段与应用程序并发执行,重置 CMS 算法相关的内部数据,为下一次 GC 循环做准备。

尽管CMS收集器采用的是并发回收(非独占式),但是在其初始化标记和再次标记这两个阶段中仍然需要执行STW机制暂停程序中的工作线程,不过暂停时间并不会太长,因此可以说明目前所有的垃圾收集器都做不到完全不需要STW,只是尽可能地缩短暂停时间。

由于最耗费时间的并发标记与并发清除都不需要暂停工作,所以整体的回收是低停顿的。

另外,由于在垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此,CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了在进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收,以确保应用程序在CMS工作过程中依然有足够的空间支持应用程序运行。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将会启动后备预案:临时启用SerialOld收集器来重新进行老年代的垃圾收集,这样停顿的时间就很长了。

CMS收集器的垃圾收集算法采用的是标记-清除算法,这意味着每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可避免地将会产生一些内存碎片。那么CMS在为新对象分配内存空间时,将无法使用指针碰撞技术,只能选择空闲列表执行内存分配。

优点:并发收集;低延迟

缺点:

  • 会产生内存碎片:由于CMS采用的是「标记清除」算法,这就意味这清理完成后会在堆中产生大量的内存碎片。内存碎片过多会带来很多麻烦,其一就是很难为大对象分配内存。导致的后果就是:堆空间明明还有很多,但就是找不到一块连续的内存区域为大对象分配内存,而不得不触发一次Full GC,这样GC的停顿时间又会变得更长。针对这种情况,CMS提供了一种备选方案,通过-XX:CMSFullGCsBeforeCompaction 和 -XX:+UseCMSCompactAtFullCollection 参数设置,当CMS由于内存碎片导致触发了N次Full GC后,下次进入Full GC前先整理内存碎片,不过这个参数在JDK9被弃用了。
  • 对CPU资源非常敏感:并发标记、并发清理阶段,虽然CMS不会触发STW,但是标记和清理需要GC线程介入处理,GC线程会占用一定的CPU资源,进而导致程序的性能下降,程序响应速度变慢。CPU核心数多的话还稍微好一点,CPU资源紧张的情况下,GC线程对程序的性能影响非常大;
  • 无法处理浮动垃圾:并发清除时,用户线程不会暂停,会一直产生垃圾,这种垃圾我们称为浮动垃圾。浮动垃圾只能在下一次执行GC时被回收。如果产生垃圾的速度过快,可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。针对这种情况,可以通过 -XX:CMSInitiatingOccupancyFraction 参数设置,降低触发CMS GC的阈值,让浮动垃圾不那么容易占满老年代。
  • 并发失败:由于浮动垃圾的存在,因此CMS必须预留一部分空间来装载这些新产生的垃圾。CMS不能像Serial Old收集器那样,等到Old区填满了再来清理。在JDK5时,CMS会在老年代使用了68%的空间时激活,预留了32%的空间来装载浮动垃圾,这是一个比较偏保守的配置。如果实际引用中,老年代增长的不是太快,可以通过-XX:CMSInitiatingOccupancyFraction参数适当调高这个值。到了JDK6,触发的阈值就被提升至92%,只预留了8%的空间来装载浮动垃圾。如果CMS预留的内存无法容纳浮动垃圾,那么就会导致「并发失败」,这时JVM不得不触发预备方案,启用Serial Old收集器来回收Old区,这时停顿时间就变得更长了。

JDK9中,CMS被标记为Deprecate。如果对JDK9及以上版本的HotSpot虚拟机使用参数-XX:UseConcMarkSweepGC来开启CMS收集器的话,用户会收到一个警告信息,提示CMS未来将会被废弃。

JDK14中,删除了CMS,如果在JDK14中使用参数-XX:UseConcMarkSweepGC的话,JVM不会报错,只是给出一个warning信息,但是不会exit。JVM会自动回退以默认的GC方式启动JVM。

参数配置

-XX:+UseConcMarkSweepGC 手动指定使用CMS收集器执行内存回收任务。开启该参数后悔自动将 -XX:+UseParNewGC打开。即:ParNew(年轻代)+ CMS(老年代)+ Serial Old的组合。

-XX:CMSInitiatingOccupancyFraction 设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收。JDK5及以前版本的默认值为68,即当老年代的空间使用率达到68%时,会执行一次CMS回收。JDK6及以后的版本默认值为92。查看CMSInitiatingOccupancyFraction的初始值为-1,查看jvm源码可知,如果CMSInitiatingOccupancyFraction在0~100之间,那么由CMSInitiatingOccupancyFraction决定。否则由按 ((100 - MinHeapFreeRatio) + (double)( CMSTriggerRatio * MinHeapFreeRatio) / 100.0) / 100.0 决定。MinHeapFreeRatio,CMSTriggerRatio的初始值分别为40和80,即当老年代达到 ((100 - 40) + (double) 80 * 40 / 100 ) / 100 = 92 %时,会触发CMS回收。如果内存增长缓慢,则可以设置一个稍大的值,大的阈值可以有效降低CMS的触发频率,减少老年代回收的次数,可以较为明显地改善应用程序性能。反之,如果应用程序内存使用率增长很快,则应该降低这个阈值,以避免频繁地触发老年代串行收集器。因此通过该选项可以有效降低Full GC的执行次数。

-XX:+UseCMSCompactAtFullCollection 开启CMS的压缩,用于指定在执行完Full GC后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。配合 CMSFullGCsBeforeCompaction参数使用。

-XX:CMSFullGCsBeforeCompaction 设置在执行多少次Full GC后对内存空间进行压缩整理,默认为0。配合 UseCMSCompactAtFullCollection 参数一起使用

-XX:ConcGCThreads=n (早期JVM版本也叫-XX:ParallelCMSThreads)定义CMS运行时的线程数。比如value=4意味着CMS周期的所有阶段都以4个线程来执行。尽管更多的线程会加快并发CMS过程,但其也会带来额外的同步开销。因此,对于特定的应用程序,应该通过测试来判断增加CMS线程数是否真的能够带来性能的提升。如果该标志未设置,JVM会根据并行收集器中的-XX:ParallelGCThreads参数的值来计算出默认的并行CMS线程数。该公式是ConcGCThreads = (ParallelGCThreads + 3)/4,向下取整。因此,对于CMS收集器,-XX:ParallelGCThreads 标志不仅影响“stop-the-world”垃圾收集阶段,还影响并发阶段ParallelCMSThreads 是年轻代并行收集器的线程数。默认情况下,ParallelCMSThreads等于CPU核心数。当CPU资源比较紧张时,受到CMS收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕。当CPU核心数超过4个时,GC线程会占用不到25%的CPU资源,如果CPU数不足4个,GC线程对程序的影响就会非常大。

7种经典垃圾回收器总结:

垃圾收集器 分类 作用位置 使用算法 特点 适用场景
Serial 串行运行 作用于年轻代 复制算法 响应速度优先 适用于单CPU环境下的Client模式
ParNew 并行运行 作用于年轻代 复制算法 响应速度优先 多CPU环境Server模式下与CMS配合使用
Parallel 并行运行 作用于年轻代 复制算法 吞吐量优先 适用于后台运算而不需要太多交互的场景
Serial Old 串行运行 作用于老年代 标记-压缩算法 响应速度优先 适用于单CPU环境下的Client模式、CMS的后备预案
Parallel Old 并行运行 作用于老年代 标记-压缩算法 吞吐量优先 适用于后台运算而不需要太多交互的场景
CMS 并发运行 作用于老年代 标记-清除算法 响应速度优先 适用于互联网或B/S系统服务端上的Java应用
G1 并发、并行运行 作用于年轻代和老年代 标记-压缩算法、复制算法 响应速度优先 面向服务端应用,将来替换CMS

选择正确的 GC 算法,唯一可行的方式就是去尝试,并找出不合理的地方,一般性的指导原则:

  • 如果想要最小化地使用内存和并行开销,选择Serial GC;
  • 如果系统想要最大化应用程序的吞吐量,CPU 资源都用来最大程度处理业务,选择Parallel GC;
  • 如果系统考虑低延迟有限,想要最小化GC的中断或停顿时间,选择CMS GC;
  • 如果系统内存堆较大,同时希望整体来看平均 GC 时间可控,使用 G1 GC。

对于内存大小的考量:

  • 一般 4G 以上,算是比较大,用 G1 的性价比较高。
  • 一般超过 8G,比如 16G-64G 内存,非常推荐使用 G1 GC。
posted @ 2021-11-25 23:33  xiaojiesir  阅读(279)  评论(0编辑  收藏  举报