【JVM系列】Java类一生有多长------Java类的生命周期(类加载机制、双亲委派机制)
欢迎关注,分享更多原创技术内容~
微信公众号:ByteRaccoon、知乎\稀土掘金\小红书:浣熊say
微信公众号内容最全,免费送电子书,欢迎关注~
什么是Java类的生命周期?
Java类和世间万物一样有着自己的一生,从类加载时的潮气蓬勃,到类卸载时的落叶归根,而Java类的一生的作用在于如何在内存中被JVM所使用。
Java类的一生可以说是磁盘->内存->磁盘的一个轮回,简单来说就是加载-使用-卸载的一个轮回。
Java类的生命开始就是从磁盘被加载到内存中的那一刻开始,就如同婴儿的新生一般。而加载过程之后的验证、准备、解析和初始化就像是一个婴儿的成长过程,需要逐渐被塑造完整。
Java类的使用阶段,仿佛是一个人的壮年,为Java系统提供着它的作为,成员变量可以被访问,成员方法可以被使用,是Java类最为发光发热的生命阶段,但同时也是最少笔墨可以说的阶段。
Java类的卸载则如同人的落叶归跟,从磁盘中来到磁盘中去,仿佛它从来没有出现在内存中过。
所以,如上图所示,Java类的生命周期简单来说主要包括以下几个阶段:
-
加载(Loading): 加载是指将类的字节码文件从文件系统、网络等位置读取到JVM的内存中。在加载阶段,JVM会根据类的全限定名查找并读取对应的字节码文件。加载的来源可以是本地文件系统、远程服务器或其他资源。
-
连接(Linking): 连接阶段包括三个子阶段:验证、准备和解析。
- 验证(Verification): 确保类的字节码符合Java虚拟机规范,防止恶意代码的执行。JVM会对字节码进行各种静态检查,包括语法验证、字节码验证、符号引用验证等,以确保字节码的结构和内容是正确的和安全的。最简单的例子是,JVM会首先验证字节码文件的开头是否有0xCAFFEBABE
- 准备(Preparation): 准备阶段是为类的静态变量(static变量)分配内存空间并设置默认初始值的过程。在准备阶段,JVM会为类的静态变量分配内存,并设置默认的初始值,例如数值类型的变量初始化为0,对象类型的变量初始化为null。
- 解析(Resolution): 将类、接口、字段和方法的符号引用解析为直接引用。在解析阶段,JVM会将类的符号引用(例如方法调用、字段访问)转换为直接引用,即具体的内存地址,以便后续的执行过程中能够直接定位到对应的方法或字段。
-
初始化(Initialization):初始化是执行类的初始化代码的过程。在初始化阶段,JVM会执行类的静态代码块和静态变量的赋值操作,以完成类的初始化工作。类的初始化是在首次使用类时触发的,包括创建对象实例、调用静态方法等。
-
使用(Usage): 在类加载完成并经过初始化后,类可以被实例化、调用静态方法、访问静态字段等。在这个阶段,程序可以通过创建对象、调用方法等方式使用类的功能。
-
卸载(Unloading): 卸载阶段是指当一个类不再被引用,且没有任何活跃的实例时,类加载器可能会将这个类从内存中卸载。Java虚拟机规范并没有明确要求虚拟机必须在何时卸载类,因此类的卸载通常由具体的虚拟机实现来决定。
Java类何时加载类到内存?
在谈论类的生命周期之前,我们需要先了解什么时候才会触发类的加载流程,换句话说,什么时候会开启类的生命周期。实际上,在Java中普通的类和数组类的加载方式会有所不同,如下:
-
普通的类: 即非数组类,通过类加载器加载对应的.class文件(二进制文件)。这包括了加载、验证、准备、解析和初始化等步骤。这些类文件通常是由编译器从源代码生成的,并且它们包含了类的结构信息、方法代码等。
-
数组类: 与普通类不同,数组类的加载不是通过类加载器加载外部的.class文件。Java虚拟机会在运行时动态创建数组类,而不是依赖于事先准备好的类文件。数组类是在虚拟机内部生成的,用于表示数组类型。
这里我们说类的加载时机主要是说触发JVM通过类加载器读取对应.class的二进制文件的过程,数组类由于依赖JVM内部生成,因此不再讨论范围之内。对于普通类来说5种类的加载的时机如下:
- 遇到特定字节码指令: 当虚拟机在执行字节码时遇到
new
、getstatic
、putstatic
或invokestatic
这四条指令时,如果类还没有初始化,则会触发其初始化。以下是示例代码:
java
public class BytecodeInstructionExample {
public static void main(String[] args) {
// 使用new关键字实例化对象
MyClass obj = new MyClass();
// 读取或设置静态字段
int value = MyClass.staticField;
// 调用静态方法
MyClass.staticMethod();
}
}
class MyClass {
static {
System.out.println("MyClass is initialized");
}
public static int staticField = 42;
public static void staticMethod() {
System.out.println("Static method called");
}
}
- 使用反射调用: 使用
java.lang.reflect
包的方法对类进行反射调用时,如果类没有进行过初始化,需要先触发其初始化。以下是示例代码:
java
import java.lang.reflect.Method;
public class ReflectionExample {
public static void main(String[] args) throws Exception {
Class<> clazz = Class.forName("MyClass");
// 使用反射调用静态方法
Method method = clazz.getMethod("staticMethod");
method.invoke(null);
}
}
class MyClass {
static {
System.out.println("MyClass is initialized");
}
public static void staticMethod() {
System.out.println("Static method called");
}
}
- 父类初始化: 在初始化一个类时,如果其父类还没有进行过初始化,会先触发其父类的初始化。以下是示例代码:
java
public class SuperClassInitializationExample {
public static void main(String[] args) {
// 初始化子类,触发父类初始化
SubClass sub = new SubClass();
}
}
class SuperClass {
static {
System.out.println("SuperClass is initialized");
}
}
class SubClass extends SuperClass {
static {
System.out.println("SubClass is initialized");
}
}
- 虚拟机启动时指定的主类: 当虚拟机启动时,用户指定的主类(包含
main()
方法的类)会先进行初始化。以下是示例代码:
java
public class MainClassInitializationExample {
public static void main(String[] args) {
// 主类的初始化
System.out.println("MainClass is initialized");
}
}
- **动态语言支持: **当使用JDK1.7的动态语言支持时,如果
java.lang.invoke.MethodHandle
实例最后的解析结果是REF_getStatic
、REF_putStatic
、REF_invokeStatic
的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,需要先触发其初始化。以下是示例代码:
java
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
public class DynamicLanguageSupportExample {
public static void main(String[] args) throws Throwable {
// 使用动态语言支持
MethodHandles.Lookup lookup = MethodHandles.lookup();
MethodHandle mh = lookup.findStatic(MyClass.class, "staticMethod",
MethodType.methodType(void.class));
mh.invokeExact();
}
}
class MyClass {
static {
System.out.println("MyClass is initialized");
}
public static void staticMethod() {
System.out.println("Static method called");
}
}
这些示例展示了每种场景下对类的主动引用,触发了类的初始化。这符合Java虚拟机规范中关于类初始化的严格规定。
Loading(加载)与Java类加载器------Java类如婴儿般呱呱坠地
我们知道Java的源文件一般是以xxx.java文件存储在磁盘上的,而Java虚拟机是无法识别这种类型的文件的,所以Java的源文件一般会被编译成xxx.class文件,即字节码文件被存储在磁盘上。
而JVM在使用类的时候,不可能每次都去磁盘上读取字节码文件,这样整体的IO时间会很长,严重影响系统效率。所以,Java字节码文件在被使用之前,一般需要先加载到内存当中的方法区里面。
因此,类加载是指将类的字节码文件从文件系统、网络等位置读取到JVM的内存的方法区中去的过程。在加载阶段,JVM会根据类的全限定名查找并读取对应的字节码文件,在进行读取和写入到内存。
JVM的类加载过程是按需进行的,即在需要使用某个类时才会进行加载和初始化。JVM会维护一个类加载器(ClassLoader)层次结构来管理类的加载,并采用双亲委派模型来保证类的安全性和一致性。通过类加载机制,JVM能够实现类的动态加载、隔离和共享等特性,为Java程序提供了灵活性和可扩展性。
Java 类加载器------接生婆?
在医疗情况不发达的情况下,经常有婴儿在出生的时候就发生夭折,永远没办法见到这个世界。
Java类虽然一般不会夭折,但是也需要接生婆帮助它从磁盘加载到内存当中,这些个接生婆们就叫做------类加载器。
JVM的类加载器(Class Loader)是负责将类字节码加载到JVM中并生成对应的类对象的组件。类加载器是JVM的重要组成部分,它负责加载Java类和资源文件,使得Java程序能够运行。
Java的接生机制比较复杂,不是单个的接生婆接生所有的类,而是根据不同的片区匹配不同的接生婆来进行加载。JVM中存在四种类加载器如下:
- 启动类加载器(Bootstrap Class Loader)------顶级接生婆: 它是JVM的内置类加载器,负责加载JVM自身需要的类库,如java.lang包下的类,包括java.lang.String、java.lang.Object等,使用C++实现。
- 扩展类加载器(Extension Class Loader)------军区大院接生婆: 它是由启动类加载器派生出来的,只负责加载数个JavaJava的扩展库(Java Extension)库的包,如:jre/lib/ext/*.jar或由-Djava.ext.dirs指定。所以说,扩展类加载器就像是军区大院的接生婆,只服务那么一小部分扩展对象。
- 应用程序类加载器(Application Class Loader)------公立医院接生婆: 它是由扩展类加载器派生出来的,也称为系统类加载器。一般的类都是通过系统类加载器进行加载的,负责加载应用程序中的类和资源,加载classpath指定的内容。所以就像是公立医生一样,普通类都是从这里加载。
- 自定义类加载器(Custom ClassLoader)------私人定制接生婆: 通过自定义类加载器,开发人员可以实现一些特殊的加载需求,例如从非标准的数据源加载类、实现类加载的加密解密等功能。自定义类加载器需要继承ClassLoader类,并重写其中的findClass()方法来实现类加载的逻辑。
类加载器采用双亲委派模型,即当一个类加载器需要加载类时,它会先将这个任务委派给父类加载器,如果父类加载器无法加载,则由子类加载器来尝试加载。需要注意的是,这里说所说的父子关系并不是真正意义上的继承关系,而是一种上下级的关系,会首先让更高级别的类加载器进行类的加载。
类加载器的双亲委派机制(Parent Delegation Model)
JVM类加载器采用了双亲委派机制(Parent Delegation Model),它是一种层次化的类加载器组织结构。
在双亲委派机制中,每个类加载器都有一个父类加载器(除了启动类加载器没有父加载器),当一个类加载器接收到加载类的请求时,它首先将该请求委派给父类加载器去尝试加载。只有当父类加载器无法加载该类时,才由当前类加载器自己去加载。
这种委派机制有助于保证类加载的一致性和安全性,它的核心思想是:优先使用父类加载器来加载类,只有当父类加载器无法加载时才由子类加载器来尝试加载。这样可以避免重复加载已经存在的类,并防止恶意代码替换核心类库。双亲委派机制的好处包括:
- 避免类的重复加载:通过委派给父类加载器,可以确保一个类只被加载一次,避免了类的重复加载,提高了类加载的效率。
- 确保类的安全性:通过委派给父类加载器,可以确保核心类库的安全性,防止恶意代码替换核心类。
- 实现类加载的隔离性:每个类加载器都有自己的命名空间,加载的类只能访问自己命名空间内的类,提供了类加载的隔离性。
当一个类加载器接收到加载类的请求时,它会按照以下顺序进行向上委派:
- 检查该类是否已经被加载过,如果已经加载则直接返回。
- 将加载请求委派给父类加载器,让父类加载器尝试加载。
- 如果父类加载器无法加载,则自己尝试加载类。
- 如果自己无法加载,将加载请求再次委派给父类加载器的父类加载器,依次向上委派,直到达到顶层的启动类加载器。
- 如果所有父类加载器都无法加载,则抛出ClassNotFoundException异常。
通过双亲委派机制,JVM可以确保类的一致性和安全性,并提供了灵活的类加载器体系,允许开发人员根据需要自定义类加载器。
双亲委派机制核心代码
- JDK 8:
java
protected Class<> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized(this.getClassLoadingLock(name)) { //加锁
Class<> c = this.findLoadedClass(name); //查找类是否已经被加载
if (c == null) { //未被加载
long t0 = System.nanoTime();
try {
if (this.parent != null) { //调用父亲加载器的loadClass
c = this.parent.loadClass(name, false);
} else {//调用bootStrap类加载器,或者抛出ClassNotFound异常
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException var10) {
}
if (c == null) { //未成功加载
long t1 = System.nanoTime();
c = this.findClass(name); //调用自定义的类加载器
PerfCounter.getParentDelegationTime().addTime(t1 - t0);
PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
this.resolveClass(c);
}
return c;
}
}
- JDK 21:
毕竟2024年了,让不能老是JDK8,让我们来瞅瞅JDK 21的源码和JDK 8 有啥区别。
auto
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
PerfCounter.getParentDelegationTime().addTime(t1 - t0);
PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
如上代码所示,JDK 21的类加载部分的源码和JDK 8的源码没有任何区别。所以说,实际上JVM的很多核心代码是不会变动的。上述代码片段是JDK 8和JDK 21中的Java类加载器中的loadClass
方法实现,整体流程如下:
-
加锁: 使用
synchronized
关键字,通过getClassLoadingLock(name)
获取与类名相关的锁对象,确保在多线程环境中对类加载的同步操作。 -
查找已加载类: 使用
findLoadedClass(name)
尝试查找已加载的类,如果已加载则直接返回。 -
尝试父类加载器加载: 如果类未加载,尝试使用父类加载器加载。如果存在父类加载器,则调用其
loadClass
方法加载类;如果不存在,则尝试使用Bootstrap类加载器加载。 -
调用自定义类加载器加载: 如果父类加载器未成功加载类,调用自定义类加载器的
findClass
方法加载类。 -
性能统计: 记录加载过程中的时间和计数,包括父类委托加载所花费的时间、调用
findClass
方法所花费的时间,以及找到类的计数。
整体而言,该代码片段实现了类加载器的基本流程,包括父类委托加载、自身加载、性能统计等步骤,确保类的加载在多线程环境中的同步,并提供了一些性能统计信息。
自定义类加载器代码
java
public class MyClassLoader extends ClassLoader{
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
File f = new File("./test/",name.replaceAll(".","/").concat(".class"));
try{
FileInputStream fis = new FileInputStream(f);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int b = 0;
while((b=fis.read())!=0){
baos.write(b);
}
byte[] bytes = baos.toByteArray();
baos.close();
fis.close();
return defineClass(name,bytes,0,bytes.length); //将二进制流转换成Class类对象
}catch (Exception e){
e.printStackTrace();
}
return super.findClass(name);
}
public static void main(String[] args) {
ClassLoader loader = new T006_MSBClassLoader();
Class clazz = loader.loadClass("com.example.jvm.hello");
Hello h = (Hello) clazz.newInstance();
}
}
自定义类加载器只需要继承ClassLoader,同时重写findClass方法。当Java类重写了findClass方法之后,类的加载就会使用自定义的类加载方法进行加载。
Linking(链接)------Java类的成长经历
Verification(验证)
JVM(Java虚拟机)的类加载过程中,verification(验证)是其中的一个重要步骤。验证的目的是确保加载的字节码是符合Java虚拟机规范的,以保证安全性和稳定性。验证阶段通常包括以下几个方面的检查:
- 文件格式验证(File Format Verification):验证字节码文件的结构是否符合Class文件格式规范,包括魔数、版本号、常量池、字段和方法表等是否正确。
- 元数据验证(Metadata Verification):对字节码中的符号引用进行验证,检查其引用的类、字段和方法是否存在、可访问等。
- 字节码验证(Bytecode Verification):对字节码进行数据流和控制流分析,检查是否存在类型安全等问题,以防止潜在的类型错误。
- 符号引用验证(Symbolic Reference Verification):检查符号引用的类和成员是否能够正确访问,包括权限校验、继承关系校验等。
通过这些验证步骤,JVM可以确保在加载类的过程中不会出现潜在的安全问题和错误。如果在验证过程中发现了任何不符合规范的情况,JVM会抛出相应的异常,阻止类的加载和初始化。简单来说就是验证文件是否符合JVM规定。
Preparation(准备)
准备阶段的主要任务是为类的静态变量分配内存,并设置默认的初始值。需要强调的是这里是设置的初始值,所谓初始值就是比如说对于int类型的会设置为0,对于String类型的会设置为null,这个过程不会执行Java方法的构造方法。
所以,实际上在准备阶段JVM主要完成两件事:为静态变量分配内存和为静态变量设置初始化值,如下:
- 内存分配: 为类的静态变量在方法区(或称为静态存储区)中分配内存空间。这些静态变量包括类级别的基本数据类型、引用类型和类变量(static变量),而不包括实例变量。
- 初始值设置: 对于基本数据类型,JVM会将其设置为默认值,例如数值类型为0,布尔类型为false。对于引用类型和类变量,则会将其设置为null。
下面是一些数据类型的默认初始值:
数据类型 | 默认零值 |
---|---|
int | 0 |
long | 0 |
short | 0 |
char | '\u0000' |
byte | 0 |
boolean | false |
float | 0.0f |
double | 0.0d |
reference | null |
在准备阶段的操作是在类加载的准备阶段进行的,并且在类初始化阶段之前。准备阶段主要是为了确保在类初始化时,静态变量已经分配了内存,并且具有初始值,以防止使用这些静态变量时出现未初始化的错误。
Resolution(解析)
我们知道,在Java的字节码文件当中有着常量池,常量池中存放的主要是变量、方法的一些符号引用,这些引用可以帮助Java类在运行过程中快速的获取类相关的熟悉。
然而,符号引用是无法直接访问到内存当中的数据的,解析阶段的主要任务是将这些符号引用转换为直接引用,以便能够正确地定位和访问类、字段和方法。在解析阶段,JVM会执行以下操作:
- 类和接口解析: 对于类和接口的解析,JVM会根据符号引用中的全限定名,定位并加载对应的类或接口。
- 字段解析: 对于字段的解析,JVM会根据类的全限定名和字段的名称,定位并获取对应的字段,包括静态字段和实例字段。
- 方法解析: 对于方法的解析,JVM会根据类的全限定名和方法的名称以及方法参数的类型签名,定位并获取对应的方法,包括静态方法和实例方法。
解析阶段的主要目的是将符号引用转换为对应的直接引用,使得在类加载后的运行过程中,能够准确地访问和调用类、字段和方法。解析阶段是在链接过程中的一个重要环节,确保程序能够正确地找到需要使用的类和成员。
解析阶段的具体实现可能因JVM的不同而有所差异。某些JVM实现可能将解析的操作延迟到运行时进行,而不是在类加载过程中完成。这被称为动态解析,它允许在运行时根据实际情况进行解析,以提供更大的灵活性和优化的机会。
Initialization(初始化)
在JVM(Java虚拟机)的类加载过程中,initialization
(初始化)是其中的最后一个重要步骤。初始化阶段的主要任务是执行类的静态初始化器(<clinit>
方法)和静态变量的显式赋值操作,以完成类的初始化工作。
需要特别强调的是初始化阶段依旧是对类的初始化,而不是对对象的初始化,所以这个过程中执行的依旧是对类的静态变量的初始化而不是执行的构造函数。在初始化阶段,JVM会执行以下操作:
- 执行静态初始化器( <
clinit>
方法): 如果类中定义了静态初始化器,JVM会在初始化阶段执行该静态初始化器。静态初始化器用于执行一些静态代码块中的初始化操作,例如静态变量的赋值、静态方法的调用等,静态初始化器在类加载过程中只会执行一次。 - 静态变量显式赋值: JVM会执行类中静态变量的显式赋值操作。这些赋值操作可以是直接的常量赋值,也可以是通过静态块或静态方法进行赋值。静态变量的显式赋值会在静态初始化器之前执行。
初始化阶段是在类加载的过程中,只有当类被首次主动使用时才会触发(懒加载)。主动使用的情况包括实例化对象、调用静态方法、访问静态变量(除了编译器常量)、使用反射访问等。如果一个类在加载过程中未被主动使用,那么其初始化阶段会被推迟。下面通过Java代码举例说明:
java
public class InitializationExample {
static {
System.out.println("Static initializer block is executed");
}
// 静态变量的显式赋值
static int staticVariable = initializeStaticVariable();
public static int initializeStaticVariable() {
System.out.println("Initializing static variable");
return 42;
}
public static void main(String[] args) {
// 主动使用类,触发初始化阶段
System.out.println("Main method is executed");
}
}
在上面的例子中,InitializationExample
类包含了静态初始化器块和一个静态变量 staticVariable
,以及一个用于初始化静态变量的方法 initializeStaticVariable
。当主动使用类时(如执行 main
方法),JVM会按照初始化阶段的顺序执行静态初始化器和静态变量的赋值操作。
Using(使用)和Unloading(卸载)------Java类的落叶归根
当类加载的流程完成之后,Java的类已经是一个成熟和完整的类被存储在内存中的方法区里面,此时有任何方法需要调用到这个类都可以去方法区当中访问。
Using(使用)阶段:
- 主动使用: 在Java中,主动使用类的情况包括实例化对象、调用类的静态方法、访问类或接口的静态字段、使用反射等。这些操作会导致类的加载、连接和初始化,并将类置于可使用状态。
- 实例化对象: 当通过关键字
new
创建类的实例时,会触发该类的初始化阶段,并完成对象的实例化过程。 - 静态方法调用: 当调用类的静态方法时,也会触发类的初始化,确保静态方法在使用前得到正确的初始化。
- 静态字段访问: 访问类的静态字段同样会触发初始化过程,以保证静态字段的正确初始化值。
Unloading(卸载)阶段:
Java虚拟机具有自动内存管理和垃圾回收机制。在这个机制下,当一个类不再被引用,且没有任何实例存在时,虚拟机可能会考虑卸载这个类,释放相关的内存资源。类的卸载是虚拟机的垃圾回收的一部分。
卸载阶段并非程序员直接操作的阶段,而是由虚拟机的垃圾回收器负责。当一个类被卸载时,其静态变量、静态方法等相关信息会被卸载,释放内存空间,从而完成类的生命周期。
需要注意的是,类的卸载是相对较少发生的,通常只有在特定条件下才会触发。例如,当类加载器不再引用某个类时,且该类没有被其他地方引用,垃圾回收器可能会考虑卸载这个类。
总结
本文主要介绍了Java类的生命周期,即Java类的.class二进制文件从加载到内存到从内存中被卸载的整个过程构成了Java类的生命周期。
Java的生命周期主要包括加载、验证、准备、解析、初始化、使用和卸载等6个阶段。其中加载阶段是将Java的二进制文件加载到内存的方法区当中,需要关注的是Java的双亲委派机制的使用。
了解Java类的生命周期可以帮助我们理解Java的.class文件在内存中的运作模式,帮助理解Java和JVM的其它的功能,是非常重要的一部分知识点。