在测试环境下,由于需要经常修改代码,反复重启,非常浪费时间,所以我们可以借助 Spring Boot 提供的热加载插件完成热部署
流程图
Spring Boot 热部署
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-devtools -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<version>2.3.1.RELEASE</version>
</dependency>
加上这个依赖以后,当我们完成代码的修改以后,可以点击重新编译,完成代码的更新
那么热部署的原理是什么呢?
其实热部署:就是修改后的 java 文件,从新编译成 class 文件,然后再通过 ClassLoader 加载到 JVM 虚拟机中去,替换 JVM 虚拟中原来的 class 对象
类加载器
JVM 默认提供了三种类加载器
- BootstrapClassLoad:启动类加载器,存在 JVM 中,加载核心 class 文件
- ExtensionClassLoad:扩展类加载器,主要加载 lib\ext 包下面的 class 文件
- ApplicationClassLoad:应用程序类加载器,加载应用程序上下文中的 class 文件
双亲委派(父类委托)机制
双亲委派的英文是:Java parent delegation,其实是早期翻译错误,正确的理解是父类委托机制,当一个类加载器去加载某一个类的时候,默认情况下不会由自己去加载,而是由其父类去加载
某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载**器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载**。
使用双亲委派模型的好处在于Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.String,它存在在 rt.jar 中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的Bootstrap ClassLoader 进行加载,因此 String 类在程序的各种类加载器环境中都是同一个类。
相反,如果没有双亲委派模型而是由各个类加载器自行加载的话,如果用户编写了一个 java.lang.String 的同名类并放在 ClassPath 中,那系统中将会出现多个不同的 String 类,程序将混乱。因此,如果开发者尝试编写一个与rt.jar 类库中重名的 Java 类,可以正常编译,但是永远无法被加载运行(因为都会向上委派,最终到 BootStrap ClassLoad,它可以加载到了 rt.jar 下的 String,所以你写的 String.class 并没有作用)
这里的 String.class 是指和 原 String.class 具有相同的包名和类名; 同一个 class 如果被不同的 ClassLoad 加载,那么类型也不相同;
ClassLoader 核心代码
先从缓存中读取 Class<?> c = findLoadedClass(name)
这个 class 文件,如果读取到,则直接返回这个 class 对象,如果没有,则调用父类的 ClassLoader 进行递归调用,最终到 Bootstrap ClassLoader 进行加载,如果 BootStrap ClassLoader 没有加载到
在这个过程中,每个类加载器都会尝试先从缓存中加载,加载到了就会直接返回,如果一直到 BootStrap ClassLoader 都没有加载到,则会抛出 ClassNotFoundException 异常信息
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
}
if (c == null) {
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;
}
}
看了上面所说的类加载机制,就会产生一个问题
既然是 ClassLoader 是父类委托机制,那么如果我们更改了一个 java 文件,编译成 class 文件之后,通过类加载机制,依然会从缓存中读取,我们的修改后的 class 文件也是无效啊,那么应该如何做呢?
所以,我们需要打破父类委托机制,我们可以通过重写从缓存中寻找 class 对象的方法,让我们自定义的 ClassLoader,从缓存中可以获取到 class 对象,这样就可以不走父类委托机制了
**
自定义类加载器
public class MyClassLoader extends ClassLoader {
// 项目根路径
private String rootPath;
// 存储所有需要由这个类加载器加载的类
private List<String> classes;
// classPaths 需要被热部署的类路径
public MyClassLoader(String rootPath, String... hotClassPaths) throws Exception {
this.rootPath = rootPath;
this.classes = new ArrayList<>();
for (String hotClassPath : hotClassPaths) {
scanClassPath(new File(hotClassPath));
}
}
/**
* 扫描目录下的 class
*
* @param file 类文件
*/
public void scanClassPath(File file) throws Exception {
if (file.isDirectory()) {
File[] files = file.listFiles();
if (files != null) {
for (File subFile : files) {
scanClassPath(subFile);
}
}
} else {
String fileName = file.getName();
String filePath = file.getPath();
String suffix = fileName.substring(fileName.lastIndexOf("."));
if (".class".equals(suffix)) {
InputStream in = new FileInputStream(file);
byte[] bytes = new byte[(int) file.length()];
in.read(bytes);
String className = fileNameToClassName(filePath);
// 加载 class 文件,并生成 class 对象,加入到 JVM 虚拟机中去
defineClass(className, bytes, 0, bytes.length);
classes.add(className);
}
}
}
/**
* 文件名转类名
*
* @param filePath 文件路径
*/
private String fileNameToClassName(String filePath) {
if (rootPath.startsWith("/")) {
rootPath = rootPath.substring(1);
}
String classPath = filePath.replace(rootPath, "").replaceAll("\\\\", ".");
return classPath.substring(1, classPath.lastIndexOf("."));
}
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
// 因为将该类已经加载到 JVM 中了,所以这里可以查询到
Class<?> loadedClass = findLoadedClass(name);
if (loadedClass == null) {
if (!classes.contains(name)) {
return getSystemClassLoader().loadClass(name);
}
throw new ClassNotFoundException("没有加载到类文件");
}
return loadedClass;
}
public static void main(String[] args) throws Exception {
String rootPath = MyClassLoader.class.getResource("/").getPath();
rootPath = new File(rootPath).getPath();
while (true) {
MyClassLoader myClassLoader = new MyClassLoader(rootPath, rootPath + "/com");
Class<?> aClass = myClassLoader.loadClass("com.example.spring.boot.classloader.TestClass");
aClass.getMethod("test").invoke(aClass.newInstance());
TimeUnit.SECONDS.sleep(2);
}
}
}
public class TestClass {
public void test() {
ClassLoader classLoader = this.getClass().getClassLoader();
System.out.println(classLoader + " => TestClass Version-1");
}
}
这里我们可以看到,当我们修改 Test 类中的方法后,点击编译 Class,此时,就会打印出修改后的内容
new 对象带来的问题
那么我们如果 new TestClass().test() 也会进行相应的改变么?
public static void main(String[] args) throws Exception {
String rootPath = MyClassLoader.class.getResource("/").getPath();
rootPath = new File(rootPath).getPath();
while (true) {
MyClassLoader myClassLoader = new MyClassLoader(rootPath, rootPath + "/com");
Class<?> aClass = myClassLoader.loadClass("com.example.spring.boot.classloader.TestClass");
aClass.getMethod("test").invoke(aClass.newInstance());
new TestClass().test();
TimeUnit.SECONDS.sleep(2);
}
}
我们可以看出来,我们 new Test() 出来的 classLoader 是 AppClassLoader,内容依然是 Version-1,并没有随着改变,这也证明了,不同的类加载器加载的对象,是不同的
那么,如何才能做到输入的效果统一呢?
全盘委托机制
即是当一个 classloader 加载一个 Class 的时候,这个 Class 所依赖的和引用的其它 Class 通常也由这个classloader 负责载入
当有 new 关键字需要类加载器加载的时候,JVM 会判断当前调用 new 关键字的类加载器是什么?然后用调用 new 关键字的类加载器来加载
所以我们可以通过全盘委托机制,来修改上面的代码
待解决的几个问题
- 解决:new 对象的时候,也默认使用我们的类加载器来加载,而不是使用 AppClassLoader,这里可以使用上述的全盘委托机制来解决
- 能做到监听文件改动的目的,而不是使用死循环来实现
代码实现
Application
我们自己写一个 Application 类,然后让在 Application 中的 run 方法,创建我们的 MyClassLoader,来加载我们需要的目录下的 class 文件
public class Application {
public static void run(Class<?> clazz, String... packageName) throws Exception {
String rootPath = MyClassLoader.class.getResource("/").getPath();
rootPath = new File(rootPath).getPath();
MyClassLoader myClassLoader = new MyClassLoader(rootPath, packageName);
startFileChangeListener(rootPath, packageName);
// 用我们自己类加载器加载程序入口
start0(myClassLoader);
}
public static void start() {
System.out.println("启动应用程序");
new TestClass().test();
}
public static void stop() {
System.gc();
System.runFinalization();
System.out.println("启动应用退出");
}
public static void start0(MyClassLoader myClassLoader) throws Exception {
Class<?> aClass = myClassLoader.loadClass("com.example.spring.boot.classloader.Application");
aClass.getMethod("start").invoke(aClass.newInstance());
}
public static void startFileChangeListener(String rootPath, String... packageName) throws Exception {
// 创建监听对象
FileAlterationObserver observer = new FileAlterationObserver(rootPath);
observer.addListener(new FileListener(rootPath, packageName));
// 每 500 毫秒监听一次
FileAlterationMonitor monitor = new FileAlterationMonitor(500);
monitor.addObserver(observer);
monitor.start();
}
}
文件变动监听
这里使用的是 Apache 的 commons-io 包,来监听文件变动,当文件发送变动的时候,我们会重新调用 Application 类的 start0 方法,进行重新加载类文件到 JVM 中去
package com.example.spring.boot.classloader;
import org.apache.commons.io.monitor.FileAlterationListenerAdaptor;
import java.io.File;
public class FileListener extends FileAlterationListenerAdaptor {
private String rootPath;
private String[] hotClassPaths;
public FileListener(String rootPath, String[] hotClassPaths) {
this.rootPath = rootPath;
this.hotClassPaths = hotClassPaths;
}
@Override
public void onFileChange(File file) {
if (file.getName().contains(".class")) {
try {
Application.stop();
MyClassLoader myClassLoader = new MyClassLoader(rootPath, hotClassPaths);
Application.start0(myClassLoader);
} catch (Exception e) {
e.printStackTrace();
}
}
}
}