前言
在 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 方法块中会用到辅助方法