自定义类加载器(实现与场景)
为什么需要自定义类加载器
三层类加载器已经能覆盖大多数场景了。但有些情况下,你需要自定义类加载器:
- 热部署:不重启 JVM 的情况下加载新版本的类
- 模块化隔离:同一个 JVM 中运行多个版本相同的类
- 加密保护:对字节码进行加密,自定义加载器负责解密
- 动态生成:运行时生成字节码并加载(ASM、CGlib 的基础)
- 非标准来源:从数据库、网络、配置中心等非标准位置加载类
自定义类加载器的实现
自定义类加载器只需要做一件事:继承 ClassLoader 类,重写 findClass() 方法。
最小实现
java
public class MyClassLoader extends ClassLoader {
private String classPath; // 类文件所在的目录
public MyClassLoader(String classPath) {
this.classPath = classPath;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] classData = loadClassData(name);
if (classData == null) {
throw new ClassNotFoundException(name);
}
// defineClass:将字节数组转换为 Class 对象
return defineClass(name, classData, 0, classData.length);
}
private byte[] loadClassData(String className) {
// 将类的全限定名转为文件路径
String fileName = className.replace('.', '/') + ".class";
String fullPath = classPath + "/" + fileName;
try (InputStream is = new FileInputStream(fullPath);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
byte[] buffer = new byte[1024];
int len;
while ((len = is.read(buffer)) != -1) {
baos.write(buffer, 0, len);
}
return baos.toByteArray();
} catch (IOException e) {
return null;
}
}
}使用自定义类加载器
java
public class UseMyLoader {
public static void main(String[] args) throws Exception {
MyClassLoader loader = new MyClassLoader("/path/to/classes");
// 加载一个类
Class<?> clazz = loader.loadClass("com.example.MyClass");
// 创建实例
Object instance = clazz.getDeclaredConstructor().newInstance();
System.out.println("Class loaded by: " + clazz.getClassLoader());
}
}关键:findClass() vs loadClass()
ClassLoader 中有两个关键方法:
java
// 1. loadClass():处理双亲委派逻辑
// 不建议重写,破坏了双亲委派
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// 包含双亲委派逻辑的实现
// ...
}
// 2. findClass():真正查找类的地方
// 推荐重写,遵循双亲委派
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 在自定义位置查找类
// ...
}最佳实践:重写 findClass(),而不是 loadClass()。这样既能自定义类的查找逻辑,又能保留双亲委派的安全保障。
典型使用场景
场景一:热部署
这是热部署的核心原理:每个模块用独立的类加载器加载。当需要更新模块时,创建一个新的类加载器重新加载,就能实现不重启 JVM 的「热更新」。
java
public class HotDeployDemo {
private ClassLoader currentLoader;
public void loadNewVersion(String jarPath) throws Exception {
// 创建新的类加载器(不继承旧加载器的命名空间)
URL[] urls = { new URL("file:" + jarPath) };
URLClassLoader newLoader = new URLClassLoader(urls, currentLoader);
// 加载新版本的类
Class<?> newClass = newLoader.loadClass("com.example.Plugin");
// 替换旧实例
Object newInstance = newClass.getDeclaredConstructor().newInstance();
// 更新引用
replacePlugin(newInstance);
// 放弃旧加载器引用,触发旧类卸载
currentLoader = newLoader;
}
}Tomcat 的热部署就是基于这个原理:每个 Webapp 有自己的类加载器,更新时销毁旧加载器、创建新加载器。
场景二:类库版本隔离
大型系统可能依赖同一个库的多个版本。自定义类加载器可以解决这个问题:
┌─────────────────────────────────────────┐
│ AppClassLoader │
│ ├─ 项目自带的 commons-lang 3.0 │
└───────────────┬───────────────────────┘
│
┌───────────┴───────────┐
▼ ▼
┌───────────────┐ ┌───────────────┐
│ LoaderA │ │ LoaderB │
│ 加载 2.x 版本 │ │ 加载 3.x 版本 │
│ com.foo.Bar │ │ com.foo.Bar │
│ (2.0) │ │ (3.0) │
└───────────────┘ └───────────────┘同一个 JVM 中可以同时存在两个 com.foo.Bar 类——只要它们由不同的类加载器加载。
场景三:加密保护
对字节码进行加密,自定义类加载器负责解密:
java
public class DecryptClassLoader extends ClassLoader {
private static final byte DECRYPT_KEY = (byte) 0x55;
public DecryptClassLoader(ClassLoader parent) {
super(parent);
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
String fileName = name.replace('.', '/') + ".class.encrypted";
byte[] encrypted = loadFile(fileName);
if (encrypted == null) {
throw new ClassNotFoundException(name);
}
// 解密
byte[] decrypted = new byte[encrypted.length];
for (int i = 0; i < encrypted.length; i++) {
decrypted[i] = (byte) (encrypted[i] ^ DECRYPT_KEY);
}
return defineClass(name, decrypted, 0, decrypted.length);
}
private byte[] loadFile(String path) {
// 读取加密后的文件
// ...
return null;
}
}场景四:数据库 / 网络加载
java
public class NetworkClassLoader extends ClassLoader {
private String serverUrl;
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] classData = downloadClass(name);
return defineClass(name, classData, 0, classData.length);
}
private byte[] downloadClass(String name) {
// 从 HTTP 服务器下载字节码
String url = serverUrl + "/" + name.replace('.', '/') + ".class";
try (InputStream is = new URL(url).openStream()) {
return is.readAllBytes();
} catch (IOException e) {
return null;
}
}
}双亲委派与自定义类加载器的关系
自定义类加载器默认也遵循双亲委派。关键在于理解如何打破它。
打破双亲委派的场景
某些 SPI(Service Provider Interface)场景下,必须打破双亲委派。例如 JDBC:
java
// 这是 JDBC 4.0 之前的使用方式
Class.forName("com.mysql.jdbc.Driver"); // 手动加载驱动
// JDBC 4.0+ 使用 SPI 机制
// ServiceLoader 需要使用线程上下文类加载器
// 才能加载 classpath 中的 Driver 实现
ServiceLoader<Driver> loader = ServiceLoader.load(Driver.class);SPI 的实现方式决定了必须用线程上下文类加载器(而非应用类加载器)才能正确找到驱动类——因为驱动的实现类是在 java.sql.DriverManager(Bootstrap 加载)内部被加载的。
java.lang.Thread
线程上下文类加载器允许在父子类加载器之间「借道」:
java
public class ContextLoaderDemo {
public static void main(String[] args) {
// 获取当前线程的上下文类加载器
ClassLoader contextLoader = Thread.currentThread().getContextClassLoader();
// 设置当前线程的上下文类加载器
Thread.currentThread().setContextClassLoader(myCustomLoader);
// 在 SPI 框架中,调用链深处会用这个上下文类加载器加载类
}
}本节小结
自定义类加载器的核心要点:
- 继承
ClassLoader,重写findClass()—— 遵循双亲委派 defineClass()—— 将字节数组转换为 Class 对象- 常见场景:热部署、版本隔离、加密保护、动态加载、SPI
- 打破双亲委派:通过线程上下文类加载器(SPI 机制依赖)
下一节,我们来看 ClassLoader 常用方法,掌握 ClassLoader API 的具体用法。
