02_JVM 类加载机制
约 2564 字大约 9 分钟
2024-08-10
JVM 在什么情况下会加载一个类?
类加载过程是非常繁琐复杂的,从实用的角度来说,我们主要把握他的核心工作原理就可以了
一般一个类从加载到使用会经历的过程:加载 -> 验证 -> 准备 -> 解析 -> 初始化 -> 使用 -> 卸载
那什么时候 JVM 就会加载一个类呢?答案就是当你的代码中用到这个类的时候
举一个例子,你有一个类(Order.class),里面有一个 main() 方法作为入口,当 JVM 进程启动之后,他一定会先把这个类(Order.class)加载到内存中,然后从 main() 方法的入口代码开始执行
public class Order {
public static void main(String[] args) {
}
}假设上面的代码中,出现如下这么一行代码
public class Order {
public static void main(String[] args) {
MqManager mqManager = new MqManager();
}
}代码中很明显需要使用 MqManager 这个类去实例化一个对象,所以这个时候就会触发 JVM 通过类加载器,从 MqManager.class 字节码文件中加载对应类到内存中使用,这样代码才能跑起来,如下图

简单总结一下:首先代码中包含 mian() 方法的主类一定会在 JVM 进程启动之后被加载到内存,开始执行你的 main() 方法中的代码,接着遇到了使用别的类,此时就会从对应的 .class 字节码文件加载对应的类到内存中来
认识一下验证、准备和解析的过程
对于这三个概念,没太大的必要去深究里面细节,这些细节很多很繁琐,我们脑子里面有这几个概念就可以了
验证阶段
根据 Java 虚拟机规范,校验加载进来的 .class 文件中的内容,是否符合指定的规范
如果 .class 文件中的字节码不符合规范,那么 JVM 是没法去执行这个字节码的,过程如下图

准备阶段
我们写好的类,可能都有一些类变量,比如 MqManager 这个类
public class MqManager {
public static int maxConsumerNumber;
}MqManager.class 文件内容被加载到内存之后,会对字节码文件内容进行规范验证,接着就会进行准备工作,这个准备工作就是给 MqManager 类分配一定的内存空间
给类中的类变量(static 修饰的变量)分配内存空间,再赋值一个默认的初始值,比如这里给 maxConsumerNumber 变量一个初始值 0,过程如下图

解析阶段
解析阶段实际上就是把符号引用替换为直接引用的过程,这个部分的内容很复杂,涉及到 JVM 的底层,这里不做详细解读,知道有这么一个阶段就可以了,如下图所示

三个阶段的小结
在这三个阶段里,最核心的就是 准备阶段
在这个阶段给加载进来的类分配好了内存空间,类变量也分配好了内存空间,并且给了默认的初始值
核心阶段:初始化
初始化
在准备阶段,就会给 MqManager 类分配好内存空间,包括他的类变量 maxConsumerNumber 也会给一个默认值 0,那么接下来的初始化阶段,就会正式执行我们的类初始化代码了,我们来看看下面这段代码
public class MqManager {
public static int maxConsumerNumber = Configuration.getInt("mq.maxConsumerNumber");
}可以看到 maxConsumerNumber 这个类变量,是打算通过 Configuration.getInt("mq.maxConsumerNumber") 这段代码获取一个配置项给他赋值,在准备阶段,仅仅是给 maxConsumerNumber 类变量开辟了一个内存空间,然后给了个初始值 0
Configuration.getInt("mq.maxConsumerNumber") 这段代码的执行则会在 初始化 阶段,完成一个配置项的读取,然后赋值给 maxConsumerNumber 这个类变量
另外像下面的 static 静态代码块,也会在初始化阶段执行
public class MqManager {
public static int maxConsumerNumber = Configuration.getInt("mq.maxConsumerNumber");
static {
loadMessageFromDisk();
}
public static void loadMessageFromDisk() {
Map<String, Object> messageMap = new HashMap<>();
}
}什么时候会初始化一个类
一般来说有以下时机:
1、包含
main()方法的主类,必须是立马初始化的2、
new MqManager()实例化对象的时候,此时会触发类的初始化,把这个类准备好,再实例化对象3、初始化一个类的时候,发现他的父类还没初始化,必须先初始化他的父类

类加载器与双亲委派机制
类加载器
加载 -> 验证 -> 准备 -> 解析 -> 初始化 这些过程是依赖类加载器实现的,简单来说有下面几种
启动类加载器
Bootstrap ClassLoader,主要负责加载我们安装的 Java 目录下的核心类,在 Java 安装目录下有一个 lib 目录,这里面是 Java 最核心的一些类库,支撑着 Java 系统的运行
一旦启动 JVM,那么首先就会依托启动类加载器,去加载 Java 安装目录下 lib 目录中的核心类库
扩展类加载器
Extension ClassLoader,主要负责加载我们安装 Java 目录下 lib/ext 目录,这里面的一些类就需要使用这个类加载器来加载,支撑系统的运行
一旦启动 JVM,就会从 Java 安装目录下,加载 lib/ext 目录中的类
应用程序类加载器
Application ClassLoader,主要负责去加载 ClassPath 环境变量所指定路径中的类
可以大概理解为是去加载你写的 Java 代码,负责将你写好的那些类加载到内存里
自定义类加载器
除了前面几种以外,我们还可以自定义类加载器,去根据自己的需要加载类
双亲委派机制
JVM 的类加载器是有亲子层级结构的,启动类加载器是最上层,扩展类加载器在第二层,应用程序类加载器在第三层,最后一层是自定义类加载器,如下图

基于这个亲子层级结构,就有一个双亲委派的机制
假设你的应用程序类加载器需要加载一个类,他首先会委派自己的父类加载器去加载,最终传导至顶层的类加载器去加载,如果父类加载器在自己负责加载的范围内找不到这个类,那么就会把加载权交给自己的子类加载器,也就是说,父级能加载则加载,加载不了的会让子类去加载,子类还加载不了,就会让子类的子类去加载
这就是所谓的双亲委派模型:先找父类去加载,不行的话再由儿子来加载。这样的话,可以避免多层级的加载器结构重复加载某些类,最后我们再来一张总的图

思考环节
1、我们用 Java 开发的 Web 系统一般采用 Tomcat 之类的 Web 容器部署的,Tomcat 本身就是用 Java 写的,他自己就是一个 JVM,那么 Tomcat 的类加载机制应该怎么设计,才能把我们动态部署进去的 war 包中的类,加载到 Tomcat 自身运行的 JVM 中,然后去执行我们写好的代码呢?
首先 Tomcat 自定义了很多类加载器的,如下图所示

Tomcat 自定义了 Common、Catalina、Shared 等类加载器,其实就是用来加载 Tomcat 自己的一些核心类库
然后 Tomcat 为每个部署在里面的 Web 应用都有一个对应的 WebApp 类加载器,负责加载我们部署的这个 Web 应用的类,至于 JSP 类加载器,则是给每个 JSP 都准备了一个 JSP 类加载器
一定要记得的是,Tomcat 是打破了双亲委派机制的,每个 WebApp 负责加载自己对应的 Web 应用的 class 文件,不会传导给上层类加载器去加载,感兴趣的话可以另行了解 Tomcat 的类加载机制
2、父类子类加载、初始化是怎么样的呢?
加载父类就是父类,除非用到了子类才会加载子类;加载子类初始化之前,必须先加载父类,初始化父类
3、类只有用到的时候才加载到内存中,那么 new 对象的时候肯定会用到,是不是也要先经历类的所有过程才将类实例化?
没错,默认的类加载机制,就是在代码运行过程中,遇到什么类就加载什么类,必须先加载类,再实例化对象
4、类加载器机制为什么是一级一级往上找,直接从顶层类加载器开始找不就行了吗?
类加载器本身做的就是父子关系模型。 双亲委派模型在某些情况下是需要打破的,从顶层类加载器开始找就实现不了这个点了。
5、是在执行 new MqManager() 这行代码的时候加载 MqManager 类吗?还是说加载 Order 的时候就同时加载了呢?
执行 new MqManager() 的时候加载类
6、JVM 和平时运行在机器上的系统之间是什么关系呢?
运行在机器上的系统,其实就是一个 JVM 进程,JVM 进程会执行你系统里的代码
7、为什么 Tomcat 需要打破双亲委派模型?
为了解决 Web 应用程序的类加载冲突问题,并提供更好的灵活性和可扩展性