Java虚拟机笔记

Java虚拟机笔记

Java虚拟机笔记

一、组成部分

Java虚拟机笔记

1、程序计数器:

指向当前线程正在执行的字节码指令。线程私有的。

2、虚拟机栈:

虚拟机栈是Java执行方法的内存模型。每个方法被执行的时候,都会创建一个栈帧,把栈帧压人栈,当方法正常返回或者抛出未捕获的异常时,栈帧就会出栈。这个区域可能有两种异常:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 *Error 异常(通常是递归导致的);JVM动态扩展时无法申请到足够内存则抛出 OutOfMemoryError异常。
(1)栈帧:栈帧存储方法的相关信息,包含局部变量数表返回值操作数栈动态链接
a、局部变量表:包含了方法执行过程中的所有变量。局部变量数组所需要的空间在编译期间完成分配,在方法运行期间不会改变局部变量数组的大小。
b、返回值:如果有返回值的话,压入调用者栈帧中的操作数栈中,并且把PC的值指向 方法调用指令 后面的一条指令地址。
c、操作数栈:操作变量的内存模型。操作数栈的最大深度在编译的时候已经确定(写入方法区code属性的max_stacks项中)。操作数栈的的元素可以是任意Java类型,包括long和double,32位数据占用栈空间为1,64位数据占用2。方法刚开始执行的时候,栈是空的,当方法执行过程中,各种字节码指令往栈中存取数据。
d、动态链接:每个栈帧都持有在运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。
(2)线程私有

3、本地方法栈:

(1)调用本地native的内存模型 (2)线程独享。

Java方法:是由java语言编写,编译成字节码,存储在class文件中的。java方法是与平台无关的。

本地方法:

​ 本地方法是由其他语言(如C、C++ 或其他汇编语言)编写,编译成和处理器相关的代码。本地方法保存在动态连接库中,格式是各个平台专用的,运行中的java程序调用本地方法时,虚拟机装载包含这个本地方法的动态库,并调用这个方法。
​ 通过本地方法,java程序可以直接访问底层操作系统的资源,但是这么用的话,程序就变成了平台相关了,因为本地方法的动态库是与平台相关的,此外,使用本地方法还可能把程序变得和特定的java平台实现相关。
​ java的本地方法接口JNI,使得本地方法可以在特定主机系统上的任何一个java平台上实现运行。
​ 如果希望使用特定主机上的资源,而他们又无法从JAVA API访问,那么可以写一个平台相关的java程序来调用本地资源。如果希望保证平台的无关性,那么只能通过JAVA API 来访问底层系统的资源。

4、方法区:

用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据

​ (1)线程共享的

​ (2)常量池可以分为Class文件常量池以及运行时常量池,Java程序运行后,Class文件中的信息被字节码执行引擎加载到了方法区,从而形成了运行时常量池。Class文件中除了有类的版本、字段、方法、接口等描述信息之外,还有一项信息是常量池表,用于存放编译器生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

​ 运行时常量池相对于Class文件常量池的另一个重要特征就是具备动态性。Java语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能够进入方法区运行时方法区,运行期间也可以将新的常量放入池中,这种特性被开发人员利用比较多的便是 String类的intern() 方法。

5、堆(Heap):

Java对象存储的地方

​ (1)Java堆是虚拟机管理的内存中最大的一块 。

​ (2)Java堆是所有线程共享的区域 。

​ (3)在虚拟机启动时创建 。

​ (4)此内存区域的唯一目的就是存放对象实例,几乎所有对象实例都在这里分配内存。存放new生成的对象和数组 。

​ (5)Java堆是垃圾收集器管理的内存区域,因此很多时候称为“GC堆”。

Java虚拟机笔记

Java堆的具体划分

首先得从两个分代收集理论说起:

  • 弱分代假说:大多数对象的生命存活时间很短。
  • 强分代假说:经过越多次垃圾收集的对象,存活的时间就越久。

正是这两个分代假说,使得设计者对Java堆的划分更加合理。

(1)新生代(Young space)

  • 是用来存放新生的对象。
  • 一般占据堆的 1/3 空间。
  • 由于频繁创建对象,所以新生代会频繁触发 MinorGC 进行垃圾回收。
  • 新生代又分为 Eden 区、ServivorFrom、ServivorTo 三个区
  • MinorGC采用复制算法

(2)老年代(Tenured space)

  • 主要存放应用程序中生命周期长的内存对象。
  • 老年代的对象比较稳定,所以 MajorGC 不会频繁执行。
  • 在进行 MajorGC 前一般都先进行了一次 MinorGC,使得有新生代的对象晋身入老年代,导致空间不够用时才触发。
  • 当无法找到足够大的连续空间分配给新创建的较大对象时也会提前触发一次 MajorGC 进行垃圾回收腾出空间。
  • MajorGC采用标记清除算法

(3)持久代(Permanent Generation)

​ 用于存放静态文件,如今Java类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。持久代大小通过-XX:MaxPermSize=进行设置。

持久代补充:持久带也称为方法区
方法区:方法区存储每一个java类的结构信息:比如运行时常量池,字段和方法数据,构造函数和普通方法的字节码内容以及类、实例、接口初始化时需要使用到的特殊方法等数据。
方法区也被称为永久代,如果不显示指定的话,GC回收的目标仅针对方法区的常量池和类型卸载
JDK8中已经把持久代(PermGen Space) 干掉了,取而代之的元空间(Metaspace)。Metaspace占用的是本地内存,不再占用虚拟机内存。

(4)新生代垃圾回收:

  • Serial:单线程,复制算法,可以获得最高的单线程垃圾收集效率。

  • Parallel New: serial的多线程版本,复制算法

  • Parallel Scavenge:达到可控制的吞吐量(高吞吐量为目标,即减少垃圾收集时间,就是每次垃圾收集时间短,但是收集次数多),多线程,复制算法。

(5)老年代垃圾回收

  • Serial old:单线程,标记-整理
  • Parallel old
  • CMS
  • G1:可控制的停顿时间

二、对象的创建与访问

当虚拟机遇到字节码new指令时,就会去运行时常量池寻找该实例化对象相对应的类是否被加载、解析和初始化。如果没有被加载,就会先加载该类的信息,否则就为新生对象分配内存。

分配内存无非有两种方法:

  • 指针碰撞:通过一个类似于指针的东西为对象分配内存,前提是堆空间是相对规整的。
  • 空闲列表:堆空间不规整,使用一个列表记录了哪些空间是空闲的,分配内存的时候会更新列表。

以上是两种不同的方法,至于虚拟机使用哪一种方法,这个就取决虚拟机的类型了。

1、对象的内存布局

对象在堆中的存储布局可以分为三个部分:

  • 对象头
    • 第一类信息:存储对象自身的运行时数据,例如哈希码、GC分代年龄、锁状态标志等等。
    • 第二类信息:指针类型,Java虚拟机通过这个指针来确定该对象是那个类的实例。
  • 实例数据:对象真正存储的有效信息。
  • 对齐填充:没有实际的意义,起着占位符的作用。

2、对象的访问定位

我们前面说到过,Java虚拟机栈中存储的是基本数据类型和对象引用。基本数据类型我们已经很清楚了,那么,这个对象引用又是什么鬼?

是这样的,对象实例存储在Java堆中,通过这个对象引用我们就可以找到对象在堆中的位置。但是,对于如何定位到这个对象,不同的Java虚拟机又有不同的方法。

通常情况下,有下面两种方法:

  • 使用句柄访问,通常会在Java堆中划分一块句柄池。
  • 使用直接指针,这样Java虚拟机栈中存储的就是该对象在堆中的地址。
Java虚拟机笔记Java虚拟机笔记

这两种访问对象的方法各有优势。使用直接指针进行访问,就可以直接定位到对象,减小了一次指针定位的时间开销(使用句柄的话会通过句柄池的指针二次定位对象),最大的好处就是速度更快。但是使用句柄的话,就是当对象发生移动的时候,可以不用改变栈中存储的reference,只需要改变句柄池中实例数据的指针。

三、对象的销毁——垃圾回收(GC)

通常,判断一个对象是否被销毁有两种方法:

  • 引用计数算法: 为对象添加一个引用计数器,每当对象在一个地方被引用,则该计数器加1;每当对象引用失效时,计数器减1。但计数器为0的时候,就表白该对象没有被引用。

    优点:简单,直接,不需要暂停整个应用

    缺点:1.需要编译器的配合,编译器要生成特殊的指令来进行引用计数的操作;2.不能处理循环引用的问题

    因此这种方法是垃圾收集的早期策略,现在很少使用。Sun的JVM并没有采用引用计数算法来进行垃圾回收,是基于根搜索算法的。

  • 可达性分析算法: 通过一系列被称之为“GC Roots”的根节点开始,沿着引用链进行搜索,凡是在引用链上的对象都不会被回收。

Java虚拟机笔记

就像上图的那样,绿色部分的对象都在GC Roots的引用链上,就不会被垃圾回收器回收,灰色部分的对象没有在引用链上,自然就被判定为可回收对象。

那么,问题来了,这个GC Roots又是什么?下面列举可以作为GC Roots的对象:

  • Java虚拟机栈中被引用的对象,各个线程调用的参数、局部变量、临时变量等。
  • 方法区中类静态属性引用的对象,比如引用类型的静态变量。
  • 方法区中常量引用的对象。
  • 本地方法栈中JNI(native方法)所引用的对象。
  • Java虚拟机内部的引用,基本数据类型对应的Class对象,一些常驻的异常对象。
  • 被同步锁(synchronized)持有的对象。

引用:

  • 强引用:引用赋值 object obj=new object()
  • 软引用:还有用,但非必须的对象
  • 弱引用:非必须对象,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。
  • 虚引用:它不能单独使用,必须和引用队列联合使用。虚 引用的主要作用是跟踪对象被垃圾回收的状态。

现在,我们已经知道哪些对像是可以回收的。那么又要采取什么方式对对象进行回收呢?垃圾回收算法主要有三种,依次是标记-清除算法标记-复制算法标记-整理算法。这三种垃圾收集算法其实也比较容易理解,下面我先介绍概念,然后在依次总结一下。

1. GC的分类

Partial GC:并不收集整个GC堆的模式

  • Minor GC/Young GC:针对新生代的垃圾收集。
  • Major GC/Old GC:针对老年代的垃圾收集。只有CMS的concurrent collection是这个模式
  • Mixed GC:收集整个young gen以及部分old gen的GC。只有G1有这个模式

Full GC:无官方定义,通常意义上而言指的是一次特殊GC的行为描述,这次GC会回收整个堆的内存,包含老年代,新生代,metaspace等。

2. GC的流程

​ 通常情况下,初次被创建的对象存放在新生代的Eden区,当第一次触发Minor GC,Eden区存活的对象被转移到Survivor区的某一块区域。以后再次触发Minor GC的时候,Eden区的对象连同一块Survivor区的对象一起,被转移到了另一块Survivor区。可以看到,这两块Survivor区我们每一次只使用其中的一块,这样也仅仅是浪费了一块Survivor区。

​ 每经历过一次垃圾回收的对象,它的分代年龄就加1,当分代年龄达到15以后,就直接被存放到老年代中。

​ 还有一种情况,给大对象分配内存的时候,Eden区已经没有足够的内存空间了,这时候该怎么办?对于这种情况,大对象就会直接进入老年代

3. GC算法

1、标记--清除算法

见名知义,标记--清除算法就是对无效的对象进行标记,然后清除。如下图:

Java虚拟机笔记

对于标记--清除算法,你一定会清楚看到,在进行垃圾回收之后,堆空间有大量的碎片,出现了不规整的情况。在给大对象分配内存的时候,由于无法找到足够的连续的内存空间,就不得不再一次触发垃圾收集。另外,如果Java堆中存在大量的垃圾对象,那么垃圾回收的就必然进行大量的标记和清除动作,这个势必造成回收效率的降低

2、复制算法

标记--复制算法就是把Java堆分成两块,每次垃圾回收时只使用其中一块,然后把存活的对象全部移动到另一块区域。如下图:

Java虚拟机笔记

标记--复制算法有一个很明显的缺点,那就是每次只使用堆空间的一半,造成了Java堆空间使用率的的下降

现在大部分Java虚拟机的垃圾回收器使用的就是标记--复制算法,但是,对于Java堆空间的划分,并不是简单地一分为二。

3、标记--整理算法

标记--整理算法算是一种折中的垃圾收集算法,在对象标记的过程,和前面两个执行的是一样步骤。但是,进行标记之后,存活的对象会移动到堆的一端,然后直接清理存活对象以外的区域就可以了。这样,既避免了内存碎片,也不存在堆空间浪费的说法了。但是,每次进行垃圾回收的时候,都要暂停所有的用户线程,特别是对老年代的对象回收,则需要更长的回收时间,这对用户体验是非常不好的。如下图:

Java虚拟机笔记

4. Full GC的触发条件

​ PS:JVM优化的目的就是减少SWT执行的时间(避免卡顿),避免频繁full gc

1. System.gc()方法的调用

​ 此方法的调用是建议JVM进行Full GC,虽然只是建议而非一定,但很多情况下它会触发 Full GC,从而增加Full GC的频率,也即增加了间歇性停顿的次数。强烈影响系建议能不使用此方法就别使用,让虚拟机自己去管理它的内存,可通过通过-XX:+ DisableExplicitGC来禁止RMI(Java远程方法调用)调用System.gc。

2. 老年代空间不足

​ 老年代空间只有在新生代对象转入及创建为大对象、大数组时才会出现不足的现象,当执行Full GC后空间仍然不足,则抛出错误:java.lang.OutOfMemoryError: Java heap space 。为避免以上两种状况引起的FullGC,调优时应尽量做到让对象在Minor GC阶段被回收、让对象在新生代多存活一段时间及不要创建过大的对象及数组。

​ 老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。

为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。

3. 空间分配担保失败

​ 使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。具体内容请参考六章第五小节。

4. JDK 1.7 及以前的永久代空间不足

​ 在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。

​ 当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出java.lang.OutOfMemoryError

​ 为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。

5. Concurrent Mode Failure

执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。

相关参数:-XX:+UseCMSInitiatingOccupancyOnly ,如果没有设置此参数,虚拟机会根据收集的数据决定是否触发(建议线上环境带上这个参数,不然会加大问题排查的难度)。

相关参数:-XX:CMSInitiatingOccupancyFraction=80,即老年代满80%时触发CMS GC。设置太高,就容易产生concurrent mode failure,设置过低,CMS GC又太过频繁。

相关参数:-XX:UseCMSCompactAtFullCollection=true,由于CMS没有对内存进行压缩,所以会有内存碎片,设置此参数,默认每次执行Full GC的时候会进行整理压缩,目前默认是true。

相关参数:-XX:CMSFullGCsBeforeCompaction=n,指定多少次不压缩的CMS GC刚才之后,跟着来一次带压缩的CMS GC。默认是0,表示每次发生forground的cms gc 都会进行压缩,但压缩会影响暂停时间,因此可以适当调整次参数。

6. 统计得到的Minor GC晋升到旧生代的平均大小大于老年代的剩余空间

当准备要触发一次young GC时,如果发现统计数据说之前young GC的平均晋升大小比目前old gen剩余的空间大,则不会触发young GC而是转为触发full GC(因为HotSpot VM的GC里,除了CMS的concurrent collection之外,其它能收集old gen的GC都会同时收集整个GC堆,包括young gen,所以不需要事先触发一次单独的young GC)

7. promotion failed

minor gc时年轻代的存活区空间不足而晋升老年代,老年代又空间不足而触发full gc。

相关参数: -XX:SurvivorRatio=8,设置eden和survivor的比例,默认8:1。

相关参数: -XX:MaxTenuringThreshold=15,最多经过多少次minor gc后存活的年轻代对象会晋升老年代,默认15。

四、HotSpot的算法细节

1、根节点枚举

​ 根节点枚举,其实就是找出可以作为GC Roots的对象,在这个过程中,所有的用户线程都必须停下。到目前为止,几乎还没有虚拟机可以做到GC Roots遍历与用户线程并发执行。当然,可达性分析算法中最耗时的寻找引用链的过程已经可以做到和用户线程并发执行了。那么,为什么需要在根节点枚举的时候停止用户线程?

​ 其实也不难考虑,如果进行GC Roots遍历的时候,用户线程没有暂停,根节点集合的对象引用关系还在不断发生变化,这样遍历到的结果是不准确的。那么,Java虚拟机在查找GC Roots的时候,是真的需要进行全局遍历?

​ 其实不是这样的,HotSpot虚拟机通过一个叫做OopMap的数据结构,可以知道哪些地方存储了对象引用。这样,大大减小了GC Roots的遍历时间。

2、安全点

安全点,即程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。Safepoint的选定既不能太少以至于让GC等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。
​ 安全点的初始目的并不是让其他线程停下,而是找到一个稳定的执行状态。在这个执行状态下,Java虚拟机的堆栈不会发生变化。这么一来,垃圾回收器便能够“安全”地执行可达性分析。只要不离开这个安全点,Java虚拟机便能够在垃圾回收的同时,继续运行这段本地代码。
​ 程序运行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的。“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生Safepoint。

对于安全点,另一个需要考虑的问题就是如何在GC发生时让所有线程(这里不包括执行JNI调用的线程)都“跑”到最近的安全点上再停顿下来。

两种解决方案:

  • 抢先式中断(Preemptive Suspension)

    抢先式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机采用这种方式来暂停线程从而响应GC事件。

  • 主动式中断(Voluntary Suspension)

    主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

3、安全区域

安全区域是安全点的拉伸和扩展,安全点解决了如何让线程停下,却没有解决如何让虚拟机进入垃圾回收状态。

安全区域是指能能够确保在某一代码片段中,引用关系不会发生变化的区域。因此,一旦线程进入了安全区域,就可以不去理会这些处于安全区域的线程。当线程离开安全区域的时候,虚拟机就会检查是否完成了根节点枚举。

4、记忆集与卡表

​ 不知道大家是否考虑过这样的一个问题?既然Java堆有新生代老年代的划分,那么对象引用是否会存在跨代?如果存在跨代,又该如何解决老年代的GC Roots遍历问题?

​ 首先,跨代引用是存在的。因此,垃圾收集器在新生代建立了一个叫做记忆集的数据结构,用来避免把整个老年代加入GC Roots的扫描范围。

记忆集是抽象的数据结构,而卡表是记忆集的具体实现,这种关系就类似与方法区与元空间。

​ 卡表将整个堆划分为一个个大小为512字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。
​ 在进行Minor GC的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到Minor GC的GC Roots里。当完成所有脏卡的扫描之后,Java虚拟机便会将所有脏卡的标识位清零。
​ 想要保证每个可能有指向新生代对象引用的卡都被标记为脏卡,那么Java虚拟机需要截获每个引用型实例变量的写操作,并作出对应的写标识位操作。

卡表能用于减少老年代的全堆空间扫描,这能很大的提升GC效率

​ 我们可以看下官方文档对G1的展望(这段英文描述比较简单,我就不翻译了):

Future:
G1 is planned as the long term replacement for the Concurrent Mark-Sweep Collector (CMS). Comparing G1 with CMS, there are differences that make G1 a better solution. One difference is that G1 is a compacting collector. G1 compacts sufficiently to completely avoid the use of fine-grained free lists for allocation, and instead relies on regions. This considerably simplifies parts of the collector, and mostly eliminates potential fragmentation issues. Also, G1 offers more predictable garbage collection pauses than the CMS collector, and allows users to specify desired pause targets.

5、写屏障

写屏障的作用很简单,就是对卡表进行维护和更新

6、并发的可达性分析

前面我们说到过为什么要暂停所有的用户线程(这个动作也被称之为Stop The World)?这其实是为了不让用户线程改变GC Roots对象的引用。试想,如果用户线程能够随便把死亡的对象重新标记为存活,或者把存活的对象标记为死亡,这岂不是会使的程序发生意想不到的错误。

Stop-The-World:

在新生代进行的GC叫做minor GC,在老年代进行的GC都叫major GC,Full GC同时作用于新生代和老年代。在垃圾回收过程中经常涉及到对对象的挪动,进而导致需要对对象引用进行更新。为了保证引用更新的正确性,Java将暂停所有其他的线程,这种情况被称为“Stop-The-World”,导致系统全局停顿。Stop-The-World对系统性能存在影响,因此垃圾回收的一个原则是尽量减少“Stop-The-World”的时间。

不同垃圾收集器的Stop-The-World情况,Serial、Parallel和CMS收集器均存在不同程度的Stop-The-Word情况;而即便是最新的G1收集器也不例外。

  • Java中一种全局暂停的现象,jvm挂起状态
  • 全局停顿,所有Java代码停止,native代码可以执行,但不能和JVM交互
  • 多半由于jvm的GC引起,如:
    1.老年代空间不足。
    2.永生代(jkd7)或者元数据空间(jkd8)不足。
    3.System.gc()方法调用。
    4.CMS GC时出现promotion failed和concurrent mode failure
    5.YoungGC时晋升老年代的内存平均值大于老年代剩余空间
    6.有连续的大对象需要分配
  • 除了GC还有以下原因:
    1.Dump线程--人为因素。
    2.死锁检查。
    3.堆Dump--人为因素。
    Full GC 是清理整个堆空间—包括年轻代和老年代。

GC时为什么会有全局停顿?

类比在聚会时打扫房间,聚会时很乱,又有新的垃圾产生,房间永远打扫不干净,只有让大家停止活动了,才能将房间打扫干净。当gc线程在处理垃圾的时候,其它java线程要停止才能彻底清除干净,否则会影响gc线程的处理效率增加gc线程负担,特别是在垃圾标记的时候。

五、经典的垃圾收集器

1、Serial 收集器

Serial 收集器是最基础、历史最悠久的收集器,它在进行垃圾收集的时候会暂停所有的工作线程,直到完成垃圾收集过程。下面是Serial垃圾收集器的运行示意图:

Java虚拟机笔记

2、ParNew 收集器

ParNew 垃圾收集器实则是Serial 垃圾收集器的多线程版本,这个多线程在于ParNew垃圾收集器可以使用多条线程进行垃圾回收。

Java虚拟机笔记

3、Parallel Scavenge 收集器

也是一款新生代垃圾收集器,同样的基于标记--复制算法实现的。它最大的特点是可以控制吞吐量。

那什么是吞吐量呢?

Java虚拟机笔记

4、Serial Old 收集器

Serial Old 收集器是Serial 收集器的老年代版本。其垃圾收集器的运行原理和Serial 收集器是一样的。

Java虚拟机笔记

5、Parallel Old 收集器

Parallel Old 收集器同样是Parallel Scavenge 收集器的老年代版本,支持多线程并发收集。下面就是它的运行示意图:

Java虚拟机笔记

6、CMS 收集器

​ 前面说到过Parallel Scavenge 收集器,它是一个可以控制吞吐量的垃圾收集器。

​ 现在要说的 CMS(Concurrent Mark Sweep)收集器,它是一个追求最短停顿时间的垃圾收集器,这是因为CMS收集器工作时,GC工作线程与用户线程可以并发执行,以此来达到降低收集停顿时间的目的。

CMS收集器仅作用于老年代的收集,是基于标记-清除算法的,它的运作过程分为4个步骤:

  • 初始标记(CMS initial mark): 需要Stop The World,这里仅仅标记GC Roots能够直接关联的对象,所以速度很快。
  • 并发标记(CMS concurrent mark): 从关联对象遍历整个GC Roots的引用链,这个过程耗时最长,但是却可以和用户线程并发运行。
  • 重新标记(CMS remark): 修正并发时间,因为用户线程可能会导致标记产生变动,同样需要Stop The World。
  • 并发清除(CMS concurrent sweep): 清除已经死亡的对象。

其中,初始标记重新标记这两个步骤仍然需要Stop-the-world。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始阶段稍长一些,但远比并发标记的时间短。

CMS以流水线方式拆分了收集周期,将耗时长的操作单元保持与应用线程并发执行。只将那些必需STW才能执行的操作单元单独拎出来,控制这些单元在恰当的时机运行,并能保证仅需短暂的时间就可以完成。这样,在整个收集周期内,只有两次短暂的暂停(初始标记和重新标记)达到了近似并发的目的

CMS收集器优点:并发收集、低停顿。

CMS收集器缺点

  • CMS收集器对CPU资源非常敏感。
  • CMS收集器无法处理浮动垃圾(Floating Garbage)。
  • CMS收集器是基于标记-清除算法,该算法的缺点都有(内存碎片)。
  • 停顿时间是不可预期的。

CMS收集器之所以能够做到并发,根本原因在于采用基于“标记-清除”的算法并对算法过程进行了细粒度的分解。前面篇章介绍过标记-清除算法将产生大量的内存碎片这对新生代来说是难以接受的,因此新生代的收集器并未提供CMS版本。

另外要补充一点,JVM在暂停的时候,需要选准一个时机。由于JVM系统运行期间的复杂性,不可能做到随时暂停,因此引入了安全点的概念。

7、Garbage First (G1)收集器

​ Garbage First(简称G1)收集器是垃圾收集器发展史上里程碑式的成果,主要面向服务端应用程序。另外G1收集器虽然还保留新生代和老年代的概念,但是新生代和老年代不在固定,它们都是一系列区域的动态集合。

​ G1重新定义了堆空间,打破了原有的分代模型,将堆划分为一个个区域。这么做的目的是在进行收集时不必在全堆范围内进行,这是它最显著的特点。区域划分的好处就是带来了停顿时间可预测的收集模型:用户可以指定收集操作在多长时间内完成。即G1提供了接近实时的收集特性。G1 的主要关注点在于达到可控的停顿时间,在这个基础上尽可能提高吞吐量。

​ G1 使用了停顿预测模型来满足用户指定的停顿时间目标,并基于目标来选择进行垃圾回收的区块数量。G1 采用增量回收的方式,每次回收一些区块,而不是整堆回收。要清楚 G1 不是一个实时收集器(只是接近实时),它会尽力满足我们的停顿时间要求,但也不是绝对的,它基于之前垃圾收集的数据统计,估计出在用户指定的停顿时间内能收集多少个区块。

G1与CMS的特征对比如下:

特征 G1 CMS
并发和分代
最大化释放堆内存
低延时
吞吐量
压实
可预测性
新生代和老年代的物理隔离

G1具备如下特点:

  • 并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop-the-world停顿的时间,部分其他收集器原来需要停顿Java线程执行的GC操作,G1收集器仍然可以通过并发的方式让Java程序继续运行。
  • 分代收集
  • 空间整合:与CMS的标记-清除算法不同,G1从整体来看是基于标记-整理算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的。但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC
  • 可预测的停顿:这是G1相对于CMS的一个优势,降低停顿时间是G1和CMS共同的关注点。

在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代,而G1不再是这样。在堆的结构设计时,G1打破了以往将收集范围固定在新生代或老年代的模式,G1收集器将整个Java堆划分为多个大小相等的独立区域(Region)。Region是一块地址连续的内存空间,G1模块的组成如下图所示:

Java虚拟机笔记

虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。Region的大小是一致的,数值是在1M到32M字节之间的一个2的幂值数,JVM会尽量划分2048个左右、同等大小的Region,

这一点可以参看如下源码。其实这个数字既可以手动调整,G1也会根据堆大小自动进行调整。

#ifndef SHARE_VM_GC_G1_HEAPREGIONBOUNDS_HPP
#define SHARE_VM_GC_G1_HEAPREGIONBOUNDS_HPP

#include "memory/allocation.hpp"

class HeapRegionBounds : public AllStatic {
private:
  // Minimum region size; we won't go lower than that.
  // We might want to decrease this in the future, to deal with small
  // heaps a bit more efficiently.
  static const size_t MIN_REGION_SIZE = 1024 * 1024;

  // Maximum region size; we don't go higher than that. There's a good
  // reason for having an upper bound. We don't want regions to get too
  // large, otherwise cleanup's effectiveness would decrease as there
  // will be fewer opportunities to find totally empty regions after
  // marking.
  static const size_t MAX_REGION_SIZE = 32 * 1024 * 1024;

  // The automatic region size calculation will try to have around this
  // many regions in the heap (based on the min heap size).
  static const size_t TARGET_REGION_NUMBER = 2048;

public:
  static inline size_t min_size();
  static inline size_t max_size();
  static inline size_t target_number();
};

#endif // SHARE_VM_GC_G1_HEAPREGIONBOUNDS_HPP

G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1会通过一个合理的计算模型,计算出每个Region的收集成本并量化,这样一来,收集器在给定了“停顿”时间限制的情况下,总是能选择一组恰当的Regions作为收集目标,让其收集开销满足这个限制条件,以此达到实时收集的目的。

对于打算从CMS或者ParallelOld收集器迁移过来的应用,按照官方 的建议,如果发现符合如下特征,可以考虑更换成G1收集器以追求更佳性能:

  • 实时数据占用了超过半数的堆空间;
  • 对象分配率或“晋升”的速度变化明显;
  • 期望消除耗时较长的GC或停顿(超过0.5——1秒)。

原文如下:
Applications running today with either the CMS or the ParallelOld garbage collector would benefit switching to G1 if the application has one or more of the following traits.

  • More than 50% of the Java heap is occupied with live data.
  • The rate of object allocation rate or promotion varies significantly.
  • Undesired long garbage collection or compaction pauses (longer than 0.5 to 1 second)

G1收集的运作过程大致如下:

  • 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短
  • 并发标记(Concurrent Marking):是从GC Roots开始堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。
  • 最终标记(Final Marking):是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行
  • 筛选回收(Live Data Counting and Evacuation):首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。这个阶段也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。

全局变量和栈中引用的对象是可以列入根集合的,这样在寻找垃圾时,就可以从根集合出发扫描堆空间。在G1中,引入了一种新的能加入根集合的类型,就是记忆集(Remembered Set)。Remembered Sets(也叫RSets)用来跟踪对象引用。G1的很多开源都是源自Remembered Set,例如,它通常约占Heap大小的20%或更高。并且,我们进行对象复制的时候,因为需要扫描和更改Card Table的信息,这个速度影响了复制的速度,进而影响暂停时间。

Java虚拟机笔记

G1 比 ParallelOld 和 CMS 会需要更多的内存消耗,那是因为有部分内存消耗于簿记(accounting)上,如以下两个数据结构:

  • Remembered Sets:每个区块都有一个 RSet,用于记录进入该区块的对象引用(如区块 A 中的对象引用了区块 B,区块 B 的 Rset 需要记录这个信息),它用于实现收集过程的并行化以及使得区块能进行独立收集。
  • Collection Sets:将要被回收的区块集合。GC 时,在这些区块中的对象会被复制到其他区块中,总体上 Collection Sets 消耗的内存小于 1%。

8、ZGC

ZGC(Z Garbage Collector)作为一种比较新的收集器,目前还没有得到大范围的关注。作为一款低延迟的垃圾收集器,它有如下几个亮点:

  • 停顿时间不会超过 10ms
  • 停顿时间不会随着堆的增大而增大(控制停顿时间在10ms内)
  • 支持堆的大小范围很广(8MB-16TB)

在ZGC中,连逻辑上的也是重新定义了堆空间(不区分年轻代和老年代),只分为一块块的page,每次进行GC时,都会对page进行压缩操作,所以没有碎片问题。虽然ZGC属于很新的GC技术, 但优点不一定真的出众,ZGC只在特定情况下具有绝对的优势, 如巨大的堆和极低的暂停需求。而实际上大多数开发在这两方面都不太成问题(尤其是在服务器端), 而对GC的性能/效率更在意。也有一种观点认为ZGC是为大内存、多cpu而生,它通过分区的思路来降低STW(Stop the World()。

ZGC在JDK14前只支持Linux, 从JDK14开始支持Mac和Windows。

可以从官网看下ZGC的Change Log:

  • JDK 15 (under development)
    • Improved NUMA awareness
    • Support for Class Data Sharing (CDS)
    • Support for placing the heap on NVRAM
    • <TBD>
  • JDK 14
    • macOS support (JEP 364)
    • Windows support (JEP 365)
    • Support for tiny/small heaps (down to 8M)
    • Support for JFR leak profiler
    • Support for limited and discontiguous address space
    • Parallel pre-touch (when using -XX:+AlwaysPreTouch)
    • Performance improvements (clone intrinsic, etc)
    • Stability improvements
  • JDK 13
    • Increased max heap size from 4TB to 16TB
    • Support for uncommitting unused memory (JEP 351)
    • Support for -XX:SoftMaxHeapSIze
    • Support for the Linux/AArch64 platform
    • Reduced Time-To-Safepoint
  • JDK 12
    • Support for concurrent class unloading
    • Further pause time reductions
  • JDK 11
    • Initial version of ZGC
    • Does not support class unloading (using -XX:+ClassUnloading has no effect)

可以看出ZGC未来可期,让我们拭目以待吧。

Java虚拟机笔记

六、常用的收集器组合

新生代GC策略 老年老代GC策略 说明
组合1 Serial Serial Old Serial和Serial Old都是单线程进行GC,特点就是GC时暂停所有应用线程。
组合2 Serial CMS+Serial Old CMS(Concurrent Mark Sweep)是并发GC,实现GC线程和应用线程并发工作,不需要暂停所有应用线程。另外,当CMS进行GC失败时,会自动使用Serial Old策略进行GC。
组合3 ParNew CMS 使用 -XX:+UseParNewGC选项来开启。ParNew是Serial的并行版本,可以指定GC线程数,默认GC线程数为CPU的数量。可以使用-XX:ParallelGCThreads选项指定GC的线程数。如果指定了选项 -XX:+UseConcMarkSweepGC选项,则新生代默认使用ParNew GC策略。
组合4 ParNew Serial Old 使用 -XX:+UseParNewGC选项来开启。新生代使用ParNew GC策略,年老代默认使用Serial Old GC策略。
组合5 Parallel Scavenge Serial Old Parallel Scavenge策略主要是关注一个可控的吞吐量:应用程序运行时间 / (应用程序运行时间 + GC时间),可见这会使得CPU的利用率尽可能的高,适用于后台持久运行的应用程序,而不适用于交互较多的应用程序。
组合6 Parallel Scavenge Parallel Old Parallel Old是Serial Old的并行版本
组合7 G1GC G1GC -XX:+UnlockExperimentalVMOptions -XX:+UseG1GC #开启; -XX:MaxGCPauseMillis=50 #暂停时间目标; -XX:GCPauseIntervalMillis=200 #暂停间隔目标; -XX:+G1YoungGenSize=512m #年轻代大小; -XX:SurvivorRatio=6 #幸存区比例

七、内存分配策略

1. 对象优先在 Eden 分配

大多数情况下,对象在新生代 Eden 区分配,当 Eden 区空间不够时,发起 Minor GC。

2. 大对象直接进入老年代

大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。

经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。

-XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 区和 Survivor 区之间的大量内存复制。

这个参数只对Serial和ParNew这两款新生代收集器有效。如果必须使用此参数进行调优可考虑ParNew+CMSS的收集器组合

3. 长期存活的对象进入老年代

为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。

-XX:MaxTenuringThreshold 用来定义年龄的阈值。

4. 动态对象年龄判定

虚拟机并不是永远地要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。

5. 空间分配担保

在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。

如果不成立的话虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那么就要进行一次 Full GC。

八、总结

目前被广泛使用的垃圾回收器是 G1,通过很少的参数配置,内存即可高效回收。CMS在JDK9中已经被标记deprecated,更高版本中(Java 14 )已经被彻底移除,由于它的 GC 时间不可控,有条件应该尽量避免使用。

查了下度娘有关G1的文章,绝大部分文章对G1的介绍都是停留在JDK7或更早期的实现很多结论已经存在较大偏差了,甚至一些过去的GC选项已经不再推荐使用。举个例子,JDK9中JVM和GC日志进行了重构,如PrintGCDetails已经被标记为废弃,而PrintGCDateStamps已经被移除,指定它会导致JVM无法启动。

本文对CMS和G1的介绍绝大部分内容也是基于JDK7。

九、出处

  1. https://juejin.cn/post/6856958647445291021
  2. https://yuanrengu.com/2020/4c889127.html
上一篇:你头秃都没想到还能这样吧!专题解析


下一篇:Java开发中遇到最难的问题!java使用随机数初始化数组