十个问题弄清JVM&GC(二)

每个java开发同学不管是日常工作中还是面试里,都会遇到JDK、JVM和GC的问题。本文会从以下10个问题为切入点,带着大家一起全面了解一下JVM的方方面面。

  • JVM、JRE和JDK的区别和联系
  • JVM是什么?以及它的主要作用
  • JVM的核心功能有哪些
  • 类加载机制和过程
  • 运行时数据区的逻辑结构
  • JVM的内存模型
  • 如何确定对象是垃圾
  • 垃圾收集的算法有哪些
  • 各种问世的垃圾收集器
  • JVM调优的参数配置

上一篇文章结尾时我们谈到,就JVM的设计规范,从使用用途角度JVM的内存大体的分为:线程私有内存区 和 线程共享内存区。

线程私有内存区在类加载器编译某个class文件时就确定了执行时需要的“程序计数器”和“虚拟栈帧”等所需的空间,并且会伴随着当前执行线程的产生而产生,执行线程的消亡而消亡,因此“线程私有内存区”并不需要考虑内存管理和垃圾回收的问题。线程共享内存区在虚拟机启动时创建,被所有线程共享,是Java虚拟机所管理内存中最应该关注的和最大的一块。首先我们来一起看一下“线程共享内存区”的内存模型是什么样的?

6、JVM的内存模型

如图所示,JVM的内存结构分为堆和非堆两大块区域。

  • 其中“非堆”就是上篇文章我们提到的方法区或叫元数据区,用来存储class类信息的。
  • 而“堆”是用来存储JVM各线程执行期间所创建的实例对象或数组的。堆区分为两大块,一个是Old区,一个是Young区。Young区分为两大块,一个是Survivor区(S0+S1),一块是Eden区S0和S1一样大,也可以叫From和To。

之所以这样划分,设计者的目的无非就是为了内存管理,也就是我们说的垃圾回收。那么什么样的对象是垃圾?垃圾回收算法有哪些?目前常用的垃圾回收器又有哪些?这篇文章我们一起弄清楚这些问题和知识点。

7、如何确定一个对象是垃圾?

要想进行垃圾回收,得先知道什么样的对象是垃圾。目前确认对象是否为垃圾的算法主要有两种:引用计数法和可达性分析法。

  • 1、引用计数法:在对象中添加了一个引用计数器,当有地方引用这个对象时,引用计数器的值就加 1,当引用失效的时候,引用计数器的值就减 1。当引用计数器的值为 0 时,JVM 就开始回收这个对象。

对于某个对象而言,只要应用程序中持有该对象的引用,就说明该对象不是垃圾,如果一个对象没有任何指针对其引用,它就是垃圾。这种方法虽然很简单、高效,但是 JVM 一般不会选择这个方法,因为这个方法会出现一个弊端:当对象之间相互指向时,两个对象的引用计数器的值都会加 1,而由于两个对象时相互指向,所以引用不会失效,这样 JVM 就无法回收。

  • 2、可达性分析法:针对引用计数算法的弊端,JVM 采用了另一种算法,以一些"GC Roots"的对象作为起始点向下搜索,搜索所走过的路径称为引用链 (Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的,即可以进行垃圾回收。否则,证明这个对象有用,不是垃圾。

上图中的 obj7 和 obj8 虽然它们互相引用,但从 GC Roots 出发这两个对象不可达,所以会被标记为垃圾。JVM 会把以下几类对象作为 GC Roots:

  • (1) 虚拟机栈(栈帧中本地变量表)中引用的对象;
  • (2) 方法区中类静态属性引用的对象;
  • (3) 方法区中常量引用的对象;
  • (4) 本地方法栈中 JNI(Native 方法)引用的对象。

注:在可达性分析算法中不可达的对象,并不是直接被回收,这时它们处于缓刑状态,至少需要进行两次标记才会确定该对象是否被回收: 第一次标记:如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记; 第二次标记:第一次标记后接着会进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法(该方法可将此对象与 GC Roots 建立联系)。在 finalize() 方法中没有重新与引用链建立关联关系的,将被进行第二次标记。 第二次标记成功的对象将真的会被回收,如果对象在 finalize() 方法中重新与引用链建立了关联关系,那么将会逃离本次回收,继续存活。

8、垃圾收集的算法有哪些

知道了如何 JVM 确定哪些对象是垃圾后,下面我们来看一下,面对这些垃圾对象,JVM 的回收算法都有哪些。

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

  • 第一步“标记”,如下图所示把堆里所有的对象都扫描一遍,找出哪些是垃圾需要回收的对象,并且把它们标记出来。
  • 第二步“清除”,把第一步标记为“UnReference Object”(无引用或不可达)的对象清除掉,释放内存空间。

这种算法的缺点主要有两点:

(1) 标记和清除两个过程都比较耗时,效率不高

(2) 清除后会产生大量不连续的内存碎片空间,碎片空间太多可能会导致当程序后续需要创建较大对象时,无法找到足够连续的内存空间而不得不再次触发垃圾回收。

2、 标记 - 复制算法(Mark-Copying)

将内存划分为两块区域,每次使用其中一块,当其中一块用满,触发垃圾回收的时候,将存活的对象复制到另一块上去,然后把之前使用的那一块进行格式化,一次性清除干净。

(清除前)

(清除后)

“标记 - 复制”算法的缺点显而易见,就是内存空间利用率低。

3、 标记 - 整理算法(Mark-Compact)

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

将所有存活的对象向一边移动,清理掉存活边界以外的全部内存空间。

结合这三种算法我们可以看到,

  • “标记 - 复制”算法的优点是回收效率高,但空间利用率上有一定的浪费。
  • 而“标记 - 整理”算法由于需要向一侧移动等一系列操作,其效率相对低一些,但对内存空间管理上十分优异。
  • 因此,“标记 - 复制”算法适用于那些生命周期短、回收频率高的内存对象,
  • 而标记 - 整理”算法适用于那些生命周期长、回收频率低,但注重回收一次内存空间得到足够释放的场景。

因此 JVM 的设计者将 JVM 的堆内存,分为了两大块区域 Young 区和 Old 区,Young 区存储的就是那些生命周期短,使用一两次就不再使用的对象,回收一次基本上该区域十之有八的对象全部被回收清理掉,因此 Young 区采用的垃圾回收算法也就是“标记 - 复制”算法。Old 区存储的是那些生命周期长,经过多次回收后仍然存活的对象,就把它们放到 Old 区中,平时不再去判断这些对象的可达性,直到 Old 区不够用为止,再进行一次统一的回收,释放出足够的连续的内存空间。

9、各种问世的垃圾收集器

鉴于 Young 区和 Old 区需要采用不同的垃圾回收算法,因此在 JVM 的整个垃圾收集器的演进各个时代里,针对 Young 区和 Old 区每个时代都是不同的垃圾收集机制。从 JDK1.3 开始到目前,JVM 垃圾收集器的演进大体分为四个时代:串行时代、并行时代、并发时代和 G1 时代。

1、串行时代:Serial(Young 区)+ Serial Old(Old 区)

JDK3(1.3)的时候,大概是 2000 年左右,那个时代基本计算机都是单核一个 CPU 的,因此垃圾回收最初的设计实现也是基于单核单线程工作的。并且垃圾回收线程的执行相对于正常业务线程执行来说还是 STW(stop the world)的,使用一个 CPU 或者一条收集线程去完成垃圾收集工作,这个线程执行的时候其它线程需要停止。

串行收集器采用单线程 stop-the-world 的方式进行收集。当内存不足时,串行 GC 设置停顿标识,待所有线程都进入安全点 (Safepoint) 时,应用线程暂停,串行 GC 开始工作,采用单线程方式回收空间并整理内存。单线程也意味着复杂度更低、占用内存更少,但同时也意味着不能有效利用多核优势。因此,串行收集器特别适合堆内存不高、单核甚至双核 CPU 的场合。

2、并行时代:Parallel Scavenge(Young 区) + Parallel Old(Old 区)

并行收集器是以关注吞吐量为目标的垃圾收集器,也是 server 模式下的默认收集器配置,对吞吐量的关注主要体现在年轻代 Parallel Scavenge 收集器上。

并行收集器与串行收集器工作模式相似,都是 stop-the-world 方式,只是暂停时并行地进行垃圾收集。年轻代采用复制算法,老年代采用标记 - 整理,在回收的同时还会对内存进行压缩。关注吞吐量主要指年轻代的 Parallel Scavenge 收集器,通过两个目标参数 -XX:MaxGCPauseMills 和 -XX:GCTimeRatio,调整新生代空间大小,来降低 GC 触发的频率。并行收集器适合对吞吐量要求远远高于延迟要求的场景,并且在满足最差延时的情况下,并行收集器将提供最佳的吞吐量。

3、 并发时代:CMS(Old 区)

并发标记清除 (CMS) 是以关注延迟为目标、十分优秀的垃圾回收算法,CMS 是针对 Old 区的垃圾回收实现。

老年代 CMS 每个收集周期都要经历:初始标记、并发标记、重新标记、并发清除。其中,初始标记以 STW 的方式标记所有的根对象;并发标记则同应用线程一起并行,标记出根对象的可达路径;在进行垃圾回收前,CMS 再以一个 STW 进行重新标记,标记那些由 mutator 线程 (指引起数据变化的线程,即应用线程) 修改而可能错过的可达对象;最后得到的不可达对象将在并发清除阶段进行回收。值得注意的是,初始标记和重新标记都已优化为多线程执行。CMS 非常适合堆内存大、CPU 核数多的服务器端应用,也是 G1 出现之前大型应用的首选收集器。

但 CMS 有以下两个缺陷

  • (1)由于它是标记 - 清除不是标记 - 整理,因此会产生内存碎片,Old 区会随着时间的推移而终究被耗尽或产生无法分配大对象的情况。最后不得不通过底层的担保机制(CMS 背后有串行的回收作为兜底)进行一次 Full GC,并进行内存压缩。
  • (2)由于标记和清除都是通应用线程并发进行,两类线程同时执行时会增加堆内存的占用,一旦某一时刻内存不够用,就会触发底层担保机制,又采用串行回收进行一次 STW 的垃圾回收。

4、G1 时代:Garbage First

G1 收集器时代,Java 堆的内存布局与就与其他收集器有很大差别,它将整个 Java 堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分 Region(不需要连续)的集合。

如上图所示,每一个 Region(分区)大小都是一样的,1~32M 之间的数值,但必须是 2 的指数。设置 Region 大小通过以下参数:-XX:G1HeapRegionSize=M。 G1 收集器的原理或特点主要有以下三点:

(1)内存逻辑上仍保留的分代的概念,每一个 Region 同一时间要么被标记为新生代,要么被标记为老年代,要么处于空闲;

(2)整体上采用了“标记 - 整理算法”,不会产生内存碎片

(3)可预测的停顿,G1 整体采用的策略是“筛选回收”,也就是回收前会对各个待回收的 Region 的回收价值和成本进行排序,根据 G1 配置所期望的回收时间,选择排在前面的几个 Region 进行回收。

其实之所以叫 G1(Garbage First)就是因为它优先选择回收垃圾比较多的 Region 分区。 整体 G1 的垃圾回收工作步骤分为:初始标记、并发标记、最终标记和筛选回收。

5、ZGC:Zero GC

这篇文章简单提一下这个最新问世的垃圾收集器,之所以叫“Zero GC”是因为它追求的是更低的 GC 停顿时间,追求的目标是:支持 TB 级堆内存(最大 4T)、最大 GC 停顿 10ms。JDK11 新引入的 ZGC 收集器,不管是物理上还是逻辑上,ZGC 中已经不存在新老年代的概念了会分为一个个 page,当进行 GC 操作时会对 page 进行压缩,因此没有碎片问题。由于其是 JDK11 和只能在 64 位的 linux 上使用,因此目前用得还比较少。

结语

以上总体两篇文章七千字,就是我从 JVM 的作用、设计框架到 JVM 内存管理的整体的体系化理解。感谢。

本文转载自宜信技术学院。

原文链接

十个问题弄清 JVM&GC(二)

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/KJU2k2GclEdXqxIkFFfh
  • 如有侵权,请联系 yunjia_community@tencent.com 删除。

扫码关注云+社区

领取腾讯云代金券