JVM探索之初识JVM

82 阅读15分钟

前言

本篇主要对JVM的的相关概念和Java的生命周期进行介绍,主要让大家对JVM的相关组件和Java在生命周期中不同阶段有一个清晰的认识

JVM相关概念

在介绍类加载之前我们先对JVM的相关概念有个整体的了解

JVM简单的理解就是一个架构在平台上的平台,在最初的C、C++这样的语言在不同的平台上例如:Linux、Unix、Windows上需要重新编译适配各自的平台之后才能运行,但是JVM却在每个平台上重新搭建一个运行环境,我们只需要安装不同环境下的JVM。我们的Java运行在JVM上因此也被称为JVM虚拟机

我们常见的Java中相关的概念就是JDK、JRE、JVM,他们的概念如下

  • JDK(Java Development Kit):Java的SDK开发工具包,包含工具包、JRE、JVM在内

  • JRE(Java Runtime Environment):Java的运行时环境,包含JavaAPI、JVM在内,提供了Java程序执行的最低的环境要求

  • JVM(Java Virtual Machine):Java自身架构的虚拟计算机平台,是基于其他平台基础上进行架构的,为Java字节码的执行提供了保障

image.png

JVM的架构模型

目前的虚拟机的架构模型主要有基于栈式和基于寄存器式两种

栈式

基于栈式指的是虚拟机在执行指令时,采用的方式是基于栈的指令集,会将需要执行的指令一条条的压入栈中,主要有入栈和出栈两种操作。基于栈式的虚拟机都会存在一个操作数栈的概念,虚拟机在真正运算时,都是通过操作数栈进行操作,与内存进行交互,简单来说就是无论任何操作,都需要通过操作数栈进行。这种模型的虚拟机最大的好处在于可以无视硬件、物理架构。当然,缺点也非常明显,因为无论什么操作都要经过操作数栈,所以性能会低一些。Java的JVM、Python的CPython、.Net的CLR等虚拟机都是基于这种栈式模型的。

寄存器式

基于寄存器模型的虚拟机存在很多虚拟寄存器的概念,用于模拟CPU中真实的PC寄存器,但它们往往都是以别名存在,如:R1、R2、R3....等,在执行时,执行引擎需要对这些别名进行解析,然后找出具体操作数的位置,然后取出操作数进行操作。这些虚拟的寄存器也并不是直接在CPU中的,而是和操作数栈一样,位于运行时栈中,通过一个数组(运行时栈帧中的连续内存空间)存储所有的虚拟寄存器。Android的Dalvik、Lua5.0的RegisterBased等虚拟机都是基于寄存器模型实现的

在性能上来说基于寄存器式性能更好,但是基于栈式的移植性更好

生命周期

JVM的生命周期分为启动、执行、退出

JVM的启动是BootStrapClassLoad加载初始类开始的,创建一个线程

JVM的执行就是执行我们的Java程序,Java程序本质上就是一个JVM线程

JVM的退出是遇到下面四种情况结束

  1. 程序正常执行结束
  2. 程序遇到异常或者错误退出
  3. 因为操作系统出错导致JVM线程终止(OOM、系统崩溃等)
  4. JNI中加载或卸载虚拟机时退出
  5. 执行System.exit()或Runtime.exit()退出

JVM架构

image.png

上面就是我们以一个JVM的完整架构包含各个组件以及相关的功能,不过这个太过详细可能不太好记下面我们展示一个简化的架构图

image.png

JVM可以简单的理解由类加载器、运行期数据区、执行引擎三部分组成。

Java生命周期

我们在讲述JVM的架构之前先了解一下Java的生命周期

image.png

上图就是Java类的生命周期流转图,依次经历了加载、连接、初始化、使用、卸载5个阶段。

  • 装载:查找和导入Class文件
  • 链接:把类的二进制数据合并到JRE中
    • 校验:检查载入Class文件数据的正确性
    • 准备:给类的静态变量分配存储空间
    • 解析:将符号引用转成直接引用
  • 初始化:对类的静态变量,静态代码块执行初始化操作

在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也称为动态绑定)。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段

关于动态绑定的概念可以参考这篇文章:juejin.cn/post/728331…

加载

类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。类的加载的最终产品是位于堆区中的Class对象,Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口

类加载器并不需要等到某个类被“首次主动使用”时再加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError错误)如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误

JVM在加载过程中步骤

  1. 通过一个类的全限定名称来获取定义此类的class二进制字节流
  2. 将这个字节流所代表的静态存储结构转换成方法区的运行时数据结构
  3. 在JVM堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口

相对于类加载过程的其他阶段,加载阶段是开发期相对来说可控性比较强,该阶段既可以使用系统提供的类加载器完成,也可以由用户自定义的类加载器来完成,开发人员可以通过定义自己的类加载器去控制字节流的获取方式

链接

链接是指将上面创建好的class类合并至Java虚拟机中,使之能够执行的过程,可分为验证、准备、解析三个阶段(验证和准备顺利是确定的但是解析有可能发生在初始化之后)

验证

验证是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

验证阶段大致会完成4个阶段的检验动作:

  1. 文件格式的验证:验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理,该验证的主要目的是保证输入的字节流能正确地解析并存储于方法区之内。经过该阶段的验证后,字节流才会进入内存的方法区中进行存储,后面的三个验证都是基于方法区的存储结构进行的。
  2. 元数据验证:对类的元数据信息进行语义校验(其实就是对类中的各数据类型进行语法校验),保证不存在不符合Java语法规范的元数据信息。
  3. 字节码验证:该阶段验证的主要工作是进行数据流和控制流分析,对类的方法体进行校验分析,以保证被校验的类的方法在运行时不会做出危害虚拟机安全的行为。
  4. 符号引用验证:这是最后一个阶段的验证,它发生在虚拟机将符号引用转化为直接引用的时候(解析阶段中发生该转化),主要是对类自身以外的信息(常量池中的各种符号引用)进行匹配性的校验。

验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用-Xverifynone参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间

准备

准备阶段是为了类的静态变量分配内存,并将其初始化为默认值。这些内存都将在方法区中进行分配,但是要注意下面几点

  • 这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中

  • 这里所设置的初始化值通常情况下是数据类型的默认的零值(如:0、0L、null、false),而不是在Java代码中被显示的赋的值

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

  1. 对基本数据类型来说,对于类变量(static)和全局变量,如果不显式地对其赋值而直接使用,则系统会为其赋予默认的零值,而对于局部变量来说,在使用前必须显式地为其赋值,否则编译时不通过。

  2. 对于同时被static和final修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;而只被final修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值。

  3. 对于引用数据类型reference来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即null。

  4. 如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值

  • 如果类字段的字段属性表中存在ConstantValue属性(同时被final和static修饰),那么在准备阶段变量value就会被初始化为ConstValue属性所指定的值

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

  • 如果static变量是final的,但是属于引用类型,那么赋值也会在初始化阶段完成

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行

符号引用(Symbolic Reference):符号引用以一组符号来描述所引用的目标,符号引用可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经在内存中。各个虚拟机实现的内存布局可以各不相同,但是他们能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的class文件格式中

直接引用(Direct Reference):直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般都不相同,如果有了直接引用,那引用的目标必定已经在内存中存在

  1. 类或接口的解析:判断所要转化成的直接引用是对数组类型,还是普通的对象类型的引用,从而进行不同的解析
  2. 字段解析:对字段进行解析时,会先在本类中查找是否包含有简单名称和字段描述符都与目标相匹配的字段,如果有,则查找结束;如果没有,则会按照继承关系从上往下递归搜索该类所实现的各个接口和它们的父接口,还没有,则按照继承关系从上往下递归搜索其父类,直至查找结束
  3. 类方法解析:对类方法的解析与对字段解析的搜索步骤差不多,只是多了判断该方法所处的是类还是接口的步骤,而且对类方法的匹配搜索,是先搜索父类,再搜索接口
  4. 接口方法解析:与类方法解析步骤类似,只是接口不会有父类,因此,只递归向上搜索父接口就行了

上面比较官方化的解释可能难以理解这里我用自己的理解通俗的解释一下:在我们的代码中无论是类、方法都可能引用了其他的类、方法、变量。当我们进行类加载的时候可能这个类还没有进行加载,我们此时并不知道引用的类在内存中的地址,所以这个时候就用一个符号来代替,比方说我们只知道引用了一个类代号com.wjx.demo.DemoClass。这个其实就是所谓的符号引用。当我们知道了这个字段的具体地址,可以直接用这个地址来表示比如:0X41251,这就引出了直接引用的概念,直接引用就是可以具体到内存中的位置,一般来说我们可以通过指针、内存偏移量、句柄来进行定位,这也是我们的直接引用的几个表现形式

初始化

类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了加载(Loading)阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java程序代码

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

  1. 声明类变量时指定初始值
  2. 使用静态代码块为类静态变量指定初始值

JVM初始化步骤

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

对初始化感兴趣的同学可以参考下这篇文章:juejin.cn/post/728703…

使用

当一个类完整的经过了类加载过程之后,在内存中已经生成了Class对象,同时在Java程序中已经通过它开始创建实例对象使用时,该阶段被称为使用阶段

卸载

当一个Class对象不再被任何一处位置引用,即不可触及时,Class就会结束生命周期,该类加载的数据也会被卸载

这里要注意一点:Java虚拟机自带的类加载器加载的类,在虚拟机的生命周期中始终不会被卸载,因为JVM始终会保持与这些类加载器的引用,而这些类加载器也会始终保持着自己加载的Class对象的引用,所以对于虚拟机而言,这些Class对象始终是可以被触及的。不过由用户自定义的类加载器加载的类是可以被卸载的

总结

本文是对JVM的相关概念、组成部分以及Java的生命周期进行了简单的介绍。可以帮助大家进行快速的入门。对于每个具体的组件没有具体的介绍,后续会出单独的文章进行介绍