Lesson 33 · JVM 原理与调优
自定义类加载器:热部署与模块化隔离
为什么需要自定义类加载器?
面试官靠在椅背上,抛出一个看似简单的问题:
如果你只回答"可以加载自定义路径的类",面试官会在心里给你打个六十分——及格,但不出彩。
真正让他眼前一亮的回答至少涵盖三个场景:
- 热部署(Hot Deployment)——不重启 JVM,运行期替换 class 文件。Tomcat、Spring DevTools 背后都是自定义类加载器。
- 插件系统 / 模块化隔离——同一 JVM 中,不同插件可以使用相同类名但不同版本的类。OSGi、Tomcat 多 Web 应用隔离靠的就是打破双亲委派。
- 加密 / 混淆加载——商业软件将 class 文件加密分发,运行时由自定义类加载器解密后 defineClass。
发车之前,先问自己:
- loadClass 和 findClass 有什么区别?重写哪个才正确?
- 同一个类名被两个类加载器各加载一次,它们是同一个 Class 对象吗?
- 热部署时旧的 Class 为什么能被 GC 回收?
带着这些问题,我们开始。
findClass vs loadClass:该重写谁?
ClassLoader.loadClass() 是类加载的入口。它的默认实现就是双亲委派逻辑:
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
// 1. 检查是否已加载
Class<?> c = findLoadedClass(name);
if (c == null) {
// 2. 委托给父加载器
if (parent != null)
c = parent.loadClass(name, false);
else
c = findBootstrapClassOrNull(name);
}
if (c == null) {
// 3. 父加载器都找不到 → 自己找
c = findClass(name);
}
if (resolve) resolveClass(c);
return c;
}
两条路线,一张图讲清楚:
面试时的回答模板:
动手写:自定义类加载器完整实现
下面这个类加载器可以从指定目录加载 .class 文件,还预留了加密解密的扩展点。
第 1 步:继承 ClassLoader,重写 findClass
public class MyClassLoader extends ClassLoader {
private final String classPath; // class 文件根目录
public MyClassLoader(String classPath) {
this.classPath = classPath;
}
protected Class<?> findClass(String name)
throws ClassNotFoundException {
// 1. 将类名转为文件路径:com.example.Foo → com/example/Foo.class
String fileName = name.replace('.', '/') + ".class";
File file = new File(classPath, fileName);
if (!file.exists())
throw new ClassNotFoundException(name);
try {
// 2. 读取字节数组
byte[] bytes = readBytes(file);
// 3. 可选:解密(商业软件常见)
// bytes = decrypt(bytes);
// 4. 调用 defineClass 将字节转为 Class 对象
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
}
private byte[] readBytes(File file) throws IOException {
return Files.readAllBytes(file.toPath());
}
}
第 2 步:使用它
public class Main {
public static void main(String[] args) throws Exception {
MyClassLoader loader = new MyClassLoader("/opt/myapp/classes");
Class<?> clazz = loader.loadClass("com.example.HelloService");
Object obj = clazz.getDeclaredConstructor().newInstance();
Method say = clazz.getMethod("say", String.class);
say.invoke(obj, "World");
}
}
核心 API 就三个:
| 方法 | 作用 | 谁调用 |
|---|---|---|
findClass(name) | 从自定义来源读取字节并调用 defineClass | 由 loadClass 内部调用(你重写它) |
defineClass(name, bytes, off, len) | 将字节数组转为 Class 对象(final 方法,不可重写) | 在 findClass 中调用 |
loadClass(name) | 类加载入口,实现双亲委派逻辑 | 外部调用(一般不重写) |
热部署:不重启 JVM 替换类
面试官追问:
关键在于一条 JVM 规则:同一个 ClassLoader 实例对同一个类名只能加载一次。想加载新版本,必须创建新的 ClassLoader 实例。
完整的热部署模式如下:
public class HotDeployer {
private volatile MyClassLoader currentLoader; // volatile 保证工作线程读到最新加载器
public HotDeployer(String classPath) {
this.currentLoader = new MyClassLoader(classPath);
}
// 部署新版本:创建新 ClassLoader,替换引用
public void deploy() {
MyClassLoader old = this.currentLoader;
this.currentLoader = new MyClassLoader(old.classPath);
// old 不再被引用 → GC 可回收旧类和旧加载器
}
// 执行业务方法
public void execute(String className, String input) throws Exception {
Class<?> clazz = currentLoader.loadClass(className);
Object svc = clazz.getDeclaredConstructor().newInstance();
clazz.getMethod("handle", String.class).invoke(svc, input);
}
}
每个 Class 对象都持有对它 ClassLoader 的引用。当旧的 ClassLoader 不再可达时:
- 旧 ClassLoader 的所有 Class 对象失去强引用链
- 旧类的所有实例如果被替换/销毁
- Metaspace 中的类元数据(方法区数据)可以被卸载
这就是 Tomcat 热部署的核心原理——每个 Web 应用有独立的 WebappClassLoader,重新部署时销毁旧加载器即可。
java.lang.OutOfMemoryError: Metaspace。类加载隔离:同一个类名,不同的 Class
JVM 判断"两个类是否相同"不仅看类名,还要看加载它们的 ClassLoader。换句话说:
这意味着两个不同的 ClassLoader 可以各自加载同一个类名,得到两个互不兼容的 Class 对象:
MyClassLoader loader1 = new MyClassLoader("/app/plugin-a/classes");
MyClassLoader loader2 = new MyClassLoader("/app/plugin-b/classes");
Class<?> classA = loader1.loadClass("com.example.Service");
Class<?> classB = loader2.loadClass("com.example.Service");
System.out.println(classA == classB); // false!
System.out.println(classA.getName()); // com.example.Service
System.out.println(classB.getName()); // com.example.Service(同名,不同类)
// 下面这行会抛 ClassCastException!
Object objA = classA.getDeclaredConstructor().newInstance();
classB.cast(objA); // ClassCastException: com.example.Service cannot be cast to com.example.Service
这个特性正是模块系统的基础。来看两个真实框架如何用它:
OSGi 的模块化隔离走得更远——它不只是隔离,还定义了 Bundle 之间如何"导出 / 导入"包:
# Bundle A
Bundle-SymbolicName: com.example.service-a
Export-Package: com.example.api;version="1.0"
Import-Package: org.slf4j;version="[1.7,2.0)"
# Bundle B
Bundle-SymbolicName: com.example.service-b
Export-Package: com.example.api;version="2.0"
Import-Package: org.slf4j;version="[2.0,3.0)"
# 两个 Bundle 各自有独立 ClassLoader,com.example.api 是两个不同的 Class
# slf4j 1.7 和 2.0 也可以在不同 Bundle 中共存
OSGi 通过重写 loadClass 实现了一套精细的委派规则:
- java.* 包 → 委派给父加载器(与标准一致)
- Import-Package 中的包 → 委派给导出该包的 Bundle 的 ClassLoader
- Bundle 内部的包 → 自己加载(不委派)
总结:什么时候用,什么时候不用
先用一张表把自定义类加载器的典型场景收拢:
| 场景 | 重写哪个方法 | 目的 | 代表框架 |
|---|---|---|---|
| 加载非标准路径的 class | findClass | 从数据库 / 网络 / 加密文件加载 | 自定义部署工具 |
| 热部署 | findClass + 每次 new ClassLoader | 加载新版本 class,旧版本交给 GC | Tomcat, Spring DevTools |
| Web 应用隔离 | loadClass(打破委派) | 不同应用可以使用同名不同版本的类 | Tomcat WebappClassLoader |
| 模块化 / 插件系统 | loadClass + 自定义委派规则 | Bundle 间按需共享或隔离 | OSGi, JPMS |
| 加密 class 加载 | findClass | 读取加密字节 → 解密 → defineClass | 商业软件保护 |
常见陷阱:
// 陷阱 1:忘记设置父加载器 → 系统类都加载不到
MyClassLoader loader = new MyClassLoader("/path");
// 如果 loadClass 里没委派给 parent,连 java.lang.String 都找不到
// 陷阱 2:热部署后旧类无法 GC → Metaspace OOM
// 原因:旧类的实例被 static 变量 / ThreadLocal / 全局缓存持有
// 排查:-verbose:class 或 -XX:+TraceClassUnloading 观察类卸载日志
// 陷阱 3:不同 ClassLoader 加载的类无法直接 cast
Object obj = loaderA.loadClass("com.example.Foo").newInstance();
Foo foo = (Foo) obj; // ClassCastException!因为 Foo 是当前类加载器加载的
// 解决:通过公共接口 + 父加载器加载接口类来实现跨加载器通信
跨加载器通信的正确姿势——让接口由父加载器加载,实现由子加载器加载:
// 接口放在公共 classpath,由父加载器加载
public interface Plugin { String execute(String input); }
// 实现类放在插件目录,由子加载器加载
public class MyPlugin implements Plugin {
public String execute(String input) { return "Hello " + input; }
}
// 主程序通过接口类型操作插件实例
Class<?> clazz = pluginLoader.loadClass("com.example.MyPlugin");
Plugin plugin = (Plugin) clazz.getDeclaredConstructor().newInstance();
// 转型成功!Plugin 接口是父加载器加载的,双方看到的是同一个 Class
最后,面试回答模板:
"自定义类加载器主要用于三个场景:热部署、模块隔离、加密加载。
重写 findClass 保留双亲委派,适合大多数场景——从自定义路径读字节、
调用 defineClass 即可。
重写 loadClass 打破双亲委派,用于需要类隔离的场景——Tomcat 的
WebappClassLoader 和 OSGi 的 BundleClassLoader 都是这么做的。
热部署的原理是:每次部署创建新的 ClassLoader 实例加载新版本 class,
旧 ClassLoader 不再被引用后,旧类和实例可被 GC 从 Metaspace 卸载。
类隔离的本质:JVM 用 (类名, ClassLoader) 二元组标识一个类,
不同加载器的同名类是不同的 Class 对象,无法互相转型。"
- 第 1 站——为什么需要自定义类加载器:热部署、插件系统、加密加载
- 第 2 站——findClass vs loadClass:保留委派 vs 打破委派
- 第 3 站——完整实现:从目录加载 class 文件,findClass + defineClass
- 第 4 站——热部署:新 ClassLoader 加载新版本,旧 ClassLoader 交给 GC
- 第 5 站——类加载隔离:(类名, ClassLoader) 二元组标识类,Tomcat 与 OSGi 实战
- 第 6 站——总结:场景表、常见陷阱、跨加载器通信的正确姿势