大家好,我是大圣,很高兴又和大家见面。
上一篇文章说了JVM 的一些基础知识,这篇文章我们继续来谈 JVM 里面的两个问题,本次大纲如下:
JVM 加载 .class 文件过程
回顾
上篇文章说过 JVM 的核心作用可以理解为就是把 .class 字节码文件翻译成操作系统可以识别的机器码指令。
那 JVM 怎么把 .class 文件加载到内存,然后按照我们写的代码进行翻译的呢?
JVM 加载 .class 文件
我们知道当我们在IDE 里面写一个 .java 的程序,然后我们点击运行。
在运行代码之前首先晖进行编译,会把我们写的 .java 文件编译成 .class 文件。然后再进行运行,最后进行我们的逻辑处理,输出结果。
下面我来详细说这个过程。
专业解释
加载代码
JVM通过类加载器(Class Loaders)加载你的Java类。这包括应用程序的类和Java API的类。
类加载器首先读取.class文件(编译后的Java代码)。
链接
验证:验证确保加载的类符合JVM规范,没有安全问题。
准备:在方法区分配内存并为类变量(static fields)设置默认初始值。
解析:将类、接口、字段和方法的符号引用转换为直接引用。
初始化
执行静态代码块:执行静态初始化器和静态初始化块。
执行
创建主类实例:如果程序是一个应用,JVM创建主类(即包含main方法的类)的实例。
调用main方法:JVM调用程序的main方法,开始执行程序。
解释/编译
JVM中的解释器逐行解释字节码,或者JIT(Just-In-Time)编译器可以编译字节码到本地机器码以提高效率
运行时
使用堆内存:JVM在堆内存中管理应用创建的对象实例。
使用栈内存:每个线程在JVM中有自己的栈,用于存储本地变量和部分其他数据。
垃圾回收:JVM定期执行垃圾回收(GC),释放不再使用的对象所占用的内存。
退出
程序完成或者通过System.exit()调用
举例说明
大家可以先看下面这个图:
图书馆的例子
加载代码(类加载):
想象图书馆管理员(类加载器)需要准备一些书籍(.class文件,即编译后的Java代码)供读者阅读。管理员从图书馆的仓库中取出这些书籍并放在阅览区。
链接
验证:管理员检查书籍是否合适(没有安全问题),确保它们是合格的出版物(符合JVM规范)。
准备:为每本书指定一个特定的位置(在方法区分配内存)并做一些初步的分类(设置类变量的默认初始值)。
解析:将书籍中的引用(比如索引和引言)转换成具体的页码或书籍(将符号引用转换为直接引用)。
初始化
图书馆准备一个特别的展览(执行静态代码块),展示某些特定主题的书籍。
执行
创建主类实例:如果是一个阅读会(应用程序),管理员准备了主要的书籍(创建主类的实例)。
调用main方法:阅读会开始,首先读取主要书籍的第一章(调用程序的main方法)。
解释/编译
管理员或阅读助手(解释器或JIT编译器)向读者解释书中的内容,或者将内容翻译成更易理解的语言(将字节码转换为机器码)。
运行时
使用堆内存:为每位读者提供的书籍摆放在一个大桌子(堆内存)上。
使用栈内存:每个读者有自己的小桌子(栈内存),用于放置当前正在阅读的书籍。
垃圾回收:管理员定期清理未再使用的书籍,为新书腾出空间。
退出
阅读会结束(程序完成),或者图书馆因特殊情况关闭(通过System.exit()调用)。
小结
通过这个比喻,我们可以看到JVM处理Java程序就像图书馆管理员管理书籍一样,有序地进行着各种活动,确保一切运转顺利。
类加载过程
说明
上面说的 JVM 加载class 字节码文件,然后去运行我们写的代码这个过程。
大家看看然后有个印象就行,不用死记硬背,我觉得这个不是特别重要。
下面说的这个类加载器和双亲委派模型比较重要,也是面试中经常问到的一个问题。
什么是类加载器
JVM在类加载的时候,会通过类加载器(Class Loaders)加载你的Java类。
这包括应用程序的类和Java API的类。类加载器首先读取.class文件。
类加载器分类
引导类加载器(Bootstrap Class Loader):
它是类加载器层次结构中的最顶层加载器,负责加载Java的核心类库,如java.lang.*包中的类。通常用原生代码实现,不是Java类。
扩展类加载器(Extension Class Loader):
它是引导类加载器的子类加载器,负责加载Java的扩展库,这些库通常位于JRE的lib/ext目录或者由系统属性java.ext.dirs指定的目录中。
系统类加载器(System Class Loader,也称为应用类加载器):
它是扩展类加载器的子类加载器,负责加载环境变量classpath或系统属性java.class.path指定路径下的类库。
用户自定义类加载器:
这是由Java开发者根据自己的特定需求创建的类加载器。它通常继承自ClassLoader类。
用户自定义类加载器可以用于加载特定来源(如网络、文件系统中不常规位置等)的类,或者实现特殊的类加载策略(如热部署、加密解密类加载等)。
举例说明
大家先看一下下面的代码:
// 引导类加载器加载的类(java.lang.String)
String coreLibraryClass = "Core Library Class";
// 扩展类加载器加载的类(假设的例子)
com.example.ExtendedLibraryClass extendedLibraryClass = new com.example.ExtendedLibraryClass();
// 系统类加载器加载的类(自定义类)
com.myapp.MyApplicationClass myApplicationClass = new com.myapp.MyApplicationClass();
// 用户自定义类加载器加载的类
// 假设有一个自定义类加载器:MyCustomClassLoader
MyCustomClassLoader customClassLoader = new MyCustomClassLoader();
Class<?> remoteClass = customClassLoader.loadClass("com.remote.RemoteClass");
Object remoteClassInstance = remoteClass.newInstance();
我会把上面的代码怎么通过类加载器加载的过程通过一个通俗易懂的例子给大家讲一遍。
例子
想象一个家庭,有祖父母、父母、孩子三代人,以及孩子的好朋友。
他们在准备晚餐时有一个规矩:如果孩子想尝试做一道新菜,他们首先会询问父母是否已经准备了这道菜;
如果父母没有准备,他们会询问祖父母。只有当祖父母和父母都没有准备这道菜时,孩子才会自己或者请朋友来尝试做这道菜。
引导类加载器加载的类(祖父母):
在代码中,使用引导类加载器加载的类是java.lang.String。
这对应于家庭做饭比喻中的祖父母负责的传统菜谱,如家传的秘制红烧肉。
这些菜谱是家庭晚餐的基础,就像java.lang.String是Java核心类库的一部分。
String coreLibraryClass = "Core Library Class";
扩展类加载器加载的类(父母):
在代码中,扩展类加载器加载的类是com.example.ExtendedLibraryClass。这相当于比喻中的父母尝试的新菜谱或特殊佐料,比如他们学会的意式烩饭。这些菜谱是对传统菜谱的扩展,增添了新风味。
com.example.ExtendedLibraryClass extendedLibraryClass = new com.example.ExtendedLibraryClass();
系统类加载器加载的类(孩子):
在代码中,系统类加载器加载的类是com.myapp.MyApplicationClass。这对应于比喻中的孩子想创造的自己的特色菜,比如蜂蜜柠檬鸡。这是孩子根据自己的创意制作的新菜式。
com.myapp.MyApplicationClass myApplicationClass = new com.myapp.MyApplicationClass();
用户自定义类加载器加载的类(孩子的朋友):
在代码中,用户自定义类加载器加载的类是通过MyCustomClassLoader动态加载的com.remote.RemoteClass。
这相当于比喻中孩子的朋友被邀请到家里,带来他们自己家乡的特色食谱,如一道独特的异国风味炖菜。
这些菜式是家庭成员不熟悉的,需要孩子的朋友按照自己的方式来准备和烹饪。
MyCustomClassLoader customClassLoader = new MyCustomClassLoader();
Class<?> remoteClass = customClassLoader.loadClass("com.remote.RemoteClass");
Object remoteClassInstance = remoteClass.newInstance();
小结
通过这种方式,代码中的四种类加载器与家庭做饭比喻中的四种角色相对应,帮助理解它们各自在Java类加载机制中的作用。
双亲委派模型
在准备晚餐时,孩子首先会询问父母和祖父母是否已经准备了某道菜。
这反映了双亲委派模型,其中孩子(系统类加载器)首先委派给父母(扩展类加载器)和祖父母(引导类加载器)加载任务。
如果他们都没有准备这道菜,孩子会选择自己动手或请朋友(用户自定义类加载器)来做,这反映了在特殊情况下使用用户自定义类加载器来加载特殊来源的类。
类加载器这样的加载过程就叫做双亲委派模型。
补充知识
在面试的时候,在这一块,面试官通常会问,你知道类加载器和双亲委派模型吗?
你就可以把我们上面举得例子给面试官说一遍就行了。
有的面试官还会继续问,那你知道怎么双亲委派模型吗?在现实中有哪些应用?
下面我们来讨论一下这个两个问题。
打破双亲委派模型
大家看下面这张图:
双亲委派模型就是当我们要加载程序中的某个类或者方法的时候,系统类加载器自己先不加载,首先委派扩展类加载器,然而扩展类加载器它自己也不加载,继续委托给引导类加载器加载。
如果引导类加载器加载发现这是需要我加载的,我就加载,如果不是,它就会让扩展类加载器加载。
扩展类加载器就会去看,如果这是需要我加载的,我就去加载,如果不是,它就会让系统类加载器加载。
其实打破双亲委派模型的含义,就是不按上面的加载类的方式去加载,这个实现方式一般都是用用户自定义的累加器来实现。
后面我会给一个打破双亲委派模型的落地代码的。
应用场景
特定环境下的需求
在某些特定环境下,比如应用服务器(如Tomcat),可能需要加载不同的应用程序,这些应用程序可能包含相同名称但不同版本的类。
为了正确加载和区分这些类,应用服务器可能需要打破双亲委派模型。
热部署(Hot Deployment)
在开发阶段,热部署允许开发者在不重启应用服务器的情况下,动态替换或更新类。为了实现这一点,类加载器可能需要绕过双亲委派模型来重新加载已经改变的类。
特殊的加载逻辑
在一些特殊情况下,可能需要根据特定的逻辑加载类,例如,从特定的位置(如网络或加密存储)加载类。在这种情况下,自定义类加载器需要实现这种特殊的加载逻辑
代码落地
在这里我用自定义类加载器模拟一个 热部署 的例子,来打破双亲委派模型。
大家先理解一下热部署的概念:
举例理解
想象一下,你的汽车(应用程序)正在行驶中,但你突然想提高它的性能或更换一个部件。
在传统的情况下(没有热部署),你需要停车(停止应用程序),更换零件(更新代码或类),然后重新启动汽车(重新启动应用程序)。
但是,如果使用热部署,这就像你能够在汽车行驶的同时,不用停车,直接更换零件。
比如,你可以在车辆行驶时更换轮胎或提升引擎性能。这样,汽车就不需要停下来,你也可以立即看到更换零件带来的效果(快速测试代码的修改)
说明
在编程中,热部署允许你在应用程序运行时更换或更新代码。例如,你有一个网站正在运行,而你想更新一个页面的布局或添加一个新功能。
使用热部署,你可以直接在服务器上更新这部分代码,而网站无需下线,用户几乎感觉不到变化,新的页面布局或功能立即生效。
这种方式特别适合快速开发和测试,因为它省去了重启应用程序的时间,让开发者能够即时看到他们所做的更改效果。
代码实现
第一部分代码:
package com.dream.xxx.test;
import java.io.IOException;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Paths;
public class MyClass {
public void printVersion() {
System.out.println("Version 1");
}
}
class MyClassLoader extends ClassLoader {
public Class<?> loadClass(String name, byte[] b) {
return defineClass(name, b, 0, b.length);
}
}
class HotDeploymentDemo {
public static void main(String[] args) throws Exception {
// 初始版本
MyClass obj = new MyClass();
obj.printVersion(); // 输出 "Version 1"
// 模拟从文件系统或网络加载新版本的类的字节码
byte[] updatedClassData = getUpdatedClassData();
MyClassLoader customClassLoader = new MyClassLoader();
// 加载新版本的类,使用完整类名
Class<?> newClass = customClassLoader.loadClass("com.dream.yys.test.classload.MyClass", updatedClassData);
Object newObj = newClass.newInstance();
// 使用新版本的类
Method printVersion = newClass.getMethod("printVersion");
printVersion.invoke(newObj); // 假设输出 "Version 2"
}
private static byte[] getUpdatedClassData() throws IOException {
String classPath = "D:\\tmp\\MyClass.class"; // MyClass的新版本.class文件路径
return Files.readAllBytes(Paths.get(classPath));
}
}
第二部分代码:
package com.dream.xxx.test.classload;
public class MyClass {
public void printVersion() {
System.out.println("Version 2");
}
}
测试出来的结果
从结果我们可以看出之前的 MyClass 文件输出的是 Version 1,然后我们把 package com.dream.xxx.test.classload 这个包下面的 MyClass 把打印出来的结果改变了。
再利用
javac -encoding UTF-8 D:\xxx\xxx-learning-flink\module-java\src\main\java\com\dream\xxx\test\classload\MyClass.java 命令把我们修改过的MyClass.java
文件进行编译,然后利用
String classPath = "D:\\tmp\\MyClass.class"; // MyClass的新版本.class文件路径 这个进行加载
就可以打印出来我们新改的MyClass 里面的内容了。
这样就模拟实现了,上面的热部署。
补充一个问题
面试的时候,你把上面的代码实现过后,如果有技术能力的面试官可能还会问一句,为什么这样就实现了打破双亲委派模型呀?
如果你能回答出来这个问题,我想那就彻底掌握了类加载过程中的类加载器和双亲委派模型了。
这个问题的答案,我放在我们的讨论群里面了,感兴趣的可以加群,我们一起讨论。
总结
内容说明
这篇文章说了Java 代码加载到 JVM 里面运行的流程,然后重点说了类加载过程,需要使用类加载器和双亲委派模型,然后也模拟写了热部署的部分代码,实现了打破双亲委派模型的落地。
下一篇文章会说运行时数据区的设计来源和每个区域都存放的是我们写的哪些代码,都会讲的清清楚楚的,让你不再死记硬背这些东西。
而是知道为什么运行时数据区会这样设计,知根知底。
共同学习,写下你的评论
评论加载中...
作者其他优质文章