目录
- 前言
- Class 文件介绍
- 如何生成 class 文件
- 观察 Bytecode 方法
- class 文件到底是什么样的呢?
- Class 加载、链接、初始化
- 加载、类加载器
- 双亲委派
- Launcher 核心类
- ClassLoader 相关源码
- ClassLoader 相关问题
- 自定义简单 ClassLoader
- 自定义加密 ClassLoader
- 打破双亲委派机制伪代码
- 类懒加载顺序
- 链接
- 初始化
- 总结
前言
在 Java 编程中,类加载是一个关键的技术点,它负责将类引入 Java 虚拟机(JVM)使得程序能够正确地加载、链接、初始化类;类加载的过程是 Java 程序执行的基础,它涉及从磁盘或网络上加载类的字节码,解析类的符号引用,最终将类加载到内存中供程序使用
类加载的过程不仅仅是将类加载到内存中,它还涉及类的链接及初始化阶段;链接阶段包括验证、准备和解析,它们确保类的字节码符合安全和结构要求,并为类的变量分配内存并进行默认初始化;初始化阶段执行类的静态初始化程序,对静态变量进行赋值及执行其他必要的设置
理解类加载技术对于 Java 开发者来说至关重要,它不仅仅是一个概念,更是实现 Java 程序的关键步骤,深入了解类加载的原理及机制,可以帮助我们更好地理解 Java 程序的运行过程,并且能够应对一些高级的类加载场景和问题
Java 是一种想解释就解释,想编译就编译的语言
默认情况下采用混合模式(解释器、热点代码编译)执行,起始阶段采用解释执行,后面进行热点代码检测,发现在整个执行过程中有某段方法或某循环执行的频率特别高,就会把这段代码编译成本地代码,将来执行这段代码的时候就执行本地代码就好了,效率提升,这个就叫混合模式
本文将详细介绍类加载技术,探讨类加载的原理和过程,以及类加载过程中的关键概念和技术点。我们将深入了解类加载的各个阶段,解析类的符号引用和解决依赖关系,还将讨论类加载器的作用和分类
Class 文件介绍
如何生成 class 文件
当 Java 开发人员写好代码以后,在编译时将 Java 源代码由 Java 编译器(javac)将其转换为字节码文件(.class)这个编译过程将源代码转换为中间表示形式,而不是直接生成可执行的机器代码;整个 .class 文件生成后的格式就是个二进制的字节流,只要你通过文本文件打开 .class 文件,一般都会是如下所示:
它是由 Java 虚拟机来解释执行的,看到 CA FE BA BE 这就是 Java 编译完的 class 文件,四个字节,这部分叫做魔术(magic number)
观察 Bytecode 方法
Java 自带的 javac 命令帮助我们把 java 源文件生成 class 字节码文件,相反可以通过 javap 命令查看字节码有哪些内容
javap -v xxx.class
推荐一个更好的可视化工具,查看字节码的 IDEA 插件:jclasslib
可以很清晰的看到,常量池、接口、字段、方法、属性里面的字节码信息,比如:在对比循环体外实例化对象和循环体内实例化对象差异化时,可以通过该工具来查看两者的区别
class 文件到底是什么样的呢?
当一个 class 文件躺在硬盘上,该内容被 load 进内存以后,会创建两块内容
- 比如:String,将 String,class 二进制内容扔到了内存中
- 该内容于是乎生成了一个 class 对象(指令)该指令指向了生成好的二进制内容
class 存放在内存分区中,内存分区就是存常量、存 class 各种各样的信息,实际上它这块内容逻辑上叫 Method Area 方法区
JDK 8 之前该方法区实现落地在 PermGen 永久代中
JDK 8 之后该方法区实现落地在 Metaspace 元空间中
永久代、元空间两者之间的区别?
- 永久代:逻辑上属于堆,物理上不属于堆,存在于虚拟机中
- 元空间:逻辑上、物理上都属于堆,但其不存在于虚拟机中,它使用的是物理内存
Class 加载、链接、初始化
Loading 加载过程:将 class 文件加载进内存中,也就是对 class 文件中的一个个二进制字节码读取后进行装载
Linking 链接过程分为以下三部分:
- Verification:校验装载进来的 class 文件是不是符合 class 文件的标准、规范,假设 > 读取进来的 class 文件开头并非以 CA FE BA BE,那么在这个步骤就会被拒绝了
- Preparation:将 class 文件中静态成员变量赋予默认值,并非赋予初始值,假设 > static int i = 1,在这个步骤并不会把变量 i 赋值为 1,而是先赋值为 0
- Resolution:将类、方法、属性等符号引用解析为直接引用,要给它转换为直接内存地址,直接可以访问到的
Initializing 初始化过程:调用类初始化代码,静态变量在此时会赋予初始化
当该 class 对应所有对象都没有引用指向以后,当发生 GC 时,这些对象资源都会被回收掉
加载、类加载器
类加载器:class 文件从虚拟机加载到内存里都是通过 ClassLoader 类加载器加载进内存的,ClassLoader 是顶级父类,它是一个 abstract 类,它一定会有对应的子类实现,若想查看某个 class 是被哪个加载器加载进内存的,可以调用 类名.class.getClassLoader() 方法进行查看,示例代码如下:
/**
* @author vnjohn
* @since 2023/6/24
*/
public class ClassLoaderLevel {
public static void main(String[] args) {
System.out.println("String:"+ String.class.getClassLoader());
System.out.println("HKSCS:"+ HKSCS.class.getClassLoader());
System.out.println("DNSNameService:"+ DNSNameService.class.getClassLoader());
System.out.println("ClassLoaderLevel:"+ ClassLoaderLevel.class.getClassLoader());
System.out.println("DNSNameService#parent#classLoader:"+ DNSNameService.class.getClassLoader().getClass().getClassLoader());
System.out.println("ClassLoaderLevel#parent#classLoader:"+ ClassLoaderLevel.class.getClassLoader().getClass().getClassLoader());
}
}
执行结果及描述如下:
// 输出结果为 null,因为其是顶级加载器 Bootstrap 所加载的
String:null
// 输出结果为 null,因为其也是顶级加载器 Bootstrap 所加载的
HKSCS:null
// 该类位于 ext 目录某个 jar 包下,所以它由扩展类加载器 ExtClassLoader 所加载
DNSNameService:sun.misc.Launcher$ExtClassLoader@2ef1e4fa
// 该类是我们自己写的类,所以它由应用类加载器 AppClassLoader 所加载
ClassLoaderLevel:sun.misc.Launcher$AppClassLoader@18b4aac2
// 输出结果为 null,父加载器并不是类的加载器的加载器
DNSNameService#parent#classLoader:null
ClassLoaderLevel#parent#classLoader:null
通过示例代码演示过后,下面来介绍不同层次的类加载器,它们各自所负责的事情是什么
- Bootstrap:启动类加载器,又称之为引导类加载器,它用于加载 lib 里 JDK 最核心的内容,比如 > rt.jar、charset.jar 等包下的核心类,当在什么时候调用 getClassLoader 方法拿到的加载器结果是 null 值时,那么就代表是从最顶层的类加载器中进行加载的
- Extension:扩展类加载器,加载扩展包各种各样的文件,扩展包一般放在 jdk 安装目录下的 jre/lib/ext/*.jar 包
- App:应用加载器,平时经常会用到的类加载器,用它来指定加载 class path 指定的内容
- Custom:自定义类加载器
Custom ClassLoader 父类加载器 > application 父类加载器 > Extension 父类加载器 > Bootstrap
它们只是语义上的继承
双亲委派
如上图,描述类加载其实在内部是遵循双亲委派机制去进行加载的,那么下面来仔细描述当一个 class 文件要被 load 进内存,是怎样的一个加载过程?
- 任何一个 class,当你有自定义 ClassLoader 类加载器时,这时候就先尝试去自定义类加载器里面找,它内部维护着缓存,说你有没有已经帮我加载进来了,如果已经加载进来一遍就不需要加载第二遍,它如果没有在自己的自定义缓存找到的话,它并不是直接加载这块内存,它会先去它的父加载器 App 应用加载器中,问父亲你有没有把我这个类加载进来呢
- App 应用加载器,这时候就会去它的缓存里面找有没有这个类,如果有就返回,没有就继续委托给它的父加载器 Extension 扩展类加载器
- Extension 扩展类加载器,这时候就会去它的缓存里面找有没有这个类,如果有就返回,没有就继续委托给它的父加载器 Bootstrap 启动类加载器
- Bootstrap 启动类加载器,这时候就会去它的缓存里面找有没有这个类,如果有就返回,没有就往回委托给它的子加载器 Bootstrap Extension 扩展类加载器
- Extension 扩展类加载器,说我只负责加载扩展 jar 包里面的类,其他的我一概不知道也找不到,然后一直向下往回委托到 App 应用加载器、Custom ClassLoader 自定义类加载器中去找
整个加载过程,经过了一圈又一圈,才会真正把类加载进来,当我们能够把该类加载进来时叫做成功,若加载不进来,抛出异常 ClassNotFound
类找不到,这整个过程就叫做双亲委派
为什么要搞双亲委派机制?
主要是为了安全,若任何一个 Class 文件都可以把它加载进内存的话,那我就可以将 java.lang.String 交由给 ClassLoader,将密码存储成 String 类型对象,把 String load 进内存后,打包给客户,然后就可以偷偷摸摸把密码随便传递,这就造成了密码隐私泄露,极其不安全
当出现了双亲委派机制后,就不会这样了,自定义类加载器加载一次,java.lang.String 就产生了警惕性,它会先去上面查有没有加载过,若上面有加载过就不会返回给你,不给你进行重新加载
Launcher 核心类
Launcher 类是 ClassLoader 中的一个包装启动类,Bootstrap、Extension、App 类加载器它们所加载的路径都来自于 Launcher 核心类的源码
Bootstrap ClassLoader 加载路径:System.getProperty(“sun.boot.class.path”)
Extension ClassLoader 加载路径:System.getProperty(“java.ext.dirs”)
App ClassLoader 加载路径:System.getProperty(“java.class.path”)
如上面,示例代码执行的结果来看,可以得知
// 该类位于 ext 目录某个 jar 包下,所以它由扩展类加载器 ExtClassLoader 所加载
DNSNameService:sun.misc.Launcher$ExtClassLoader@2ef1e4fa
// 该类是我们自己写的类,所以它由应用类加载器 AppClassLoader 所加载
ClassLoaderLevel:sun.misc.Launcher$AppClassLoader@18b4aac2
Launcher 类来自于 sun,misc 包,ExtClassLoader、AppClassLoader 来自于 Launcher 源码,它默认显示为类名字后面 + 哈希 code 码,$ 符号 > 代表的意思就是 ExtClassLoader、AppClassLoader 都属于 Launcher 类的内部类!
下面通过一段小程序来看看这三个类加载器里到底加载了哪些文件,先通过指定的路径拿到属性值,然后再将指定符号替换为换行符
Windows 通过 ; 符号替换,Mac 通过 : 符号替换
/**
* @author vnjohn
* @since 2023/6/24
*/
public class ClassLoaderScope {
public static void main(String[] args) {
String bootstrapProperty = System.getProperty("sun.boot.class.path");
System.out.println("Bootstrap ClassLoader:");
System.out.println(bootstrapProperty.replaceAll(":", System.lineSeparator()));
System.out.println();
String extProperty = System.getProperty("java.ext.dirs");
System.out.println("Ext ClassLoader:");
System.out.println(extProperty.replaceAll(":", System.lineSeparator()));
System.out.println();
String appProperty = System.getProperty("java.class.path");
System.out.println("App ClassLoader:");
System.out.println(appProperty.replaceAll(":", System.lineSeparator()));
}
}
执行结果如下:
Bootstrap ClassLoader:
/Library/Java/JavaVirtualMachines/jdk1.8.0_333.jdk/Contents/Home/jre/lib/resources.jar
/Library/Java/JavaVirtualMachines/jdk1.8.0_333.jdk/Contents/Home/jre/lib/rt.jar
/Library/Java/JavaVirtualMachines/jdk1.8.0_333.jdk/Contents/Home/jre/lib/sunrsasign.jar
/Library/Java/JavaVirtualMachines/jdk1.8.0_333.jdk/Contents/Home/jre/lib/jsse.jar
/Library/Java/JavaVirtualMachines/jdk1.8.0_333.jdk/Contents/Home/jre/lib/jce.jar
/Library/Java/JavaVirtualMachines/jdk1.8.0_333.jdk/Contents/Home/jre/lib/charsets.jar
/Library/Java/JavaVirtualMachines/jdk1.8.0_333.jdk/Contents/Home/jre/lib/jfr.jar
/Library/Java/JavaVirtualMachines/jdk1.8.0_333.jdk/Contents/Home/jre/classes
Ext ClassLoader:
/Users/vnjohn/Library/Java/Extensions
/Library/Java/JavaVirtualMachines/jdk1.8.0_333.jdk/Contents/Home/jre/lib/ext
/Library/Java/Extensions
/Network/Library/Java/Extensions
/System/Library/Java/Extensions
/usr/lib/java
App ClassLoader:
/Library/Java/JavaVirtualMachines/jdk1.8.0_333.jdk/Contents/Home/jre/lib/charsets.jar
/Library/Java/JavaVirtualMachines/jdk1.8.0_333.jdk/Contents/Home/jre/lib/deploy.jar
/Library/Java/JavaVirtualMachines/jdk1.8.0_333.jdk/Contents/Home/jre/lib/ext/cldrdata.jar
/Library/Java/JavaVirtualMachines/jdk1.8.0_333.jdk/Contents/Home/jre/lib/ext/dnsns.jar
/Library/Java/JavaVirtualMachines/jdk1.8.0_333.jdk/Contents/Home/jre/lib/ext/jaccess.jar
/Library/Java/JavaVirtualMachines/jdk1.8.0_333.jdk/Contents/Home/jre/lib/ext/jfxrt.jar
/Library/Java/JavaVirtualMachines/jdk1.8.0_333.jdk/Contents/Home/jre/lib/ext/localedata.jar
/Library/Java/JavaVirtualMachines/jdk1.8.0_333.jdk/Contents/Home/jre/lib/ext/nashorn.jar
// ..... 省略其他
ClassLoader 相关源码
实现自定义类加载器之前,先阅读一下 ClassLoader 相关的源码部分
// 当前类加载器的父加载器
private final ClassLoader parent;
/**
* name:当前要加载的全限定类名
* resolve:是否将符号引用转换为可以直接访问的地址
*/
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// getClassLoadingLock(name):为加载类时都获取一把锁
synchronized (getClassLoadingLock(name)) {
// 首先,先检查该类是否被加载过了,若加载过了直接返回
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
// 若父加载器不为空
if (parent != null) {
// 父类先进行加载
c = parent.loadClass(name, false);
} else {
// 父加载器为空时,说明当前加载器是启动类加载器:Bootstrap
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 若类找不到就抛出 ClassNotFoundException 异常
}
if (c == null) {
// 仍然未找到,调用 findClass 方法查找该类
long t1 = System.nanoTime();
c = findClass(name);
// 定义类加载器,记录统计的数据
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
// 转换为可以执行访问的类
if (resolve) {
resolveClass(c);
}
return c;
}
}
loadClass 方法执行过程说明了,当你要加载一个类时只需要调用 ClassLoader#loadClass 方法就能够把该类加载进内存,加载到内存以后它会返回一个 Class 类的对象
经过上面的源码分析,若在自己缓存中没有找到,父加载器中也没有加载成功,最后只能回来自己再去调用 ClassLoader#findClass 方法去加载,它由 protected 修饰受保护的,只能在子类里面去进行访问,如下:
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}
该方法实现只有一句话,只能在它的子类里面去进行访问,很简单,我们只需要实现这个方法,就可以自定义类加载器加载我们所需要的类,这个就是钩子函数 > 模版方法设计模式
ClassLoader 相关问题
1、什么时候需要自定义加载器去实现?
Tomcat 在加载自定义的那部分类时(WEB-INF/classes 目录、WEB-INF/lib 目录中的 JAR文件),肯定是需要自定义类加载器(WebAppClassLoader)去加载这些 class 文件的,热部署的实现也是基于此加载器去实现的
2、如何指定类加载器的 parent
通过 super(parent) 指定
如下是示例代码:
/**
* @author vnjohn
* @since 2023/6/24
*/
public class ClassLoaderParent extends ClassLoader{
private static ClassLoaderParent PARENT = new ClassLoaderParent();
private static class MyClassLoader extends ClassLoader {
public MyClassLoader() {
super(PARENT);
}
}
}
3、如何打破双亲委派机制?
1、JDK 1.2 之前,自定义类加载器都必须重写 ClassLoader#loadClass 方法
2、ThreadContextClassLoader 提供了一种机制来绕过双亲委派机制,以实现在特定的线程上下文中加载类,通过 Thread#setContextClassLoader(ClassLoader cl) 方法可以设置线程上下文类加载器
3、在 OSGi 模块化开发框架中,各自应用程序存在自己的模块化机制和类加载器,可以加载同一个类库的不同版本,并且可以加载同名的类。在这种情况下,打破双亲委派机制的主要方式是使用双亲委派模型的变种,即双亲委派模型的扩展
自定义简单 ClassLoader
1、定义一个测试类:HelloWorld 后,通过 javac 编译成 class 文件
/**
* @author vnjohn
* @since 2023/6/24
*/
public class HelloWorld {
public void sayHello() {
System.out.println("Hello Vnjohn");
}
}
2、自定义类加载器:MyLoader,实现 URLClassLoader 类,重写 findClass 方法,在 findClass 方法块中会用到辅助方法 defineClass > 将字节数组转换为指定类名的 class 类对象
/**
* @author vnjohn
* @since 2023/6/24
*/
public class MyClassLoader extends URLClassLoader {
private static final String FILE_PATH = "/Users/vnjohn/Desktop/";
public MyClassLoader() {
super(new URL[0]);
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// File 创建一个位于 /Users/vnjohn/Desktop 目录,传入的名字是 org.vnjohn.HelloWorld
// 然后将中间的 . 替换成 / 最后就可以通过全路径找到 class 文件所在位置
File f = new File(FILE_PATH, name.replace(".", "/").concat(".class"));
FileInputStream fis = null;
try {
fis = new FileInputStream(f);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int b;
while ((b = fis.read()) != 0) {
baos.write(b);
}
// 转换为二进制字节数组
byte[] bytes = baos.toByteArray();
baos.close();
// 通过 defineClass 将字节数组转换为指定类名 name 的 class 类对象
return defineClass(name, bytes, 0, bytes.length);
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
assert fis != null;
fis.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return super.findClass(name); //throws ClassNotFoundException
}
public static void main(String[] args) throws Exception {
ClassLoader l = new MyClassLoader();
Class<?> clazz = l.loadClass("org.vnjohn.HelloWorld");
Class<?> clazz1 = l.loadClass("org.vnjohn.HelloWorld");
// 返回 true,class 对象只会加载一次,第二次不会再加载
System.out.println(clazz == clazz1);
// 通过反射获取实例对象再访问其方法
HelloWorld h = (HelloWorld) clazz.newInstance();
h.sayHello();
// AppClassLoader 加载器
System.out.println(l.getClass().getClassLoader());
// AppClassLoader 加载器
System.out.println(l.getParent());
// ClassLoader 默认的加载器就是 AppClassLoader
System.out.println(getSystemClassLoader());
}
}
3、执行 main 主方法,结果如下:
true
Hello Vnjohn
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$AppClassLoader@18b4aac2
自定义加密 ClassLoader
自定义类加载器时还可以自己加密 class 文件,防止反编译、篡改,可以进行手动解密、加密,在这里采用简单一点的办法 > 异或处理;异或一次就是加密、再异或一次就是解密,先把 Class 文件读出来进行异或加密,然后再行写回去;还是以测试类:HelloWorld 为例
/**
* @author vnjohn
* @since 2023/6/24
*/
public class MyClassLoaderWithEncription extends URLClassLoader {
public MyClassLoaderWithEncription() {
super(new URL[0]);
}
private static final int SEED = 0B10110110;
private static final String FILE_PATH = "/Users/vnjohn/Desktop/";
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// File 创建一个位于 /Users/vnjohn/Desktop 目录,传入的名字是 org.vnjohn.HelloWorld
// 然后将中间的 . 替换成 / 最后就可以通过全路径找到 class 文件所在位置
File f = new File(FILE_PATH, name.replace(".", "/").concat(".class"));
FileInputStream fis = null;
try {
fis = new FileInputStream(f);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int b;
while ((b = fis.read()) != 0) {
baos.write(b ^ SEED);
}
// 转换为二进制字节数组
byte[] bytes = baos.toByteArray();
baos.close();
// 通过 defineClass 将字节数组转换为指定类名 name 的 class 类对象
return defineClass(name, bytes, 0, bytes.length);
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
assert fis != null;
fis.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return super.findClass(name); //throws ClassNotFoundException
}
// 读文件操作
private static void encFile(String name) throws Exception {
File f = new File(FILE_PATH, name.replace('.', '/').concat(".class"));
FileInputStream fis = new FileInputStream(f);
// 写入到 /Users/vnjohn/Desktop 目录下的 .class 文件
FileOutputStream fos = new FileOutputStream(new File(FILE_PATH, name.replaceAll("\\.", "/").concat(".class")));
int b;
while((b = fis.read()) != -1) {
// 对每一个字节进行异或加密
fos.write(b ^ SEED);
}
fis.close();
fos.close();
}
public static void main(String[] args) throws Exception {
// 先将文件读出来进行异或加密
encFile("HelloWorld");
ClassLoader l = new MyClassLoaderWithEncription();
Class<?> clazz = l.loadClass("org.vnjohn.HelloWorld");
Class<?> clazz1 = l.loadClass("org.vnjohn.HelloWorld");
// 返回 true,class 对象只会加载一次,第二次不会再加载
System.out.println(clazz == clazz1);
// 通过反射获取实例对象再访问其方法
HelloWorld h = (HelloWorld) clazz.newInstance();
h.sayHello();
// AppClassLoader 加载器
System.out.println(l.getClass().getClassLoader());
// AppClassLoader 加载器
System.out.println(l.getParent());
// ClassLoader 默认的加载器就是 AppClassLoader
System.out.println(getSystemClassLoader());
}
}
执行结果跟 自定义简单 ClassLoader 执行的结果是一样的!!
打破双亲委派机制伪代码
只是重写 ClassLoader#findClass 方法是无法打破双亲委派机制的,若要打破它只能重写 ClassLoader#loadClass 方法,如下:
/**
* @author vnjohn
* @since 2023/6/24
*/
public class MyClassLoaderBreak extends URLClassLoader {
private static final String FILE_PATH = "/Users/vnjohn/Desktop/";
public MyClassLoaderBreak() {
super(new URL[0]);
}
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
File f = new File(FILE_PATH + name.replaceAll("\\.", "/").concat(".class"));
if(!f.exists()) {
return super.loadClass(name);
}
try {
InputStream is = new FileInputStream(f);
byte[] b = new byte[is.available()];
is.read(b);
return defineClass(name, b, 0, b.length);
} catch (IOException e) {
e.printStackTrace();
}
return super.loadClass(name);
}
public static void main(String[] args) throws Exception {
ClassLoader classLoader = new MyClassLoaderBreak();
Class<?> clazz = classLoader.loadClass("HelloWorld");
// 若在这中间 HelloWorld 改变了代码,下面的类加载器是可以加载到最新代码的.
classLoader = new MyClassLoaderBreak();
Class<?> clazzNew = classLoader.loadClass("HelloWorld");
// false
System.out.println(clazz == clazzNew);
}
}
注意:看如下这段代码,改变了这段代码的逻辑:
if(!f.exists()) {
return super.loadClass(name);
}
首先调用 loadClass 方法去找你要加载的 class 文件,若没找到就会让父加载器去 load,若找到了就自己 load;之前在这里会调用 findLoadedClass 方法去判断在缓存中是否加载过了,若要加载同一个 class 是覆盖不了的,但是这里把整体 ClassLoader 双亲委派机制干掉就行了
Tomcat 热部署就是这么干的,自定义类加载器重新实例化,然后再去加载所需要调用的类,所以说热部署会相当来说比较慢!!
类懒加载顺序
懒加载,规范来说应该是lazy initializing,JVM 规范并没有规定何时去加载,JVM 虚拟机内部实现都是用的懒加载,当什么时候需要用到这个类时才去进行加载,并不是一个 jar 包文件里面有几千个多类,但我只用到了一个类,还要去将它全部加载进来
JVM 严格规定几种情况下会必须初始化,如下:
- new、getstatic、putstatic、invokestatic 指令,只单独访问 final 变量不会初始化
- java.lang.reflect 对类进行反射调用时
- 初始化子类时,其父类会先进行初始化
- 虚拟机启动时,被执行的主类 main 方法必须初始化,比如:WebApplication
- 动态语言支持:java.lang.invoke.MethodHandle 解析的结果为 REF_getstatic、REF_putstatic、REF_invokestatic 方法句柄时,该类必须初始化
什么时候需要时才去加载,看如下示例代码:
/**
* @author vnjohn
* @since 2023/6/24
*/
public class LazyLoading {
public static class Parent {
final static int I = 8;
static int j = 9;
static {
// Parent 一旦打印则说明该 Class 已经被加载进来了
System.out.println("Parent");
}
public Parent() {
System.out.println("Parent Constructor");
}
}
public static class Child extends Parent {
static {
// Child 一旦打印则说明该 Class 已经被加载进来了
System.out.println("Child");
}
public Child() {
System.out.println("Child Constructor");
}
}
public static void main(String[] args) throws ClassNotFoundException {
// Parent parent;
// Child child = new Child();
// System.out.println(Parent.I);
// System.out.println(Parent.j);
// Class.forName("org.vnjohn.sms.LazyLoading$Parent");
}
}
我们来一一分析,main 方法这五行代码分别执行会是什么情况,如下:
- Parent parent:没有通过 new 操作符创建对象,不会被加载,控制台打印为空
- Child child = new Child():当通过 new 操作符创建子对象时,其父类 Parent 会被优先加载,然后再加载 Child,静态代码块执行顺序优先于构造函数,控制台输出内容如下:
Parent
Child
Parent Constructor
Child Constructor
- Parent.I:由于变量 I 使用了 final 修饰,打印 final 值不会加载
- Parent.j:由于变量 j 未使用了 final 修饰,普通的 static 变量,生成了 getstatic 指令,会加载 Parent 类,控制台输出内容如下:
Parent
9
- Class.forName(“org.vnjohn.sms.LazyLoading$Parent”):通过反射方式加载类,会加载 Parent 内部类,但未调用
newInstance 方法
生成对象,故控制台会输出内容如下:
Parent
到这里为止,关于 Class 加载的相关内容已经都介绍完成
链接
链接如上面所描述的一致,到这里再简单阐述一下,分为以下三步:
- Verfication:校验文件是否符合 JVM 语法二进制内容规范(CA FE BA BE),规范不符合就不会在进行下一步
- Preparation:给静态成员变量赋予默认值
- Resolution:将类、方法、属性等符号引用解析为直接引用,将常量池中的各种引用解析为指针,偏移量等内存地址的直接引用
符号引用、直接引用,区别如下:
符号引用:一种用来表示引用目标的符号名称,比如:类名、字段名、方法名等;符号引用与实际的内存地址无关,只是一个标识符,用于描述被引用的目标,类似于变量名;符号引用是在编译期间产生的,在编译后的 class 文件中存储起来
直接引用:实际指向目标的内存地址,比如:类的实例、方法的字节码等;直接引用与具体的内存地址相关,是在程序运行期间动态生成的
初始化
通过一段案例代码来演示,调用类初始化代码以及给静态成员变量赋予初始值同时进行时,会发生的情况,如下:
/**
* @author vnjohn
* @since 2023/6/25
*/
public class ClassInitializing {
static class InitializingObj {
public static InitializingObj obj = new InitializingObj();
public static int count = 2;
public InitializingObj() {
count++;
}
}
public static void main(String[] args) {
System.out.println(InitializingObj.count);
}
}
如下,这两块代码谁在上面谁在下面发生变化时,count 值也会随着发生变化
public static InitializingObj obj = new InitializingObj();
public static int count = 2;
当 count 在上时,通过
InitializingObj.count
获取到的值为 3,因为当调用 InitializingObj 类静态成员变量非 final 修饰时,它会去进行加载 loading,然后进行 Linking:先通过 Verification 校验,再进行 Preparation 赋予默认值 > count = 0、obj = null,最后进行 Resolution 将符号引用解析为直接引用;再执行 Initializing 初始化阶段,将静态成员变量赋初始值 > count = 2、obj = new InitializingObj(),先执行 count = 2,此时 count 值为 1,然后执行 new 关键字调用构造方法执行 count++ 操作,最终 count 值由 2 变为 3.
当 count 在下面时,loading、Linking 阶段都是一致的,到了 Initializing 初始化阶段,将静态成员变量赋初始值 > obj = new InitializingObj()、count = 2,new 关键字调用构造方法执行 count++,此时 count 值为 1,然后执行 count = 2,将 2 赋值给 count,最终 count 值由 1 变为 2.
并不是只有访问静态变量会有这样的过程,访问成员变量也是有分步骤进行的,当访问成员变量时,比如:private int i = 8;
1、给当前这个实例对象申请好内存空间
2、刚 new 出来之后的实例对象,成员变量还未赋值,默认值为 0
3、最后,调用构造方法,执行构造方法以后才会赋初始值 8
Class 加载过程:load、赋予静态成员默认值、赋予静态成员初始值
new Object 过程:申请内存空间、赋予实例成员默认值、赋予实例成员初始值
总结
该篇博文介绍了类加载时 Class 文件的简要结构及如何通过自带的一些工具、插件观察编译生成的字节码信息,核心在于详细说明了 Class 加载的过程:Loading、Linking、Initializing 阶段,在 Loading 阶段时介绍了很多有意思、有深意的知识点,如加载类时的双亲委派机制、探知 Tomcat 如何打破双亲委派机制的伪代码、如何自定义类加载器以简单|加密的方式去实现加载我们特殊的类,还有意思的分析了 Launcher 核心类中三大类加载器所加载的 jar 包、目录等文件,ClassLoader 相关的源码是如何去实现的;最后,分析了类在懒加载非 final 静态、final 静态、静态代码块、父子实例对象的懒加载顺序,初始化阶段 > 加载静态成员、实例成员之间的区别;希望该篇博文能帮助到您快速学习类是如何加载的以及它里面的核心知识!!!
博文放在 Java 专栏里,欢迎订阅,会持续更新!
如果觉得博文不错,关注我 vnjohn,后续会有更多实战、源码、架构干货分享!
推荐专栏:Spring、MySQL,订阅一波不再迷路
大家的「关注❤️ + 点赞👍 + 收藏⭐」就是我创作的最大动力!谢谢大家的支持,我们下文见!