JVM

Java虚拟机-JVM

介绍

为什么学习JVM?

项目管理、性能调优-程序员必备技能

虚拟机?

  • 虚拟机(Virtual Machine),虚拟计算机。是一款软件,具有计算机完整硬件功能的软件,大体上虚拟机可以分为系统虚拟机和程序虚拟机
  • VMware属于系统虚拟机,完全对物理计算机的仿真,提供一个可运行完整操作系统的平台。程序虚拟机的典型代表是java虚拟机,专门为执行某个计算机程序而设计。
    在java虚拟机中执行的指令称为java字节码指令
  • Java虚拟机是一种执行java字节码文件的虚拟机,它拥有独立的运行机制
  • Java技术的核心就是Java虚拟机,因为所有的java程序都要在java虚拟机内部运行

JVM作用?

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

特点

  1. 一次编译到处运行
  2. 自动内存管理
  3. 自动垃圾回收功能

JVM不仅可以执行java字节码文件,还可以执行其他语言编译后的字节码文件,是一个跨语言平台

JVM & JRE & JDK?

JDK(ava Development Kit)

  • JRE(Java Runtime Environment):JDK包含了一个完整的JRE,它允许Java程序运行。
  • 编译器(javac):用于将Java源代码编译成字节码的编译器。
  • 开发工具:如java命令、javap(查看编译后的字节码)、jdb(调试器)等。
  • Java库:包含了运行Java程序所需的类库。
  • 其他工具:如jar(打包和解包Java归档文件)、javadoc(生成API文档)等。

JRE(Java Runtime Environment)

  • JRE是Java运行时环境,它是运行任何Java应用程序的软件包。JRE包括了JVM、核心类库和其他支持Java程序运行的必要组件。JRE不包含开发工具,因此它只用于运行Java程序,不用于开发。

JVM(Java Virtual Machine)

  • 本文要说的

JVM指令集架构

指令集架构

  • 栈式架构
    • 设计和实现简单,全部使用零地址指令分配
    • 不需要硬件支持,更好跨平台
    • 指令集小
    • 执行性能没寄存器架构高
  • 寄存器架构:类似于X86汇编语言
    • 依赖硬件,不同公司产的CPU可能指令集就不同,例如X86和MIPS,就是这两种指令集

JVM使用了栈式架构

JVM生命周期

  1. 启动
    由引导类加载器BootStrap class loader 创建一个初始类实现JVM的启动

  2. JVM的执行

    JVM启动的唯一原因是要执行Java程序,但对于操作系统来说,没有Java程序,运行的是JVM进程

  3. 虚拟机的退出
    以下几种情况:

    • 正常执行结束
    • 执行过程中遇到了异常、错误而终止
    • 操作系统叫停
    • 某线程调用Runtime类或System类的exit方法,或 Runtime 类的 halt 方法,并且 Java 安全管理器也允许本次的操作

HotSpot

HotSpot到底是什么?

  1. 一种 VM 实现方式:

    2000 年,JDK1.3 发布,Java HotSpot virtual machine 正式发布

    HotSpot VM 是 Sun JDK 与 Open JDK 默认的 JVM

    采用解释器与即时编译器 JIT 并存的结构

    目前,HotSpot VM 是广泛的 JVM 实现,主要学习的也就是这个!

  2. 一种技术 —— 热点代码探测技术

    Java 原先是把源代码编译为字节码在虚拟机执行,这样执行速度较慢。

    HotSpot 将常用的部分代码编译为本地(原生,native)代码,这样显着提高了性能。

JVM结构

image-20240527103226036

image-20240906173255463

  • Class Loader:类加载子系统
  • Runtime DataAreas:运行时数据区
  • Execution Engine:执行引擎

Class Loader 类加载子系统

Class Loader 作用

  1. 负责从文件系统或网络中加载 Class 文件,生成运行时数据结构
  2. 只负责加载,不确保可以运行(Execution Engine 决定)
  3. 加载的类信息存放在 Method Area 方法区

类的加载过程

总共三大步:

  1. 加载Loading
  2. 链接Linking
  3. 初始化Initialization

Loading加载

  1. 通过一个类的全限定名获得类的二进制字节流
  2. 将字节流的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表这个类的java.lang.Class对象

对于加载来源的补充, .class 文件可以来自:本地直接加载、网络获取、zip 压缩包直接读取的(jar、war 格式的基础)、运行时自动生成(动态代理技术)、其他文件生成(例如 JSP 文件)、从加密文件中获取(防止反编译)

Linking 连接

链接又分成三个步骤

  1. 验证verify

    目的:为了保证.class 文件内容符合当前虚拟机的规范

    (例如我是 HotSpot VM 你不能给我 Taobao Vm 的字节码文件,这样我读不懂)

    包括四种验证:文件格式验证、元数据验证、字节码验证、符号引用验证

  2. 准备 Prepare
    类变量分配内存,并设置默认初始值(此时并不会给真正的值,初始化时才会给真正的值)
    注意:

    • 对于 final 修饰的类变量,在编译时就分配了内存,在此阶段只是显式初始化
    • 不会为实例变量初始化,因为实例变量会随对象一起分配到堆区
  • 类变量:static修饰的变量,类变量的信息会放在方法区中
  • 实例变量:对象的变量,没有使用 static 修饰,会存放在 Java
  1. 解析Resolve

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

    • 符号引用(Symbolic Reference)是指在编译时期或者运行时期使用的一种符号名称,它并不直接指向内存中的位置。它是一个符号,用于表示某个类或者类的成员(字段、方法)
    • 直接引用:直接引用(Direct Reference)是指直接指向内存中的位置的引用。

Initialization 初始化

初始化阶段就是执行类构造器方法 <clinit>() 的过程(<clinit>() 被称为类构造器方法,与类的构造器完全不同)

注:<clinit>() 方法不需要定义,是 javac 编译器自动收集类中的所有类变量的赋值动作和静态代码块中的语句合并而来

  • final 修饰的类变量不会在 <clinit> 方法中初始化
  • 如果该类有父类,会确保父类的 <clinit> 方法先执行
  • 一个类的 <clinit> 方法只会在首次使用这个类的时候运行,只运行一次
  • 虚拟机必须保证,一个类的 <clinit> 方法会在并发下被同步加锁(即这个方法只会运行一次)

类加载器的分类

JVM规范定义,所有派生自ClassLoader的类加载器都划分为自定义类加载器

  • 引导类加载器(BootStrap ClassLoader):没有继承ClassLoader类
  • 自定义类加载器(User Define ClassLoader)

常见的类加载器

  1. 引导类加载器
    • 使用C/C++实现,在JVM内部
    • 用来加载 Java 核心库JAVA_HOME/jre/lib/rt.jarresources.jarsun.boot.class.path 路径下的内容,用于提供 JVM 自身运行所需要的类)(比如 String 类、Integer 类等等核心类库)
    • 没有继承 ClassLoader,没有父类加载器
    • 加载另两个加载器,是他们的父类加载器
    • 出于安全考虑,只会加载包名为 java, javax, sun 开头的类
    • 如果一个类使用 name.class.getClassLoader 方法获取到为 NULL 说明它是由引导类加载器加载的
  2. 扩展类加载器(Extension ClassLoader)
    • Java 语言编写,是 sun.misc.Launcher$ExtensionClassLoader内部类
    • 间接继承自 ClassLoader
    • java.ext.dirs 系统属性指定的目录中加载类库,或从 JDK 的安装目录 jre/lib/ext 子目录下加载类库。如果我们写的 JAR 也放在这里,也会由扩展类加载器自动加载
  3. 应用程序类加载器(App ClassLoader,也叫系统类加载器 System ClassLoader)
    • Java 语言编写,Launcher 的内部类
    • 父类为扩展类加载器
    • 负责加载环境变量 classpath 或系统属性 java.class.path 指定路径下的类库
    • 加载自定义的类,可以使用 ClassLoader.getSystemClassLoader() 获取

三个类加载器之间的关系

85f3a1163cff4f8ab92a9fd2d2dd8c2f

  1. 逻辑上的父子关系
  2. 但是不是继承关系!!!

自定义类加载器

什么时候用自定义类加载器?

  • 隔离加载类(防止用中间件导致命名空间冲突)
  • 修改类加载的方式
  • 扩展加载源
  • 防止源码泄露(可以对指定的字节码文件进行解密)

步骤

  1. 继承ClassLoader类
  2. 实现findClass()方法
  3. 如果没有太复杂的需求,可以继承 URLClassLoader 类,避免自己去编写 findClass() 方法及其获取字节流的方式

获取ClassLoader的途径

1
2
3
4
5
6
7
8
9
//【法一】:通过当前类的Class对象来获取
ToGetClassLoader.class.getClassLoader();
//【法二】:获取当前线程上下文的ClassLoader
Thread.currentThread().getContextClassLoader();
//【法三】:ClassLoader获取AppClassLoader
ClassLoader.getSystemClassLoader();
//【法四】:通过获取AppClassLoader,进而获取ExtensionClassLoader
ClassLoader.getSystemClassLoader().getParent();

双亲委派机制

加载 class 文件时,把加载请求逐级向上递交,上级加载器不加载此 class 时,才会交由低级的加载器加载。

85f3a1163cff4f8ab92a9fd2d2dd8c2f

面试问题

如何将自己写的 java.lang.String 导入 JVM

答:

如果是 java.lang 包下的内容,是无法加载的,因为双亲委派机制的存在,java.lang 包下的内容全部会被引导类加载器加载。

即使使用了自定义的类加载器去加载,规避双亲委派机制,但由于是 java. 开头的包,也会被沙箱安全机制拦截,报出安全异常。

作用

  1. 避免类的重复加载

    一个类只会由一个加载器加载,不会出现多个加载器加载一个类的情况

  2. 保护程序安全,防止核心 API 被随意更改

其他问题

  1. JVM 中表示 两个 class 对象是否为同一个类 的两个必要条件是什么?

    • 类的全限定类名必须一致
    • 加载这个类的 ClassLoader 必须相同
  2. 类的主动使用与被动使用(类的主动使用和被动使用区别就在于,有没有类加载过程中的初始化过程

    • 主动使用,有七种情况:

      • 创建类的实例:通过关键字 new 实例化一个类

      • 访问某个类或接口的静态变量,或者对静态变量赋值

      • 调用类的静态方法

      • 反射:使用 Java 反射机制访问类的方法或字段。

      • 初始化一个类的子类

      • JVM 启动时被标明为启动类的类

      • JDK 7 开始提供的动态语言

    • 被动使用是指没有直接引用类,而是通过其他途径间接引用类,不会导致类的初始化,只会触发类的加载

      • 访问类的常量:访问类的常量(被 final 修饰的基本类型或字符串类型)。
      • 使用类的数组:使用数组类型,该数组的元素类型是类,不会触发该类的初始化。
      • 通过子类引用父类的静态变量:通过子类引用父类的静态变量,不会触发子类的初始化。
      • 通过类名获取 Class 对象:通过 Class.forName("ClassName") 获取类的 Class 对象,不会触发该类的初始化。

Runtime Data Areas 运行时数据区

Runtime Data Areas 基本结构

有五大部分:

  • 方法区Method Area(在JDK1.8 后叫元数据区
  • 堆Heap
  • 程序计数器 Program Count Register(PC)
  • 本地方法栈 Native Method Stack(NMS)
  • 虚拟机栈 JVM Stack(VMS)

其中,加粗的部分为每个进程一份(即整个 JVM 只有一个方法区和堆区),其他部分每个线程各有一份,共用方法区和堆区

而且 JVM 中的线程与操作系统的线程是一一映射关系的

PC

PC程序计数寄存器:不同于CPU内的PC,而是一种模仿的抽象,也叫程序钩子

功能

PC 寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎(Execution Engine)读取下一条指令。

image-20240321182519265

特点

  1. 小而精:占很小的一块内存,处于运行速度最快的区域
  2. 线程私有,PC与线程共存亡
  3. 不会发生OOM(Out of Memory)
  4. 记录当前方法的 JVM 指令地址(若执行 Native Method,则 PC 是 undefined)

当前方法:任何时间线程只能有一个方法在执行,这个方法就在当前虚拟机栈栈顶

两个问题

  1. 使用PC存储字节码指令地址有什么用?或者说为什么使用PC记录当前线程的执行地址?

    因为 CPU 会不停的切换线程,当切换回此线程时,得知道从哪儿继续任务,所以就使用 PC 这个结构来存储指令地址

  2. PC 寄存器为什么要设置为线程私有?
    如果不设置 PC 为私有,那么就得把 PC 内的值存到一个地方,这样切换线程时,需要不停的存 PC 读 PC,增大了切换线程的开销。要是给每个线程一个 PC,那么切换就消除了这个开销

JVM Stack

栈!!!

功能

保存程序运行期间的局部变量(8 种基本数据对象、引用对象的地址)、部分结果、参与方法的返回和调用

特点

  • 线程私有,与线程公存亡
  • 存储的基本单位:栈帧(Stack Frame)
  • 速度仅次于PC
  • 不存在垃圾回收问题,但是存在Stack Overflow 和 Out Of Memory 异常
  • JVM对其有两个操作:入栈,出栈

栈的Stack Overflow 与 OOM

JVM允许栈可以是固定不变的,也可以是动态增长的:

  • 固定不变:线程创建时就指定了具体的大小,如果此线程运行过程中,超出了最大容量,那么会报出 Stack Overflow Error 异常
  • 动态增长:栈可以自己动态增长,但是在尝试扩展时,无法申请到足够的内存,或者在创建线程时,内存达不到申请的要求,就会抛出 OOM 异常

总结:超出了栈范围 -> Stack Overflow,无法申请到内存 -> OOM

可以使用 Java 的 - Xss 参数设置栈内存大小:

java -Xss1m // 设置 1M 的内存;k 代表 kb ;G 代表 Gb

栈帧Stack Frame

特点
  • 每一个方法对应一个栈帧
  • 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息
  • 处于栈顶的栈帧,叫做当前栈帧;对应的方法,称为当前方法;对应执行这个方法的类,就叫做当前类
  • 执行引擎运行的所有字节码指令只对当前栈帧进行操作
  • 若该方法调用其他方法,则会创建新的栈帧,压入栈中,待其运行完成后,出栈

注意:

  • 不同线程的所含的栈帧不允许相互引用
  • 如果方法嵌套使用,内方法的返回值会传回外方法的栈帧
  • 方法有两种方式将栈帧弹出:
    • return 语句
    • throw 抛出异常
栈帧的结构
  • 局部变量表 Local Variables (LV)
  • 操作数栈 Oprand Stack (或叫表达式栈 OS)
  • 动态链接 Dynamic Linking (或叫 指向运行时常量池的方法引用)
  • 方法返回地址 Return Address (或叫 方法正常退出或异常退出的定义)
  • 一些附加信息

后三个又叫帧数据区

局部变量表
  • 是一个数字数组int byte char short 等均为数字;bool 转换 0 表示假,非 0 表示真;引用类型可以使用地址,也为数字)
  • 局部变量表在栈中,栈线程私有,因此不存在数据安全问题
  • 局部变量表所需的容量大小在编译期就确定下来,保存在方法的 code 属性的 maximum local variables 数据项中,在方法运行期间不会改变。
  • 方法嵌套的次数由栈的大小决定。(如果一个方法的参数和局部变量越多,使得局部变量表变大,那么嵌套次数就会变少)
  • 局部变量表只在当前方法调用中有效
插槽Slot

Slot是局部变量表最基本的存储单元

  • byte short char bool 存储前会被转换为 int

  • 32 位以内占用一个 slot(包括引用类型returnAddress);64 位占用两个 slot 如 long double

  • JVM 会给每一个槽都分配一个索引,通过这个索引则可以取到值;方法被调用时,它的方法参数局部变量都会按照顺序被复制到局部变量表的一个 slot

  • 对于构造方法实例方法,会自动引入一个 this 变量,放在 index 为 0 的插槽(也就是第一个插槽)

  • 槽也可以重用,如果过了局部变量的作用域,那么下面的变量会占用此槽

类变量可以不给初值使用,但是局部变量不行

现在我们知道原因了,因为:

类在加载过程中,有加载、链接、初始化三个过程,而第二步链接又有验证、准备、解析三个过程,在准备阶段,所有的类变量会被给默认值,到了初始化阶段才会将程序员给变量的值赋值给类变量。

但是对于局部变量来说,一个方法的局部变量表就没这么多过程了,如果没给初始值,系统也不知道这个值是多少,也就没法使用

  • 对于 GC 来说,局部变量表所直接引用或间接引用的对象,都不会被回收
操作数栈

基于数组实现的栈,也叫表达式栈

作用:

  • 根据字节码指令,往栈中写入数据和读出数据(即入栈与出栈

  • 保存计算的中间过程,作为运算结果的临时存储空间

  • 操作数栈相当于 JVM 的临时工作区,在方法开始调用时,此栈是空的

  • 操作数栈有其

    栈深度,在编译器就已确定,保存在 Code 属性中,为max_stack的值

    • 32bit 占一个栈深度,64bit 占两个栈深度
  • 虽然是基于数组实现的,但不能直接用索引访问,只能进行入栈与出栈操作

  • 如果方法有返回值,其返回值会被压入当前栈帧操作数栈,并更新 PC,执行下一条指令

  • Java 虚拟机的解释引擎是基于栈的执行引擎,指的就是操作数栈

了解

一个新技术:栈顶缓存技术(ToS Top-of-Stack Cashing)

指:将栈顶的元素全部存储在 CPU 的寄存器当中

原因:JVM 是基于栈式的指令,虽然零地址的使用简单,但是会增大入栈和出栈的次数(即增多了对内存的访问次数),所以提出了此项技术。

动态链接

在栈帧中的一个指向运行时常量池中方法的引用

方法返回地址

存放调用该方法的 PC 寄存器的值

一个方法结束,本质上是当前栈帧出栈的过程

  • 正常结束:调用者的 PC 的值作为返回地址
  • 出现异常退出:返回地址要通过异常表来确定,栈帧中一般不会保存这部分信息

两种退出方式的区别就在于,异常退出的方法不会给上层调用者返回任何的信息

附加信息

为了给程序调试提供支持的信息

关于JVM栈的问题

  1. 举例栈溢出的情况

    Stack Overflow 栈溢出,栈中存放栈帧,每一个栈帧代表一个方法,日常编程中,递归调用方法时,当栈帧累计增加起来,就会导致栈的大小不足,导致栈溢出

  2. -Xss调整栈大小,就能不出现Stack Overflow吗?

    当然不能,无论调多大的栈内存,都有可能用完。不过栈越大,能跑的方法也就越多,有时候调整栈变大,会解决 Stack Overflow 的问题。

  3. 垃圾回收是否涉及到 JVM 栈

    垃圾回收不涉及 VMS,只有方法区和堆才设计 GC 操作

  4. 方法中定义的局部变量是否线程安全

    线程安全

    • 只有一个线程可以操作此数据,必是线程安全的;
    • 若有多个线程可以操作此数据,那这个数据就是共享数据,若没有进行同步,则存在安全问题

Native Method Stack

VMS 用来管理 Java 方法调用,NMS 来管理本地方法调用

作用:登记方法中使用到的本地方法

什么是本地方法栈?

本地方法栈与Java栈非常相似,但它专门为本地方法服务。当JVM执行本地方法时,它会在本地方法栈中创建一个栈帧,用于存储方法调用的局部变量和返回信息。每个本地方法调用都会创建一个新的栈帧,并且每个栈帧都会在方法执行完成后被移除。

本地方方法栈的作用

  1. 执行本地方法:本地方法栈用于执行那些通过Java Native Interface(JNI)或其他机制引入的本地方法。
  2. 管理本地方法的调用:它负责管理本地方法的调用过程,包括参数传递、局部变量的存储以及方法返回。
  3. 与Java栈的交互:在JNI调用中,本地方法栈和Java栈之间可能会有交互。例如,一个Java方法可能会调用一个本地方法,或者一个本地方法可能会调用回Java方法。

Java Heap

特点

  • 进程共有,一个 JVM 只有一个堆内存
  • JVM 最大的一块内存空间
  • 内存大小可以调节物理上不必连续,逻辑上连续(使用参数 -Xms10m -Xmx20m 设置堆最小 10m,最大 20m)
  • 线程可以在此划分私有的缓冲区(Thread Local Allocation Buffer ,TLAB
  • 方法结束后,堆中的对象不会被马上移除,只有 GC 时才会移除

堆内存结构

JDK1.7

逻辑上堆分为:年轻代、老年代、永久代

  • 年轻代:
    • Eden 区
    • Survivor 区
      • Survivor 0
      • Survivor 1
  • 老年代
  • 永久代:不属于堆空间的一部分,只是逻辑上分到了这一部分
JDK1.8

逻辑上分为:年轻代、老年代、元空间

年轻代与老年代没有变化

  • 元空间:物理上在内存内,不在堆中

堆内存设置

堆内存在JVM建立时就确立了,可以通过参数来设置堆空间(新生代+老年代)大小:

1
2
3
4
5
-Xms 堆区的起始内存 等价于 -XX:InitialHeapSize

-Xmx 堆区的最大内存 等价于 -XX:MaxHeapSize


如果堆区内存超过设置的最大内存,就会出现OOM错误

通常设置两个值为相同的值,是为了GC清理完堆区后,不需要重新分隔计算堆区的大小,从而提高性能

默认的初始化值,按电脑内存不同而不同,大致关系如下

  • 起始内存的值 = 电脑内存大小 / 64
  • 最大内存的值 = 电脑内存大小 / 4

查看自己JVM堆内存的Demo

1
2
3
4
5
6
7
8
9
10
11
12
public class HeapMem {
public static void main(String[] args) {
// 查看堆空间大小
long initialMemory = Runtime.getRuntime().totalMemory();
long maxMemory = Runtime.getRuntime().maxMemory();
System.out.println("-Xms: "+ initialMemory / 1024 / 1024 + "M");
System.out.println("-XmX: "+maxMemory / 1024 / 1024 + "M");
System.out.println("系统内存大小(用-Xms来计算)" + initialMemory * 64.0 / 1024 + "G");
System.out.println("系统内存大小(用-Xmx来计算)" + maxMemory * 4.0 / 1024 + "G");
}
}

运行结果:

1
2
3
4
5
-Xms: 245M
-XmX: 3614M
系统内存大小(用-Xms来计算)1.6089088E7G
系统内存大小(用-Xmx来计算)1.4804992E7G

新生代与老年代

  • 默认比例:新生代:老年代=1:2
    可以通过参数进行设置-XX:NewRatio=n 其中n表示一个数字,假如为5,那么新生代与老年代比例就为1:5
    新生代的大小可以用参数-Xmn显示指定,而且优先度大于上面的选项

  • 新生代中,Eden与另外两个Survivor区的比例是8:1:1

    这个数值也可以调整 -XX:SurvivorRatio=8

    但是去验证一下,会发现其实并不是完全的8:1:1,因为默认开启自适应,JVM 会自动进行调整(但就算显示关闭自适应,也不会是 8:1:1,只有显示声明参数设 -XX:SurvivorRatio=8,才会是 8:1:1

  • 几乎所有的 Java 对象都在 Eden 区被 new(例外:直接 new 了一个大于 Eden 区的对象)

  • 绝大部分 Java 对象都在新生代被销毁了

对象的分配过程

image-20240328203818973

  1. 创建一个新的对象时,首先判断Eden区是否放得下,如果放得下,就为其分配内存,放不下进行YGC(将Eden区的不再被其他对象引用的对象进行销毁,加载新的对象到Eden区)
  2. 然后再判断Eden区是否放得下,放得下就放在 S0/S1区域,每次YGC移动,对象超过阈值的时候晋升Old区
  3. 如果Eden区还是放不下放入Old区,Old放不下进行FGC,放得下就放在Old区
  4. 如果Old区还是放不下出现OOM异常

注意

  • YGC会清理Eden与Survivor的游离对象;触发YGC的只能是Eden区满
  • Survivor区满,会直接将对象promotion至老年代
  • form区和to区是根据survivor 0 与 survivor 1 区谁满谁不满而言的,空的就是to区,另一个就是from区
  • 阈值默认为15
  • GC频繁发生在新生代,很少发生在老年代,几乎不在永久区/元空间收集

MinorGC、MajorGC、FullGC区别

GC分为两大类型

  • 部分收集(Partial GC)

    • 新生代收集(Minor GC/Young GC):只对新生代进行垃圾收集
    • 老年代收集(Major GC/Old GC):只对老年代进行垃圾收集
      • 只有 CMS GC会有只收集老年代的行为
    • 混合收集(Mixed GC):收集整个新生代及部分老年代
      • 只有G1 GC会有Mixed GC
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集

GC触发时机
  • YGC
    • 触发时机:Eden区空间不足
    • 引发STW(Stop the world),暂停其他用户线程,只有GC结束后,才会使其继续执行
  • Major GC
    • 触发时机:老年代空间不足,先触发YGC,如果还不足触发MajorGC
    • STW时间更长
  • Full GC
    • 触发时机
      • 调用System.gc(),系统建议执行Full GC,但不一定
      • 老年代空间不足
      • 方法区空间不足
      • YGC后,进入老年代的平均大小大于老年代可用内存
      • Eden、form区向to复制时,大小大于to区,也大于了老年代内存
    • Full GC应尽量避免

堆空间分代思想

不分代也可以正常工作,只不过性能没有分代强。在 Java 程序中,70%~80% 对象都是临时对象,如果不进行分代,每次进行 GC 都需要遍历很多很多对象,这样性能肯定不会强。如果分为新生代、老年代,就可以大大加快效率

对象提升规则

针对不同年龄段的对象分配原则如下:

  • 优先分配到 Eden
  • 大对象直接分配到老年代
  • 长期存活的对象分配到老年代
  • 动态对象年龄判断
    • 如果 Survivor 区中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 要求的年龄
  • 空间分配担保
    • Survivor 区无法存放的对象放入老年代
    • -XX:HandlePromotionFailure

TLAB

Thread Local Allocation Buffer

  • 堆是线程共享的区域,TLAB是堆上属于线程私有的区域
  • TLAB在Eden区,仅占Eden空间的1%,我们可以通过选项 -XX:TLABWasteTargetPercent 来设置 TLAB 空间所占的大小
  • JVM 首选 TLAB 进行分配,如果内存不够大,会使用锁方式确保原子性,在非 TLABEden 区域进行分配

为什么用TLAB

为避免多个线程操作同一地址,需要使用锁机制,影响分配速度。加入 TLAB 可以直接避免线程安全问题,提高内存分配效率(这种分配方式也叫快速分配策略

逃逸分析

Method Areas (Metaspace)

JDK7之前,都叫做方法区,JDK8之后改为元空间;
本次以JDK8 + 元空间位标准

栈、堆、方法区

线程私有:

  • 虚拟机栈
  • 本地方法栈
  • 程序计数器

线程共享:

  • 元空间

image-20240526201955828

方法区存放着类型的信息

特点

  • 逻辑上属于堆,但其实是独立于Java堆的内存空间(No-Heap)
  • 属于共享区域
  • 物理上内存空间可以不连续
  • 大小可以固定也可以动态扩展;决定了系统可以保存多少个类
  • 如果类太多,元空间存不下,那么会出现OOM
  • 与JVM同生死

方法区的演进过程

JDK7及之前,称方法区为永久代(方法区和永久代并不等价,仅在 hotspot vm 实现而言,两者等价

JDK8+,使用元空间取代了永久代,但有区别

  • 元空间不在虚拟机设置的内存中,而是直接使用本地内存
  • 内部结构也进行了调整

image-20240526204247568

GC垃圾回收 (重要)

什么是垃圾?

游离的对象,具体来说,就是不可达的对象

OS内存分配的两种方式

  • 空闲列表(适用在内存不规整的情况)
    维护一个列表,存放了内存中空闲的大小及其地址,如果要分配空间,就遍历这个表

  • 指针碰撞(适用在内存规整的情况)

    用一个指针标记已使用内存与未使用内存的分界点,然后每次分配只需要移动这个指针即可,很快

STW

GC标记某阶段,会让所有用户线程暂停

Java的四种引用

  • 强引用
    永远不会GC,即使OOM也不会回收
    所有可达对象都属于强引用
  • 软引用
    快要发生OOM的时候,回收所有的软引用
  • 弱引用
    每次GC都会被回收
  • 虚引用
    没有实际的作用,唯一的作用就是可以在对象被回收的时候通知另一个对象

垃圾回收算法

标记阶段算法

引用计数法
  • 每个对象维护一个字段,记录被引用的次数
  • 优点:实现简单,垃圾对象容易判断
  • 缺点:如果形成环,容易导致不会被GC掉
可达性分析算法
  • 从GCRoots开始,依次向内遍历,标记那些可达对象
  • GCRoots包含哪些元素?
    • 虚拟机栈中:局部变量关联的对象
    • 本地方法栈中:引用的对象
    • 元空间中:静态变量和常量池指向的对象
    • 同步锁持有的对象
    • Class对象,基本的异常对象

清除阶段算法

标记清除算法
  • 从GC Roots开始,依次向内进行遍历,标记那些可达的对象,然后将没有标记的对象清除
  • 特点
    • 只能用空闲列表法
    • 清楚速度中等
标记压缩算法
  • 标记清除算法执行完成后,将所有对象压缩到一端,避免内存碎片
  • 特点
    • 可以使用指针碰撞
    • 清楚速度最慢的一种算法,因为需要进行压缩
复制算法
  • 使用两个空间,每次GC时,将所有依然存活的对象直接移入另一个空间,将此空间全部清除掉
  • 特点
    • 需要成倍的空间
    • 速度非常快
    • 空间换时间的一种算法
分代算法
  • 将空间分代,分为新生代、老年代、永久代,然后进行不同的遍历频率,新生带的垃圾是最多的地方
三色标记算法
  • 使用三种颜色
    • 白色:不可达对象
    • 黑色:可达对象
    • 灰色:还未遍历的对象
  • 开始将多有对象都设为白色,然后从GCRoots开始,如果一个对象还有子引用的对象,就标记为灰色,如果没有子引用对象,标记为黑色,最后清楚所有的白色对象
  • CMS回收器就用的这个方法后面会详细讲到
  • 缺点:无法解决漏标与浮动垃圾的问题
    • 漏标:标记前是垃圾,但是标记完成后就不是垃圾了,错误的被GC掉
    • 浮动垃圾:原本不是垃圾,清除完成后变为垃圾的垃圾
分区算法
  • 将所有的空间进行分区,称为Region
  • 每个Region都有一个角色
    • Eden
    • Survivor
    • Old
    • Humongous
  • 会计算每一块分区垃圾对象和存活对象的比例,维护一个优先队列,每次GC优先清楚哪些回收价值大的区
  • 特点
    • 局部看属于分区算法,总台看属于标记压缩算法
    • G1回收器使用的一种算法
    • 需要大空间

垃圾回收器

回收器性能参考指标

  • 吞吐量:即执行用户代码时间与总执行时间的比例
  • 暂停时间 执行GC时,STW的时间
  • 内存占用

年轻代回收器

Serial
  • 特点
    • 第一款垃圾回收期
    • 是串行执行的,串行GC效率第一
    • 意味着GC过程中应用线程不能执行
  • GC算法 复制算法
ParNew
  • 特点
    • 相当于Serial的并行版本
    • 但是尽管多个GC线程一同执行,但是用户线程还是不能执行
  • GC算法 复制算法
Parallel Scavenge GC
  • 特点
    • 并行GC垃圾回收器
    • 相较于ParNew,更注重吞吐量
    • 有自适应策略 可以调整年轻代、老年代之间的大小比例
    • JDK8默认的GC回收期
  • GC算法 复制算法

老年代回收器

Serial Old
  • 特点
    • 串行回收老年代
    • 作为CMS的备胎方案
  • GC算法
    • 标记压缩算法
Parllel Old GC
  • 特点 并行回收老年代
  • GC算法 标记压缩算法
CMS GC
  • 特点
    • 第一款并发GC回收器
    • 并发指用户线程可以与GC线程一同执行
    • 主打底延迟
    • JDK14去除了CMS回收器
    • GC可能会失败,启动被动方案Serial Old
  • GC 算法 三色标记
  • 执行过程
    • 初始标记阶段
      • 标记GC Roots
      • 需要STW,但是暂停时间很短
    • 并发标记阶段
      • 用户线程可以与GC线程一起执行
      • 需要执行很长时间
    • 重新标记阶段
      • 标记初始阶段不能确认的对象
      • 需要STW
    • 并发清除阶段
      • 清除没有标记的对象
  • 注意
    • CMS不是在OOM前才进行GC的,而是达到一定的阈值就会进入GC
    • GC的过程中用户程序还可以执行,所以存在预留的空间不够的情况 此时CMS会采用后备方案,使用Serial Old来进行GC
    • 有内存碎片,所以只能用空闲列表法
    • 无法解决漏标、浮动垃圾的问题

新/老年代都用的

G1 回收器
  • 特点
    • JDK9开始默认的GC回收器
    • 新生代、老年代都可以回收
    • 提供三种垃圾回收方式
  • GC 算法
    • 分区算法
  • 分区思想
    • 将存储分为一个一个区,默认大小1-32M之间
    • 每个区都存储垃圾堆积的价值,即可以回收到的空间与总空间的比值,维护了一个优先队列,优先回收那些价值大的区域
    • 所有Region大小相同,并且运行期间角色不可以改变
    • GC的时间可以预测!
    • 四种角色
      • Eden
      • Survivor
      • Old
      • Humongous 用来存储大对象,如果一个不够,那就找两个连续的H区
    • 每一个Region内的对象不是孤立的,那么如果有一个新生代的对象被老年代的对象引用,我们是不是得去遍历所有的老年代?
      • 不需要这么做,每一个Region都有一个记忆集
        记忆集就是一个集合,如果有对该Region的写操作,检查来源是不是别的Region,如果是就将对应的Region的记忆集也改一下。
        记忆集,具体实现是一个哈希表key是该region的地址,value是一个集合,存放其他Region的索引
    • 如何判断一个Region是否被引用?
      • 每一个Region都维护了一个记忆集,具体的实现就是卡表
      • G1的卡表和CMS的不太一样
      • CMS的卡表是一个byte数组,只能告诉是否被引用,而不能知道是哪里引用的
      • G1卡表是一个HashMap,key是Region的起始地址,Value是一个集合存放了引用区域的对应的地址
    • GC机制
      • YGC
        1. 扫描GC Roots及记忆集的外部引用作为入口
        2. 更新卡表 将所有的卡表更新到最新状态(处理脏卡表)
        3. 处理记忆集 识别指向Eden区的对象
        4. 复制算法清理对象
          • 达到年龄阈值,放入Old
          • 将Eden存活的对象放入Survivor
        5. 处理引用 清空Eden区
      • YGC + 并发标记
        1. 初始标记,会发生STW;并且会触发一次YGC
        2. 区域扫描,扫描Survivor区可以直接进入Old区的对象
        3. 并发标记,计算每一个Region区域的活性(存活对象的比例);如果一个Region全是垃圾,那么会立即回收
        4. 再次标记,STW
        5. 独占清理,STW,将Region按回收价值排序,但不会真的去清理
        6. 并发清理
      • Major GC
        • 当越来越多的对象晋升到Old区,此时G1会选择使用Major GC,而不是YGC
          会回收年轻代+部分老年代
      • FGC 保证措施
        G1使用FGC作为保底机制
        • 触发条件
          1. Old区放不下要晋升的对象
          2. 如果并发标记过程中内存不够用
          3. 最大GC停顿时间太短,导致规定时间内没有GC