JVM组成部分
组成架构
类加载器
类加载器是用于加载编译后的class
文件的,但它只负责将符合格式要求的class
字节码信息加载进内存,而只要符合格式规范的class
文件都能被加载,至于加载进入的class
文件到底是否能执行就并不是它负责的了,这是执行引擎子系统的范围之内的责任。
分类
- 启动类加载器(bootstrap):使用C++语言实现,是JVM自身的一部分,主要负责将
<JAVA_HOME>\lib
路径下的核心类库或-Xbootclasspath
参数指定的路径下的jar包加载到内存中。只为JVM提供加载服务,开发者不能直接使用它来加载自己的类。 - 扩展类加载器(extention):主要负责加载
<JAVA_HOME>\lib\ext
目录下或者由系统变量-Djava.ext.dir
指定位路径中的类库。它可以直接被开发者使用。 - 应用类加载器(application):负责加载系统类路径
java -classpath
或-D java.class.path
指定路径下的类库,也就是经常用到的classpath
路径。应用程序类加载器也可以直接被开发者使用。一般情况下,该类加载器是程序的默认类加载器,我们可以通过ClassLoader.getSystemClassLoader()方法可以直接获取到它。 - 自定义类加载器(user):在Java程序中,运行时一般都是通过如上三种类加载器相互配合执行的,如果有特殊的加载需求也可以自定义类加载器,通过继承
ClassLoader
类实现。
双亲委派模型
各加载器之间是层级引用关系,而非继承或包含关系,当一个类加载器需要加载类时,它首先会委托给父加载器加载。这种委托的关系使得加载器之间形成了一种树状结构,即启动类加载器->扩展类加载器->应用类加载器->自定义类加载器。例如:虽然扩展类加载器不是启动类加载器的直接子类,但由于委托关系,可以说扩展类加载器在某种程度上扮演了启动类加载器的子加载器角色。这种委托是双亲委派思想的体现,即:向上委托,向下加载
- 自下向上检查类是否已经被加载
- 从上至下尝试加载类
作用:
- 避免重复加载
- 保障Java核心类的安全性问题
打破双亲委派模型
线程上下文类加载器
线程上下文类加载器(Thread Context ClassLoader)允许在运行时动态地为线程设置类加载器。
最典型的应用场景是Java的SPI机制,位于rt.jar
包中的SPI接口,是由Bootstrap类加载器完成加载的,在SPI接口中,会经常调用实现者的代码,所以一般会需要先去加载自己的实现类,但实现类并不在Bootstrap类加载器的加载范围内,而是位于classpath
路径下,应该由App
类加载器进行加载,此时就必须使用线程上下文类加载器来打破双亲委派规则。当父加载器无法完成类加载工作,会将其直接交由线程上下文类加载来进行加载。而通常App
类加载器会被设置为默认的线程上下文类加载器。
重写loadClass方法
无论是ExtClassLoader
还是AppClassLoader
加载器,其本身都未打破ClassLoader.loadClass()
方法中定义的双亲委派逻辑,Bootstrap、Ext、App
这些JVM自带的类加载器都默认会遵守双亲委派模型。
因此若想打破双亲委派模型,可以实现自定义类加载器,并重写loadClass方法
类加载过程
- 加载:加载阶段是指通过完全限定名查找Class文件二进制数据并将其加载进内存的过程。大体流程会分为三步:
- 通过完全限定名查找定位
.class
文件,并获取其二进制字节流数据 - 把字节流所代表的静态存储结构转换为运行时数据结构
- 在堆中间中为其创建一个
Class
对象,作为程序访问这些数据的入口
- 通过完全限定名查找定位
- 连接:连接步骤包含了验证、准备、解析三个阶段。这三个阶段中,前两个执行顺序是确定的,但解析阶段不一定,可能会发生在初始化之后。
- 验证: 验证确保被加载的类的正确性,包括文件格式的验证、元数据的验证、字节码的验证等。
- 准备: 在准备阶段,类加载器为类的静态变量分配内存,并设置默认初始值(不是在代码中赋的值)。这里的静态变量是指类的成员(static修饰),但不包含final修饰,因为
final
在编译的时候就会分配了,准备阶段会显示初始化。 - 解析: 解析阶段是虚拟机将类的符号引用转换为直接引用的过程,这个过程可能在初始化阶段之前或之后。
- 初始化:在初始化阶段,类加载器执行类的初始化代码,包括对静态变量的赋值和执行静态块中的代码。
- 使用:当一个类完整的经过了类加载过程之后,在内存中已经生成了Class对象,同时在Java程序中已经通过它开始创建实例对象使用时,该阶段被称为使用阶段。
- 卸载:当一个Class对象不再被任何一处位置引用,即不可触及时,Class就会结束生命周期,该类加载的数据也会被卸载。Java虚拟机自带的类加载器加载的类,在虚拟机的生命周期中始终不会被卸载。
类加载的时机
类加载器在以下情况下会触发类的加载:
- 当创建类的实例对象时,也就是使用
new
关键字。 - 当调用类的静态方法时。
- 当访问类的静态字段(被
final
修饰、已在编译期把结果放入常量池的静态字段除外)时。
需要注意的是,类的加载是懒汉式的,即只有在需要使用类的时候才会加载。
执行引擎
执行引擎子系统担任着JVM的“翻译官”角色,它负责将加载进内存的class
字节码指令“翻译”成机器语言交由硬件执行。而字节码可以通过解释器和即使编译器两种途径转换为机械指令。HotSpot
虚拟机中,采用的便是解释器+即时编译器混合执行的工作模式。
运行时数据区
运行时数据区是整个JVM中的重点,开发者编写的所有代码最终都会被加载在这里之后再开始执行。Java运行时数据区主要可分为PC程序计数器、本地方法栈、虚拟机栈、元数据空间(方法区)以及堆空间五大区域,其中堆空间和方法区是线程共享的,而程序计数器,本地方法栈和虚拟机栈则是线程私有的。
堆
- Java堆是Java虚拟机管理的最大的一块内存区域,被所有线程共享。
- 在Java程序运行时,系统运行过程中产生的大部分实例对象以及数组对象都会被放到堆中存储。
- 对于JVM来说,堆空间是唯一的,每个JVM只会存在一个堆空间,同时容量大小会在创建时就被确定,但可以通过参数
-Xms
和-Xmx
指定堆的起始内存大小和最大内存大小。 - 一般所说的新生代、老年代、永久代是一种逻辑上的说法(1.8前物理也分代)。
- 本质上来说,Java堆结构是跟JVM运行时所使用的垃圾回收器息息相关的,由GC器决定了运行时的堆空间会被划分为何种结构。
方法区(元数据空间)
- 方法区(Method Area),也称为元数据空间(Metadata Space),是 Java 虚拟机的内存区域之一,用于存储类的结构信息、常量、静态变量、即时编译器编译后的代码等数据。
- 方法区在 Java 虚拟机的内存模型中是一个逻辑上的概念,它不一定要求在实现中与堆区分配在物理上是不同的内存空间。
- 方法区在不同的 Java 虚拟机实现中可能有所不同,有些虚拟机可能会将方法区中的部分数据存储在堆区或其他内存区域中。
- 方法区的大小是有限制的,在一些情况下可能会出现内存溢出(Out of Memory)的错误。
虚拟机栈
- 生命周期与线程相同
- 每个方法的执行都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
- 栈帧的大小在编译时就已经确定,因此在栈上分配的内存是确定的。
程序计数器
- 程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。生命周期与线程一致,随线程启动而生,线程销毁而亡。
- 是JVM所有内存区域中唯一不会发生OOM的区域,GC机制不会触及的区域。
- 在多线程环境下,每个线程都有一个独立的程序计数器,互不影响,用于记录各自线程执行的位置。
- 当线程执行一个Java方法时,记录线程正在执行的字节码指令地址,当执行引擎处理完某个指令后,程序计数器需要进行对应更新,将指针改向下一条要执行的指令地址,执行引擎会根据PC计数器中记录的地址进行对应的指令执行。
- 当线程在执行一些由
C/C++
编写的Native
方法时,PC计数器中则为空(Undefined
)。 - 保证线程发生CPU时间片切换后能恢复到正确的位置执行。
本地方法栈
- 本地方法栈类似于Java虚拟机栈,但是为本地方法服务。
- 本地方法栈也是线程私有的,用于存储本地方法(由Native关键字修饰的方法)的信息。
- 当开始执行一个本地方法时,就会进入不再受虚拟机限制的环境,级别与虚拟机一样,可以直接访问JVM的任何内存区域,也可以直接使用CPU处理器的寄存器和本地内存等。本地方法栈只是存储了线程要运行这个方法的必要信息。
本地方法接口和本地方法库
本地方法接口的作用主要是为了融合不同的编程语言为Java所用,它的初衷是融合C/C++程序。因为在Java诞生时,正是C语言横行的时候,要想立足就必须要有一个能够调用C代码的模块,于是就专门在内存中开辟了一块区域处理标记为native的方法。简而言之,本地方法接口就是一个Java调用非Java代码的接口,这个非Java代码一般泛指C语言所编写的本地方法库中的函数。
补充
符号引用与直接引用
符号引用到直接引用的转换是为了在程序运行时能够快速访问到目标,提高执行效率。这个过程发生在类加载的链接阶段,确保在程序执行时能够正确地调用类的方法和访问类的字段。
解释
符号引用: 符号引用是一种编译时的引用,它以一种符号形式来表示目标,而不直接指向目标。在Java中,符号引用可以是类和接口的全限定名、字段的名称和描述符、方法的名称和描述符等。
直接引用: 直接引用是可以直接指向目标的引用,相对于符号引用,它是在虚拟机加载类的过程中将符号引用转换而来的。直接引用可以是指向方法区的指针、指向堆中对象的指针、直接存储常量值等。
符号引用转换为直接引用的过程
- 在链接的解析阶段,虚拟机将符号引用转换为直接引用。解析主要有两种方式:静态解析和动态解析。
- 静态解析:
- 静态解析是在编译期间完成的,通过类加载器进行。
- 对于一些被
final
修饰的静态变量,编译期已经确定了其值,可以直接将符号引用转换为常量值。
- 动态解析:
- 动态解析是在运行期间完成的,主要针对方法和字段的动态调用。
- 虚拟机会根据类的方法表或字段表在运行时动态地解析符号引用,转换为直接引用。
- 动态解析的过程可以涉及到方法的动态绑定(多态)等机制。
举例
考虑一个方法调用的过程,例如 obj.method()
,其中 obj
是一个对象引用,method
是一个方法的符号引用。在解析阶段,虚拟机会将这个符号引用转换为实际的方法地址,这个地址就是直接引用。这个转换的过程涉及到动态绑定,即根据实际类型来确定调用哪个版本的方法。
自定义类加载器的作用
- 当
class
文件不在classpath
路径下时,需要自定义类加载器加载特定路径下的class
- 当一个
class
文件是通过网络传输过来的并经过了加密处理,需要首先对class
文件做了对应的解密处理后再加载到内存中时,需要自定义类加载器 - 线上环境不能停机时,要动态更改某块代码,这种情况下需要自定义类加载器。比如:当需要实现热部署功能时(一个class文件通过不同的类加载器产生不同class对象从而实现热部署功能)
虚拟机栈中栈帧的组成
- 局部变量表(Local Variable Table):
- 局部变量表用于存储方法中的局部变量和参数。每个局部变量在局部变量表中占据一个槽位(Slot),槽位的大小可以是 32 位或 64 位,取决于局部变量的数据类型。
- 局部变量表从索引为 0 的位置开始,按顺序存储方法的参数和局部变量。
- 操作数栈(Operand Stack):
- 操作数栈用于存储方法执行过程中的操作数和中间结果。当方法调用一个操作时,操作数栈用于传递参数和保存临时数据。
- 操作数栈是一个后进先出(LIFO)的数据结构,可以执行各种运算指令,如加减乘除等。
- 动态链接(Dynamic Linking):
- 动态链接用于在运行期间解析调用方法的引用。每个栈帧都包含一个指向运行期常量池中该方法的符号引用的指针,用于动态链接。
- 方法出口(Return Address):
- 方法出口存储了方法执行完毕后返回的地址。当方法执行结束时,程序将跳转到方法出口所指向的地址,继续执行接下来的指令。
方法区包含的主要内容
- 类的结构信息:
- 方法区存储了加载的类的结构信息,包括类的字段、方法、构造方法等信息,以及方法的字节码等。
- 运行时常量池:
- 方法区中包含每个类的运行时常量池,用于存储编译时生成的各种字面量(如字符串、数字)、符号引用(如类和方法的引用)等信息。
- 静态变量:
- 方法区存储了类的静态变量,即被 static 修饰的成员变量,这些变量在程序运行期间只会被初始化一次,并且在整个程序生命周期内都可以被访问。
- 即时编译器编译后的代码:
- 方法区还存储了即时编译器(JIT Compiler)编译后的本地机器代码(Native Code),这些代码用于执行经过优化的方法。
堆的分代结构
- JDK7及之前:堆空间包含新生代、年老代以及永久代。
- JDK8:堆空间包含新生代和年老代,永久代被改为元数据空间,位于堆之外。
- JDK9:堆空间从逻辑上保留了分代的概念,但物理上本身不分代。
- JDK11:堆空间从此以后逻辑和物理上都不分代。