我是靠谱客的博主 柔弱棒棒糖,最近开发中收集的这篇文章主要介绍JVM学习笔记——JVM垃圾回收JVM 垃圾回收,觉得挺不错的,现在分享给大家,希望可以做个参考。

概述

文章目录

  • JVM 垃圾回收
    • 垃圾回收通识
      • 什么是垃圾?
      • Java的垃圾回收机制
        • 存在的问题
        • 回收的区域
    • 垃圾回收相关算法
      • 垃圾标记阶段:垃圾对象判断
        • 引用计数算法
        • 可达性分析算法
          • 可达性分析
          • GC Roots
          • 注意
          • 回收过程
      • 垃圾清除阶段
        • 标记-清除算法(1960)
          • 执行过程
          • 缺点
        • 复制算法(1963)
          • 核心思想
          • 优点
          • 缺点
          • 应用场景
        • 标记-压缩算法(1970)
          • 执行过程
      • 分代收集算法
      • 增量收集算法(CMS)
        • 基本思想
        • 缺点
      • 分区收集算法(G1)
    • 垃圾回收相关概念
      • System.gc()的理解
      • 内存溢出与内存泄漏
        • 内存溢出(OOM)
        • 内存泄漏(Memory Leak)
      • Stop The World
      • 垃圾回收的并行与并发
        • 并发(Concurrent)
        • 并行(Parallel)
        • 并发VS并行
        • 垃圾回收的并发与并行
      • 安全点与安全区域
        • 安全点(Safe Point)
        • 安全区域(Safe Region)
    • 垃圾回收器
      • 概述
      • GC分类
        • 按GC线程数
        • 按工作模式
        • 按碎片处理方式
        • 按工作的内存区间
      • GC性能指标
      • 不同的垃圾回收器概述
        • 垃圾收集器发展史
        • 7款经典的垃圾收集器
        • 7款经典的垃圾收集器与垃圾分代关系
        • 查看默认的垃圾回收器
      • Serial回收器(串行)
      • ParNew回收器(并行)
      • Parallel回收器(吞吐量优先)
      • CMS回收器(低延迟)
        • 工作原理
        • 优缺点
        • 参数配置
        • JDK后续版本中CMS的变化
      • G1回收器(区域化分代式)
        • 优缺点
        • 参数配置
        • 操作步骤
        • 适用场景
        • 分区Region:化整为零
        • RememberedSet
        • 垃圾回收过程
        • 年轻代GC
        • G1回收过程一:年轻代GC
        • G1回收过程二:并发标记过程
        • G1回收过程三:混合回收
        • G1回收过程四:Full GC
        • G1回收器优化建议

JVM 垃圾回收

垃圾回收通识

什么是垃圾?

  • 通过 GC Roots 无法关联的 Java 对象,就被视为垃圾对象。
  • GC Roots 里面最常见的,就是可以被栈里面的变量直接引用的对象。

Java的垃圾回收机制

  • 自动内存管理机制,无需开发人员手动参与内存分配与回收,这样就降低了内存泄漏和内存溢出的风险。
    • 自动内存管理机制,是由各个垃圾收集器去实现的。

存在的问题

  • 对于 Java 开发人员而言,自动内存管理就像是一个黑盒子,如果过度依赖于”自动“,那么这将会是一场灾难,最严重的就是会弱化 Java 开发人员在程序出现内存溢出时定位问题和解决问题的能力。
  • 此时,了解 JVM 的自动内存分配和内存回收原理就显得非常重要,只有在真正了解 JVM 是如何管理内存后,我们才能在遇到 OOM 等问题时,快速地根据错误异常日志定位问题和解决问题。
  • 当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些”自动化“的技术实施必要的监控和调节。

回收的区域

在这里插入图片描述

垃圾回收相关算法

垃圾标记阶段:垃圾对象判断

那么,在 JVM 中究竟是如何标记一个对象是垃圾对象呢?

  • 简单来说,当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为是垃圾对象。
  • 判断垃圾对象一般有两种方式:引用计数算法和可达性分析算法。

引用计数算法

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

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

  • 优点:
    • 实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。
  • 缺点:
    • 它需要单独的字段存储计数器,这样的做法增加了存在空间的开销;
    • 每次赋值都需要更新计数,伴随着加法和减法操作,这增加了时间开销;
    • 引用计数器有一个严重的问题,即无法处理循环引用的情况,这是一条致命缺陷,导致在 Java 的垃圾回收器中没有使用这类算法。
  • 循环引用:

在这里插入图片描述

public class RefCountGC {
    /**
     * 这个成员属性唯一的作用就是占用一点内存, 5MB
     */
    private byte[] bigSize = new byte[5 * 1024 *1024];
    private Object reference = null;

    public static void main(String[] args) {
        RefCountGC obj1 = new RefCountGC();
        RefCountGC obj2 = new RefCountGC();
        
        obj1.reference = obj2;
        obj2.reference = obj1;
        
        obj1 = null;
        obj2 = null;
        
        // 显示的执行垃圾回收,这里发生GC,obj1和obj2能否被回收?
        System.gc();
    }
}
  • 小结:
    • 引用计数算法,是很多语言的资源回收选择,例如 python,它同时支持引用计数和垃圾收集机制。
    • 具体哪种最优是要看场景的,业界有大规模实践中仅保留引用计数机制,以提高吞吐量的尝试。
    • Java 并没有选择引用计数,是因为其存在一个根本问题,就是很难处理循环引用。
    • python 是如何解决循环引用?
      • 收到解除:很好理解,就是在适合的时机,解除引用关系。
      • 使用弱引用 weakref、weakref 是 python 提供的标准库,旨在解决循环引用。

可达性分析算法

可达性分析
  • 可达性分析,又称为“根搜索算法、追踪性垃圾收集”,相较于引用计数算法,这里的可达性分析就是 Java、C#选择的。这种类型的垃圾收集通常也叫作追踪性垃圾收集(Tracing Garbage Collection)。
  • 可达性分析算法是以根对象集合(GC Roots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。
  • 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径为引用链(Reference Chain)。
  • 所谓“GC Roots”根集合就是一组必须活跃的引用,如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象已经死亡,可以标记为垃圾对象。
  • 在可达性分析算法中,只有能够被根对象集合直接或间接连接的对象才是存活对象。
GC Roots
  • 在 Java 语言中,GC Roots 包括以下几类元素:
    • 虚拟机栈中引用的对象:比如各种线程被调用的方法中使用到的参数、局部变量等。
    • 本地方法栈内 JNI (通常说的本地方法) 引用的对象
    • 方法去中类静态属性引用的对象:比如 Java 类的引用类型静态变量。
    • 方法去中常量引用的对象:比如字符串常量池(String Table)里的引用。
    • 所有被同步锁 synchronized 持有的对象。
    • Java 虚拟机内部的引用:基本数据类型对应的 Class 对象,一些常驻的异常对象(NullPointerException、OutOfMemoryError),系统类加载器。
    • 反映 Java 虚拟机内部情况的 JMXBean、JVMTI中注册的回调、本地代码缓存等。
注意
  • 如果要使用可达性分析算法俩判断内存是否可回收,那么分析工作必须在一个保障一致性的快照中进行。这点不满足的话分析结果的准确性就无法保证。
  • 这点也是导致 GC 进行时必须“Stop The World”的一个重要原因,即使是号称几乎不会发生停顿的 CMS 收集器中,枚举根节点时也是必须要停顿的。
回收过程
  • 第一次判断:使用可达性分析算法分析之后,判断对象不可达。
  • 第二次判断:finalize() 方法(上述或者对象自我救赎的唯一方式),该方法会被垃圾回收器去调用,并且只会调用一次。所以可以在 finalize 方法中,重新建立可达性关联,那么就完成了自我救赎,否则被第二次标记。
/**
 * 1. 对象可以在被GC时自我救赎
 * 2. 这种自救的机会只有一次,因为一个对象的 finalize() 方法最多只会被系统自动调用一次。
 */
public class FinalizeEscapeGC {
    private static FinalizeEscapeGC SAVE_HOOK = null;

    public void isAlive() {
        System.out.println("yes, i am still alive :)");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed!");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

    public static void main(String[] args) throws Exception {
        SAVE_HOOK = new FinalizeEscapeGC();

        // 对象第一次成功拯救自己
        SAVE_HOOK = null;
        System.gc();
        // 因为 finalize 方法优先级很低,所以暂停 0.5 秒
        TimeUnit.MILLISECONDS.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }
        // 下面这段代码与上面的完全相同,但是这次自救却失败了
        SAVE_HOOK = null;
        System.gc();
        TimeUnit.MILLISECONDS.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }
    }
}
  • 输出:
finalize method executed!
yes, i am still alive :)
no, i am dead :(

垃圾清除阶段

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

标记-清除算法(1960)

  • 标记-清除算法是一种非常基础和常见的垃圾算法,该算法被 J.McCarthy 等人在 1960 年提出并应用于 Lisp 语言。
执行过程
  • 当堆中的有效内存空间被耗尽时,就会停止整个程序(STW),然后进行两项工作:
    • 标记:Collector 从引用根节点开始遍历,标记所有被引用的对象,一般是在对象 Header 中记录为可达对象。
    • 清除:Collector 对堆内存从头到尾进行线性的遍历,如果发现某个对象在其 Header 中没有标记为可达对象,则将其回收。

标记-清除算法

何为清除?

这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里,下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够就存放。

缺点
  • 效率不算高
  • 在进行 GC 时,需要停止整个应用程序,导致用户体验差。
  • 这种方式清理出来的空闲内存不是连续的,产生内存碎片,需要维护一个空闲列表。

复制算法(1963)

  • 为了解决标记-清除算法在垃圾回收效率方面的问题,M.L.Minsky 于 1963 年发表了著名的论文:“使用双存储区的Lisp语言垃圾收集器CA LISP Garbage Collector Algorithm Using Serial Secondary Storage”。M.L.Minsky 在该论文中描述的算法被人们称为复制(Coping)算法,它也被 M.L.Minsky 本人成功地引入到 Lisp 语言的一个实现版本中。
核心思想
  • 将内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。

复制算法

优点
  • 没有标记和清除过程,实现简单、运行高效。
  • 复制过去以后保证空间的连续性,不会出现“碎片”问题。
缺点
  • 次算法的缺点也是很明显,就是需要两倍的内存空间。
  • 对于 G1 这种分拆成大量的 Region 的 GC,复制而不是移动,意味着 GC 需要维护 region 之间对象引用关系,不管是内存占用或者时间开销也不小。
应用场景
  • 新生代中使用的垃圾回收算法,一次通常可以回收 70% ~ 90% 的内存空间,回收性价比很高。

标记-压缩算法(1970)

  • 复制算法的高效性是建立在存活对象少、垃圾对象多的前提下,这种情况在新生代中经常发生。但是在老年代,更常见的情况是大部分对象都是存活对象,如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。
  • 标记-清除算法的确可以应用在老年代中,但是该算法不仅执行效率低下,而且在执行完内存回收后还会产生内存碎片,所以 JVM 的设计者需要在此基础之上进行改进。
  • 1970 年前后,G.L.Steele、C.J.Chene 和 D.s.Wise 等研究者发布标记-压缩算法,在许多现代的垃圾收集器中 ,人们都使用了标记-压缩算法或其改进版本。
执行过程
  • 第一阶段和标记-清除算法一样,从根节点开始标记所有被引用对象。
  • 第二阶段将所有存活对象压缩到内存的一端,按顺序排放。
  • 之后清理边界外所有空间。

标记-压缩算法

  • 标记-压缩算法的最终效果等同于标记清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩算法。

二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压缩是移动式的,是否移动回收后的存活对象是一项优缺点并存的风险决策。

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

分代收集算法

  • 前面所有这些算法中,并没有一种算法可以完全替代其它算法,它们都具有自己独特的优势和特点。
  • 分代收集算法,是基于一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年底的特点使用不同的回收算法,以提高垃圾回收的效率。
  • 在 Java 程序运行过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如 Http 请求中的 Session 对象、线程、Socket连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如 String 对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。

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

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

增量收集算法(CMS)

  • 上述现有的算法,在垃圾回收过程中,应用软件将处于一种 Stop the World 的状态(STW)。在 STW 状态下,应用程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回送的完成。如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。为了解决这个问题,即对实时垃圾收集算法的研究直接导致了增量收集算法的诞生。

基本思想

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

缺点

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

分区收集算法(G1)

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

分代算法是按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间。

  • 每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多个小区间。

在这里插入图片描述

垃圾回收相关概念

System.gc()的理解

  • 在默认情况下,通过 System.gc() 或者 Runtime.getRuntime().gc() 的调用,会显示触发 Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。

然而 System.gc() 调用附带一个免责声明,无法保证对垃圾收集器的调用。

  • JVM 实现者可以通过 System.gc() 调用来决定 JVM 的 GC 行为。而一般情况下,垃圾回收应该是自动进行的,无法手动触发,否则就太过于麻烦了。在一些特殊情况下,入我们正在编写一个性能基准,我们可以再运行之间调用 System.gc() 。
public class SystemGCTest {
    public static void main(String[] args) {
        new SystemGCTest();
        // 提醒 JVM 的垃圾回收器执行 GC,但是不确定是否马上执行GC
        // 与 Runtime.getRuntime().gc();的作用一样
        System.gc();

        // 强制调用使用引用的对象的 finalize() 方法
        System.runFinalization();
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("System GC Test 重写了finalize()");
    }
}

内存溢出与内存泄漏

内存溢出(OOM)

OutOfMemoryError

  • 内存溢出相对于内存泄漏来说,尽管更容易被理解,但是同样的,内存溢出也是引发程序崩溃的罪魁祸首之一。
  • 由于 GC 一直在发展,所有一般情况下,除非应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度,否则不太容易出现 OOM 的情况。
  • 大多数情况下,GC 会进行各种年龄段的垃圾回收,实在不行了就放大招,来一次独占式的 Full GC 操作,这时候会回收大量的内存,供应用程序继续使用。
  • Java doc 中对 OutOfMemoryError 的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存。
  • 没有空闲内存,说明 Java 虚拟机的堆内存不够,原因有二:
    • Java 虚拟机的堆内存设置不够。可能存在内存泄漏,也可能是堆内存大小设置不合理,可以通过参数 -Xms、-Xmx 来调整。
    • 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用),对于老版本的 Oracle JDK,因为永久代大小是有限的,并且 JVM 对永久代垃圾回收非常不积极,所以当我们不断添加新类型的时候,永久代出现 OutOfMemoryError 也非常多见,尤其是在运行时存在大量动态类型生成的场景;类似 intern 字符串缓存占用太多空间,也会导致 OutOfMemoryError 问题。对应的异常信息,会标记出来和永久代相关:“java.lang.OutOfMemoryError: Perm Gen space"。
  • 随着元数据区的引入,方法区内存已经不再那么窘迫,所以相应的 OOM 有所改观,出现 OOM 异常信息则变成了:“java.lang.OutOfMemoryError: Metaspace"。直接内存不足,也会导致 OOM。
  • 这里面隐含着一层意思是,在抛出 OutOfMemoryError 之前,通常垃圾收集器会被触发,尽其所能去清理出空间。
    • 例如在引用机制分析中,涉及到 JVM 会去尝试回收软引用指向的对象等。
    • 在 java.nio.Bits.reserveMemory() 方法中,我们能清楚的看到,System.gc() 会被调用,以清理空间。
  • 当然,也不是在任何情况下垃圾收集器都会被触发的。
    • 比如,我们去分配一个超大对象,类似一个超大数组超过堆的最大值,JVM 可以判断出垃圾收集并不能解决这个问题,所以直接抛出 OutOfMemoryError。

内存泄漏(Memory Leak)

  • 也称为“存储渗漏”,严格来说,只有对象不会再被程序用到了,但是 GC 又不能回收它们的情况,才叫做内存泄漏。
  • 但实际情况很多时候,一些不太好的实践(或疏忽)会导致对象的生命周期变得很长甚至导致OOM,也可以叫做宽泛意义上的“内存泄漏”。
  • 尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐渐蚕食,直至耗尽所有内存,最终出现 OutOfMemoryError 异常,导致程序崩溃。

注意,这里的存储空间并不是指物理内存,而是指虚拟内存大小,这个虚拟内存大小取决于磁盘交换区设定的大小。

  • 举例:
    • 一些提供 close 的资源未关闭导致内存泄漏:数据库连接(dataSource.getConnection),网络连接(Socket)和 IO 连接必须手动 close,否则是不能被回收的。

Stop The World

  • 简称 STW,指的是 GC 事件发生过程中,会产生应用程序的停顿。停顿产生时,整个应用程序都会被暂停,没有任何响应。有点像卡死的感觉,这个停顿称为 STW。
  • 可达性分析算法中枚举根节点(GC Roots)会导致所有 Java 执行线程停顿。
    • 对象标记分析工作必须在一个能保证一致性的快照中进行;
    • 一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上;
    • 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证。
  • 在 STW 中断的应用程序线程会在完成 GC 后恢复,频繁中断会让用户感觉像是网速不快造成卡带一样,所以我们需要减少 STW 的发生。
  • STW 事件和采用哪款 GC 无关,所有的 GC 都有这个事件。哪怕是 G1 也不能完全避免 STW 的发生,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能地缩短了暂停时间。
  • STW 是 JVM 在后台自动发起和自动完成的,在用户不可见的情况下,把用户正常的工作线程全部停掉。
  • 开发中不要用 System.gc(),这会导致 STW 的发生。

垃圾回收的并行与并发

并发(Concurrent)

  • 在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理器上运行。
  • 并发不是真正意义上的“同时进行”,只是 CPU 把一个时间段划分成几个时间片段(时间区间),然后再这个时间区间来回切换,由于 CPU 处理的速度非常快,只要时间间隔处理得当,即可让用户感觉是多个应用程序同时在进行。
    • 从一段时间来看,有多个任务在执行;
    • 从单一时间片上看,只有一个任务在进行;
    • 实际上就是 CPU 在快速切换任务交替执行。

并行(Parallel)

  • 当系统有一个以上 CPU 时,当一个 CPU 执行一个进程时,另一个 CPU 可以执行另一个进程,两个进程互不抢占 CPU 资源,可以同时进行,我们称为并行。
  • 其实决定并行的因素不是 CPU 的数量,而是 CPU 的核心数量,比如一个 CPU 多个核也是可以并行。
  • 适用于科学计算、后台处理等弱交互场景。

并发VS并行

在这里插入图片描述

  • 并发,指的是多个事情,在同一时间段内同时发生了;
  • 并行,指的是多个事情,在同一时间点上同时发生了;
  • 并发的多个任务之间是互相抢占资源的;
  • 并行的多个任务之间是不互相抢占资源的;
  • 只有在多 CPU 或者一个 CPU 多核的情况下,才会发生并行。否则,看似同时发生的事情,其实都是并发执行的。

垃圾回收的并发与并行

在垃圾收集器中的并发与并行,可以解释如下:

  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态,入 Parallel New、Parallel Scavenge、Parallel Old。
  • 串行(Serial):相较于并行的概念,其是单线程执行的。如果内存不够,则程序暂停,启动 JVM 垃圾回收器进行垃圾回收,回收完,再启动程序的线程。

在这里插入图片描述

  • 并发:指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),垃圾回收线程在执行时,不会停顿用户程序的运行。
    • 用户程序在继续运行,而垃圾收集程序线程运行于另一个 CPU 上,入如 CMS、G1
    • STW:暂停整个应用,时间可能会很长;
    • 并发:更为复杂,GC 可能会抢占应用的 CPU。

安全点与安全区域

安全点(Safe Point)

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

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

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

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

安全区域(Safe Region)

  • Safe Point 机制保证了程序执行时,在不太长的时间内就会遇到可进入 GC 的 Safe Point。但是,程序“不执行”的时候呢?例如线程处于 Sleep 状态或 Blocked 状态,这时候线程无法响应 JVM 的中断请求,走到安全点去中断挂起,JVM 也不太可能等线程被唤醒。对于这种情况,就需要安全区域(Safe Region)来解决。
  • 安全区域是指在一段代码中,对象的引用关系不会发生变化,在这个区域中的任何位置开始 GC 都是安全的,我们也可以把 Safe Region 看作是被扩展了的 Safe Point。
  • 实际执行时:
    • 当线程运行到 Safe Region 的代码时,首先标识已经进入到 Safe Region,如果这段实际发生 GC,JVM 会忽略标识为 Safe Region 状态的线程。
    • 当线程即将离开 Safe Region 时,会检查 JVM 是否已经完成 GC,如果完成了,则继续运行;否则线性必须等待直到收到可以安全离开 Safe Region 的信号为止。

垃圾回收器

概述

  • 垃圾收集器没有在规范中进行过多的规定,可以由不同的厂商、不同版本的 JVM 来实现。由于 JDK 版本处于高速迭代过程中,因此 Java 发展至今已经衍生了众多的 GC 版本。
  • 从不同角度分析垃圾收集器,可以将 GC 分为不同的类型。

GC分类

按GC线程数

  • 可以分为串行垃圾收集器并行垃圾收集器
  • 串行回收指的是同一时间段内只允许一个 CPU 用于执行垃圾回收操作,此时工作线程被暂停,直至垃圾收集工作结束。
    • 在诸如单 CPU 处理器或者较小的应用内存等硬件平台,不是特别优越的场合,串行回收器的性能表现可以超过并行回收器和并发回收器。所以,串行回收默认被应用在客户端的 Client 模式下的 JVM 中。
    • 在并发能力比较强的 CPU 上,并行回收器产生的停顿时间要短于串行回收器。
  • 和串行回收相反,并行收集可以运用多个 CPU 同时执行垃圾回收,因此提升了应用的吞吐量,不过并行回收仍然与串行回收一样,采用独占式,使用了 STW 机制。

按工作模式

  • 可以分为并发式垃圾回收器独占式垃圾回收器
  • 并发式垃圾回收器与应用程序交替工作,已尽可能减少应用程序的停顿时间。独占式垃圾回收器(STW)一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束。

按碎片处理方式

  • 可以分为压缩式垃圾回收器非压缩式垃圾回收器
  • 压缩式垃圾回收器会在回收完成后,对存活对象进行压缩整理,消除回收后的碎片。非压缩式的垃圾回收器不进行整理。

按工作的内存区间

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

GC性能指标

  • 吞吐量 = 程序运行时间 / 总运行时间——运行用户代码的时间占总运行时间的比例

    • GC 时间:暂停时间总和(暂停有很多次,每次可以很短)
    • 程序运行时间
    • 总运行时间 = GC时间 + 程序运行时间
    • 低延迟衡量的标准 = 每个 GC 时间
  • 垃圾收集开销:吞吐量的补数,垃圾收集所用时间与总运行时间的比例。

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

    • 收集频率:相对于应用程序的执行,收集操作发生的频率。
  • 内存占用:Java堆区所占的内存大小。

  • 吞吐量、暂停时间、内存占用,这三者共同构成一个“不可能三角”。三者总体的表现会随着技术进步而越来越好,一款优秀的垃圾收集器通常最多同时满足其中的两项。

  • 这三项里,暂停时间的重要性日益凸显。因为随着硬件发展,内存占用多是能容忍的,硬件性能的提升也有助于降低收集器运行时对应用程序的影响,即提高了吞吐量。而内存的扩大,对延迟反而带来负面效果。

  • 简单来说,主要抓住两点:

    • 吞吐量
    • 停顿时间

不同的垃圾回收器概述

垃圾收集器发展史

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

7款经典的垃圾收集器

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

7款经典的垃圾收集器与垃圾分代关系

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

在这里插入图片描述

  • 两个收集器间有连线,表示它们可以搭配使用:Serial+Serial Old、Serial+CMS、ParNew+Serial Old、ParNew+CMS、Parallel Scavenge+Serial Old、Parallel Scavenge+Parallel Old、G1。
  • 其中 Serial Old 作为 CMS 出现"Concurrent Mode Failure" 失败的后备预案。
  • 【红色虚线】由于维护和兼容性测试的成本,在 JDK 8 时将 Serial+CMS、ParNew+Serial Old 这两个组合声明为废弃(JEP173),并在 JDK9 中完全取消了这些组合的支持(JEP214),即:移除。
  • 【绿色虚线】JDK14中,弃用 Parallel Scavenge+Serial Old(JEP366)。
  • 【驼色虚线】JDK14中,删除 CMS 垃圾回收器(JEP 363)。

查看默认的垃圾回收器

  • -XX:+PrintCommandLineFlags:查看命令行相关参数(包含使用的垃圾收集器)
  • 使用命令行指令:jinfo -flag 相关垃圾回收器参数 进程ID

Serial回收器(串行)

  • Serial 收集器是最基本、历史最悠久的垃圾收集器了,JDK1.3之前回收新生代的唯一选择。
  • Serial 收集器作为 Hotspot 中 Client 模式下的默认新生代垃圾收集器
  • Serial 收集器采用复制算法、串行回收和 STW 机制的方式执行内存回收。
  • 除了年轻代之外,Serial 收集器还提供了用于执行老年代垃圾收集的 Serial Old 收集器。Serial Old 收集器同样也采用了串行回收和 STW 机制,只不过内存回收算法使用的是标记-压缩算法。
    • Serial Old 是运行在 Client 模式下默认的老年代的垃圾回收器。
    • Serial Old 在 Server 模式下主要有两个用途:
      • 与新生代的 Parallel Scavenge 配合使用;
      • 作为老年代 CMS 收集器的兜底垃圾收集方案。

在这里插入图片描述

  • 这个收集器是一个单线程的收集器,但它的“单线程”的意义 并不仅仅说明它只会使用一个 CPU 或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其它所有的工作线程,直到它收集结束。
  • 优势:简单而高效(与其它收集器的单线程比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
    • 运行在 Client 模式下的虚拟机是个不错的选择。
  • 在用户的桌面应用场景中,可用内存一般不大(几十MB至一两百MB),可以在较短时间内完成垃圾收集(几十ms至一百多ms),只要不频繁发生,使用串行回收器是可以接受的。
  • 在 HotSpot 虚拟机中,使用-XX:+UseSerialGC参数可以指定年轻代和老年代都使用串行收集器。
    • 等价于新生代用 Serial GC,且老年代用 Serial Old GC。
  • 总结:
    • 这种垃圾收集器大家了解,现在已经不用串行的了,而且在限定单核 CPU 才可以用,而现在都不是单核的了。
    • 对于交互较强的应用而言,这种垃圾收集器是不能接受的,一般在 Java web 应用程序中是不会采用串行垃圾收集器的。

ParNew回收器(并行)

  • 如果说 Serial GC 是年轻代中的单线程垃圾收集器,那么 ParNew 收集器则是 Serial 收集器的多线程版本。
    • Par是Parallel的缩写,New:只能处理的是新生代。
  • ParNew 收集器除了采用并行回收的方式执行内存回收外,两款垃圾收集器之间几乎没有任何区别。ParNew收集器在年轻代中同样也是采用复制算法+STW机制。
  • ParNew是很多 JVM 运行在 Server 模式下新生代的默认垃圾收集器

在这里插入图片描述

  • 对于新生代,回收次数频繁,使用并行方式高效。
  • 对于老年代,回收次数少,使用串行方式节省资源。
  • 由于 ParNew 收集器是基于并行回收,那么是否可以断定 ParNew 收集器的回收效率在任何场景下都会比 Serial 收集器更高效?
    • ParNew 收集器运行在多 CPU 的环境下,由于可以充分利用多 CPU、多核等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量。
    • 但是在单个 CPU 的 环境下,ParNew 收集器不比 Serial 收集器更高效。虽然 Serial 收集器是基于串行回收,但是由于 CPU 不需要频繁地做任务切换,因此可以有效避免多线程交互过程中产生的一些额外开销。
  • 除 Serial 外,目前只有 ParNew GC 能与 CMS 收集器配合工作。
  • 在程序中,开发人员可以通过-XX:+UseParNewGC手动指定使用 ParNew 收集器执行内存回收任务,它表示年轻代使用并行收集器,不影响老年代。
  • -XX:ParallelGCThreads限制线程数量,默认开启和CPU数量相同的线程数。

Parallel回收器(吞吐量优先)

Parallel Scavenge

  • HostSpot 的年轻代中除了拥有 ParNew 收集器是基于并行回收的以外, Parallel Scavenge 收集器同样也采用了复制算法、并行回收和 STW 机制。那么 Parallel 收集器的出现是否多此一举?
    • 和 ParNew 收集器不同,Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput),它也被称为吞吐量优先的垃圾收集器。
    • 自适应调节策略也是 Parallel Scavenge 与 ParNew 的一个重要区别。
  • 高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,执行批量处理、订单处理、工资支付、科学计算的应用程序。
  • Parallel 收集器在 JDK1.6 时提供了执行老年代垃圾收集的 Parallel Old 收集器,用来代替老年代的 Serial Old 收集器。
  • Parallel Old 收集器采用了标记-压缩算法,但同样也是基于并行回收和STW机制。

在这里插入图片描述

  • 在程序吞吐量优先的应用场景中,Parallel 收集器和 Parallel Old 收集器的组合,在 Server 模式下的内存回收性能很不错。
  • 在 Java8中,默认是此垃圾收集器。
  • 参数配置:
    • -XX:UseParallelGC手动指定年轻代使用 Parallel 并行收集器执行内存回收任务。
    • -XX:+UseParallelOldGC手动指定老年代都是使用并行回收收集器。
      • 分别适用于新生代和老年代,默认JDK8是开启的。
      • 上面两个参数,默认开启一个,另一个也会被开启(互相激活)。
    • -XX:ParallelGCThreads设置年轻代并行收集器的线程数,一般最好与 CPU 数量相等,以避免过多的的线程数影响垃圾收集性能。
      • 在默认情况下,当 CPU 数量小于 8 个,ParallelGCThreads 的值等于 CPU 数量。
      • 当 CPU 数量大于 8,ParallelGCThreads 的值等于 3+[5*CPU_Count/8]。
    • -XX:MaxGCPauseMillis设置垃圾收集器最大停顿时间,单位毫秒。
      • 为了尽可能地把停顿时间控制在 MaxGCPauseMillis 以内,收集器在换工作时会调整 Java 堆大小或者其他一些参数。
      • 对于用户来讲,停顿时间越短体验越好。但是在服务器端,我们注重高并发,整体的吞吐量。所以,服务器端适合 Parallel 进行控制。
      • 该参数使用需谨慎。
    • -XX:GCTimeRatil垃圾收集时间占总时间的比例,用于衡量吞吐量的大小。
      • 取值范围(0, 100),默认值99,也就是垃圾回收时间不超过 1 %。
      • 与前一个-XX:MaxGCPauseMillis参数有一定的矛盾性,暂停时间越长,Ratio 参数就容易超过设定的比例。
    • -XX:+UseAdaptiveSizePolicy设置 Parallel Scavenge 收集器具有自适应调节策略
      • 在这种模式下,年轻代的大小、Eden 和 Survivor 的比例、晋升老年代的对象年龄等参数会被自动调整,以达到在堆大小、吞吐量和停顿时间之间的平衡点。
      • 在手动调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量(GCTimeRatio)和停顿时间(MaxGCPauseMillis),让虚拟机自己完成调优工作。

CMS回收器(低延迟)

  • 在 JDK1.5 时期,HotSpot 推出了一款在强交互应用中几乎认为有划时代意义的垃圾收集器:CMS(Concurrent-Mark-Sweep)收集器,这款收集器是 HotSpot 虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作
  • CMS收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验。
    • 目前很大一部分的 Java 应用集中在互联网站或者 B/S 系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS 收集器就非常符合这类应用的需求。
  • CMS 的垃圾收集算法采用标记-清除算法,并且也会 STW。
  • 不幸的是,CMS 作为老年代的收集器,却无法与 JDK 1.4.0 中已经存在的新生代收集器 Parallel Scavenge 配合工作,所以在 JDK1.5 中使用 CMS 来收集老年代的时候,新生代只能选择 ParNew 或者 Serial 收集器中的一个。
  • 在 G1 出现之前,CMS 使用还是非常广泛的,直到今天,仍然有很多系统使用 CMS GC。

在这里插入图片描述

  • 枚举 GC Roots(STW) ==>> 按照引用链进行追踪(并发操作) ==>> 清除(并发操作)

工作原理

CMS 整个过程比之前的收集器要复杂,整个过程分为四个阶段:初始标记阶段、并发标记阶段、重新标记阶段和并发清除阶段。

  • 初始标记(Initial-Mark)阶段:在这个阶段中,程序中所有的工作线程都将会因为 STW 机制而出现短暂的暂停,这个阶段的主要任务仅仅只是标记出 GC Roots 能直接关联到的对象。一旦标记完成就会恢复之前被暂停的所有应用线程。由于直接关联对象比较少,所以这里的速度非常快。
  • 并发标记(Cocurrent-Mark)阶段:从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长,但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
  • 重新标记(Remark)阶段:由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始阶段稍长一些,但也远比并发标记阶段的时间短。
  • 并发清除(Concurrent-Sweep)阶段:此阶段清理删除掉标记阶段判断出的已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

优缺点

  • CMS 的优点:
    • 并发收集
    • 低延迟:初始标记和重新标记都是停顿时间很短的 STW 行为
  • CMS 的弊端:
    • 因为使用标记-清除算法,所以会产生内存碎片,导致并发清除后,用户线程可用的空间不足。在无法分配大对象的情况下,不得不提前触发 Full GC。
    • CMS 收集器对 CPU 资源非常敏感。在并发阶段,它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
    • CMS 收集器无法处理浮动垃圾。可能出现“Concurrent Mode Failure”失败而导致另一次 Full GC 的产生。在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发标记阶段如果产生新的垃圾对象,CMS将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一执行 GC 时释放这些之前未被回收的内存空间。浮动垃圾的收集,一般都是通过 Serial Old 去进行。

参数配置

  • -XX:+UseConcMarkSweepGC:手动指定使用 CMS 收集器执行内存回收任务。
    • 开启该参数后会自动将 -XX:+UseParNewGC 打开,即:ParNew(Young区) + CMS(Old区) + SerialOld 组合。
  • -XX:CMSInitiatingOccupanyFraction设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收。
    • JDK5 及以前版本的默认值是 68%,即当老年代的空间使用率达到 68% 时,会执行一次 CMS 回收,JDK6 及以上版本默认值为 92%。
    • 如果内存增长缓慢,则可以设置一个稍大的值,大的阈值可以有效降低 CMS 的触发频率,减少老年代回收的次数可以较为明显地改善应用程序性能。反之,如果应用程序内存使用率增长很快,则应该降低这个阈值,以避免频繁触发老年代串行收集器。因此通过该选项便可以有效降低 FullGC 的执行次数。
  • -XX:UseCMSCompactAtFullCollection用于指定在执行完 Full GC 后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。
  • -XX:CMSFullGCsBeforeCompaction设置在执行多少次 FullGC 后对内存空间进行压缩整理。
  • -XX:ParallelCMSThreads设置 CMS 的线程数量
    • CMS 默认启动的线程数是 (parallelGCThreads+3)/4,parallelGCThreads 是年轻代并发收集器的线程数。当 CPU 资源比较紧张时,受到 CMS 收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕。

JDK后续版本中CMS的变化

  • JDK9 新特性:CMS 被标记为 Deprecate 了(JEP291)
    • 如果对 JDK9 及以上版本的 HotSpot 虚拟机使用参数 -XX:UseConcMarkSweepGC 来开启 CMS 收集器的话,用户会收到一个警告信息,提示 CMS 未来将会被废弃。
  • JDK 14新特性:删除 CMS 垃圾回收器(JEP363)
    • 移除了 CMS 垃圾收集器,如果在 JDK14 中使用 -XX:UseConcMarkSweepGC 的话,JVM不会报错,只是给出一个警告信息,但是不会 Exit,JVM 会自动回退以默认 GC 方式启动 JVM。

G1回收器(区域化分代式)

既然我们已经有了前面几个强大的 GC,为什么还要发布 Garbage First(G1) GC?

  • 原因就在于应用程序所应对的业务越来越庞大、复杂,用户越来越多,没有 GC 就不能保证应用程序正常进行,而经常造成 STW 的 GC 又跟不上实际的需求,所以才会不断地尝试对 GC 进行优化。G1 垃圾回收器是在 Java 7 update 4 之后引入的一个新的垃圾回收器,是当今收集器技术发展的最前言成果之一。
  • 与此同时,为了适应现在不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间(pause time),同时兼顾良好的吞吐量。官方给 G1 设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起“全功能收集器”的重任与期望。

优缺点

与其它 GC 收集相比,G1 使用了全新的分区算法,其特点如下:

  • 并行与并发
    • 并行性:G1 在回收期间,可以有多个 GC 线程同时工作,有效利用多核计算能力,此时用户线性 STW。
    • 并发性:G1 拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况。
  • 分代收集
    • 从分代上看,G1 依然属于分代型垃圾回收器,它会分年轻代和老年代,年轻代依然有 Eden 区和 Survivor 区。但是从堆的结构上看,它不要求整个 Eden 区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。
    • 将堆空间分为若干区域(Region),这些区域中包含了逻辑上的年轻代和老年代。
    • 和之前的各类回收器不同,它同时兼顾年轻代和老年代,对比其他回收器:要么工作在年轻代,要么工作在老年代。
  • 空间整合
    • CMS 是“标记-清除”算法、内存碎片、若干次 GC 后进行一次碎片整理
    • G1 将内存划分为一个个的 region,内存的回收是以 region 作为基准单位的。Region 之间是复制算法,但整体上实际可看作是标记-压缩算法,两种方法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象不会因为无法找到连续内存空间而提前触发下一 GC。尤其是当 Java 堆非常大的时候,G1 的优势更加明显。
  • 可预测的停顿时间模型(软实时 soft real-time):这是 G1 相对于 CMS 的另一大优势,G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 M 毫秒。
    • 由于分区原因,G1 可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。
    • G1 跟踪各个 region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 region。保证了 G1 收集器在有限的时间内可以获取尽可能高的收集效率。
    • 相比于 CMS GC,G1 未必能做到 CMS 在最好情况下的延迟停顿,但是最差情况要好很多。
  • 缺点
    • 相比于 CMS,G1 还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1 无论是为了垃圾收集产生的内存占用(Footprint),还是程序运行时的额外执行负载(Overload)都要比 CMS 要高。
    • 从经验上来说,在小内存应用上 CMS 的表现大概率会优于 G1,而 G1 在大内存应用上则发挥其优势,平衡点在 6~8GB 之间。

参数配置

  • -XX:UseG1GC手动指定使用 G1 收集器执行内存回收任务。
  • -XX:G1HeapRegionSize设置每个 Region 的大小,值是 2 的幂次,范围是 1MB ~ 32MB 之间,目标是根据最小的 Java 堆大小划分出约 2048 个区间。默认是堆内存的 1/2000。
  • -XX:MaxGCPauseMillis设置期望达到的最大 GC 停顿时间指标(JVM会尽力实现,但不保证达到),默认值是 200ms。
  • -XX:ParallelGCThread设置 STW 时 GC 并发线程数的值,最多设置为 8。
  • -XX:ConcGCThreads设置并发标记的线程数,将 n 设置为并行垃圾回收线程数 (ParallelGCThread) 的 1/4 左右。
  • -XX:initiatingHeapOccupancyPercent设置触发并发 GC 周期的 Java 堆占用率阈值。超过此值,就触发 GC,默认值是 45。

操作步骤

  • G1 的设计原则就是简化 JVM 性能调优,开发人员只需要简单的三步即可完成调优:
    • 第一步:开启 G1 垃圾收集器
    • 第二步:设置堆的最大内存
    • 第三步:设置最大的停顿时间
  • G1 提供了三种垃圾回收模式:Young GC、Mixed GC 和 Full GC,在不同的条件下触发。

适用场景

  • 面向服务端应用,针对具有大内存、多处理器的机器(在普通大小的堆里表现并不惊喜)。
  • 最主要的应用时需要低 GC 延迟,并具有大堆的应用程序提供解决方案。如:在堆大小约 6GB 或者更大,可预测的停顿时间可以低于 0.5 秒:G1通过每次只清理一部分而不是全部的 region 的增量式清理来保证每次 GC 停顿时间不会太长。
  • 用来替换掉 JDK1.5 中的 CMS 收集器,在下面情况时,使用 G1 可能比 CMS 好:
    • 超过 50% 的 Java 堆被活动数据占用;
    • 对象分配频率或年代提升频率变大很大;
    • GC 停顿时间过长(长于 0.5 至 1 秒)。
  • HotSpot 垃圾收集器里,除了 G1 以外,其它的垃圾收集器使用内置 JVM 线程执行 GC 的多线程操作,而 G1 可以采用应用线程承担后台运行的 GC 工作,即当 JVM 的 GC 线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程。

分区Region:化整为零

  • 使用 G1 收集器时,它将整个 Java 堆划分为约 2048 个大小相同的独立 region 块,每个 region 块大小根据堆空间的实际大小而定,整体被控制在 1~32MB之间,且为 2 的 n 次幂,即 1MB、2MB、4MB、8MB、16MB、32MB。可以通过 -XX:G1HeapRegionSize设定,所有的 region 大小相同,且在 JVM 生命周期内不会被改变。
  • 虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的,它们都是一部分 region(不需要连续)的集合,通过 region 的动态分配方式实现逻辑上的连续。
    在这里插入图片描述
  • 一个 region 有可能属于 Eden、Survivor 或者 Old/Tenured 内存区域,但是一个 region 只可能属于一个角色。图中的 E 表示该 region 属于 Eden 内存区域,S 表示属于 Survivor 内存区域,O 表示属于 Old 区域。图中空白的表示没有使用的内存区域。
  • G1 垃圾收集器还增加了一种新的内存区域,叫做 Humongous 内存区域,比如图中的 H 块,主要用于存储大对象,如果超过 1.5 个 region,就放到 H 区。

设置 H 的原因?

  • 对于堆中的大对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象,就会被垃圾收集器造成负面影响。为了解决这个问题,G1 划分了一个 Humongous 区,它用来专门存放大对象。如果一个 H 区装不下一个大对象,那么 G1 会寻找连续的 H 区来存储。为了能找到连续的 H 区,有时候不得不启动 Full GC,G1 的大多数行为都把 H 区作为老年代的一部分来看待。
    • 内存分配方式:Bump-the-pointer(指针碰撞)、TLAB

RememberedSet

一个对象被不同区域引用的问题

  • 一个 region 区不可能是孤立的,一个 region 中的对象可能被其它任意 region 中的对象引用,判断对象存活时,是否需要扫描整个 Java 堆才能保证准确?
  • 在其他分代收集器,也存在这样的问题(G1更加突出)。
  • 回收新生代也不得不同时扫描老年代?
  • 这样的话是否会降低 Minor GC 的效率?

解决方法

  • 无论 G1 还是其他分代收集器,JVM 都是使用 RememberedSet 来避免全局扫描,每个 region 都有一个对应的 RememberedSet;
  • 每次 Reference 类型数据写操作时,都会产生一个 Write Barrier 暂时中断操作,然后检查将要写入的引用指向的对象是否和该 Reference 类型数据在不同的 region(其它收集器:检查老年代对象是否引用了新生代对象);
  • 如果不同,则通过 cardTable 把相关引用信息记录到引用指向对象的所在 region 对应的 RememberedSet 中,当进行垃圾收集时,在 GC 根节点的枚举范围加入 RememberedSet,就可以保证不进行全局扫描,也不会有遗漏。
    在这里插入图片描述

垃圾回收过程

G1 GC 的垃圾回收过程主要包括如下三个环节:

  • 年轻代 GC(Young GC)
  • 老年代并发标记过程(Concurrent Marking)
  • 混合回收(Mixed GC)
  • 如果需要,单线程、独占式、高强度的 Full GC 还是继续存在的。针对 GC 的评估失败提供了一种失败保护机制,即强力回收。

在这里插入图片描述

  • 执行顺序:Young GC ==>> Young GC + Concurrent Marking ==>> Mixed GC

具体流程

  • 应用程序分配内存,当年轻代的 Eden 区用尽时开始年轻代的回收过程:G1的年轻代收集阶段是一个并行的独占式收集器。在年轻代回收期,G1 GC 暂停所有应用程序线程,启动多线程执行年轻代回收,然后从年轻代区间移动存活对象到 Survivor 区间或者老年区间,也有可能是两个区间都会涉及。
  • 当堆内存使用达到一定值(默认 45%)时,开始老年代并发标记过程。
  • 标记完成马上开始混合回收过程。对于一个混合回收器,G1 GC 从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的 G1 回收器和其它 GC 不同,G1 的老年代回收器不需要整个老年代回收,一次只需要扫描/回收一小部分老年代的 Region 就可以了。同时,这个老年代 Region 是和年轻代一起被回收的。

年轻代GC

  • JVM 启动时,G1 先准备好 Eden 区,程序在运行过程中不断创建对象到 Eden 区,当 Eden 空间耗尽时,G1启动一次年轻代垃圾回收过程。年轻代垃圾回收只会回收 Eden 区和 Survivor 区。YGC 时,首先 G1 停止应用程序的执行(STW),G1 创建回收集(Collection Set),回收集是指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代 Eden 区和 Survivor 区所有的内存分段。

G1回收过程一:年轻代GC

  • **第一阶段:扫描根。**根是指 static 变量指向的对象,正在执行的方法调用链条上的局部变量等,根引用连同 RSet 记录的外部引用作为扫描存活对象的入口。
  • **第二阶段:更新 RSet。**处理 dirty card queue 中的 card,更新 RSet。此阶段完成后,RSet 可以准确的反映老年代对所在的内存分段中对象的引用。
  • **第三阶段:处理 Rset。**识别被老年代对象指向的 Eden 中的对象,这些被指向的 Eden 中的对象被认为是存活的对象。
  • **第四阶段:复制对象。**此阶段对象被遍历,Eden 区内存段中存活的对象会被复制到 Survivor 区中空的内存分段 Survivor 区内存段中存活的对象如果年龄未达阈值,年龄会加1,达到阈值会被复制到 Old 区中空的内存分段。如果 Survivor 空间不够,Eden 空间的部分数据会直接晋升到老年代空间。
  • **第五阶段:处理引用。**处理 Soft、Weak、Phanton、Final、JNI Weak 等引用,最终 Eden 空间的数据为空,GC 停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。

对于应用程序的引用赋值语句 Object field = object,JVM 会在之前和之后执行特殊的操作以在 dirty card queue 中入队一个保存了对象引用信的 card。在年轻代回收的时候,G1 会对 dirty card queue 中所有的 card 进行处理,已更新 RSet 保证 RSet 实时准确的反映引用关系。

那为什么不在引用赋值句处直更新 RSet 呢?这是为了性能的需要,RSet 的处理需要线程同步,开销会很大,使用队列性能会好很多。

G1回收过程二:并发标记过程

  • 初始标记阶段:标记从根节点直接可达的对象,这个阶段是 STW 的,并且会触发一次年轻代 GC。
  • 根区域扫描(Root Region Scanning):G1 GC 扫描 Survivor 区直接可达的老年区域对象,并标记被引用的对象,这一过程必须在 Young GC 之前完成。
  • 并发标记(Concurrent Marking):在整个堆中进行并发标记(和应用程序并发执行),此过程可能被 Young GC 中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。
  • 再次标记(Remark):由于应用程序持续进行,需要修正上一次的标记结果,是 STW 的。G1 中采用了比 CMS 更快的初始快照算法(Snapshot-At-The-Beginning:SATB)。
  • 独占清理(Cleanup,STW):计算各个区域的存活对象和 GC 回收比例,并进行排序,识别可以混合回收的区域。为下阶段做铺垫,这个阶段并不会实际上去做垃圾的收集。
  • 并发清理阶段:识别并清理完全空闲的区域。

G1回收过程三:混合回收

  • 当越来越多的对象晋升到老年代 Old Region 时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即 Mixed GC,该算法并不是一个 Old GC,除了回收整个 Young Region,还会回收一部分 Old Region。

这里需要注意:是一部分老年代,而不是全部老年代。可以选择哪些 Old Region 进行收集,从而可以对垃圾回收的耗时时间进行控制。也要注意的是 Mixed GC 并不是 Full GC。

在这里插入图片描述

  • 并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。默认情况下,这些老年代的内存分段会分 8 次(可以通过 -XX:G1MixedGCCountTarget设置)被回收。
  • 混合收回的回收集(Collection Set)包括八分之一的老年代内存分段,Eden 区内存分段,Survivor 区内存分段。混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段。具体过程请参考上面的年轻代回收过程。
  • 由于老年代中的内存分段默认分 8 次回收,G1 会优先回收垃圾多的内存分段。垃圾占内存分段比例越高,越会被先回收。并且有一个阈值会决定内存分段是否被回收,-XX:G1MixedGCLiveThresholdPercent,默认为 65%,意思是垃圾占内存分段比例要达到 65% 才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间。
  • 混合回收并不一定要进行 8 次,有一个阈值 -XX:G1HeapWastePercent,默认值为 10%,意思是允许整个堆内存中有 10% 的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于 10%,则不再进行混合回收。因为 GC 会花费很多的时间,但是回收到的内存却很少。

G1回收过程四:Full GC

  • G1 的初衷就是要避免 Full GC 的出现,但是如果上述方式不能正常工作,G1 会停止应用程序的执行(STW),使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。
  • 要避免 Full GC 的发生,一旦发生需要进行调整。什么时候会发生 Full GC 呢?比如堆内存太小,当 G1 在复制存活对象的时候,没有空的内存分段可用,则会回退到 Full GC,这种情况可以通过增大内存解决。
  • 导致 G1 Full GC 的原因可能有两个:
    • Evacuation 的时候没有足够的 to-space 来存放晋升的对象;
    • 并发处理过程完成之前空间耗尽。

G1回收器优化建议

  • 年轻代大小:
    • 避免使用 -Xmn 或 -XX:NewRatio 等相关选项显示设置年轻代大小
    • 固定年轻代的大小会覆盖暂停时间目标
  • 暂停时间目标不用太过严苛:
    • G1 GC 的吞吐量目标是 90% 的应用程序时间和 10% 的垃圾回收时间
    • 评估 G1 GC 的吞吐量时,暂停时间目标不用太严苛,目标太过严苛表示你愿意承受更多的垃圾回收开销,而这些会直接影响到吞吐量。

最后

以上就是柔弱棒棒糖为你收集整理的JVM学习笔记——JVM垃圾回收JVM 垃圾回收的全部内容,希望文章能够帮你解决JVM学习笔记——JVM垃圾回收JVM 垃圾回收所遇到的程序开发问题。

如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。

本图文内容来源于网友提供,作为学习参考使用,或来自网络收集整理,版权属于原作者所有。
点赞(42)

评论列表共有 0 条评论

立即
投稿
返回
顶部