敬请指正

才疏学浅,若有不对之处烦请指正

前言

在大型 Java 项目的开发过程中,开发人员往往需要频繁地修改代码并进行测试,而每次的修改都伴随着项目的重启。可惜的是,对于体量庞大、依赖繁多的项目来说,启动一次可能就需要一两分钟甚至更久,这无疑大大拖慢了开发效率。虽然你可以趁这段时间偷偷摸个🐟——咳咳,当然不是鼓励摸鱼——但从整体来看,这种低效的迭代方式是非常不利于快速开发和调试的。

这个时候,热部署(Hot Deployment)技术就成了提升开发体验的“救星”。它可以在不完全重启应用的前提下,将代码的改动动态加载进正在运行的程序中,大大缩短了等待时间,让我们可以更加专注于业务逻辑的实现和问题的定位。

术语说明

在实际开发中,“热部署(Hot Deployment)”和“热加载(Hot Reloading)”这两个词经常被交替使用,乍一看差不多,其实在技术实现和粒度上是有区别的:

  • 热部署:通常指重新部署应用或模块,而不重启整个 JVM,比如 Web 容器(如 Tomcat)重新加载 .war 包。
  • 热加载:指在应用运行时,动态替换某个类或资源文件,代码改动即时生效,甚至不需要重启应用容器。

本文所讲的“热部署”是一个泛指的说法,重点讲的是“类级别”的热更新机制,也就是热加载。
主要通过自定义类加载器打破双亲委派机制,实现运行时动态加载修改后的 .class 文件,达到无需重启即可更新逻辑的效果。

参考链接

1. 热部署是什么

热部署(Hot Deployment)是指在不重启整个应用或 JVM 的前提下,动态加载修改过的类或资源的能力。

原理

通过新的类加载器打破双亲委派机制,再生成新的类对象实例,从而实现动态更新

是不是有点懵

你可能有这些疑问
  • 编译过后不是生成新的字节码了吗?为什么没有通过新的字节码重新生成类实例?
  • 为什么需要新的类加载器?不能用默认的类加载器吗?
  • 双亲委派机制是什么?为什么要打破它?

先讲点前置知识

2. 前置知识

类加载机制

图我偷的,懒得画了

简单来讲,类加载机制是JVM将类的字节码文件(.class)加载到内存并初始化的核心过程,主要分为以下阶段

  1. 编译(Compile)

    这是整个流程的起点(严格属于Java编译过程,非类加载阶段)。将.java源码编译为JVM可识别的.class字节码文件。也就是javac

  2. 加载(Loading)

    • 任务:通过类加载器(ClassLoader)查找.class文件(可来自本地、网络或动态生成)。
    • 核心动作:生成类的Class对象(存储在方法区),作为程序访问类元数据的入口。
    • 双亲委派机制:类加载器优先委派父类加载,避免重复加载和安全问题。
  3. 连接(Linking)

    • 验证(Verification):检查字节码是否符合JVM规范(如魔数、版本、语法)。
    • 准备(Preparation):为类变量(static变量)分配内存并赋默认值(如int→0,对象→null)。
    • 解析(Resolution):将符号引用(如类/方法名)转为直接引用(内存地址)。此阶段可能延迟到初始化后(动态绑定)。
  4. 初始化(Initialization)

    • 触发条件:首次主动使用类时(如new对象、访问静态变量)。
    • 核心动作:执行类构造器<clinit>(),包括静态变量赋值和静态代码块。
  5. 使用(Using)

    类完成初始化后,程序可通过Class对象创建实例、调用方法、访问字段等。

  6. 卸载(Unloading)

    • 条件:类的Class对象无引用,且对应的类加载器被回收。
    • 实现:由JVM垃圾回收机制完成,通常发生在长时间不使用的场景。

类加载器

没错,这也是我偷的

类加载器(ClassLoader)是JVM实现类加载机制的核心组件,负责将字节码(.class文件)动态加载到内存中,形成可执行的Class对象。其核心设计基于双亲委派模型,通过层级化的加载器结构保障Java程序的安全性和稳定性。

  1. BootstrapClassLoader(启动类加载器)
    • 职责:加载JRE核心类库(如rt.jarresources.jar),包含java.lang.*等基础类。
    • 实现:由C/C++代码实现,是JVM的一部分,无Java层面的ClassLoader实例。
    • 路径:对应JAVA_HOME/jre/lib目录。
  2. ExtClassLoader(扩展类加载器)
    • 职责:加载Java扩展库(如javax.*),默认路径为JAVA_HOME/jre/lib/extjava.ext.dirs指定目录。
    • 父加载器:BootstrapClassLoader
  3. AppClassLoader(应用程序类加载器)
    • 职责:加载用户类路径(ClassPath)下的类,即程序中的自定义类。
    • 父加载器:ExtClassLoader
    • 默认加载器:Java程序的main()方法默认使用此加载器。
  4. UserApp1/2ClassLoader(自定义类加载器)
    • 职责:开发者扩展类加载方式(如网络加载、加密文件、热部署)。
    • 父加载器:AppClassLoader
    • 实现:继承ClassLoader类,重写findClass()方法。
注意

同一个类加载器在同一个命名空间内加载某个类时,若该类名和包名完全相同,只会定义一次 Class 对象,后续请求将返回缓存的 Class 实例。

不同的ClassLoader加载相同的Class对象之间不可转换,本质上是不同对象

双亲委派机制

类加载器遵循向上委托的加载规则:

1
2
3
1. 收到类加载请求时,先不自行加载,而是委派给父加载器。  
2. 父加载器递归向上委派,直到BootstrapClassLoader。
3. 若父加载器无法加载(搜索范围不包含该类),子加载器才尝试加载。
  • 示例:用户自定义类User.class的加载流程:
    UserApp1ClassLoaderAppClassLoaderExtClassLoaderBootstrapClassLoader → 回退到AppClassLoader加载ClassPath中的类。
  • 核心作用:
    ✅ ​​避免重复加载​​:父加载器已加载的类,子加载器不会重复加载。
    ✅ ​​防止核心类篡改​​:用户无法自定义java.lang.String等核心类(由Bootstrap加载)。
    ✅ ​沙箱安全​:不同层级的类隔离,保障系统稳定性。

源码

以ClassLoader.java为例

  1. 用户请求加载类
  2. 当前类加载器检查是否已加载 → 已加载则直接返回
  3. 未加载 → 委派父加载器递归向上(直到Bootstrap)
  4. 所有父加载器均未找到 → 当前类加载器调用findClass自行加载
  5. 返回Class对象,可选执行解析
findLoadedClass方法

这个方法先通过checkName校验名称的有效性,再调用本地方法findLoadedClass0

findLoadedClass0的作用:查询当前类加载器是否已加载过指定名称的类

JVM 为每个类加载器维护一个独立命名空间(Namespace),记录该加载器已加载的类。findLoadedClass0 的查询范围仅限于当前类加载器的命名空间

findBootstrapClassOrNull方法

作用:在双亲委派模型下,尝试从 JVM 的启动类加载器(Bootstrap ClassLoader)中查找并加载指定的类。

它是类加载器在委派链条顶端的“最后一环”,专门用于访问由 JVM 核心加载的核心类库(如 java.lang 包中的类)。

findClass方法

作用:类加载器(ClassLoader)的 核心扩展点,用于定义自定义类加载逻辑。当类加载器的 loadClass 方法在双亲委派模型下 未能通过父类加载器加载类 时,会调用 findClass 尝试自行加载。ClassLoaderfindClass 默认抛出 ClassNotFoundException

findClass重写规则
  1. 定位类字节码:根据类名(如 com.example.Foo)找到字节码文件(如 .class 文件、网络资源、数据库等)。
  2. 读取字节码:将字节码转换为 byte[]
  3. 定义类:调用 defineClass 方法将字节码转换为 Class 对象。
resolve参数

控制类加载后是否立即解析符号引用

  • resolve=true:提前解析,确保类立即可用,适合需要立即操作类成员的场景。
  • resolve=false:延迟解析,优化性能,适合批量加载或按需加载的场景。

3. 理论准备

知道了这些,就可以解答上面提到的问题了

问题解答

编译过后不是生成新的字节码了吗?为什么没有通过新的字节码重新生成类实例?

如果类已经被类加载器加载过,在findLoadedClass方法的时候就能找到该类的信息,所以不会再次被加载

为什么需要新的类加载器?不能用默认的类加载器吗?

如果想重新生成类实例的话,同一个类加载器的命名空间中已经记录了该类,就不会再次加载。所以需要新的类加载器重新加载

发散一下
我不是杠精

假设重写了 loadClass 方法,修改了 findLoadedClass 逻辑(例如新增字节码变动检测),能否绕过限制?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 自定义逻辑:检查类是否已加载,并判断字节码是否变动
Class<?> clazz = findLoadedClass(name);
if (clazz != null) {
if (isBytecodeModified(name)) { // 自定义的字节码变动检查
// 尝试卸载旧类(实际不可行,JVM 不允许卸载已加载类)
// 重新加载新类
clazz = reloadClass(name);
}
return clazz;
}
// 委派父加载器或自行加载
return super.loadClass(name, resolve);
}
}

答案是不行的,为啥嘞?

  1. 无法卸载已加载的类:

    JVM 的类卸载条件非常苛刻:

    • Class 对象、其 ClassLoader 及所有相关引用必须都不可达;但由于类的静态字段或线程栈帧可能持有引用,实际中很难满足
    • 对应的类加载器实例已被回收。
      即使通过反射清空缓存,也无法强制卸载类(JVM 底层未开放此能力)。
  2. 重复加载同名类的冲突:

    若尝试通过原类加载器多次加载同一类名(即使字节码已修改),会抛出 LinkageError,因为 JVM 禁止同一加载器定义同名类。

为什么要打破双亲委派机制?

双亲委派模型的“向上委托”逻辑导致同一个类加载器只能加载一次某个类,即使类文件已被修改,也无法重新加载新版本。为了实现热部署(不重启应用更新代码),必须绕过这一限制,使新的类加载器能独立加载修改后的类。

如何打破双亲委派机制呢

注意

打破双亲委派可能导致类冲突(例如多个版本的同名类共存),需谨慎控制加载范围。

简单,重写 loadClass 方法,加载的时候,如果是你需要热部署的类,就用自定义的类加载器加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 自定义类加载器实现热部署
public class HotDeployClassLoader extends ClassLoader {
@Override
protected Class<?> loadClass(String name, boolean resolve) {
// 1. 检查是否已加载
Class<?> clazz = findLoadedClass(name);
if (clazz != null) {
return clazz;
}
// 2. 对特定包下的类,优先自己加载(打破委派)
if (name.startsWith("com.example.hotdeploy")) {
clazz = findClass(name);
if (resolve) {
resolveClass(clazz);
}
return clazz;
}
// 3. 其他类仍走双亲委派
return super.loadClass(name, resolve);
}
}
另一条歪路

既然loadClass一开始就会检测类是否被加载了,那在这之前加载类不就行了吗

在类加载器构造时就加载所需类(:该类不能先被其他加载器加载)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class HotDeployClassLoader extends ClassLoader {
private final String targetClassName;

public HotDeployClassLoader(ClassLoader parent, String targetClassName) {
this.targetClassName = targetClassName;
try {
// 直接调用 findClass,绕过双亲委派检查
Class<?> clazz = findClass(targetClassName);
resolveClass(clazz);
} catch (ClassNotFoundException e) {
throw new RuntimeException("Failed to load class", e);
}
}

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 从自定义路径加载类字节码
byte[] classBytes = loadClassBytes(name);
return defineClass(name, classBytes, 0, classBytes.length);
}
}
不对啊,既然用新的类加载器生成的类对象和旧的不是同一个对象,那旧的去哪了?

这就要聊到垃圾回收机制(Garbage Collection)了,主要和可达性相关

  • 若没有其他引用指向旧类加载器及其加载的类和实例,它们会被回收。
  • 若旧类实例仍被引用,如某个 static List<Object> cache持有引用,则无法回收。

诶,这时候就发现另一个问题了,内存泄漏

热部署导致的内存泄漏如何解决
  1. 及时清理引用:

    • 替换类加载器时,确保旧类实例、静态字段、线程引用等被清除。
  2. 使用弱引用(WeakReference):

    1
    2
    // 使用弱引用缓存,允许GC回收旧类
    WeakReference<Class<?>> weakClassRef = new WeakReference<>(oldClass);
  3. 避免跨加载器引用:

    • 不要将旧类加载器加载的对象传递给新类加载器管理的代码(如通过接口隔离)。

这里就不再扩展介绍了,感兴趣的同学可以去研究一下哦

4. 实操

基于以上理论,咱们来上手验证一下

代码

项目结构

HotDeployObject.java

1
2
3
4
5
6
7
8
9
10
package org.example.hotdeploy;

public class HotDeployObject {
public static String version = "2.0";

public void init() {
System.out.println("当前版本:" + version + " 类哈希码:" + System.identityHashCode(this.getClass()));
System.out.println("类加载器:" + this.getClass().getClassLoader());
}
}

HotDeployClassLoader.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
package org.example.hotdeploy;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

public class HotDeployClassLoader extends ClassLoader {
private final Path baseDir;

public HotDeployClassLoader(String baseDir) {
this.baseDir = Paths.get(baseDir);
}

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 1. 将类名转换为文件路径(例如:com.example.hotdeploy.Test -> com/example/hotdeploy/Test.class)
String relativePath = name.replace('.', '/') + ".class";
Path classPath = baseDir.resolve(relativePath);

try {
// 2. 读取类文件字节码
byte[] classBytes = Files.readAllBytes(classPath);

// 3. 调用 defineClass 定义类
return defineClass(name, classBytes, 0, classBytes.length);
} catch (IOException e) {
throw new ClassNotFoundException("Failed to load class: " + name, e);
}
}

@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// 1. 检查是否已加载
Class<?> clazz = findLoadedClass(name);
if (clazz != null) {
return clazz;
}
// 2. 对特定包下的类,优先自己加载(打破委派)
if (name.startsWith("org.example.hotdeploy")) {
clazz = findClass(name);
if (resolve) {
resolveClass(clazz);
}
return clazz;
}
// 3. 其他类仍走双亲委派
return super.loadClass(name, resolve);
}

public static void main(String[] args) throws Exception {
String targetClassesPath = new File(HotDeployClassLoader.class.getResource("/").toURI()).getAbsolutePath();

while (true) {
// 使用默认加载器加载
new HotDeployObject().init();

// 每次创建新的类加载器实例,加载 target/classes 下的类
ClassLoader loader = new HotDeployClassLoader(targetClassesPath);

Class<?> clazz = loader.loadClass("org.example.hotdeploy.HotDeployObject");
Object instance = clazz.newInstance();
clazz.getMethod("init").invoke(instance);

System.out.println("\n-------------我是分割线--------------\n");

Thread.sleep(5000); // 等待重新编译后再次加载
}
}
}

运行结果

结果解析

  1. 第一、二遍循环中

    • 默认类加载器一组中,哈希码不变,地址不变。

      因为用的类加载器没变,而且第二次的类实例是从缓存里获取

    • 自定义类加载器一组,哈希码改变,地址改变。

      说明类加载器和类实例都发生改变

  2. 第二、三遍循环中(修改了版本号并重新编译

    • 默认类加载器一组中,哈希码不变,地址不变,类字节码改变了但版本并没有改变

      说明已加载过的类不会再次加载

    • 自定义类加载器一组,哈希码改变,地址改变。

      因为类重新加载了,所以会把变更都加载

扩展一下

上面的代码中,只有通过类加载器反射得到的类才是由自定义类加载器加载的,不可能热部署都得用反射的方式才能加载吧,有没有不用反射的打法呢?

有的,孩子,有的

重点来啦

一个类通过其自身代码中 new 关键字引用其他类时,这些类的加载由它的类加载器负责寻找和加载(不一定加载成功,还可能触发双亲委派流程,除非你打破它)

也就是说,可以通过外面再套一层的形式,实现自定义全局的类加载器

Application.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package org.example.hotdeploy;

import java.io.File;

public class Application {
public static void run(Class clazz) throws Exception {
String targetClassesPath = new File(clazz.getResource("/").toURI()).getAbsolutePath();

start0(new HotDeployClassLoader(targetClassesPath));
}


public static void start() {
init();
new HotDeployObject().init();
}

private static void init() {
System.out.println("初始化项目.............");
}

public static void start0(ClassLoader loader) throws Exception {
Class<?> aClass = loader.loadClass("org.example.hotdeploy.Application");
Object instance = aClass.newInstance();
aClass.getMethod("start").invoke(instance);
}

public static void main(String[] args) throws Exception {
Application.run(Application.class);
}
}

这时候new出来的对象的类加载器就是自定义的了

再加亿点点细节,单开一个线程监听target目录的变动,实现真正的热部署

Application.java(完整版)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package org.example.hotdeploy;

import java.io.File;

public class Application {
public static void run(Class clazz) throws Exception {
String targetClassesPath = new File(clazz.getResource("/").toURI()).getAbsolutePath();
new ClassFileWatcher(targetClassesPath, () -> {
try {
Application.start0(new HotDeployClassLoader(targetClassesPath));
} catch (Exception e) {
e.printStackTrace();
}
}).startWatching();

start0(new HotDeployClassLoader(targetClassesPath));

// ✅ 无限等待当前主线程,防止程序退出
Thread.currentThread().join();
}


public static void start() {
init();
new HotDeployObject().init();
}

private static void init() {
System.out.println("初始化项目.............");
}

public static void start0(ClassLoader loader) throws Exception {
Class<?> aClass = loader.loadClass("org.example.hotdeploy.Application");
Object instance = aClass.newInstance();
aClass.getMethod("start").invoke(instance);
}

public static void main(String[] args) throws Exception {
Application.run(Application.class);
}
}

ClassFileWatcher.java(GPT友情提供)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
package org.example.hotdeploy;

import java.io.IOException;
import java.nio.file.*;
import static java.nio.file.StandardWatchEventKinds.*;

public class ClassFileWatcher {

private final Path watchPath;
private final Runnable onChangeCallback;

private static final long DEBOUNCE_MS = 1000; // 防抖时间(毫秒)
private static long lastReloadTime = 0;

public ClassFileWatcher(String pathToWatch, Runnable onChangeCallback) {
this.watchPath = Paths.get(pathToWatch);
this.onChangeCallback = onChangeCallback;
}

public void startWatching() {
Thread watchThread = new Thread(() -> {
try (WatchService watchService = FileSystems.getDefault().newWatchService()) {

registerAllDirs(watchPath, watchService);

// System.out.println("监听线程启动中,监听路径: " + watchPath.toAbsolutePath());

while (true) {
WatchKey key = watchService.take(); // 阻塞等待事件

for (WatchEvent<?> event : key.pollEvents()) {
WatchEvent.Kind<?> kind = event.kind();
Path changed = (Path) event.context();

if (changed.toString().endsWith(".class")) {
long now = System.currentTimeMillis();
if (now - lastReloadTime < DEBOUNCE_MS) {
continue;
}
lastReloadTime = now;

Path dir = (Path) key.watchable();
Path fullPath = dir.resolve(changed);

// System.out.println("检测到 class 文件变化: " + fullPath);

// 延迟确保文件写入完成
Thread.sleep(500);

if (Files.exists(fullPath)) {
onChangeCallback.run();
} else {
// System.out.println("文件尚未生成,跳过本次热部署: " + fullPath);
}
}
}

boolean valid = key.reset();
if (!valid) {
// System.out.println("监听键无效,退出监听");
break;
}
}

} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
});

watchThread.setDaemon(true);
watchThread.start();
}

private void registerAllDirs(Path start, WatchService watchService) throws IOException {
Files.walk(start)
.filter(Files::isDirectory)
.forEach(path -> {
try {
path.register(watchService, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE);
// System.out.println("注册监听目录: " + path);
} catch (IOException e) {
System.err.println("无法注册目录: " + path);
e.printStackTrace();
}
});
}
}

运行结果

5. 实战案例

Spring为例

一、Spring Boot DevTools(官方推荐)

Spring Boot 自带的热部署支持工具。

工作原理:

  • 监控 classpath 下的文件改动(如 .class 文件);
  • 如果发现改动,重启 Spring 容器(不是 JVM!);
  • 使用 两个类加载器
    • Base 类加载器:加载不变的类(第三方依赖等);
    • Restart 类加载器:加载会变化的应用代码;
  • 重启只会重新加载 Restart 部分,速度很快

特点:

  • 自动集成,无需配置;
  • 修改代码后自动 reload;
  • 适合开发阶段使用。

二、JRebel(商业工具)

非常强大的 Java 热部署工具。

工作原理:

  • 修改 Java 源码后,JRebel 在运行时动态修改字节码

  • 不需要重启 JVM,不需要重启 Spring 容器;

  • 甚至可以热更新:

    • 添加方法/字段
      • 修改方法体
      • 修改注解等

特点:

  • 真正的热部署(无重启);

  • 支持 Spring、Hibernate 等主流框架;

  • 收费,但开发效率极高。

三、Spring Loaded(已废弃)

Spring 早期提供的一个 Java agent 工具。

原理:

  • 类似于 JRebel,基于 Java Agent 插桩修改类;
  • 支持 Spring 框架;
  • 已不再维护,不推荐使用。

四、IDE 的热部署支持(如 IntelliJ IDEA)

IntelliJ 提供 Build → Compile on Save + Hotswap 的功能。

特点:

  • 修改代码 → 编译 → IDEA 自动替换 .class 文件;
  • 基于 JVM 的 HotSwap 机制;
  • 仅限 方法体内部的修改(不能加字段/方法);
  • 常与 DevTools 配合使用。

总结对比表

方案 是否重启容器 是否重启 JVM 修改范围 是否推荐
Spring Boot DevTools ✅ 是 ❌ 否 类级别(不支持结构变化) ✅ 推荐开发使用
JRebel ❌ 否 ❌ 否 类、方法、注解等 ✅ 强烈推荐(需付费)
Spring Loaded ❌ 否 ❌ 否 类级别 ❌ 已废弃
IDEA HotSwap ❌ 否 ❌ 否 方法体内部 ✅ 辅助使用

感兴趣的同学可以去研究一下哦

PS:热加载

热部署(Hot Deployment)和热加载(Hot Reloading)本质上是同一个目标的不同实现粒度,目的都是为了在不重启 JVM 的情况下让代码变更生效,只是它们操作的范围不同

最大区别:粒度不同

对比点 热部署(Hot Deployment) 热加载(Hot Reloading)
操作粒度 模块级 / 应用级别 类级 / 方法级别
是否替换类 ✅ 是,但通常是整个模块/包级别 ✅ 是,替换特定类、方法体或资源
使用场景 Web 应用、插件系统、微服务重新部署 Spring Bean 更新、页面热刷新、开发阶段调试
需要重启容器 有时是(比如 DevTools 会快速重启 Spring 容器) 通常不需要
框架示例 Tomcat 热部署 Web 应用、Spring Boot DevTools JRebel、DCEVM、Agent 插件、Spring Boot DevTools
类加载器 创建新的类加载器替换整个模块 创建类加载器或使用 Instrumentation 替换指定类