类的加载机制
-
类加载的概念
- 类加载是 Java 虚拟机(JVM)把字节码文件(.class 文件)转变为 Java 类型的复杂且关键的过程。这就如同把一份详细的设计图纸(字节码文件)加工成一个可以实际运行和使用的软件模块(Java 类型)。字节码文件包含了类的完整定义,包括类的结构、成员变量、方法、构造函数等信息,类加载机制就是要把这些信息合理地加载到内存中,使得程序能够有效地利用这些类来执行各种操作。
-
类加载的时机
- 主动引用(一定会触发类加载)
- 创建类的实例:
- 当使用
new
关键字创建对象时,JVM 会检查这个类是否已经加载。如果没有加载,就会触发类加载过程。这是因为在创建对象之前,JVM 需要知道这个类的结构信息,比如对象的大小、成员变量的布局等,这些信息都存储在类的字节码文件中,只有通过类加载才能获取。例如,对于一个自定义的Person
类,当执行Person p = new Person();
时,JVM 会首先加载Person
类。
- 当使用
- 访问类的静态变量(除了被
final
修饰的常量且在编译期就能确定值的情况):- 静态变量属于类本身,当访问一个类的静态变量时,JVM 需要确保这个类已经加载到内存中。对于非编译期确定值的
final
静态变量,其初始化可能涉及到复杂的代码逻辑,所以访问这类变量会触发类加载。例如,有一个类Counter
,其中有一个静态变量static int count;
,当执行Counter.count
时,就会触发Counter
类的加载。但如果是final static int MAX_COUNT = 100;
这种在编译阶段就能确定值的常量,JVM 在编译时就已经将其值放入常量池,访问它不会触发类加载,因为不需要再去加载类获取这个常量的值。
- 静态变量属于类本身,当访问一个类的静态变量时,JVM 需要确保这个类已经加载到内存中。对于非编译期确定值的
- 调用类的静态方法:
- 静态方法与类相关联,而不是与对象相关联。所以,当调用一个类的静态方法时,JVM 必须先加载这个类。例如,在一个
MathUtils
类中有一个静态方法public static int add(int a, int b) { return a + b; }
,当执行MathUtils.add(1, 2);
时,JVM 会加载MathUtils
类,因为只有加载了这个类才能找到并执行add
方法。
- 静态方法与类相关联,而不是与对象相关联。所以,当调用一个类的静态方法时,JVM 必须先加载这个类。例如,在一个
- 使用反射方式对类进行操作:
- 反射是 Java 中强大的机制,允许程序在运行时动态地获取类的信息并操作类。当使用
java.lang.reflect
包中的方法,如Class.forName("com.example.MyClass")
或者通过类的Class
对象的newInstance()
方法来创建类的实例时,会触发类加载。这是因为反射操作需要访问类的内部结构信息,而这些信息只有在类加载后才能获取。例如,在一个数据库连接框架中,通过Class.forName("com.mysql.jdbc.Driver")
加载数据库驱动类,使得框架能够使用这个驱动类来建立数据库连接。
- 反射是 Java 中强大的机制,允许程序在运行时动态地获取类的信息并操作类。当使用
- 初始化一个类的子类:
- 当创建一个子类的对象时,JVM 会先检查父类是否已经加载。如果父类没有加载,就会先加载父类。这是因为子类继承了父类的成员变量和方法,子类的对象在内存中的布局和初始化过程依赖于父类的结构信息。例如,有一个父类
Animal
和一个子类Dog
,当执行Dog d = new Dog();
时,JVM 会先加载Animal
类,然后再加载Dog
类。
- 当创建一个子类的对象时,JVM 会先检查父类是否已经加载。如果父类没有加载,就会先加载父类。这是因为子类继承了父类的成员变量和方法,子类的对象在内存中的布局和初始化过程依赖于父类的结构信息。例如,有一个父类
- 作为启动类(包含
main
方法的类)被执行:- 包含
main
方法的类是 Java 程序的入口点。当运行一个 Java 程序时,JVM 首先要加载这个启动类,因为main
方法是程序执行的起点。例如,对于一个简单的HelloWorld
程序,其包含main
方法的HelloWorld
类就是启动类,JVM 会先加载这个类,然后从main
方法开始执行程序。
- 包含
- 创建类的实例:
- 被动引用(不会触发类加载)
- 通过子类引用父类的静态变量,不会导致子类的加载,只会加载父类:
- 当通过子类来访问父类的静态变量时,JVM 能够根据子类的信息找到父类的符号引用,并且只需要加载父类就可以获取这个静态变量的值。例如,父类
Parent
有一个静态变量static int parentVar = 10;
,子类Child
,当执行System.out.println(Child.parentVar);
时,只会加载Parent
类,因为 JVM 可以通过Child
类的继承关系找到Parent
类中parentVar
的定义,不需要加载Child
类。
- 当通过子类来访问父类的静态变量时,JVM 能够根据子类的信息找到父类的符号引用,并且只需要加载父类就可以获取这个静态变量的值。例如,父类
- 定义类数组,不会触发类的加载:
- 定义一个类数组只是声明了一个可以存储该类对象的容器,并没有实际使用这个类的任何成员。例如,
A[] aArray = new A[10];
只是在内存中分配了一个可以存储 10 个A
类对象的空间,但没有创建A
类的对象,也没有访问A
类的静态成员,所以不会触发A
类的加载。
- 定义一个类数组只是声明了一个可以存储该类对象的容器,并没有实际使用这个类的任何成员。例如,
- 通过子类引用父类的静态变量,不会导致子类的加载,只会加载父类:
- 主动引用(一定会触发类加载)
-
类加载的过程
- 加载(Loading)
- 字节码文件获取:
- 类加载器(ClassLoader)是类加载过程的核心角色。它负责根据类的全限定名(如
com.example.MyClass
)来查找并获取类的字节码文件。字节码文件的来源多种多样。在最常见的本地文件系统场景下,类加载器会按照classpath
环境变量指定的路径去查找.class 文件。例如,如果classpath
包含了/project/src
目录,当要加载com.example.MyClass
类时,类加载器会在/project/src/com/example/MyClass.class
路径下查找字节码文件。除了本地文件系统,字节码文件还可以来自网络,这在一些动态加载的应用场景中很有用,比如 Java Applet 或者一些基于网络的插件系统,类加载器可以通过网络协议(如 HTTP)从远程服务器下载字节码文件。另外,在一些特殊的应用场景下,字节码文件甚至可以从数据库或者其他自定义的存储介质中获取。
- 类加载器(ClassLoader)是类加载过程的核心角色。它负责根据类的全限定名(如
- 字节码文件转换为二进制流:
- 一旦找到字节码文件,类加载器会将其读取并转换为二进制字节流。这个过程类似于将文件从硬盘的存储格式转换为计算机能够处理的内存中的数据格式。对于本地文件系统中的.class 文件,类加载器会通过文件输入流(
FileInputStream
)等机制读取文件内容,并将其转换为二进制字节流。这个字节流是后续操作的基础,它包含了类的所有信息,包括类的结构、方法代码、变量定义等。
- 一旦找到字节码文件,类加载器会将其读取并转换为二进制字节流。这个过程类似于将文件从硬盘的存储格式转换为计算机能够处理的内存中的数据格式。对于本地文件系统中的.class 文件,类加载器会通过文件输入流(
- 在内存中生成代表这个类的
java.lang.Class
对象:- 在将字节码文件转换为二进制字节流之后,类加载器会在内存中创建一个
java.lang.Class
对象来代表这个类。这个Class
对象是 Java 反射机制的基石,它就像是类在内存中的一个 “代理” 或者 “身份证”。通过这个Class
对象,可以访问和操作类的各种属性,如获取类的名称、访问类的方法、获取类的成员变量等。例如,可以通过Class
对象的getName()
方法获取类的名称,通过getMethods()
方法获取类的所有方法信息。这个Class
对象在内存中的存储位置和管理方式由 JVM 内部机制决定,它是类在内存中的一种抽象表示,使得 JVM 能够在运行时对类进行各种操作。
- 在将字节码文件转换为二进制字节流之后,类加载器会在内存中创建一个
- 字节码文件获取:
- 验证(Verification)
- 文件格式验证:
- 这是验证过程的第一步,主要检查字节码文件是否符合 Java 虚拟机规范的格式要求。字节码文件有一个特定的格式,开头部分是魔数(
CAFEBABE
),这是一个用于标识字节码文件的特殊标记。验证过程会检查文件是否以正确的魔数开头,就像检查一个文件是否是合法的字节码文件的 “身份证” 一样。同时,还会检查文件的版本号是否在当前 JVM 支持的范围内。Java 字节码文件的版本号随着 Java 语言的发展而更新,JVM 只能处理与其兼容的版本号范围内的字节码文件。如果文件格式不符合要求,JVM 会抛出java.lang.VerifyError
异常,拒绝加载这个类。
- 这是验证过程的第一步,主要检查字节码文件是否符合 Java 虚拟机规范的格式要求。字节码文件有一个特定的格式,开头部分是魔数(
- 元数据验证:
- 元数据验证主要关注类的元数据信息,即类的结构相关的信息。这包括检查类的父类是否正确,例如,一个类不能继承一个
final
类,验证过程会检查这种非法的继承关系。同时,对于实现接口的类,会检查是否实现了接口中要求的所有方法。例如,如果一个类声称实现了java.util.List
接口,那么就需要验证它是否实现了List
接口中定义的所有方法,如add
、remove
、get
等方法。此外,还会检查类的成员变量和方法的访问修饰符是否合法,例如,一个private
方法不能在类外部被访问,验证过程会确保这种访问限制得到遵守。
- 元数据验证主要关注类的元数据信息,即类的结构相关的信息。这包括检查类的父类是否正确,例如,一个类不能继承一个
- 字节码验证:
- 字节码验证是对类的字节码指令进行详细检查,确保字节码流可以被虚拟机安全地执行。这是验证过程中最复杂的部分,因为它需要对字节码的逻辑和操作进行深入分析。例如,检查字节码是否存在非法的操作码,每个操作码在字节码文件中都有其特定的含义和规则,不合法的操作码可能会导致 JVM 执行错误的操作。同时,还会检查跳转指令是否指向合法的位置,字节码中的跳转指令用于实现循环、条件判断等逻辑结构,验证过程需要确保这些跳转指令不会导致程序执行到非法的内存区域或者跳过一些必要的初始化代码。如果字节码验证失败,JVM 会抛出
java.lang.VerifyError
异常。
- 字节码验证是对类的字节码指令进行详细检查,确保字节码流可以被虚拟机安全地执行。这是验证过程中最复杂的部分,因为它需要对字节码的逻辑和操作进行深入分析。例如,检查字节码是否存在非法的操作码,每个操作码在字节码文件中都有其特定的含义和规则,不合法的操作码可能会导致 JVM 执行错误的操作。同时,还会检查跳转指令是否指向合法的位置,字节码中的跳转指令用于实现循环、条件判断等逻辑结构,验证过程需要确保这些跳转指令不会导致程序执行到非法的内存区域或者跳过一些必要的初始化代码。如果字节码验证失败,JVM 会抛出
- 符号引用验证:
- 在 Java 程序中,代码中的类、方法和变量等可能是通过符号引用来表示的。符号引用是一种在编译阶段使用的引用方式,它通过字符串来描述目标的名称和位置。在解析阶段,这些符号引用会被转换为直接引用,而在这之前需要进行符号引用验证。主要验证符号引用中通过字符串描述的全限定名是否能找到对应的类、字段和方法等。例如,当一个类
A
引用了另一个类B
时,在字节码文件中可能只是通过符号(如B
的全限定名)来表示这个引用。符号引用验证会检查这个全限定名是否能够正确地定位到一个实际存在的B
类。如果符号引用验证失败,JVM 会抛出java.lang.NoClassDefFoundError
异常。
- 在 Java 程序中,代码中的类、方法和变量等可能是通过符号引用来表示的。符号引用是一种在编译阶段使用的引用方式,它通过字符串来描述目标的名称和位置。在解析阶段,这些符号引用会被转换为直接引用,而在这之前需要进行符号引用验证。主要验证符号引用中通过字符串描述的全限定名是否能找到对应的类、字段和方法等。例如,当一个类
- 文件格式验证:
- 准备(Preparation)
- 在准备阶段,JVM 会为类的静态变量分配内存空间,并设置默认初始值。这个过程是类加载过程中的一个重要环节,它主要是为后续的初始化阶段做准备。对于基本数据类型的静态变量,会根据其类型设置默认值。例如,对于
static int num;
,JVM 会为num
分配 4 个字节的内存空间(假设int
类型占 4 个字节),并将其初始值设置为 0。对于static boolean flag;
,会将其初始值设置为false
。对于引用类型的静态变量,会将其初始值设置为null
。需要注意的是,如果是static final
常量,并且其值在编译期就能确定(如static final int CONST_NUM = 10;
),那么在这个阶段会直接将其值设置为 10,而不是先设置为默认值再进行初始化。这是因为编译期确定值的常量在字节码文件中已经被当作一个常量值处理,不需要再经过初始化阶段。
- 在准备阶段,JVM 会为类的静态变量分配内存空间,并设置默认初始值。这个过程是类加载过程中的一个重要环节,它主要是为后续的初始化阶段做准备。对于基本数据类型的静态变量,会根据其类型设置默认值。例如,对于
- 解析(Resolution)
- 符号引用转换为直接引用:
- 在 Java 程序中,很多引用是通过符号引用来表示的。符号引用是一种在编译阶段使用的、比较抽象的引用方式,它通过字符串来描述目标的名称和位置。例如,当一个类
A
引用了另一个类B
时,在字节码文件中可能只是通过符号(如B
的全限定名)来表示这个引用。解析阶段的一个重要任务就是将这些符号引用转换为直接引用。直接引用可以是指向目标的指针、相对偏移量或者能直接定位到目标的句柄。例如,将对类B
的符号引用转换为指向B
类在内存中Class
对象的指针,这样在程序执行过程中,当需要访问B
类时,就可以通过这个直接引用来快速定位到B
类。
- 在 Java 程序中,很多引用是通过符号引用来表示的。符号引用是一种在编译阶段使用的、比较抽象的引用方式,它通过字符串来描述目标的名称和位置。例如,当一个类
- 解析类或接口、字段、方法的引用:
- 这包括对类和接口的加载和链接(如果还没有完成),以及对字段和方法的查找和绑定。例如,当一个类
A
调用了另一个类B
的方法method
时,在解析阶段需要找到B
类的method
方法在内存中的位置,并建立起A
类和B
类method
方法之间的调用关系。对于类和接口的解析,会检查引用的类或接口是否已经加载,如果没有加载,会触发加载过程。对于字段和方法的解析,会在类的字节码中查找对应的字段和方法的定义,并确定其在内存中的访问方式。这可能涉及到权限检查,例如,一个private
方法只能在其所属的类内部被访问,如果在其他类中试图访问这个private
方法,解析过程会检查这种访问是否合法。
- 这包括对类和接口的加载和链接(如果还没有完成),以及对字段和方法的查找和绑定。例如,当一个类
- 符号引用转换为直接引用:
- 初始化(Initialization)
- 执行类的初始化代码:
- 这是类加载过程的最后一步,会执行类的初始化代码。初始化代码包括静态变量的初始化语句和静态初始化块(
static {...}
)中的代码。这些代码会按照在类中出现的顺序依次执行。例如,对于一个类C
,有static int num = 10;
和static { System.out.println("Initializing C"); }
,在初始化阶段,会先将num
的值初始化为 10,然后执行静态初始化块中的打印语句。初始化代码是类在加载后第一次被使用时执行的代码,它用于初始化类的静态资源,例如,初始化一个静态的数据库连接池、加载配置文件等操作。
- 这是类加载过程的最后一步,会执行类的初始化代码。初始化代码包括静态变量的初始化语句和静态初始化块(
- 初始化顺序:
- 如果一个类有父类,那么先初始化父类,再初始化子类。这是因为子类可能会继承父类的静态变量和静态方法,需要先确保父类的初始化完成,才能正确地初始化子类。而且,在初始化过程中,同一个类只会被初始化一次,这是通过一个标记位来控制的。当一个类开始初始化时,JVM 会设置一个标记,表示这个类正在初始化。如果在初始化过程中遇到对同一个类的再次初始化请求,JVM 会忽略这个请求,避免重复初始化导致的错误。
- 执行类的初始化代码:
- 加载(Loading)
-
类加载器(ClassLoader)
- 启动类加载器(Bootstrap ClassLoader)
- 它是 Java 虚拟机内置的类加载器,处于类加载器层次结构的最顶层。启动类加载器负责加载 Java 核心类库,这些核心类库对于 Java 程序的运行是至关重要的,如
java.lang.Object
、java.util.ArrayList
等。它使用原生代码(C/C++)来实现,这使得它能够高效地加载最基础的类库。由于其特殊的实现方式,启动类加载器没有对应的java.lang.ClassLoader
对象,所以在 Java 代码中无法直接引用它。启动类加载器的加载路径是固定的,通常是<JAVA_HOME>/jre/lib
目录下的核心类库,这些类库是经过严格测试和优化的,构成了 Java 运行环境的基础。
- 它是 Java 虚拟机内置的类加载器,处于类加载器层次结构的最顶层。启动类加载器负责加载 Java 核心类库,这些核心类库对于 Java 程序的运行是至关重要的,如
- 扩展类加载器(Extension ClassLoader)
- 扩展类加载器位于类加载器层次结构的第二层,它负责加载 Java 的扩展类库,即
<JAVA_HOME>/jre/lib/ext
目录下的类库。扩展类加载器是由sun.misc.Launcher$ExtClassLoader
实现的,它是java.lang.ClassLoader
的子类。它可以加载一些第三方的扩展库,这些库提供了一些额外的功能,比如加密算法库、XML 解析库等。扩展类加载器的存在使得 Java 程序可以方便地扩展其功能,而不需要将所有的功能都集成在核心类库中。它的加载路径除了<JAVA_HOME>/jre/lib/ext
目录外,还可以通过系统属性java.ext.dirs
来指定其他扩展目录。
- 扩展类加载器位于类加载器层次结构的第二层,它负责加载 Java 的扩展类库,即
- 应用程序类加载器(Application ClassLoader)
- 也称为系统类加载器,它是类加载器层次结构中的第三层,负责加载用户类路径(
classpath
)上的类库。它是sun.misc.Launcher$AppClassLoader
实现的,也是java.lang.ClassLoader
的子类。在大多数情况下,我们编写的 Java 程序中的类都是由应用程序类加载器加载的。它可以加载我们自己编写的类、第三方的库(如果这些库在classpath
中)等。应用程序类加载器的加载路径可以通过系统属性classpath
来指定,它会按照classpath
中指定的目录和文件来查找和加载类。同时,它还会委托给其父类加载器(扩展类加载器和启动类加载器)来加载类,这是类加载器的委托机制的一部分。
- 也称为系统类加载器,它是类加载器层次结构中的第三层,负责加载用户类路径(
- 自定义类加载器(Custom ClassLoader)
- 除了上述三种标准的类加载器外,我们还可以根据自己的需求创建自定义类
- 启动类加载器(Bootstrap ClassLoader)