Photo by rizknas from Pexels:
Java是一种面向对象的编程语言,它的核心特性之一就是动态加载类。Java程序在运行时可以根据需要加载和卸载类,从而实现灵活的功能扩展和更新。那么,Java中类是如何从文件加载到内存中的Class对象呢?本文将从Java虚拟机的角度,详细介绍类的加载过程,包括类加载器、类文件格式、类加载阶段、类初始化过程等方面,以深入理解Java中类加载的本质和机制。
前言
在Java中,数据类型分为基本数据类型和引用数据类型。基本数据类型由虚拟机预先定义,引用数据类型则需要进行类的加载。所谓类的加载,就是将Java类的字节码文件加载到虚拟机中,并在运行期间动态生成Java类的实例。这个过程涉及到了Java虚拟机的类加载器、运行时数据区等多个方面,并且包含了很多的细节和技术问题。
为什么要了解Java中类的加载过程呢?有以下几个原因:
- 类的加载过程是Java虚拟机实现动态语言特性的基础,它影响着Java程序的运行效率和安全性。
- 类的加载过程涉及到了很多重要的概念和技术,如字节码、反射、热部署等,掌握这些知识可以提高Java开发者的编程能力和水平。
- 类的加载过程也是Java面试中常见的考点,了解这些知识可以帮助Java开发者更好地应对面试。
另外,虽然Spring Boot 3.0开始已经强制使用Java 17,很多开发者在学习的时候也积极跟进,但是很多公司不会为了追求新特性和功能,冒着风险升级底层依赖和框架,所以本文内讲述的内容依旧基于Java8。
类加载器
类加载器(ClassLoader)是负责将类装载到内存中,并为其创建一个Class对象。Class对象是在加载类的过程中创建的,每个类都对应有一个Class类型的对象。Class对象是访问类型元数据(元数据:描述数据信息) 的接口,也是实现反射(反射:在运行时动态获取类的信息和操作类的对象)的关键数据、入口。通过Class对象提供的接口,可以获得目标类所关联的.class文件中具体的数据结构:方法、字段等信息。
Java虚拟机定义了三种类加载器,分别为 Bootstrap ClassLoader
、Extension ClassLoader
、System ClassLoader
,它们按照层次关系进行组织,而且每个类加载器都有自己独立的命名空间,保证了不同类加载器之间的隔离性。
- Bootstrap ClassLoader(启动类加载器):由C++编写,负责加载Java运行环境(JRE)核心库,例如java.lang包等。它是JVM的内置类加载器,在JVM启动时就会被初始化。
- Extension ClassLoader(扩展类加载器):用来加载Java扩展库,位于JRE的/lib/ext目录下,或者通过java.ext.dirs系统变量指定的其他目录中。
- System ClassLoader(系统类加载器):用来加载应用程序路径上的类,也称为应用程序类加载器。它是ClassLoader类的子类,通常是由Java应用程序创建的默认类加载器。
除了这三种内置的类加载器之外,还可以通过继承ClassLoader类并重写findClass()方法来自定义类加载器。自定义类加载器可以从指定的路径或者网络地址上加载字节码文件,也可以实现一些特殊的功能,如加密解密、热部署等。
类加载器的双亲委派模型
类加载器的双亲委派模型是指当一个类加载器需要加载一个类时,它首先会将这个任务委托给它的父类加载器去完成,如果父类加载器无法加载,则再由自己来尝试加载。这样就形成了一个从下到上的层次结构,保证了同一个命名空间中不会出现重复的类。
双亲委派模型有以下几个优点:
- 避免了重复加载同一个类,节省了内存空间和时间。
- 保证了Java核心库的安全性和稳定性,防止了用户自定义的恶意代码覆盖或篡改Java核心库。
- 促进了不同模块之间的协作和解耦,提高了代码的可复用性和可维护性。
双亲委派模型也有一些缺点:
- 降低了灵活性和兼容性,可能导致一些合法且有用的代码无法被正确地执行。
- 不适合一些需要隔离或动态更新的场景,如OSGi、Spring Boot等。
为了解决这些问题,可以使用线程上下文类加载器(Thread Context ClassLoader
)来打破双亲委派模型。线程上下文类加载器是每个线程所持有的一个属性,它可以通过 Thread.currentThread().setContextClassLoader()
方法来设置,并通过Thread.currentThread().getContextClassLoader()
方法来获取。线程上下文类加载器可以让用户自己指定需要使用哪个类加载器来加载某个类或资源,从而实现更加灵活和动态的类加载机制。
类文件格式
Java源文件经过编译后会生成.class文件,也就是字节码文件。字节码文件是一种二进制文件,它包含了Java虚拟机可以执行的指令集和相关数据。字节码文件是Java实现跨平台特性的基础,它可以在不同平台上运行相同或不同类型的Java虚拟机。
类文件格式的结构
Java虚拟机规范定义了.class文件格式的结构由以下几个部分组成:
- 魔数(Magic Number):占用4个字节,用来标识这是一个有效的.class文件,其固定值为0xCAFEBABE。
- 次版本号(Minor Version):占用2个字节,用来表示.class文件的次版本号,一般为0。
- 主版本号(Major Version):占用2个字节,用来表示.class文件的主版本号,与Java平台的版本对应,如52代表Java 8,55代表Java 11等。
- 常量池计数器(Constant Pool Count):占用2个字节,用来表示常量池中常量的数量,其值为常量池大小加1。
- 常量池(Constant Pool):占用不定长度的字节,用来存放各种常量信息,如类名、字段名、方法名、字面量等。常量池中每个常量都有一个标志位(tag),用来表示常量的类型,如1代表Utf8字符串,7代表类或接口符号引用等。
- 访问标志(Access Flags):占用2个字节,用来表示类或接口的访问权限和属性,如public、final、abstract等。每个标志位都有一个固定的含义,如0x0001代表public,0x0010代表final等。
- 类索引(This Class):占用2个字节,用来表示当前类在常量池中的索引值。
- 父类索引(Super Class):占用2个字节,用来表示当前类的父类在常量池中的索引值。如果当前类是Object类,则父类索引为0。
- 接口计数器(Interfaces Count):占用2个字节,用来表示当前类实现的接口数量。
- 接口表(Interfaces):占用不定长度的字节,用来存放当前类实现的接口在常量池中的索引值。
- 字段计数器(Fields Count):占用2个字节,用来表示当前类或接口声明的字段数量。
- 字段表(Fields):占用不定长度的字节,用来存放当前类或接口声明的字段信息。每个字段信息包括访问标志、名称索引、描述符索引、属性计数器和属性表等。
- 方法计数器(Methods Count):占用2个字节,用来表示当前类或接口声明的方法数量。
- 方法表(Methods):占用不定长度的字节,用来存放当前类或接口声明的方法信息。每个方法信息包括访问标志、名称索引、描述符索引、属性计数器和属性表等。
- 属性计数器(Attributes Count):占用2个字节,用来表示当前类或接口附加的属性数量。
- 属性表(Attributes):占用不定长度的字节,用来存放当前类或接口附加的属性信息。每个属性信息包括名称索引、长度和具体内容等。
列举出的内容可参考Java8文档:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html
这里就不把文档里的东西都翻译了,学习的时候可以对照着官方文档尝试阅读其底层结构。
类加载阶段
从.class文件到加载到内存中的类,到类卸载出内存位置,它的整个生命周期包括如下七个阶段:
- 加载(Loading):通过类加载器读取.class文件中的二进制字节流,并将其转换成Java虚拟机中的Class对象。
- 连接(Linking)可再分为以下三步
- 验证(Verification):对类的字节码进行格式、语义和字节码等方面的检查,以确保它是正确、安全且符合规范的。
- 准备(Preparation):为类的静态变量分配内存,并将其初始化为默认值。
- 解析(Resolution):将类的符号引用替换为直接引用,即确定类的实际位置。
- 初始化(Initialization):执行类的静态初始化器和静态初始化块,对类的静态变量进行赋值操作。
- 使用(Using):创建类的实例,调用类的方法,访问类的字段等。
- 卸载(Unloading):回收类所占用的内存空间。
从程序中类的使用过程看,加载、验证、准备、解析、初始化五个步骤的执行过程,就是类的加载过程。使用和卸载两个过程,不属于类加载过程。
但是,这五个阶段并不是严格意义上的按顺序完成,在类加载的过程中,这些阶段会互相混合,交叉运行,最终完成类的加载和初始化。例如,在加载阶段,需要使用验证的能力去校验字节码正确性。在解析阶段,可能会触发某些类的初始化。在初始化阶段,可能会导致某些超类或接口的加载等。
graph LR A(开始) B(加载类文件) C(验证类文件) D(准备阶段) E(解析符号引用) F(初始化阶段) G(生成实例) H(结束) A --> B B --> C C --> D D --> E E --> F F --> G G --> H下面分别介绍每个类加载阶段的具体作用和内容。
加载阶段
加载阶段是类加载过程的第一阶段,它主要完成以下三件事情:
- 通过类的全名,获取类的二进制数据流
- 解析类的二进制数据流为方法区内的数据结构(Java类模型)
- 创建java.lang.Class对象,表示该类型。作为方法区这个类的各种数据的访问入口
获取二进制字节流
获取二进制字节流是指从不同的数据源中读取类文件或者动态生成类文件,并将其转换成字节数组形式。这个过程由Java虚拟机的类加载器(ClassLoader)来完成。
Java虚拟机规范并没有规定从哪里获取二进制字节流,也没有规定如何获取,只要求最终能够得到一个有效的字节数组即可。因此,在Java发展过程中,出现了很多不同类型和功能的类加载器,它们可以从不同的途径和方式来获取二进制字节流。例如:
- 从本地文件系统中读取.class文件。
- 从ZIP包中读取.class文件,例如JAR、WAR、EAR等格式。
- 从网络中获取.class文件,例如Applet、RMI等技术。
- 从数据库中读取.class文件。
- 运行时动态生成.class文件,例如动态代理、JSP等技术。
- 其他自定义方式。
就以JSP这种比较老的技术来举例,可能现在只有在一些技术栈很落后的公司或者大学内才会使用这种技术了。JSP的编译和类加载过程通常由JSP容器(如Tomcat)来完成。容器在首次访问JSP页面时会执行上述过程,将JSP转换为Java源代码并生成相应的类文件。
Jasper是Tomcat的JSP引擎,通过Jasper,Tomcat能够动态地将JSP文件转换为可执行的Java类,并在需要时进行编译和加载。这使得Tomcat能够实时响应JSP页面的变化,并提供动态生成的Web内容。
对具体实现感兴趣的同学可以参考Tomcat源码中的org.apache.jasper.compiler.Compiler#compile(boolean, boolean)
和 org.apache.jasper.JspCompilationContext#compile
。
转化运行时数据结构
转化运行时数据结构是指将二进制字节流所代表的静态存储结构转化为方法区(JDK1.8后为元数据区)中存储的运行时数据结构。这个过程主要涉及到了方法区和常量池两个重要的组成部分。
方法区是Java虚拟机规范中定义的一种规范,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。方法区在JDK1.7及之前被称为永久代(PermGen),在JDK1.8及之后被称为元数据区(Metaspace)。不同版本和不同厂商的虚拟机实现方式可能会有所不同,但是都需要遵循方法区的规范。
常量池是每个Class文件中都包含的一个数据结构,它用于存储编译期生成的各种字面量和符号引用。常量池在Class文件中以表格形式存在,在运行时会被加载到方法区中,并且每个Class对象都有一个指向自己常量池在方法区中位置的引用。常量池中存储了很多重要的信息,例如:
- 字面量:文本字符串、声明为final的常量值等。
- 符号引用:类和接口的全限定名、字段名称和描述符、方法名称和描述符等。
- 动态调用点:
invoke dynamic
指令所需的引导方法等。
在转化运行时数据结构时,虚拟机会根据Class文件中存储的信息,在方法区中创建一个Class对象,并为其分配内存空间。然后,虚拟机会将Class文件中除了常量池之外的其他信息(如版本号、修饰符、字段表、方法表等)复制到Class对象中,并对其中一些信息进行必要的处理。例如:
- 为每个字段分配一个唯一偏移地址。
- 为每个方法生成一个唯一索引号。
- 为每个接口生成一个唯一索引号。
- 为每个内部类型生成一个唯一标识符。
同时,虚拟机还会将Class文件中存储的常量池表复制到方法区中,并对其中一些信息进行必要的处理。例如:
- 将CONSTANT_Utf8_info型常量转换成String对象。
- 将CONSTANT_Class_info型常量解析为Class对象的引用。
- 将CONSTANT_Fieldref_info、CONSTANT_Methodref_info和CONSTANT_InterfaceMethodref_info型常量解析为字段、方法和接口方法的直接引用。
- 将CONSTANT_String_info型常量解析为String对象的引用。
- 将CONSTANT_MethodHandle_info型常量解析为方法句柄对象的引用。
- 将CONSTANT_MethodType_info型常量解析为方法类型对象的引用。
- 将CONSTANT_Dynamic_info和CONSTANT_InvokeDynamic_info型常量解析为动态调用点对象的引用。
常量池的举例
为了更好地理解常量池的内容和格式,我们可以使用javap命令来查看一个简单类的常量池信息。假设我们有一个如下所示的Java类:
/**
* @author fengxiao
* @created 2023/6/10
*/
public class ConstantPoolExample {
public static final int NUM = 100;
public static final String STR = "Hello";
public void print() {
System.out.println(NUM + STR);
}
}
使用javac编译、javap查看类信息:
javac .\ConstantPoolExample.java
javap -v ConstantPoolExample.class
控制台打印结果:
PS D:\Project\laboratory\src\main\java\com\landscape\jvm> javap -v ConstantPoolExample.class
Classfile /D:/Project/laboratory/src/main/java/com/landscape/jvm/ConstantPoolExample.class
Last modified 2023年6月10日; size 535 bytes
SHA-256 checksum ad75ffa74eb21ea42042d9fdd0130914be741a65b98a55b1f044d43eadb6a1aa
Compiled from "ConstantPoolExample.java"
public class com.landscape.jvm.ConstantPoolExample
minor version: 0
major version: 61
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #13 // com/landscape/jvm/ConstantPoolExample
super_class: #2 // java/lang/Object
interfaces: 0, fields: 2, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Fieldref #8.#9 // java/lang/System.out:Ljava/io/PrintStream;
#8 = Class #10 // java/lang/System
#9 = NameAndType #11:#12 // out:Ljava/io/PrintStream;
#10 = Utf8 java/lang/System
#11 = Utf8 out
#12 = Utf8 Ljava/io/PrintStream;
#13 = Class #14 // com/landscape/jvm/ConstantPoolExample
#14 = Utf8 com/landscape/jvm/ConstantPoolExample
#15 = String #16 // 100Hello
#16 = Utf8 100Hello
#17 = Methodref #18.#19 // java/io/PrintStream.println:(Ljava/lang/String;)V
#18 = Class #20 // java/io/PrintStream
#19 = NameAndType #21:#22 // println:(Ljava/lang/String;)V
#20 = Utf8 java/io/PrintStream
#21 = Utf8 println
#22 = Utf8 (Ljava/lang/String;)V
#23 = Utf8 NUM
#24 = Utf8 I
#25 = Utf8 ConstantValue
#26 = Integer 100
#27 = Utf8 STR
#28 = Utf8 Ljava/lang/String;
#29 = String #30 // Hello
#30 = Utf8 Hello
#31 = Utf8 Code
#32 = Utf8 LineNumberTable
#33 = Utf8 print
#34 = Utf8 SourceFile
#35 = Utf8 ConstantPoolExample.java
{
public static final int NUM;
descriptor: I
flags: (0x0019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL
ConstantValue: int 100
public static final java.lang.String STR;
4: return
LineNumberTable:
line 7: 0
public void print();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #15 // String 100Hello
5: invokevirtual #17 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 12: 0
line 13: 8
}
SourceFile: "ConstantPoolExample.java"
从上面的输出中,我们可以看到常量池表中有多种类型的常量,例如:
- CONSTANT_Methodref_info:表示方法的符号引用,例如#1表示java.lang.Object类的
方法。 - CONSTANT_Fieldref_info:表示字段的符号引用,例如#7表示java.lang.System类的out字段。
- CONSTANT_String_info:表示字符串字面量,例如#29表示"Hello"字符串。
- CONSTANT_Class_info:表示类或接口的符号引用,例如#2表示java.lang.Object类。
- CONSTANT_Utf8_info:表示UTF-8编码的字符串,例如#5表示"
"字符串。 - CONSTANT_NameAndType_info:表示字段或方法的名称和描述符,例如#14表示"
标签:Java,字节,虚拟机,深入分析,java,方法,加载 From: https://www.cnblogs.com/novwind/p/17473445.html