Contents

JVM

Contents

参考


虚拟机与Java虚拟机

虚拟机

所谓虚拟机(Virtual Machine),就是一台虚拟的计算机。它是一款软件,用来执行一系列虚拟计算机指令。大体上,虚拟机可以分为系统虚拟机和程序虚拟机。

  • 大名鼎鼎的Visual Box,Mware就属于系统虚拟机,它们完全是对物理计算机的仿真,提供了一个可运行完整操作系统的软件平台。
  • 程序虚拟机的典型代表就是Java虚拟机,它专门为执行单个计算机程序而设计,在Java虚拟机中执行的指令我们称为Java字节码指令。

无论是系统虚拟机还是程序虚拟机,在上面运行的软件都被限制于虚拟机提供的资源中。

Java虚拟机

Java虚拟机是一台执行Java字节码的虚拟计算机,它拥有独立的运行机制,其运行的Java字节码也未必由Java语言编译而成。

JVM平台的各种语言可以共享Java虚拟机带来的跨平台性、优秀的垃圾回器,以及可靠的即时编译器。

Java技术的核心就是Java虚拟机(JVM,Java Virtual Machine),因为所有的Java程序都运行在Java虚拟机内部。

Java虚拟机就是二进制字节码的运行环境,负责装载字节码到其内部,解释/编译为对应平台上的机器指令执行。每一条Java指令,Java虚拟机规范中都有详细定义,如怎么取操作数,怎么处理操作数,处理结果放在哪里。

特点:

  • 一次编译,到处运行
  • 自动内存管理
  • 自动垃圾回收功能

JVM的位置

JVM是运行在操作系统之上的,它与硬件没有直接的交互

https://gitee.com/moxi159753/LearningNotes/raw/master/JVM/1_%E5%86%85%E5%AD%98%E4%B8%8E%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E7%AF%87/1_JVM%E4%B8%8EJava%E4%BD%93%E7%B3%BB%E7%BB%93%E6%9E%84/images/image-20200704183048061.png

Java的体系结构

https://gitee.com/moxi159753/LearningNotes/raw/master/JVM/1_%E5%86%85%E5%AD%98%E4%B8%8E%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E7%AF%87/1_JVM%E4%B8%8EJava%E4%BD%93%E7%B3%BB%E7%BB%93%E6%9E%84/images/image-20200704183236169.png

java代码执行流程

https://gitee.com/shilongshen/image-bad/raw/master/img/20201024190613.png

https://gitee.com/shilongshen/image-bad/raw/master/img/20201024190454.png

https://gitee.com/shilongshen/image-bad/raw/master/img/20201024191152.png


JVM内存区域

Java虚拟机所管理的内存包括以下几个运行时数据区

https://gitee.com/shilongshen/xiaoxingimagebad/raw/master/img/20210318213536.png

程序计数器

当前线程所执行的字节码的行号指示器,每条线程都有一个独立的程序计数器。如果一个线程正在执行一个Java方法,则计数器记录的是字节码的指令的地址,如果执行的一个Native方法,则计数器的记录为空。此内存区域是唯一一个没有规定任何OutOfMemoryError的区域。

虚拟机栈

虚拟机栈描述的是Java方法执行的线程内存模型: 每个方法被执行的时候, Java虚拟机都会同步创建一个栈帧,用于存储局部变量表、 操作数栈、 动态连接、 方法出口等信息。 方法的执行过程就是栈帧在JVM中出栈和入栈的过程。

方法只有在调用的时候才会在栈中分配空间,并且调用时是在压栈,方法执行结束后,该方法所需要的空间就会被释放了。

栈帧(Frame)是用来存储数据和部分过程结果的数据结构,同时也被用来处理动态链接(Dynamic Linking)、方法返回值和异常分派(Dispatch Exception)。

局部变量表中存放的是各种基本数据类型,如boolean、byte、char、等8种,及引用类型(存放的是指向各个对象的内存地址),因此,它有一个特点:内存空间可以在编译期间就确定,运行期不在改变。

对于虚拟机栈这个内存区域规定了两种异常情况;如果线程请求的栈的深度大于虚拟机所允许的区域,将抛出StackOverflowError。

如果java虚拟机栈的容量可以动态拓展,当栈拓展时无法申请到足够的内存会抛出OutOfMenoryError

栈不会有垃圾回收

本地方法栈

与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务, 而本地方法栈则是为虚拟机使用到的本地方法服务。

Java堆是虚拟机所管理的内存中最大的一块。 Java堆是被所有线程共享的一块内存区域, 在虚拟机启动时创建。 此内存区域的唯一目的就是存放对象实例, Java世界里“几乎”所有的对象实例都在这里分配内存。 Java堆是垃圾收集器管理的内存区域, 因此一些资料中它也被称作“GC堆”。

由于现代 JVM 采用分代收集算法, 因此 Java 堆从 GC 的角度还可以细分为: 新生代(Eden 区、 From Survivor 区和 To Survivor 区)和老年代

在32位系统上最大为2G,64位系统上无限制。可通过-Xms和-Xmx控制,-Xms为JVM启动时申请的最小Heap内存,-Xmx为JVM可申请的最大Heap内存。

如果在堆中没有内存完成实例分配,并且堆也无法拓展时,Java虚拟机会抛出OutOfMenoryError

方法区

方法区与Java堆一样, 是各个线程共享的内存区域, 它用于存储已被虚拟机加载的类型信息、 常量、 静态变量、 即时编译器编译后的代码缓存等数据。(方法区存放.class文件片段)(jvm在运行应用时要大量使用存储在方法区中的类型信息.)

类型信息 1. 类型的全限定名 2. 超类的全限定名 3. 直接超接口的全限定名 4. 类型标志(该类是类类型还是接口类型) 5. 类的访问描述符(public、private、default、abstract、final、static)
字段信息 1. 字段修饰符(public、protect、private、default) 2. 字段的类型 3. 字段名称
方法信息 1.. 方法名 2.方法的返回类型(包括void)3. 方法参数的类型、数目以及顺序 4. 方法修饰符(public、private、protected、static、final、synchronized、native、abstract) 5. 针对非本地方法,还有些附加方法信息需要存储在方法区中(局部变量表大小和操作数栈大小、方法体字节码、异常表)
静态变量 指该类所有对象共享的变量,即使没有创建该对象实例,也可以访问的类变量。它们与类进行绑定

https://gitee.com/shilongshen/xiaoxingimagebad/raw/master/img/20210319184504.png

方法区中一个重要的概念:运行时常量池主要用于存放在编译过程中产生的字面量(字面量简单理解就是常量)和符号引用。一般情况,常量的内存分配在编译期间就能确定,但不一定全是,有一些可能就是运行时也可将常量放入常量池中,如String类中有个Native方法intern()。

对象的创建

https://gitee.com/shilongshen/xiaoxingimagebad/raw/master/img/20210318224137.png

https://gitee.com/shilongshen/xiaoxingimagebad/raw/master/img/20210407195758.png

https://gitee.com/shilongshen/xiaoxingimagebad/raw/master/img/20210407195833.png

对象内存分配方法

  • 指针碰撞

假设堆的内存是规整的,即使用过的内存放在一边,没有使用过的内存放在一边,中间有一个指针作为两者的边界指示器,那所分配的内存就是将指针向空闲内存那一边移动要创建对象的大小即可。

  • 空闲列表

假设堆的内存不是规整的,存在空间碎片,虚拟机就需要维护一个列表,用于记录哪块内存被使用过了,哪一块没有被使用过。在分配内存的时候根据列表找到一个合适的位置将对象进行分配,同时将列表进行更新。

内存分配的并发安全问题

对于内存分配的并发安全问题有两种解决方案:

  • 对分配内存空间的操作进行同步处理

JVM中采用的是CAS加上失败重试的方案进行同步处理,以保证更新操作的原子性

  • 将内存分配的操作按照不同的线程划分到不同的空间中进行

即每个线程在堆中预先分配一小块内存(称为本地线程分配缓冲,TLAB),线程创建对象时就在各自对应的这一块内存中进行空间分配,这样就不会产生多线程并发的问题了。

只有当本地线程分配缓冲用完了,才进行同步锁定,重新分配本地线程分配缓冲。

JVM对象内存布局

参考

在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header),实例数据( Instance Data)和对齐填充(Padding)。

https://gitee.com/shilongshen/xiaoxingimagebad/raw/master/img/20210408155223.png

对象头:

第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳、对象分代年龄,这部分信息称为“Mark Word”;Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据自己的状态复用自己的存储空间;

第二部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例; 如果对象是一个 Java 数组,那在对象头中还必须有一块用于记录数组长度的数据。因为虚拟机可以通过普通 Java 对象的元数据信息确定Java 对象的大小,但是从数组的元数据中无法确定数组的大小。这部分数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中分别为32bit 和 64bit。

实例数据:

实例数据部分是对象真正存储的有效信息,也既是我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的都需要记录下来。

对齐填充:

对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍。对象头正好是8字节的倍数(1倍或者2倍),因此当对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

堆内存分配的策略

堆一般被分为新生代和老年代,针对不同的区域采用不同的垃圾收集算法。

https://gitee.com/shilongshen/xiaoxingimagebad/raw/master/img/20210321214903.png

https://gitee.com/shilongshen/xiaoxingimagebad/raw/master/img/20210328163623.png

  • 大对象直接进入老年代 因为大对象需要大的、连续的内存空间。如果将其分配到eden区,就有可能由于标记-复制垃圾收集算法带来的效率底下问题

  • 长期存活的对象将进入老年代

    Eden区的对象每经过一次垃圾收集后仍能够存活就会被移到survivor区,并将年龄加1。在survivor区中的对象每经过一次垃圾收集,并能够存活,年龄就加1,当其年龄增加到一定程度后(默认15岁),就会被进入老年代。

类加载子系统

类的加载是将字节码文件(.class文件)中的二进制数据读入到内存中,将其放在方法区中,然后在堆中创建一个对象,用来封装类在方法区中的数据结构。

类的加载的最终结果是位于堆中的对象,对象封装了类在方法区中的数据结构,并向程序员提供了访问方法区内的数据结构的接口。

注意:因为Java是面向对象的编程语言,所以字节码文件中代表的是一个类或一个接口,类中由字段和方法构成,把类的数据放在了方法区中。属于类的对象是放在堆中的,对象可以调用类中的方法,所以说对象封装了类在方法区中的数据结构。或者说这个对象为方法区中这个类的各种数据的访问入口。

https://gitee.com/shilongshen/xiaoxingimagebad/raw/master/img/20210319185331.png

类加载分为一下几个步骤:

https://gitee.com/shilongshen/image-bad/raw/master/img/20201127201627.png

加载阶段

查找并加载类的二进制数据

  • 通过类的全限定名来获取此类的二进制字节流
  • 将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构
  • 在堆中生成一个表示这个类的对象,作为方法区这个类的各种数据的访问入口 –>具体的步骤可以参照对象的创建具体步骤

验证阶段

确保被加载的类的正确性

  • 文件格式验证
  • 元数据验证
  • 字节码验证
  • 符号引用验证

准备阶段

为类中的变量(即静态变量,被static修饰的变量)分配内存并设置初始值。

注意:

  • 1、这时候进行内存(方法区)分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中
  • 2、这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。

假设一个类变量的定义为: public static int value=3

那么变量value在准备阶段过后的初始值为0,而不是3,因为这时候尚未开始执行任何Java方法,而把value赋值为3的 public static指令是在程序编译后,存放于类构造器 <clinit>()方法之中的,所以把value赋值为3的动作将在初始化阶段才会执行。

这里还需要注意如下几点:

  • 对基本数据类型来说,对于类变量(static)和实例变量,如果不显式地对其赋值而直接使用,则系统会为其赋予默认的零值,而对于局部变量来说,在使用前必须显式地为其赋值,否则编译时不通过。
  • 对于同时被static和final修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;而只被final修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值。
  • 对于引用数据类型reference来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即null。
  • 如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值。
  • 3、如果类字段的字段属性表中存在 Constant Value属性,即同时被final和static修饰,那么在准备阶段变量value就会被初始化为ConstValue属性所指定的值。

假设上面的类变量value被定义为: public static final int value=3

编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据 ConstantValue的设置将value赋值为3。我们可以理解为static final常量在编译期就将其结果放入了调用它的类的常量池中

解析阶段

将常量池中的符号引用转换为直接引用

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。符号引用就是一组符号来描述目标,可以是任何字面量。

直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

初始化

初始化,为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。在Java中对类变量进行初始值设定有两种方式:

  • ①声明类变量是指定初始值
  • ②使用静态代码块为类变量指定初始值

JVM初始化步骤

  • 1、假如这个类还没有被加载和连接,则程序先加载并连接该类
  • 2、假如该类的直接父类还没有被初始化,则先初始化其直接父类
  • 3、假如类中有初始化语句,则系统依次执行这些初始化语句

类初始化时机:只有当对类的主动使用的时候才会导致类的初始化,类的主动使用包括以下六种:

  • 创建类的实例,也就是new的方式
  • 访问某个类或接口的静态变量,或者对该静态变量赋值
  • 调用类的静态方法
  • 反射(如 Class.forName(“com.shengsiyuan.Test”)
  • 初始化某个类的子类,则其父类也会被初始化
  • Java虚拟机启动时被标明为启动类的类( JavaTest),直接使用 java.exe命令来运行某个主类

类初始化顺序:

父类静态变量、->父类静态代码块、->子类静态变量、->子类静态代码块、->父类普通变量、->父类普通代码块、->父类构造函数、->

子类普通变量、->子类普通代码块、->子类构造函数

类加载器

“通过类的全限定名来获取此类的二进制字节流”,实现这个动作的代码称为类加载器。

比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义。否则,即使在这两个类来自同一个class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

站在虚拟机的角度上,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分;另外一种就是其它所有的类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全部继承自java.lang.ClassLoader

从Java开发人员的角度看,类加载器还可以划分得更细一些,如下:

Bootstrap ClassLoader启动类加载器 :最顶层的加载类,主要加载核心类库。这个类加载器负责将放置在<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定路径中的,并且是虚拟机能识别的(仅按照文件名识别**,如rt.jar,**名字不符合的类库即使放置在lib目录中也不会被加载**)类库加载到虚拟机内存中。**启动类加载器无法被Java程序直接使用**;

Extention ClassLoader扩展类加载器**:负责加载 JAVA_HOME\lib\ext 目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,**开发者可以直接使用扩展类加载器**;

Application ClassLoader应用程序类加载器:由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也被称为系统类加载器**。**它负责加载用户类路径上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。**

双亲委派模型

各种类加载器之间的层次关系被称为类加载器的“双亲委派模型”

双亲委派模型要求除了顶层的启动类加载器之外,其余的类加载器都应当有自己的父类加载器。这里的类加载器之间的父子关系一般不会以继承的关系来实现,而是使用组合关系来复用父加载器的代码。

一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完全这个加载请求时,子加载器才会尝试自己去加载。

优先:采用双亲委派的一个好处是比如加载位于rt.jar 包中的类 java.lang.Object,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同样一个 Object 对象。

破坏双亲委派模型

(1)双亲委派模型的第一次“被破坏”其实发生在双亲委派模型出现之前–即JDK1.2发布之前。JDK1.2之后已不再提倡用户再去覆盖loadClass()方法,应当把自己的类加载逻辑写到findClass()方法中,在loadClass()方法的逻辑里,如果父类加载器加载失败,则会调用自己的findClass()方法来完成加载,这样就可以保证新写出来的类加载器是符合双亲委派模型的。

(2)双亲委派模型的第二次“被破坏”是这个模型自身的缺陷所导致的,双亲委派模型很好地解决了各个类加载器的基础类统一问题(越基础的类由越上层的加载器进行加载),如果基础类又要调用用户的代码,这时候就出现问题了,出现了线程上下文类加载器,也就是父类加载器请求子类加载器去完成类加载动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型

(3)双亲委派模型的第三次“被破坏”是由于用户对程序的动态性的追求导致的,例如OSGi(是面向 Java 的动态模型系统,是 Java 动态化模块化系统的一系列规范)的出现。类加载器不再是双亲委派模型中的树状结构,而是进一步发展为网状结构

执行引擎

输入是字节码二进制流,输出是机器指令。

执行引擎属于JVM的下层,里面包括 解释器、及时编译器(JIT)、垃圾回收器

JVM的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM所识别的字节码指令、符号表,以及其他辅助信息。

https://gitee.com/moxi159753/LearningNotes/raw/master/JVM/1_%E5%86%85%E5%AD%98%E4%B8%8E%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E7%AF%87/12_%E6%89%A7%E8%A1%8C%E5%BC%95%E6%93%8E/images/image-20200710081118053.png

那么,如果想要让一个Java程序运行起来,执行引擎(Execution Engine)的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM中的执行引擎充当了将高级语言翻译为机器语言的译者

https://gitee.com/shilongshen/xiaoxingimagebad/raw/master/img/20210319153605.png

解释器

对字节码进行逐行解释,将每条字节码翻译为对应平台的本地机器指令进行执行。(通过汇编语言执行)

​ 优点:响应速度块

​ 缺点:运行效率低。

编译器

​ 将字节码编译成和本地机器平台相关的机器语言。(二进制码,能够直接被cpu读取运行)

​ 优点:响应速度慢

​ 缺点:运行效率高

参考

java虚拟机中采用解释器和编译器并存的运行架构,

Java程序最初是通过解释器进行解释执行的,当Java虚拟机发现某个方法或代码块运行特别频繁的时候,就会认为这是“热点代码”(Hot Spot Code)。JIT即时编译器会将这些“热点代码”编译成与本地机器相关的机器指令,进行各个层次的优化。

一些重复出现的代码(热点代码),就可以将其编译为本地机器指令,重复使用,从而提高效率。

当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地机器指令之后,可以获取更高的执行效率。

当程序运行环境中内存资源限制较大,可以使用解释器执行节约内存,反之可以使用编译执行来提升效率。

无论是使用解释器进行解释执行,还是使用编译器进行编译后执行,最终源代码都需要被转换为对应平台的本地机器指令。

首先,从Python这种编程语言说起。

它有以下几个特点:

  • 面向对象:简单来说是指在程序设计中能够采用封装、继承、多态的设计方法。
  • 动态语言:是在运行时可以改变其结构的语言;例如,在程序运行过程中,给一个类的对象添加原本不存在的属性。
  • 动态数据类型:变量不需要指定类型,但需要解释器执行代码时去辨别数据类型;这个特点让编程变得简单,但代码执行效率变低。
  • 高级语言:是指高度封装了的编程语言,相对于机器语言,更加适合人类编写与阅读。
  • 解释型语言:是指无需编译,直接能够将源代码解释为机器语言进行运行的语言。

C和C++。

这两种语言都是编译型语言。

编译型语言的特点是执行速度快,缺点是什么呢?

编译型语言需要编译器处理,主要工作流程如下:

源代码 (source code) → 预处理器 (preprocessor) → 编译器 (compiler) → 目标代码 (object code) → 链接器 (Linker) → 可执行程序 (executables)

在这个工作流程中,编译器调用预处理器进行相关处理,将源代码进行优化转换(包括清除注释、宏定义、包含文件和条件编译),然后,通过将经过预处理的源代码编译成目标代码(二进制机器语言),再通过调用链接器外加库文件(例如操作系统提供的API),从而形成可执行程序,让机器能够执行。

在这个工作流程中,目标代码要和机器的CPU架构相匹配,库文件要和操作系统相匹配。

如果想在不同CPU的机器或者系统上运行C语言的源代码,就需要针对不同的CPU架构和操作系统进行编译,这样才能够在机器上运行程序。

所以,编译型语言的缺点我们就看到了,它不适合跨平台、相应速度慢

早期的解释器就是这样的工作流程:源代码 (source code) → 解释器 (interpreter) ,源代码无需预先编译成可执行程序。

在程序执行时,解释器读取一句源代码之后,先进行词法分析和语法分析,再将源代码转换为解释器能够执行的中间代码(字节码),最后,由解释器将中间代码解释为可执行的机器指令。(逐行将字节码翻译为机器码)

所以,编译型语言的可执行程序产生的是直接执行机器指令,而解释型语言的每一句源代码都要经过解释器解释为可以执行的机器指令,相比之下解释型语言的执行效率会低一些。

运行时的栈帧结构

Java虚拟机以方法作为最基本的执行单元,栈帧则是用于支持虚拟机进行方法调用和执行的数据结构。

对于执行引擎来说,在活动线程中,只有位于栈顶的方法才是运行的,只有位于栈顶的栈帧才是有效的,其被称为活动栈帧,与这个栈帧关联的方法称为当前方法。执行引擎所运行的所有字节码指令都针对当前栈帧进行操作。

https://gitee.com/shilongshen/xiaoxingimagebad/raw/master/img/20210318225744.png

局部变量表

局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。局部变量不像前面介绍的类变量那样存在“准备阶段”。类变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予程序员定义的值。因此即使在初始化阶段程序员没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值。但局部变量就不一样了,如果一个局部变量定义了但没有赋初始值是不能使用的(局部变量必须进行初始化)。

操作数栈

虚拟机把操作数栈作为它的工作区——大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈。比如,iadd指令就要从操作数栈中弹出两个整数,执行加法运算,其结果又压回到操作数栈中,看看下面的示例,它演示了虚拟机是如何把两个int类型的局部变量相加,再把结果保存到第三个局部变量的:

1
2
3
4
5
6
begin  
iload_0    // push the int in local variable 0 onto the stack  
iload_1    // push the int in local variable 1 onto the stack  
iadd       // pop two ints, add them, push result  
istore_2   // pop int, store into local variable 2  
end  
1
在这个字节码序列里,前两个指令iload_0和iload_1将存储在局部变量中索引为0和1的整数压入操作数栈中,其后iadd指令从操作数栈中弹出那两个整数相加,再将结果压入操作数栈。第四条指令istore_2则从操作数栈中弹出结果,并把它存储到局部变量区索引为2的位置。下图详细表述了这个过程中局部变量和操作数栈的状态变化,图中没有使用的局部变量区和操作数栈区域以空白表示。

动态链接

在Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态连接。

方法的返回地址

方法执行完后有两种方式退出这个方法,一是正常完成出口,并可能会有返回值;二是异常完成出口,是不会给它的调用者产生任何返回值的。无论采用何种方式退出,在方法退出之前,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。

方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调用PC计数器的值以指向方法调用指令后面的一条指令等。


垃圾收集器和内存分配管理

什么是”垃圾“:在运行程序中没用指针指向的对象被称为垃圾。

垃圾收集主要是针对堆。

如何判断对象是否存活:

1. 引用计数器算法

​ 原理:在对象中添加一个计数器,每当有一个地方引用该对象时,计数器就加一,当引用失效时,计数器就减一。当计数器为0的对象就是不能够再被引用的对象。

​ 优点:实现简单,判别效率高

​ 缺点:无法处理循环引用的情况,即当两个对象相互引用,但是二者已经没有作用时,按照常规,应该对其进行垃圾回收,但是其相互引用,又不符合垃圾回收的条件,因此无法完美处理这块内存清理

2 可达性分析算法(在java垃圾回收中使用的算法)

​ 原理:通过一系列称为”GC Roots“的根对象作为起始节点集,从这些节点开始向下搜索,搜索过程所走过的路径称为”引用链“。如果某个对象到”GC Roots“之间没用任何引用链相连,则称为此对象是不可能再被引用的对象。

​ 可作为”GC Roots“的对象有:

  • 在虚拟机栈中引用的对象
  • 在方法区中类静态属性引用的对象
  • 在方法区中常量引用的对象
  • 在本地方法栈(Native方法)中引用的对象
  • java虚拟机内部的引用
  • 所有被同步锁持有的对象
  • 反映java虚拟机内部情况的JMXBean,JVMTI中注册的回调,本地代码缓存等
  • 还可以有其他对象临时加入->例如分代收集和局部回收

小技巧:因为ROOT采用栈存放变量和指针,所以如果一个指针指向堆内存里面的对象,但是自己又不在堆中,那它就是一个ROOT

强软弱虚引用

强、软、弱、虚四种引用,被标记为这四种引用的对象,在GC时分别有不同的意义:

(1)强引用:就是为刚被new出来的对象所加的引用,只要强引用还存在,垃圾收集器就永远不会回收掉被引用的对象。

(2)软引用:声明为软引用的类,是可被回收的对象,如果JVM内存并不紧张,这类对象可以不被回收,如果内存紧张,则会被回收。

此处有一个问题,既然被引用为软引用的对象可以回收,为什么不去回收呢?其实我们知道,Java中是存在缓存机制的,就拿字面量缓存来说,有些时候,缓存的对象就是当前可有可无的,只是留在内存中如果还有需要,则不需要重新分配内存即可使用,因此,这些对象即可被引用为软引用,方便使用,提高程序性能。

软引用可用来实现内存敏感的高速缓存,比如网页缓存、图片缓存等。使用软引用能防止内存泄露,增强程序的健壮性。

1
2
3
MyObject aRef = new MyObject(); 

SoftReference aSoftRef=new SoftReference(aRef); 

(3)弱引用:弱引用的对象就是一定需要进行垃圾回收的,不管内存是否紧张,当进行GC时,标记为弱引用的对象一定会被清理回收。

(4)虚引用:也成为幽灵引用或者欢迎引用,它是最弱的一种引用关系。虚引用弱的可以忽略不计,JVM完全不会在乎虚引用,其唯一作用就是做一些跟踪记录,辅助finalize函数的使用。

虚引用的主要作用是跟踪对象被垃圾回收的状态。

一个对象是否具有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。虚引用必须和引用队列关联使用,程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

1
2
3
ReferenceQueue<String> queue = new ReferenceQueue<String>(); 

PhantomReference<String> pr = new PhantomReference<String>(new String("hello"), queue); 

对象的三种状态

要宣告一个对象的真正死亡,要经过两个标记阶段。

  1. 如果一个对象经过可达性分析后发现和ROOT不存在引用链,那这个对象就会被第一次标记
  2. 再进行一次筛选,条件是该对象是否有必要执行finalize()方法。假设对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过了,那么虚拟机将这两种情况都是为没有必要执行。

https://gitee.com/shilongshen/xiaoxingimagebad/raw/master/img/20210319232833.png

垃圾回收算法

1 标记-清除算法(Mark-sweep)

​ 原理:标记存活的对象,统一回收未被标记的对象

​ 缺点:执行效率不稳定:当有大量的对象是需要回收的,需要进行大量的标记和回收动作;内存空间碎片化:标记,清除后会产生大量不连续的空间碎片。以至于在后续程序中需要分配较大的对象时找不到足够大的连续空间。

2 标记-复制算法()

​ 原理:将内存划分为两块相同的大小,每次只能够使用其中的一块。当这一块内存用完了,则将还存活的对象复制到另一块内存上,然后将已经使用过的内存空间进行清除。

​ 优点:能够保证空间的连续性,没有内存空间碎片化的问题。实现简单,运行高效。

​ 缺点:当内存中存在较多存活的对象时,空间利用率低(适合于新生代,新生代的存活对象少)

一般Sun的JVM会将Eden区和Survivor区的比例调为8:1,保证有一块Survivor区是空闲的,这样,在垃圾回收的时候,将不需要进行回收的对象放在空闲的Survivor区,然后将Eden区和第一块Survivor区进行完全清理,这样有一个问题,就是如果第二块Survivor区的空间不够大怎么办?这个时候,就需要当Survivor区不够用的时候,暂时借持久代的内存用一下。此算法适用于新生代。

https://gitee.com/shilongshen/xiaoxingimagebad/raw/master/img/20210321214903.png

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

​ 原理:标记存活的对象,将存活的对象移动到空间的一端,然后直接清理掉边界以外的内存。

​ 优点:没有内存空间碎片化的问题;消除了在标记-复制算法中,内存空间减半的高额代价

​ 缺点:效率要低于标记-复制算法,因为在移动对象的过程中,如果对象被其他对象引用,则还需要调整引用地址。移动过程中,需要全程暂停用户应用程序。(适合用于老年代,老年代的存活对象多)

小结

效率上来说,复制算法是当之无愧的老大,但是却浪费了太多内存。

而为了尽量兼顾上面提到的三个指标,标记-整理算法相对来说更平滑一些,但是效率上不尽如人意,它比复制算法多了一个标记的阶段,比标记-清除多了一个整理内存的阶段。

标记清除 标记整理 复制
速率 中等 最慢 最快
空间开销 少(但会堆积碎片) 少(不堆积碎片) 通常需要活对象的2倍空间(不堆积碎片)
移动对象

综合我们可以找到,没有最好的算法,只有最合适的算法

如何解决跨代引用

在分析JVM的分代垃圾收集算法的时候,可能存在老年代对新生代的引用,无法真正确定对象已死。

一、解决跨代引用:记忆集

记忆集(Remembered Set):一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构,在对象层面来说就是非收集区域对象对收集区域对象的引用的记录。

它存放在收集区域,比如在新生代里面存放着老年代对新生代对象的每一个引用。这样在收集新生代的时候,我们就可以根据记忆集知道哪些对象被老年代对象所引用,不能回收,这就解决了跨代引用的问题。

记忆集根据记录的精度分三类:

字长精度:记录的是老年代指向新生代地址。

对象精度:记录的是老年代引用的新生代对象。

卡精度:记录的是新生代一段地址是否存在被老年代引用的记录。

二、记忆集的实现:卡表

卡表(Card Table):是以第三种卡精度的方式实现的记忆集,也是目前最常用的方式。记忆集是抽象的概念,而卡表就是记忆集的一种具体实现。

卡表最简单的形式可以是一个字节数组,HotSpot就是这样实现的。

CARD_TABLE [this address » 9] = 0;

大致示意图:

https://gitee.com/shilongshen/xiaoxingimagebad/raw/master/img/20210408155223.png

把地址的值右移9位相当于除于512就是卡表索引,每字节512为一组对应卡表同一个元素,一组就是一个卡页,如果这个卡页中只要有一个对象被其他区域对象所引用,对应卡表元素的值就变成1,也就是所谓的元素变脏。

在垃圾回收时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页对应的内存包含跨代指针,把他们加入GC Rootsz中一并扫描。

三、卡表数据的收集:写屏障

**写屏障:**可以看成是虚拟机层面在”引用类型字段赋值“这个动作的AOP切面,引用对象赋值的时候产生一个环形通知,进行一些额外的处理,这样就是引用对象赋值这个操作都在写屏障的覆盖范围内,赋值前的写屏障叫写前屏障,复制后的写屏障叫写后屏障。

这样我们就可以通过写屏障,一旦发生赋值操作就可以把引用的更新写进卡表。

System.gc()的理解

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

然而system.gc() )调用附带一个免责声明,无法保证对垃圾收集器的调用。(不能确保立即生效)

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

代码演示是否触发GC操作

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
/**
 * System.gc()
 *
 * @author: 陌溪
 * @create: 2020-07-12-19:07
 */
public class SystemGCTest {
    public static void main(String[] args) {
        new SystemGCTest();
        // 提醒JVM进行垃圾回收
        System.gc();
        //System.runFinalization();
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("SystemGCTest 执行了 finalize方法");
    }
}

运行结果,但是不一定会触发销毁的方法,调用System.runFinalization()会强制调用 失去引用对象的finalize()

1
SystemGCTest 执行了 finalize方法

手动GC来理解不可达对象的回收

代码如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/**
 * 局部变量回收
 *
 * @author: 陌溪
 * @create: 2020-07-12-19:12
 */
public class LocalVarGC {

    /**
     * 触发Minor GC没有回收对象,然后在触发Full GC将该对象存入old区
     */
    public void localvarGC1() {
        byte[] buffer = new byte[10*1024*1024];
        System.gc();
    }

    /**
     * 触发YoungGC的时候,已经被回收了
     */
    public void localvarGC2() {
        byte[] buffer = new byte[10*1024*1024];
        buffer = null;
        System.gc();
    }

    /**
     * 不会被回收,因为它还存放在局部变量表索引为1的槽中
     */
    public void localvarGC3() {
        {
            byte[] buffer = new byte[10*1024*1024];
        }
        System.gc();
    }

    /**
     * 会被回收,因为它还存放在局部变量表索引为1的槽中,但是后面定义的value把这个槽给替换了
     */
    public void localvarGC4() {
        {
            byte[] buffer = new byte[10*1024*1024];
        }
        int value = 10;
        System.gc();
    }

    /**
     * localvarGC5中的数组已经被回收
     */
    public void localvarGC5() {
        localvarGC1();
        System.gc();
    }

    public static void main(String[] args) {
        LocalVarGC localVarGC = new LocalVarGC();
        localVarGC.localvarGC3();
    }
}

内存溢出和和内存泄漏

内存溢出(OOM):没有空闲内存,并且垃圾回收器也无法提供更多的内存。

  • java虚拟机的堆内存设置不够
  • 代码中创建了大量的对象,并且由于存在被引用,无法被垃圾回收器回收(在抛出OutofMemoryError之前,通常垃圾收集器会被触发,尽其所能去清理出空间。)

内存泄漏:

严格来说只有对象不被程序用到,但是垃圾回收器无法回收他们的情况才称为内存泄漏

​ 宽泛来说,实际中一些不好的实践会导致对象的生命过长,甚至导致OOM,也称为内存泄漏

强引用所指向的对象不会被回收,可能导致内存泄漏,虚拟机宁愿抛出OOM也不会去回收他指向的对象。意思就是你用资源的时候为他开辟了一段空间,当你用完时忘记释放资源了,这时内存还被占用着,一次没关系,但是内存泄漏次数多了就会导致内存溢出。

注意:内存泄漏有可能导致内存溢出;

尽管内存泄漏不会立刻引起程序的崩溃,但是一旦发生内存泄漏,内存就会被蚕食殆尽,最终导致内存溢出。

图示说明

https://gitee.com/moxi159753/LearningNotes/raw/master/JVM/1_%E5%86%85%E5%AD%98%E4%B8%8E%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E7%AF%87/16_%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E7%9B%B8%E5%85%B3%E6%A6%82%E5%BF%B5/images/image-20200712195158470.png

Java使用可达性分析算法,最上面的数据不可达,就是需要被回收的。后期有一些对象不用了,按道理应该断开引用,但是存在一些链没有断开,从而导致没有办法被回收。

举例

  • 单例模式

单例的生命周期和应用程序是一样长的,所以单例程序中,如果持有对外部对象的引用的话,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生。

  • 一些提供close的资源未关闭导致内存泄漏

数据库连接(dataSourse.getConnection() ),网络连接(socket)和io连接必须手动close,否则是不能被回收的。

Stop The World

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

可达性分析算法中枚举根节点(GC Roots)会导致所有Java执行线程停顿。

  • 分析工作必须在一个能确保一致性的快照中进行
  • 一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上
  • 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证

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

STW事件和采用哪款GC无关,所有的GC都有这个事件。

哪怕是G1也不能完全避免Stop-the-world情况发生,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能地缩短了暂停时间。

STW是JVM在后台自动发起和自动完成的。在用户不可见的情况下,把用户正常的工作线程全部停掉。

开发中不要用system.gc() 会导致stop-the-world的发生。

并发和并行对比

并发,指的是多个事情,在同一时间段内同时发生了。

并行,指的是多个事情,在同一时间点上同时发生了。

并发的多个任务之间是互相抢占资源的。并行的多个任务之间是不互相抢占资源的。

只有在多CPU或者一个CPU多核的情况中,才会发生并行。

否则,看似同时发生的事情,其实都是并发执行的

垃圾回收的并行与并发

并发和并行,在谈论垃圾收集器的上下文语境中,它们可以解释如下:

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

https://gitee.com/moxi159753/LearningNotes/raw/master/JVM/1_%E5%86%85%E5%AD%98%E4%B8%8E%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E7%AF%87/16_%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E7%9B%B8%E5%85%B3%E6%A6%82%E5%BF%B5/images/image-20200712203607845.png

并发和并行,在谈论垃圾收集器的上下文语境中,它们可以解释如下:

并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),垃圾回收线程在执行时不会停顿用户程序的运行。>用户程序在继续运行,而垃圾收集程序线程运行于另一个CPU上;

如:CMS、G1

https://gitee.com/moxi159753/LearningNotes/raw/master/JVM/1_%E5%86%85%E5%AD%98%E4%B8%8E%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E7%AF%87/16_%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E7%9B%B8%E5%85%B3%E6%A6%82%E5%BF%B5/images/image-20200712203815517.png

安全点与安全区域

安全点

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

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

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

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

安全区域

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

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

执行流程:

  • 当线程运行到Safe Region的代码时,首先标识已经进入了Safe Relgion,如果这段时间内发生GC,JVM会忽略标识为Safe Region状态的线程

  • 当线程即将离开Safe Region时,会检查JVM是否已经完成GC,如果完成了,则继续运行,否则线程必须等待直到收到可以安全离开Safe Region的信号为止;

分代收集算法

根据对象不同的生命周期将内存(java堆)划分为不同的区域,垃圾回收器可以针对不同的区域采用不同的收集算法。

例如针对新生代对象朝生夕灭的特点,常常采用标记-复制算法;针对老年代对象生命周期长的特点常常采用标记-清除算法或者标记-整理算法

Minor GC, Major GC,Full GC

  • 部分收集(partial GC):指不是完整收集整个java堆的垃圾收集,其中又分为:

    • 新生代收集(Minor GC/Young GC):针对新生代的收集
    • 老年代收集(Major GC/Old GC): 针对老年代的垃圾收集
    • 混合收集(Mix GC): 针对整个新生代和部分呢老年代的垃圾收集
  • 整堆收集(Full GC): 针对整个JAVA堆和方法区的垃圾收集

经典垃圾收集器

新生代收集器:Serial、ParNew、Parallel Scavenge;

老年代收集器:Serial old、Parallel old、CMS;

整堆收集器:G1;

垃圾收集器的组合关系

https://gitee.com/moxi159753/LearningNotes/raw/master/JVM/1_%E5%86%85%E5%AD%98%E4%B8%8E%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E7%AF%87/17_%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E5%99%A8/images/image-20200713094745366.png

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

为什么要有很多收集器,一个不够吗?因为Java的使用场景很多,移动端,服务器等。所以就需要针对不同的场景,提供不同的垃圾收集器,提高垃圾收集的性能。

虽然我们会对各个收集器进行比较,但并非为了挑选一个最好的收集器出来。没有一种放之四海皆准、任何场景下都适用的完美收集器存在,更加没有万能的收集器。所以我们选择的只是对具体应用最合适的收集器。

Serial收集器 / Serial old 收集器

Serial 标记-复制算法 新生代 串行
Serial old 标记-整理算法(标记-压缩) 老年代 串行

Serial收集器是最基本、历史最悠久的垃圾收集器了。JDK1.3之前回收新生代唯一的选择。

Serial收集器作为HotSpot中client模式下的默认新生代垃圾收集器。

Serial收集器采用复制算法、串行回收和**“stop-the-World**“机制的方式执行内存回收。

除了年轻代之外,Serial收集器还提供用于执行老年代垃圾收集的Serial old收集器。Serial old收集器同样也采用了串行回收和"stop the World"机制,只不过内存回收算法使用的是标记**-压缩**算法。

  • Serial old是运行在Client模式下默认的老年代的垃圾回收器
  • Serial 0ld在Server模式下主要有两个用途:
    • 与新生代的Parallel scavenge配合使用
    • 作为老年代CMS收集器的后备垃圾收集方案

https://gitee.com/moxi159753/LearningNotes/raw/master/JVM/1_%E5%86%85%E5%AD%98%E4%B8%8E%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E7%AF%87/17_%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E5%99%A8/images/image-20200713100703799.png

这个收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(Stop The World)

优势:简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

运行在client模式下的虚拟机是个不错的选择。

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

在HotSpot虚拟机中,使用-XX:+UseSerialGC参数可以指定年轻代和老年代都使用串行收集器。

等价于新生代用Serial GC,且老年代用Serial old GC

ParNew收集器

ParNew 标记-复制算法 新生代 并行

如果说serialGC是年轻代中的单线程垃圾收集器,那么ParNew收集器则是serial收集器的多线程版本。

  • Par是Parallel的缩写,New:只能处理的是新生代

ParNew 收集器除了采用并行回收的方式执行内存回收外,两款垃圾收集器之间几乎没有任何区别。ParNew收集器在年轻代中同样也是采用复制算法、“stop-the-World"机制。

ParNew 是很多JVM运行在Server模式下新生代的默认垃圾收集器。

https://gitee.com/moxi159753/LearningNotes/raw/master/JVM/1_%E5%86%85%E5%AD%98%E4%B8%8E%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E7%AF%87/17_%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E5%99%A8/images/image-20200713102030127.png

  • 对于新生代,回收次数频繁,使用并行方式高效。
  • 对于老年代,回收次数少,使用串行方式节省资源。(CPU并行需要切换线程,串行可以省去切换线程的资源)

由于ParNew收集器是基于并行回收,那么是否可以断定ParNew收集器的回收效率在任何场景下都会比serial收集器更高效?

因为除Serial外,目前只有ParNew GC能与CMS收集器配合工作

在程序中,开发人员可以通过选项”-XX:+UseParNewGC"手动指定使用ParNew收集器执行内存回收任务。它表示年轻代使用并行收集器,不影响老年代。

-XX:ParallelGCThreads限制线程数量,默认开启和CPU数据相同的线程数。

Parallel Scavenge收集器 / Parallel Scavenge old 收集器 ->高吞吐量

Parallel Scavenge 标记-复制算法 新生代 并行
Parallel Scavenge old 标记-整理算法 老年代 并行

HotSpot的年轻代中除了拥有ParNew收集器是基于并行回收的以外,Parallel Scavenge收集器同样也采用了复制算法、并行回收和"Stop the World"机制。

那么Parallel 收集器的出现是否多此一举?

  • 和ParNew收集器不同,Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput),它也被称为吞吐量优先的垃圾收集器
  • 自适应调节策略也是Parallel Scavenge与ParNew一个重要区别。

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

Paralle1收集器在JDK1.6时提供了用于执行老年代垃圾收集的Paralle1o1d收集器,用来代替老年代的serialold收集器。

Parallel old收集器采用了标记-压缩算法,但同样也是基于并行回收和"stop-the-World"机制。

https://gitee.com/moxi159753/LearningNotes/raw/master/JVM/1_%E5%86%85%E5%AD%98%E4%B8%8E%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E7%AF%87/17_%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E5%99%A8/images/image-20200713110359441.png

在程序吞吐量优先的应用场景中,Parallel scvenge收集器和Parallel old收集器的组合,在server模式下的内存回收性能很不错。在Java8中,默认是此垃圾收集器

参数配置

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

-XX:+UseParalleloldcc 手动指定老年代都是使用并行回收收集器。

  • 分别适用于新生代和老年代。默认jdk8是开启的。
  • 上面两个参数,默认开启一个,另一个也会被开启。(互相激活)

-XX:ParallelGcrhreads设置年轻代并行收集器的线程数。一般地,最好与CPU数量相等,以避免过多的线程数影响垃圾收集性能。

在默认情况下,当CPU数量小于8个,ParallelGcThreads的值等于CPU数量。

当CPU数量大于8个,ParallelGCThreads的值等于3+[5*CPU Count]/8]

-XX:MaxGCPauseMillis 设置垃圾收集器最大停顿时间(即STw的时间)。单位是毫秒。

为了尽可能地把停顿时间控制在MaxGCPauseMi11s以内,收集器在工作时会调整Java堆大小或者其他一些参数。 对于用户来讲,停顿时间越短体验越好。但是在服务器端,我们注重高并发,整体的吞吐量。所以服务器端适合Parallel,进行控制。该参数使用需谨慎。

-XX:GCTimeRatio垃圾收集时间占总时间的比例(=1/(N+1))。用于衡量吞吐量的大小。

取值范围(0,100)。默认值99,也就是垃圾回收时间不超过1。

与前一个-xx:MaxGCPauseMillis参数有一定矛盾性。暂停时间越长,Radio参数就容易超过设定的比例。

-XX:+UseAdaptivesizepplicy 设置Parallel scavenge收集器具有自适应调节策略

在这种模式下,年轻代的大小、Eden和Survivor的比例、晋升老年代的对象年龄等参数会被自动调整,已达到在堆大小、吞吐量和停顿时间之间的平衡点。

在手动调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量(GCTimeRatio)和停顿时间(MaxGCPauseMil1s),让虚拟机自己完成调优工作。

CMS收集器->低延迟

CMS 标记-清除算法 老年代 并发

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

CMS收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验。

目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。

CMS的垃圾收集算法采用标记-清除算法,并且也会"stop-the-world”

不幸的是,CMS作为老年代的收集器,却无法与JDK1.4.0中已经存在的新生代收集器Parallel Scavenge配合工作,所以在JDK1.5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Serial收集器中的一个。

在G1出现之前,CMS使用还是非常广泛的。一直到今天,仍然有很多系统使用CMS GC。

https://gitee.com/moxi159753/LearningNotes/raw/master/JVM/1_%E5%86%85%E5%AD%98%E4%B8%8E%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E7%AF%87/17_%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E5%99%A8/images/image-20200713205154007.png

CMS整个过程比之前的收集器要复杂,整个过程分为4个主要阶段,即初始标记阶段、并发标记阶段、重新标记阶段和并发清除阶段。(涉及STW的阶段主要是:初始标记 和 重新标记)

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

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

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

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

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

https://gitee.com/moxi159753/LearningNotes/raw/master/JVM/1_%E5%86%85%E5%AD%98%E4%B8%8E%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E7%AF%87/17_%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E5%99%A8/images/image-20200713212230352.png

CMS为什么不使用标记整理算法?

答案其实很简单,因为当并发清除的时候,用Compact整理内存的话,原来的用户线程使用的内存还怎么用呢?要保证用户线程能继续执行,前提的它运行的资源不受影响嘛。Mark Compact更适合“stop the world” 这种场景下使用

优点

  • 并发收集
  • 低延迟

缺点

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

设置的参数

  • -XX:+UseConcMarkSweepGC手动指定使用CMS收集器执行内存回收任务。

开启该参数后会自动将-xx:+UseParNewGC打开。即:ParNew(Young区用)+CMS(01d区用)+Serial old的组合。

  • -XX:CMSInitiatingoccupanyFraction 设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收。

JDK5及以前版本的默认值为68,即当老年代的空间使用率达到68%时,会执行一次cMs回收。JDK6及以上版本默认值为92%

如果内存增长缓慢,则可以设置一个稍大的值,大的阀值可以有效降低CMS的触发频率,减少老年代回收的次数可以较为明显地改善应用程序性能。反之,如果应用程序内存使用率增长很快,则应该降低这个阈值,以避免频繁触发老年代串行收集器。因此通过该选项便可以有效降低Ful1Gc的执行次数。

  • -XX:+UseCMSCompactAtFullCollection用于指定在执行完Ful1

GC后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。

  • -XX:CMSFullGCsBeforecompaction 设置在执行多少次Ful1GC后对内存空间进行压缩整理。
  • -XX:ParallelcMSThreads 设置cMs的线程数量。

CMs默认启动的线程数是(Paralle1GCThreads+3)/4,ParallelGCThreads是年轻代并行收集器的线程数。当CPU资源比较紧张时,受到CMS收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕。

小结

HotSpot有这么多的垃圾回收器,那么如果有人问,Serial GC、Parallel GC、Concurrent Mark Sweep GC这三个Gc有什么不同呢?

请记住以下口令:

  • 如果你想要最小化地使用内存和并行开销,请选Serial GC;
  • 如果你想要最大化应用程序的吞吐量,请选Parallel GC;
  • 如果你想要最小化GC的中断或停顿时间,请选CMs GC。

G1(Garbage First)

官方给G1设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起“全功能收集器”的重任与期望

主要思想:

  • 将堆内存分割成一系列(不需要连续)的区域(region),使用不同的region来表示新生代(Eden,Survivor 0 ,Survivor 1 )和老年代。
  • 避免在整个堆中进行全区域的垃圾收集,以region作为单次回收的最小单元。G1 跟踪各个region里面的垃圾堆积的“价值”大小,然后在后台维护一个优先级列表,每次根据允许的收集时间,优先回收价值价值最大的region。(价值即指回收所获得的空间大小以及回收所需时间的经验值)
工作过程
分区

使用G1收集器时,它将整个Java堆划分成约2048个大小相同的独立Region块,每个Region块大小根据堆空间的实际大小而定,整体被控制在1MB到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表示属于01d内存区域。图中空白的表示未使用的内存空间。

G1垃圾收集器还增加了一种新的内存区域,叫做Humongous内存区域,如图中的H块。主要用于存储大对象,如果超过1.5个region,就放到H。

**设置H的原因:**对于堆中的对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放大对象。如果一个H区装不下一个大对象,那么G1会寻找连续的H区来存储。为了能找到连续的H区,有时候不得不启动Fu11Gc。G1的大多数行为都把H区作为老年代的一部分来看待。

每个Region都是通过指针碰撞来分配空间

回收过程

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

  • 年轻代GC(Young GC)
  • 老年代并发标记过程(Concurrent Marking)
  • 混合回收(Mixed GC)

(如果需要,单线程、独占式、高强度的Fu11GC还是继续存在的。它针对GC的评估失败提供了一种失败保护机制,即强力回收。)

顺时针,young gc->young gc+concurrent mark->Mixed GC顺序,进行垃圾回收。

  • 应用程序分配内存,当年轻代的Eden区用尽时开始年轻代回收过程;G1的年轻代收集阶段是一个并行的独占式收集器。在年轻代回收期,G1GC暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到Survivor区间或者老年区间,也有可能是两个区间都会涉及。

  • 当堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程。

  • 标记完成马上开始混合回收过程。对于一个混合回收期,G1GC从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了。同时,这个老年代Region是和年轻代一起被回收的。

举个例子:一个Web服务器,Java进程最大堆内存为4G,每分钟响应1500个请求,每45秒钟会新分配大约2G的内存。G1会每45秒钟进行一次年轻代回收,每31个小时整个堆的使用率会达到45%,会开始老年代并发标记过程,标记完成后开始四到五次的混合回收。

年轻代GC

标记-复制算法

JVM启动时,G1先准备好Eden区,程序在运行过程中不断创建对象到Eden区,当Eden空间耗尽时,G1会启动一次年轻代垃圾回收过程。

YoungGC时,首先G1停止应用程序的执行(stop-The-Wor1d),G1创建回收集(Collection Set),回收集是指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代Eden区和Survivor区所有的内存分段。

eden是当空间耗尽时,主动进行YoungGC,而survivor是被被动进行垃圾回收(YoungGC)的

然后开始如下回收过程:

  • 第一阶段,扫描根

根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等。根引用连同记忆集记录的外部引用作为扫描存活对象的入口。

  • 第二阶段,更新记忆集

处理dirty card queue(见备注)中的card,更新记忆集。此阶段完成后,记忆集可以准确的反映老年代对所在的内存分段中对象的引用。

  • 第三阶段,处理RSet

识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。

  • 第四阶段,复制对象。

此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段,Survivor区内存段中存活的对象如果年龄未达阈值,年龄会加1,达到阀值会被会被复制到old区中空的内存分段。如果Survivor空间不够,Eden空间的部分数据会直接晋升到老年代空间。

  • 第五阶段,处理引用

处理Soft,Weak,Phantom,Final,JNI Weak 等引用。最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。

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

当越来越多的对象晋升到老年代old region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC,该算法并不是一个old GC,除了回收整个Young Region,还会回收一部分的old Region。这里需要注意:是一部分老年代,而不是全部老年代。可以选择哪些o1d 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会花费很多的时间但是回收到的内存却很少。

记忆集

用于解决跨代引用的问题

一个Region不可能是孤立的,一个Region中的对象可能被其他任意Region中对象引用,判断对象存活时,是否需要扫描整个Java堆才能保证准确?

在其他的分代收集器,也存在这样的问题(而G1更突出)回收新生代也不得不同时扫描老年代?这样的话会降低MinorGC的效率;


无论G1还是其他分代收集器,JVM都是使用记忆集(Remembered Set)来避免全局扫描:

  • 每个Region都有一个对应的Remembered Set;

  • 每次Reference类型数据写操作时,都会产生一个写屏障(Write Barrier)暂时中断操作;

  • 然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region(其他收集器:检查老年代对象是否引用了新生代对象);如果不同,通过卡表(cardTable)把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中;

  • 当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set;就可以保证不进行全局扫描,也不会有遗漏。

https://gitee.com/shilongshen/xiaoxingimagebad/raw/master/img/20210504185717.png

如上图,region1引用了region2,所以在region2的记忆集中记录region1;同理region3引用了region2,所以在region2的记忆集中记录region3.

G1垃圾收集器的优点

并行与并发

  • 并行性:G1在回收期间,可以有多个GC线程同时工作,有效利用多核计算能力。此时用户线程STW
  • 并发性:G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况

分代收集

  • 从分代上看,G1依然属于分代型垃圾回收器,它会区分年轻代和老年代,年轻代依然有Eden区和Survivor区。但从堆的结构上看,它不要求整个Eden区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。

  • 将堆空间分为若干个区域(Region),这些区域中包含了逻辑上的年轻代和老年代。

  • 和之前的各类回收器不同,它同时兼顾年轻代和老年代。对比其他回收器,或者工作在年轻代,或者工作在老年代;

空间整合

  • CMS:“标记-清除”算法、内存碎片、若干次Gc后进行一次碎片整理
  • G1将内存划分为一个个的region。内存的回收是以region作为基本单位的。Region之间是复制算法,但整体上实际可看作是标记-压缩(Mark-Compact)算法,两种算法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。尤其是当Java堆非常大的时候,G1的优势更加明显。

可预测的停顿时间模型(即:软实时soft real-time) 这是G1相对于CMS的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

  • 由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。
  • G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。
  • 相比于CMSGC,G1未必能做到CMS在最好情况下的延时停顿,但是最差情况要好很多。
G1垃圾收集器的缺点

相较于CMS,G1还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(overload)都要比CMS要高。

从经验上来说,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用上则发挥其优势。平衡点在6-8GB之间。

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

G1的设计原则就是简化JVM性能调优,开发人员只需要简单的三步即可完成调优:

  • 第一步:开启G1垃圾收集器
  • 第二步:设置堆的最大内存
  • 第三步:设置最大的停顿时间

G1中提供了三种垃圾回收模式:YoungGC、Mixed GC和Fu11GC,在不同的条件下被触发。

G1收集器的适用场景

面向服务端应用,针对具有大内存、多处理器的机器。(在普通大小的堆里表现并不惊喜)

最主要的应用是需要低GC延迟,并具有大堆的应用程序提供解决方案;

如:在堆大小约6GB或更大时,可预测的暂停时间可以低于e.5秒;(G1通过每次只清理一部分而不是全部的Region的增量式清理来保证每次Gc停顿时间不会过长)。 用来替换掉JDK1.5中的CMS收集器;在下面的情况时,使用61可能比CMS好:

  • 超过5e%的Java堆被活动数据占用;
  • 对象分配频率或年代提升频率变化很大;
  • GC停顿时间过长(长于e.5至1秒)

HotSpot垃圾收集器里,除了61以外,其他的垃圾收集器使用内置的JVM线程执行Gc的多线程操作,而G1GC可以采用应用线程承担后台运行的GC工作,即当JVM的GC线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程。

垃圾回收器总结

截止JDK1.8,一共有7款不同的垃圾收集器。每一款的垃圾收集器都有不同的特点,在具体使用的时候,需要根据具体的情况选用不同的垃圾收集器。

https://gitee.com/moxi159753/LearningNotes/raw/master/JVM/1_%E5%86%85%E5%AD%98%E4%B8%8E%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E7%AF%87/17_%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E5%99%A8/images/image-20200714075738203.png

GC发展阶段:Serial=> Parallel(并行)=> CMS(并发)=> G1 => ZGC

不同厂商、不同版本的虚拟机实现差距比较大。HotSpot虚拟机在JDK7/8后所有收集器及组合如下图

https://gitee.com/moxi159753/LearningNotes/raw/master/JVM/1_%E5%86%85%E5%AD%98%E4%B8%8E%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E7%AF%87/17_%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6%E5%99%A8/images/image-20200714080151020.png

怎么选择垃圾回收器

Java垃圾收集器的配置对于JVM优化来说是一个很重要的选择,选择合适的垃圾收集器可以让JVM的性能有一个很大的提升。怎么选择垃圾收集器?

  • 优先调整堆的大小让JVM自适应完成。
  • 如果内存小于100M,使用串行收集器
  • 如果是单核、单机程序,并且没有停顿时间的要求,串行收集器
  • 如果是多CPU、需要高吞吐量、允许停顿时间超过1秒,选择并行或者JVM自己选择
  • 如果是多CPU、追求低停顿时间,需快速响应(比如延迟不能超过1秒,如互联网应用),使用并发收集器
  • 官方推荐G1,性能高。现在互联网的项目,基本都是使用G1。

最后需要明确一个观点:

  • 没有最好的收集器,更没有万能的收集
  • 调优永远是针对特定场景、特定需求,不存在一劳永逸的收集器

JVM调优

参考

链接

参考