在springboot项目中,devtools是一个热部署工具,能够让我们的服务器在运行的过程中,动态监听到项目中代码的改变,并快速将改变应用到服务器上,而不需要重启整个服务器来适应变动。
它内部实现的原理其实是使用了两个ClassLoader,一个ClassLoader加载那些不会被改变的类(第三方jar包),另一个ClassLoader加载用户编写的会更改的类,称为restart ClassLoader。
这样在有代码更改的时候(devtools会监听classpath下的文件变动,并且会立即重启应用,发生在保存时机),原来的restart ClassLoader被丢弃(让GC回收),重新创建一个restart ClassLoader,由于需要加载的类相对比较少,所以实现了较快的重启时间(5秒以内)。
为了实现在文件变动的时候,让GC能够回收用户自定义的类以及restart ClassLoader这个类加载器,我们首先要知道类卸载的前提。
JVM中的Class只有满足以下三个条件,才能被GC回收,也就是该Class被卸载(unload):
由于每个对象都有相应的Class对象,所以当该类仍有实例的时候,是无法卸载的,因为此时Class对象仍可达;
对于ClassLoader对象,留意双亲委托机制中,每个ClassLoader都会记录自身已加载的类信息,所以如果ClassLoader可达,那么Class对象仍是可达的,这就解释了为什么我们为什么需要自定义ClassLoader,因为系统的ClassLoader永远是可达的,他们加载的类在运行时永远不会被卸载;
那现在问题就简单了,我们在加载类的方法时,定义一个临时的ClassLoader,返回结果为Class对象,当这个方法结束后,就仅有该Class对象可以获取到这个ClassLoader;也就是说,当该类的所有实例对象都被gc后,就仅有Class对象可以获得这个ClassLoader了,当我们把这个Class置为空并进行gc后,这个类就会被卸载。
首先,看下整个项目的目录结构:
com.cose.start包下放的是我们自定义devtools工具所涉及的类,com.jvm.plugin包下放的是测试类(即模拟用户自定义的会发生变动的类)。
package com.jvm.plugin;
import java.sql.DriverManager;
import java.util.ArrayList;
import java.util.List;
public class TestTools {
public static void main(String[] args) {
System.out.println("[TestTools] Constant.k:" + Constant.k);
List<String> list = new ArrayList<>();
list.add("1");
System.out.println(list);
DriverManager.getLoginTimeout();
}
}
package com.jvm.plugin;
public class Constant {
public static int k = 10;
}
我们最终要实现的情况就是,启动项目后,动态修改Constant里面k的值,可以看到TestTools里面main方法打印出的k值一样发生了改变。
该类加载器即前面提到的restart ClassLoader。
baseClassPath保存用户类的.class文件的路径;classMap维护所有被该类加载器加载的Class,便于类加载器卸载时,将所有的类进行卸载。
重写findClass方法,由类的全限定名从类路径下定位到该类的.class文件,并加载。
package com.cose.start;
import java.io.*;
import java.util.HashMap;
import java.util.Map;
public class MyClassLoader extends ClassLoader {
private String baseClassPath;
public static Map<String, Class<?>> classMap = new HashMap<>();
public MyClassLoader(ClassLoader parent, String baseClassPath) {
super(parent);
this.baseClassPath = baseClassPath;
}
/**
* 重写 findClass方法,加载指定文件
* @param name
* @return
* @throws ClassNotFoundException
*/
@Override
protected Class<?> findClass(String name) {
String classPath = baseClassPath.concat(name);
File classFile = new File(classPath);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
try {
InputStream stream = new FileInputStream(classFile);
int b;
while((b = stream.read())!=-1){
outputStream.write(b);
}
} catch (IOException e) {
e.printStackTrace();
}
byte[] bytes = outputStream.toByteArray();
int pos = name.lastIndexOf(".class");
String defineClassName = name.substring(0, pos).replace("/", ".");
Class<?> defineClass = super.defineClass(defineClassName, bytes, 0, bytes.length);
classMap.put(defineClassName, defineClass);
return defineClass;
}
/**
* 清除该ClassLoader加载的所有类
*/
public void deleteClasses() {
classMap.forEach((name, clazz) -> clazz = null);
}
}
让我们自定义的MyClassLoader去加载用户类路径下的.class文件,在初始化MyClassLoader时需要指定类的父加载器为空,绕过双亲委派机制,防止用户类被系统的类加载器加载。
然后去用户类路径下扫描出所有的类字节码文件的路径(这里由于工具类和测试类在一个项目下,需要排除掉我们使用的工具类的类路径)。将所有的.class文件路径放入一个.txt文件中保存起来,这样做的原因是,用户类之间可能存在依赖关系,存在依赖关系的类如果单独用javac命令编译会报错提示“找不到符号”,因此我们让所有的用户类一起编译。
重新编译用户类之后,再用自定义类加载器去加载所有的用户类。利用反射,调用了测试类里面的main方法。
调用完成后,先卸载所有的用户类,再卸载自定义类加载器。此后进入下一次循环,模拟用户类被改变的情况,就可以重新加载用户类,模拟devtools实现了热部署机制。
package com.cose.start;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.lang.reflect.Method;
import java.util.LinkedList;
import java.util.List;
import java.util.stream.Collectors;
public class MyDevTools {
public static void main(String[] args) throws Exception {
//死循环模拟生产环境
while (true) {
//项目路径
String projectPath = System.getProperty("user.dir");
//生成.class文件的路径
String classesPath = projectPath + "/out/production/MyDevTools/";
//定义一个类加载器,指定类的父加载器为空,绕过双亲委派机制
MyClassLoader loader = new MyClassLoader(null, classesPath);
//类路径下所有.java格式的文件
List<String> allClassPath = getAllClassPath(projectPath.concat("/src"), new LinkedList<>());
//由于本项目需要,排除掉src目录下属于com/cose/start目录下的类
allClassPath = allClassPath.stream()
.filter(path -> !path.contains("com/cose/start"))
.collect(Collectors.toList());
//将List写入到一个txt文件中
String sourceFilePath = "./source.txt";
writeFileContext(allClassPath, sourceFilePath);
//同时编译所有的.java文件为.class
Process process = Runtime.getRuntime().exec("javac -d " + classesPath + " @" + sourceFilePath);
process.waitFor();
//加载类
for (String path : allClassPath) {
int pos1 = path.lastIndexOf("src/") + 4;
String className = path.substring(pos1).replace("java", "class");
loader.loadClass(className);
}
//项目启动类名称
String startClassName = "com.jvm.plugin.TestTools";
Class<?> clazzTestTools = MyClassLoader.classMap.get(startClassName);
Method method = clazzTestTools.getMethod("main", new Class[]{String[].class});
method.invoke(null, new String[]{null});
//类卸载
loader.deleteClasses();
System.out.println("gc1...类卸载");
System.gc();
Thread.sleep(3000);
loader = null;
System.out.println("gc2...类加载器卸载");
System.gc();
//休眠5秒后重启
Thread.sleep(5000);
}
}
/**
* 递归获取某路径下的所有.java格式的文件
* @param path
* @return 所有文件地址
*/
public static List<String> getAllClassPath(String path, List<String> pathList) {
File file = new File(path);
//如果这个路径是文件夹
if (file.isDirectory()) {
//获取路径下的所有文件
File[] files = file.listFiles();
if (files != null) {
for (File curFile : files) {
//如果还是文件夹,递归获取里面的文件,文件夹
if (curFile.isDirectory()) {
getAllClassPath(curFile.getPath(), pathList);
} else {
String filePath = curFile.getPath();
if (filePath.endsWith(".java")) {
pathList.add(filePath);
}
}
}
}
} else {
String filePath = file.getPath();
if (filePath.endsWith(".java")) {
pathList.add(filePath);
}
}
return pathList;
}
/**
* 将list按行写入到txt文件中
* @param strings
* @param path
* @throws Exception
*/
public static void writeFileContext(List<String> strings, String path) throws Exception {
File file = new File(path);
//如果没有文件就创建
if (!file.isFile()) {
file.createNewFile();
}
BufferedWriter writer = new BufferedWriter(new FileWriter(path));
for (String str : strings){
writer.write(str + "\r\n");
}
writer.close();
}
}
Constant.k的初始值是10,修改为20后,可以看出下一次加载编译用户类时,Constant.k的值也被更新为20了。
完整项目见 https://download.csdn.net/download/qq_40121502/11892038