类加载器(ClassLoader)
类加载过程的描述

当编写完 Java 源代码之后,需要通过 javac
编译成 Java 字节码,编译后的字节码仍然以文件的形式存储在硬盘中。而程序是在内存中运行的,因此从硬盘到内存这一个过程就是(狭义上的)类的加载过程。
类加载过程包括加载(Loading)、链接(Linking)、初始化(Initialization)这三个步骤,其中类加载器(ClassLoader)便是负责加载(Loading)过程。
Loading 过程:
- ClassLoader 将硬盘中的字节码文件加载到 JVM 内存的方法区中字节码对象
- 选择 ClassLoader
- 从 ClassLoader 负责的区域加载字节码文件(.class文件)
- 在堆区建立一个 Class 对象,引用方法区中的这个字节码对象
Linking 过程:
- Verification:验证字节码的格式,确保不会损害 JVM
- Preparation:为静态变量(static)分配内存,并设置初始值(0)。目的是预分配内存。
- Resolve:
Initialization 过程:
- 为静态变量(static)赋值
- 为成员变量初始化
ClassLoader 加载顺序
- BootstrapClassLoader(负责 $JRE_HOME/lib 下的 rt.jar 和 resources.jar 等 jar 包)
- ExtClassLoader、PlatformClassLoader(负责 $JRE_HOME/lib/ext 目录或系统变量 java.ext.dirs 对应目录下的 jar 包)
- SystemClassLoader、AppClassLoader(负责从项目的类路径 classpath 或系统变量 java.class.path 对应的目录中加载类,是程序的默认类加载器)
- MyClassLoader(加载自定义类,当前项目下的类)
- 抛出 ClassNotFoundException 异常
双亲委派机制(父委派机制)优点:层级设计体现一个优先级概念,可以避免核心类库被破坏,例如自己写一个 java.lang.String 类,由于是 BootstrapClassLoader 加载,会优先加载 JDK 中的 java.lang.String。
如果上一级的 ClassLoader 不能够顺利加载指定的类,那么会交给下一级的 ClassLoader。
判断是否加载的顺序是反过来的,由 AppClassLoader 先判断。
BootstrapClassLoader 由 C++ 编写,无法通过 Java 代码进行获取和访问。
自定义类加载器
loadClass 方法
- 调用 findLoadedClass 方法,判断这个类是否已经被加载过。若没有被加载,才进行后续处理
- 在没有被加载的情况下,优先委托给父加载器,让父加载器进行加载
- 上面都没有找到的情况下,调用 findClass 方法(默认抛出异常,自定义类加载器需要重载该方法)查找该类,然后调用 resolveClass 方法进行解析。
总结:loadClass 方法总得来说分两步,(1)找到字节码的位置,(2)解析
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{
synchronized (getClassLoadingLock(name)) {
// 1. 检查类是否已加载
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
// 2. 这里的if-else只是因为BootStrapClassLoader不是Java实现的,本质上都是优先委托给父加载器加载
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
// 如果上面都没有加载成功,通过findClass查找该类
if (c == null) {
// If still not found, then invoke findClass in order to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
// 解析类的方法,AppClassLoader中也调用了该方法
if (resolve) {
resolveClass(c);
}
return c;
}
}
-
findClass 方法: 默认是抛出异常,对于自定义类加载器而言,需要重写该方法
protected Class<?> findClass(String name) throws ClassNotFoundException { throw new ClassNotFoundException(name); }
-
defineClass 方法: 将字节数组加载为 Class 对象,在自定义类加载器中一般搭配 findClass 方法使用。
-
resolveClass 方法: 在 loadClass 方法中,findClass 和 resolveClass 搭配使用。当然在 AppClassLoader 内部实际也使用了 resolveClass
resolve 变量用来判断是否需要进行 Linking 阶段
应用案例:遵循双亲委派机制的自定义类加载器(重写 findClass 方法)
-
编写一个类,生成字节码文件,即 HelloWorldDemo.class,用于自定义类加载器的输入
package org.example; public class HelloWorldDemo { public void show() { System.out.println("Hello World"); } }
-
将上面的源文件使用
javac
手动编译后,将生成的字节码文件移动到下面自定义类加载器的 BASE_PATH 路径下 -
编写自定义类加载器,加载合法的字节码文件
注:如果需要从 URL 中获取 InputStream,调用 URL 对象的 openStream 方法即可
网络地址、jar 包都可以封装成 URL 对象
public class MyClassLoader extends ClassLoader { // 1. 这里体现出自定义类加载器加载自定义位置路径上的class字节码文件,可以修改成任意指定路径。可以通过有参构造进行改造 private static final String BASE_PATH = System.getProperty("user.dir"); private static final String POSTFIX = ".class"; // 2. 重载findClass方法 @Override protected Class<?> findClass(String name) { // 自定义类加载器可以加载任意后缀的文件,只要该文件的实际内容是符合Java字节码文件规范的 // 例如将HelloWorld.class重命名为HelloWorld.myclass,然后希望自定义类加载器能够加载以myclass为后缀的字节码文件,那么只需要改变这里的后缀即可。 String classFilePath = BASE_PATH + File.separator + name.replace('.', File.separatorChar) + POSTFIX; byte[] bytes = new byte[0]; try { // 如果是从url中获取InputStream,调用URL对象的openStream方法即可 // URLClassLoader将文件路径、网络地址、压缩包地址等统一封装成URL,再从URL中获取输入流。这里其实也可以使用相同的方式 FileInputStream fis = new FileInputStream(classFilePath); bytes = new byte[fis.available()]; fis.read(bytes); } catch (IOException e) { e.printStackTrace(); } // 在findClass方法中调用defineClass方法 return defineClass(name, bytes, 0, bytes.length); } // 直接使用自定义的类加载器,调用loadClass方法 public static void main(String[] args) throws Exception { ClassLoader classLoader = new MyClassLoader(); // 这里传入类的全类名,必须包含package,否则报错 Class<?> clazz = classLoader.loadClass("org.example.HelloWorldDemo"); Object obj = clazz.newInstance(); Method method = clazz.getDeclaredMethod("show"); method.invoke(obj); } }
URLClassLoader
对 ClassLoader 的扩展,可以从本地或者网络上的指定位置加载类。自定义类加载器可以直接使用 URLClassLoader。
public class URLClassLoaderDemo {
private static final String BASE_PATH = System.getProperty("user.dir");
public static void main(String[] args) throws Exception {
// 为URLClassLoader指定加载目录
URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{Paths.get(BASE_PATH).toUri().toURL()});
// 输入包含包名在内的全类名
Class<?> clazz = urlClassLoader.loadClass("org.example.HelloWorldDemo");
Object obj = clazz.newInstance();
Method method = clazz.getMethod("show");
method.invoke(obj);
}
}
还可以将字节码文件放入到 Tomcat 服务器中,同样注意包和全类名,使用 new URL(<网络根路径>)
应用案例:不遵循双亲委派机制的自定义类加载器(Tomcat)
TODO:下面的文字内容有待完善整理
越过双亲委派机制(不是破坏)
委派流程由 loadClass 方法实现,要破坏上面提到的双亲委派机制,就不要调用 loadClass 方法。
疑问:为什么不在自定义类加载器中重写 loadClass 方法呢?
答:在加载类的时候,如果父类没有被加载那么会先加载父类,假设此时 Object 还未被加载,那么 Object 也会被自定义类加载器加载,但是该自定义类加载器是破坏了双亲委派机制的,因此 Object 不会被 BootStrapClassLoader 加载,而自定义类加载器一般是加载自定义的指定目录,假设为 D:/
,那么使用破坏了双亲委派机制的自定义类加载器加载 java.lang.Object
时的绝对路径就是 D:/java/lang/Object
,与实际 java.lang.Object 所在的 JDK_HOME 下的路径并不相同,因而产生错误。所以推荐,使用双亲委派机制 loadClass 方法,不使用双亲委派机制 findClass 方法。(不完全的理解,也可以在 loadClass 中做出一些判断)
疑问:为什么重写 loadClass 方法,并在其中调用 findClass 方法和直接调用 findClass 方法会产生不同的类加载顺序?
使用 clazz.getClassLoader()
来查看真正加载类的 ClassLoader,有时候调用自定义的类加载器,但是真正加载类的并不是这个自定义的类加载器,而是自定义的类加载器的父加载器
疑问:为什么 Tomcat 要破坏双亲委派机制呢?
答:
- Tomcat 是 web 容器,一个 web 容器可能需要部署多个应用程序。
- 不同的应用程序可能会依赖同一个第三方库的不同版本(类似 SpringBoot2、SpringBoot3),不同版本中可能有同名类(大多数类大概率是一样的)
- 按照默认的双亲委派机制,是无法加载两个同名类的,就好比 HashMap 中不能存在两个相同的 key
- 所以 Tomcat 破坏双亲委派机制,提供隔离机制,即为每一个应用程序提供一个自定义的 WebAppClassLoader,这个 WebAppClassLoader 重写了 loadClass 方法,会优先加载当前应用程序下的类,而不是优先交给父加载器。
不同类加载器加载多个不同版本的类
强制类型转换要求:类相同并且类加载器相同
标识类和类是否相同,不仅仅看全类名是否相同,还要看类加载器是否相同。疑问:这里的类加载器相同是类型相同还是要同一个类加载器对象?
在双亲委派机制下,不会出现全类名相同,但是类加载器不同的情况。但是打破双亲委派机制后,就有可能出现,这种情况下,两个不同类加载器加载的类并不能够进行强制类型转换。
问题描述
在当前项目和依赖的 jar 包中创建全类名相同的类,使用两个不同的类加载器对其进行加载,这两个类不同够进行强制类型转换。
问题描述
在项目(假设为 A)中使用某个 jar 包,如果对 jar 包中的类进行修改并覆盖,运行中的项目 A 并不能够立即生效,需要在项目 A 重启后才能够再次加载修改后的jar 包中的类。但实际使用时,项目 A 不能够因为这种小事情重启,因此希望项目 A 在不重启的情况下,让修改后的 jar 包中的类能够生效。(本质上是 loadClass 方法中的缓存造成无法实现热加载)
核心思路
类加载器对象(ClassLoader)中含有缓存,想要实现对某一个修改后的字节码文件的热加载,需要对每次修改后的字节码使用新的 ClassLoader 对象。
为什么热加载不常用?
- 热加载产生非常多的垃圾对象,给 GC 系统造成非常大的负担
- jar 包修改过程中,可能存在一个临时过程,即文件还没有完全修改完成的时候去读取该文件会报错,需要给一个时间缓冲或重复读取。
SpringBoot 中 devtool 是通过重启来实现重新加载修改后的 class 文件,而 JRebel 是这里介绍的热加载的方式
在双亲委派机制下,一个类只能够被加载一次。而热部署就是希望能够在同一个类发生改变之后再次被加载,因此一个类需要被加载多次。所以,为了实现热部署,必须越过双亲委派机制。而双亲委派机制在 loadClass 方法中实现,因此不要调用 loadClass 方法而直接调用 findClass 方法。
public class HotDeploymentClassLoader extends ClassLoader {
private String basePath;
public HotDeploymentClassLoader(String basePath) {
this.basePath = basePath;
}
// Tomcat中的加载机制:优先当前类加载器加载逻辑
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
Class<?> c = null;
// 先查找缓存
if((c = findLoadedClass(name)) != null){
return c;
}
// 再查找自定义的目录
if((c = findClass(name)) != null){
return c;
}
// 最后双亲委派机制
return super.loadClass(name);
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
String classFilePath = basePath + File.separator + name.replace('.', File.separatorChar) + ".class";
byte[] bytes = new byte[0];
try {
FileInputStream fis = new FileInputStream(classFilePath);
bytes = new byte[fis.available()];
fis.read(bytes);
} catch (IOException e) {
e.printStackTrace();
}
Class<?> clazz = defineClass(name, bytes, 0, bytes.length);
resolveClass(clazz);
return clazz;
}
// 测试代码:同一个类需要使用不同的类加载器实例才能够多次加载
// 越过双亲委派机制体现在,加载的两个Class对象的hashCode不相同
public static void main(String[] args) throws Exception {
ClassLoader classLoaderA = new HotDeploymentClassLoader(System.getProperty("user.dir"));
// 隐式加载(先加载依赖类)Object类
Class<?> clazz = classLoaderA.loadClass("org.example.HelloWorldDemo");
System.out.println(clazz.hashCode());
ClassLoader classLoaderB = new HotDeploymentClassLoader(System.getProperty("user.dir"));
Class<?> aClass = classLoaderB.loadClass("org.example.HelloWorldDemo");
System.out.println(aClass.hashCode());
Object obj = clazz.newInstance();
Method method = clazz.getDeclaredMethod("show");
method.invoke(obj);
}
}
替换掉反射调用
前提背景
MyClassLoader:加载 jar 包中的类
AppClassLoader:加载 classpath 下的类
需求
希望通过 MyClassLoader 加载的类可以像普通的正常类(由 AppClassLoader 加载的类)一样使用,而不是必须得通过反射调用。
SPI 机制
SPI(Service Provider Interface),是 Java 提供的一套 API 接口,其实现由第三方厂商提供,常见的 SPI 有 JDBC、JNDI 等。
产生背景
这些 SPI 接口,例如 java.sql.Driver 属于核心类库,存在于 rt.jar 包中,应该由 BootStrapClassLoader 加载;而第三方依赖的 jar 包,例如 mysql-connector-java 等,存在于项目的 classpath(类路径)下,应该由 AppClassLoader 加载。但是双亲委派机制决定了只能向上委托,因此 BootStrapClassLoader 委托 AppClassLoader 加载第三方的 jar 包。为解决这个问题,出现了 ContextClassLoader(线程上下文类加载器)。
以 JDBC 为例,使用 java.sql.DriverManager 中的 loadInitialDrivers
方法来注册实现了 java.sql.Driver 接口的驱动类
关键目录
classpath 目录下的 META-INF/services
目录
一般 classpath 目录在项目中表示为 resources 目录
SPI 机制
SPI 机制是在被依赖的那个项目中提供 services 目录,而不是在提供仅仅规定了接口的那个项目中提供 services 目录。当我们在自己的项目中同时引入 JDK 和 mysql-connector-java 这两个 jar 包时,那么 mysql-connector-java 项目中 classpath 下的 META-INF/services 目录同样会被我们当前项目整合,此时相当于将目录进行了一次复制粘贴,因此我们的项目可以通过 SPI 机制,顺利加载 mysql-connector-java 这个 jar 包中的类。
疑问:将 META-INF 目录复制粘贴到当前项目是个人猜测,还没有经过实际检验,也许要将项目打包后才能看出来这个结论是否正确
已经验证是正确的
- 在 services 目录中提供以接口全类名为文件名的文件,该文件中每一行对应接口的一个实现类的全类名
- 使用
ServiceLoader.load(xxx, xxx)
来获取接口的实现类的集合(迭代器)
SpringBoot 的自动加载也是基于这种 SPI 机制,只是规定的目录、文件规范不相同。还有 SLF4J 和 Logback、Log4j 等之间的关系。
线程上下文中可以保存(设置)一个类加载器
ServiceLoader
private class LazyIterator implements Iterator<S>{
// ServiceLoader.load()方法传入的接口的Class对象
Class<S> service;
// 默认情况下是线程上下文中设置的类加载器
ClassLoader loader;
Enumeration<URL> configs = null;
Iterator<String> pending = null;
String nextName = null;
private LazyIterator(Class<S> service, ClassLoader loader) {
// ServiceLoader将实际工作委托给LazyIterator
this.service = service;
this.loader = loader;
}
private boolean hasNextService() {
if (nextName != null) {
return true;
}
if (configs == null) {
// PREFIX是META-INF/services目录
// service.getName()是接口的全类名
// fullName是META-INF/services下的文件名(正式因为这行代码,所以规定了文件的命名规范)
String fullName = PREFIX + service.getName();
if (loader == null)
configs = ClassLoader.getSystemResources(fullName);
else
configs = loader.getResources(fullName);
}
while ((pending == null) || !pending.hasNext()) {
if (!configs.hasMoreElements()) {
return false;
}
pending = parse(service, configs.nextElement());
}
nextName = pending.next();
return true;
}
private S nextService() {
if (!hasNextService())
throw new NoSuchElementException();
String cn = nextName;
nextName = null;
Class<?> c = null;
try {
c = Class.forName(cn, false, loader);
} catch (ClassNotFoundException x) {
fail(service,
"Provider " + cn + " not found");
}
if (!service.isAssignableFrom(c)) {
fail(service,
"Provider " + cn + " not a subtype");
}
try {
S p = service.cast(c.newInstance());
providers.put(cn, p);
return p;
} catch (Throwable x) {
fail(service,
"Provider " + cn + " could not be instantiated",
x);
}
throw new Error(); // This cannot happen
}
public boolean hasNext() {
if (acc == null) {
return hasNextService();
} else {
PrivilegedAction<Boolean> action = new PrivilegedAction<Boolean>() {
public Boolean run() { return hasNextService(); }
};
return AccessController.doPrivileged(action, acc);
}
}
public S next() {
if (acc == null) {
return nextService();
} else {
PrivilegedAction<S> action = new PrivilegedAction<S>() {
public S run() { return nextService(); }
};
return AccessController.doPrivileged(action, acc);
}
}
public void remove() {
throw new UnsupportedOperationException();
}
}
疑问
SPI 机制中剩下的最关键的问题:如果同时引入两个实现类,services 下面出现两个相同文件名的文件,经过测试,这两个文件不会进行整合,而是其中一个被丢弃。进而引出两个问题,Spring 中的自动配置类也是在不同项目下面出现相同的文件,这些文件应该会按文件内容整合到一起,为什么不按照 Spring 那种设计机制?第二个问题,这两个文件哪一个会被丢弃,规律是什么?即如果导入一个日志门面和两个日志实现框架,那么哪一个日志实现框架会被使用。