2、jvm 类加载机制深度剖析-爱代码爱编程
今天我们就来看看JVM的类加载机制到底是怎么样的,搞清楚这个过程了,那么以后在面试时,对面试官常问的JVM类加载机制,就能把一些核心概念说清楚了。
2.1、JVM在什么情况下会加载一个类?
类加载过程虽然繁琐复杂,但在日常工作中,我们主要需要掌握其核心工作原理。一个类从加载到使用,通常会经历以下过程:加载、验证、准备、解析、初始化、使用和卸载。
首先要弄清楚的问题是,在执行我们编写的代码过程中,JVM在什么情况下会加载一个类?也就是说,什么时候会从“.class”字节码文件中加载这个类到JVM内存中?其实答案很简单,就是在你代码中使用到这个类的时候。
以一个简单的例子来说明,假设有一个类(User.class),其中包含一个“main()”方法作为主入口。那么,一旦JVM进程启动,它一定会先将这个类(User.class)加载到内存中,然后从“main()”方法的入口代码开始执行。
public class User {
public static void main() {
// 业务代码
}
}
我们还是坚持一步一图,大家先看看下图,感受一下:
接着假设上面的代码中,出现了如下的这么一行代码:
public class User {
public static void main() {
// 业务代码
UserManager userManager = new UserManager();
}
}
在编程过程中,我们经常会遇到需要实例化某个类的对象的情况。例如,我们的代码中可能需要使用“UserManager”这个类来创建一个对象。在这种情况下,我们需要将“UserManager.class”字节码文件中的这个类加载到内存中,以便程序能够正常运行。
那么,这个过程是如何实现的呢?
首先,当我们的代码中出现需要实例化“UserManager”这个类的对象时,JVM会触发类加载器进行工作。类加载器的主要职责就是从字节码文件(如“UserManager.class”)中加载对应的类到内存中。
在这个过程中,类加载器会读取“UserManager.class”字节码文件,解析其中的二进制数据,然后将其转换为Java虚拟机可以识别和执行的指令。这样,当代码中需要创建“UserManager”的对象时,JVM就可以直接从内存中获取到这个类的相关信息,从而完成对象的实例化。我们来看下面的图:
上述内容是一个例子,旨在帮助大家更好地理解。可以简单概括为:
在Java程序中,当启动JVM(Java虚拟机)进程后,主类中的"main()"方法将被加载到内存中并执行。这个主类通常包含一个名为"main()"的方法,它是程序的入口点,用于启动应用程序。
如果在"main()“方法中使用了其他类,比如"UserManager”,那么对应的类将会被加载到内存中。这些类是通过从相应的".class"字节码文件中加载而得到的。
总结来说,JVM进程启动后,主类中的"main()"方法将被加载并执行,如果使用了其他类,它们也会相应地被加载到内存中。
2.2、轻松掌握验证、准备与初始化的必备步骤
关于类加载时机的问题,对于许多有经验的开发者来说,这可能并不是什么难题。然而,对于那些刚刚踏入编程世界的新手而言,这是一个至关重要且需要清晰理解的概念。下面,我们将从实际应用的角度,简洁地介绍另外三个相关概念:验证、准备和初始化。
实际上,对于这三个概念,我们并不需要深入挖掘其内部细节。这些细节繁琐复杂,对于大多数开发者来说,只需在脑中形成以下基本概念即可。
2.2.1、验证阶段
简单来说,这一步骤的目的是根据Java虚拟机规范,对加载进来的".class"文件内容进行校验,以确保其符合特定的规范。
这个过程中,我们首先要理解的是,如果一个".class"文件被篡改,其中的字节码可能完全不符合JVM规范。在这种情况下,JVM将无法执行这个字节码。
因此,在将".class"文件加载到内存之后,必须对其进行验证。只有当校验结果显示该文件完全符合JVM规范时,才能将其交给JVM进行运行。
以下是相应的代码示例,用于演示如何进行".class"文件的校验:
import java.io.*;
import java.util.jar.*;
public class ClassFileVerifier {
public static void main(String[] args) {
String classFilePath = "path/to/your/ClassFile.class";
try {
// 读取".class"文件
byte[] classBytes = readClassFile(classFilePath);
// 创建ClassReader对象
ClassReader reader = new ClassReader(classBytes);
// 创建Verifier对象,用于校验".class"文件内容是否符合JVM规范
Verifier verifier = new Verifier();
// 调用verify方法进行校验
verifier.verify(reader);
// 校验通过,可以进行后续操作
System.out.println("Class file is valid and can be executed by JVM.");
} catch (IOException e) {
e.printStackTrace();
} catch (ClassFormatError e) {
System.err.println("Class file format error: " + e.getMessage());
}
}
private static byte[] readClassFile(String filePath) throws IOException {
FileInputStream fis = new FileInputStream(filePath);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = fis.read(buffer)) != -1) {
baos.write(buffer, 0, bytesRead);
}
fis.close();
return baos.toByteArray();
}
}
上述代码中,我们使用ClassReader
和Verifier
类来对".class"文件进行校验。首先,通过readClassFile
方法读取".class"文件的字节码内容,然后创建一个ClassReader
对象,并将其传递给Verifier
对象的verify
方法进行校验。如果校验通过,则输出"Class file is valid and can be executed by JVM.",表示该文件符合JVM规范,可以由JVM执行。如果校验失败,将抛出相应的异常。下面用一张图,展示了这个过程:
2.2.2、准备阶段
在当前的这个阶段,我们能够轻松理解一个基本概念。在我们编写的类中,通常会包含一些类变量。例如,我们来看下面的“UserManager”这个类:
public class UserManager {
public static int demoNum;
}
当你有一个名为"UserManager"的类时,在将其"UserManager.class"文件的内容加载到内存后,首先会进行验证,以确保该字节码文件的内容是符合规范的。接下来,会进行一些准备工作。
这个准备工作主要包括为"UserManager"类分配一定的内存空间,然后为其内部的类变量(即使用static修饰的变量)分配内存空间,并赋予它们一个默认的初始值。
以示例中的"demoNum"类变量为例,会为其分配内存空间,并将其初始值设置为0。整个过程,如下图所示:
2.2.3、解析阶段
在这个阶段,我们实际上是在进行一种替换,将符号引用转换为直接引用。这个过程的复杂度较高,深入涉及到JVM的底层机制。
然而从实践的角度来看,对于大多数同学来说,在日常工作中应用JVM技术时,可能并不需要深入到这个阶段。所以,目前大家只需要了解这个阶段的存在和它的基本概念就足够了。同样,我还是给大家画图展示一下:
2.2.4、三个阶段的小结
在这三个阶段中,最需要大家关注的核心阶段是“准备阶段”。
在这个阶段,系统会为加载进来的类分配内存空间。具体来说,它为类的实例变量和类变量分配了内存空间。同时,这些变量也会被赋予默认的初始值。
这个重要的概念,大家必须牢记于心。
2.3、核心阶段:初始化
之前说过,在准备阶段时,就会把我们的“UserManager”类给分配好内存空间
另外他的一个类变量“demoNum”也会给一个默认的初始值“0”,那么接下来,在初始化阶段,就会正式执行我们的类初始化的代码了。
那么什么是类初始化的代码呢?我们来看看下面这段代码:
在之前的讨论中,我们提到,在准备阶段,会为我们的“UserManager”类分配内存空间。此外,该类的一个类变量“demoNum”会被赋予一个默认的初始值“0”。接下来,在初始化阶段,将会正式执行类的初始化代码。
那么,什么是类的初始化代码呢?让我们来观察下面的代码片段:
public class UserManager {
public static int demoNum = Configuration.getInt("config.demoNum");
}
这段代码展示了在初始化阶段,类初始化代码的执行情况。它包含了一些特定的操作和设置,用于确保类的正确初始化和运行。通过这段代码,我们可以了解到如何进行类的初始化操作以及相关的逻辑处理。
大家可以看到,对于“demoNum”这个类变量,我们计划通过Configuration.getInt("config.demoNum")
这段代码来获取一个值,并将其赋值给它。
然而,在准备阶段会执行这个赋值逻辑吗?答案是否定的!在准备阶段,我们仅仅是为“demoNum”类变量分配内存空间,并赋予其初始值“0”。
那么这段赋值的代码何时执行呢?答案是在初始化阶段进行。
在这个阶段,我们会执行类的初始化代码,例如上面的Configuration.getInt("config.demoNum")
代码将在这里执行。它将完成一个配置项的读取,然后将值赋给类变量“demoNum”。
此外,比如下面的static静态代码块,也会在这个阶段执行。可以理解为类初始化的时候,调用“loadUserInfoFromDish()”方法从磁盘中加载数据副本,并且放在静态变量“userInfos”中:
public class UserManager {
public static int demoNum = Configuration.getInt("config.demoNum");
public static Map<String, UserInfo> userInfos;
static {
loadUserInfoFromDB();
}
public static void loadUserInfoFromDB() {
this.userInfos = new HashMap<String, UserInfo>();
}
}
理解了类的初始化是什么之后,我们接下来探讨类的初始化规则。
那么,何时会进行类的初始化呢?通常有以下几种情况:
-
当我们使用 “new UserManager()” 这样的语句来实例化一个类的对象时,会触发类的加载到初始化的全过程。在这个过程中,系统会先准备好这个类,然后实例化出一个对象。
-
对于包含 “main()” 方法的主类,必须立即进行初始化。
此外,还有一个非常重要的规则需要了解:在初始化一个类的过程中,如果发现其父类尚未初始化,必须先初始化其父类。比如下面的代码:
public class UserManager extends AbstractBaseManager{
public static int demoNum = Configuration.getInt("config.demoNum");
public static Map<String, UserInfo> UserInfos;
static {
loadUserInfoFromDB();
}
public static void loadUserInfoFromDB() {
this.UserInfos = new HashMap<String, UserInfo>();
}
}
在尝试通过 “new UserManager()” 语句创建该类的实例时,系统将会先加载 UserManager 类。然而,在初始化 UserManager 类之前,系统会检查发现其父类 AbstractManager 尚未被加载和初始化。
为了解决这个问题,系统必须首先加载 AbstractBaseManager 父类,并完成其初始化过程。只有当 AbstractBaseManager 父类加载和初始化完成后,UserManager 类才能继续进行加载和初始化。
这一步骤确保了在初始化子类之前,所有依赖的父类已经被正确地加载和初始化。这是面向对象编程中的一个重要概念,有助于避免运行时错误和未预期的行为。
这个规则,大家必须得牢记,再来一张图,借助图片来进行理解:
2.4、类加载器和双亲委派机制
现在,我相信大家已经对整个类加载的过程有了清晰的理解,从触发的时机到初始化的过程。接下来,我将向大家介绍类加载器的概念。
类加载器是实现上述过程的关键工具。在Java中,有多种类型的类加载器,简单来说,主要包括以下几种:
2.4.1、启动类加载器
Bootstrap ClassLoader,主要负责加载我们计算机上安装的Java目录下的核心类。
众所周知,无论是在Windows笔记本还是Linux服务器上运行自己编写的Java程序,都需要安装JDK。
在你的Java安装目录中,会有一个名为“lib”的文件夹,你可以自行查找。这个文件夹包含了一些支撑您Java系统运行的最核心类库。
因此,每当你的JVM启动时,首先会依赖启动类加载器,去加载Java安装目录下的“lib”目录中的核心类库。
2.4.2、扩展类加载器
Extension ClassLoader,这是一个类加载器,其工作原理与之前描述的类似。在Java安装目录下,会有一个名为“lib\ext”的目录。这个目录中存放了一些类文件,这些类文件需要通过这个类加载器进行加载,以支持系统的运行。
当JVM启动时,它确实需要从Java安装目录下的“libext”目录中加载这些类。
2.4.3、应用程序类加载器
Application ClassLoader,这是一种类加载器,它的主要职责是加载由“ClassPath”环境变量所指定的路径中的类。你可以大致理解为,它负责将你编写的Java代码加载到内存中。这个类加载器的工作就是将你所编写的那些类加载到内存里。
2.4.4、自定义类加载器
除了上面那几种之外,还可以自定义类加载器,去根据你自己的需求加载你的类。
2.4.5、双亲委派机制
JVM的类加载器采用分层结构,其中启动类加载器位于最顶层,负责加载核心Java库中的类。紧接着是扩展类加载器,它位于第二层,主要负责加载Java安装目录的ext文件夹下的类。第三层是应用程序类加载器,负责加载应用程序中的所有类。最后一层是自定义类加载器,允许开发人员根据需要自定义加载策略来加载特定路径下的类。大家看下图:
在Java中,类加载器遵循一个称为双亲委派的层级结构。这个机制的核心思想是,当一个类加载器需要加载一个类时,它首先会将这个任务委托给它的父类加载器。这个过程会一直上溯到顶层的类加载器,也就是启动类加载器。
具体来说,如果一个类不在当前类加载器的加载范围内,它会将加载请求委托给其父类加载器。如果父类加载器也无法加载该类,它会进一步将请求委托给更上层的类加载器。这个过程会一直持续,直到找到能够加载该类的类加载器为止。
让我们通过一个例子来说明这个过程。假设JVM需要加载一个名为"UserManager"的类。应用程序类加载器首先会询问其父类加载器,也就是扩展类加载器,是否能够加载这个类。扩展类加载器会进一步询问其父类加载器,也就是启动类加载器,是否能够加载这个类。
如果启动类加载器在其负责的目录(例如Java安装目录)中找不到这个类,它会将加载请求回传给扩展类加载器。扩展类加载器在自己的负责目录中也找不到这个类,它会将加载请求回传给应用程序类加载器。
最后,应用程序类加载器在自己的负责范围(例如用户编写的jar包)中找到了"UserManager"类,并将其加载到内存中。
这就是双亲委派模型的工作原理:首先尝试由父类加载器加载类,如果失败则由子类加载器加载。这种机制可以避免重复加载同一个类,确保了类的唯一性和一致性。
最后,再通过一张图来感受一下类加载器的双亲委派模型。