最近发现javassist中一个十分好玩的类,HotSwapper,可以动态更改运行中的类,于是便想写一个代码热更新的小工具
热更新效果(不仅热更修改部分还保留了原来的变量值)
Test1逻辑
编写过程我又突发奇想与原来的MakeR结合自动更新资源文件
MakeR:https://blog.csdn.net/weixin_44598449/article/details/118309945
自动更新资源文件效果
对指定资源目录的文件进行增删会自动更新R类
更新后的R类
HotSwapper使用起来也非常简单
// 必须指定虚拟机参数:-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8000
HotSwapper swapper=new HotSwapper(8000);// 这里的端口要与上面配置的address一致
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
//编译代码
int result = compiler.run(null, null, null,"E:/IDEA/MyTest3/src/main/java/part15/TestQ.java");
System.out.println("result=" + result);
//获取字节码并热加载
byte[] bytes = Files.readAllBytes(Paths.get("E:\\IDEA\\MyTest3\\src\\main\\java\\part15\\TestQ.class"));
swapper.reload(TestQ.class.getName(),bytes);
上面的程序还依赖 JDK\lib\tools.jar 这个jar包,可以直接引入jar也可以通过依赖
<dependency>
<groupId>com.sun</groupId>
<artifactId>tools</artifactId>
<version>1.8</version>
<scope>system</scope><!--使用环境变量-->
<systemPath>${java.home}/../lib/tools.jar</systemPath>
</dependency>
代码整体来说还是非常简单的
基本就是通过调用updateCode更新代码,通过代码文件的时间戳判断是否需要更新
@Slf4j
public class CodeUpdate {
private Path basePath;
private Map<String,Long> timeStampMap;
private JavaCompiler compiler;
private HotSwapper swapper;
private List<UpdateListenner> listenners;
private int updateCount;
public CodeUpdate(String basePackage, int port) {
initBasePath(basePackage);
initTimeStampMap();
initOther(port);
}
// -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8000
private void initOther(int port){
compiler = ToolProvider.getSystemJavaCompiler();
try {
swapper=new HotSwapper(port);
log.info("已成功连接到{}端口",port);
} catch (IOException|IllegalConnectorArgumentsException e) {
e.printStackTrace();
log.error("连接失败请配置虚拟机参数:-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address={}",port);
}
listenners=new ArrayList<>();
}
public void addListenner(UpdateListenner listenner){
listenners.add(listenner);
}
public void removeListenner(UpdateListenner listenner){
listenners.remove(listenner);
}
private void initBasePath(String basePackage) {
StringBuilder builder=new StringBuilder();
builder.append(System.getProperty("user.dir")).append("/src/main/java");
String[] items = basePackage.split("\\.");
for (String item : items) {
builder.append('/').append(item);
}
basePath=Paths.get(builder.toString());
}
@SneakyThrows
private void initTimeStampMap(){
timeStampMap=new LinkedHashMap<>();
Files.walk(basePath).forEach(path -> {
File file = path.toFile();
if(file.getName().endsWith(".java")){
timeStampMap.put(file.toString(),file.lastModified());
}
});
}
@SneakyThrows
public void updateCode(){
updateCount=0;
Files.walk(basePath).forEach(path -> {
File file = path.toFile();
if(file.getName().endsWith(".java")){
if(timeStampMap.getOrDefault(file.toString(),0L)<file.lastModified()){
timeStampMap.put(file.toString(),file.lastModified());
updateCode(file);
updateCount++;
}
}
});
listenners.forEach(listenner -> listenner.update(updateCount));
}
private void updateCode(File file) {
int result = compiler.run(null, null, null,file.getAbsolutePath());
String className = getClassName(file);
if(result==0){
byte[] bytes=getAndDelete(file);
swapper.reload(className,bytes);
log.info("热更新{}成功!",className);
}else {
log.error("编译{}失败!",className);
}
}
private String getClassName(File file) {
String path = file.toString();
String classPath = path.substring(path.indexOf("\\java\\") + 6, path.lastIndexOf('.'));
return classPath.replaceAll("\\\\",".");
}
@SneakyThrows
private byte[] getAndDelete(File file) {
String name = file.getName();
file=new File(file.getParent(),name.substring(0,name.lastIndexOf('.'))+".class");
byte[] bytes = Files.readAllBytes(file.toPath());
file.delete();
return bytes;
}
}
剩下的就是代码更新并调用updateCode方法,实际我本人更喜欢,把updateCode方法绑定到快捷键,手动应用更新
这里使用WatchService监听文件更改
简单使用
WatchService watchService = FileSystems.getDefault().newWatchService();//获取观察服务
//注册文件夹(只能注册文件夹)并指定监听事件
Paths.get(triggerDir).register(watchService,StandardWatchEventKinds.ENTRY_MODIFY);
/* 支持的文件事件
StandardWatchEventKinds.OVERFLOW:事件丢失或失去
StandardWatchEventKinds.ENTRY_CREATE:目录内实体创建或本目录重命名
StandardWatchEventKinds.ENTRY_MODIFY:目录内实体修改
StandardWatchEventKinds.ENTRY_DELETE:目录内实体删除或重命名
*/
try {
while (!Thread.interrupted()){
WatchKey poll = watchService.take();//阻塞获取事件
//watchService.poll();//非阻塞获取 没有事件返回null
//watchService.poll(12, TimeUnit.SECONDS);//指定有超时时间的
if(fileChange!=null)fileChange.change(poll.pollEvents());//必须pollEvents否则事件不消失
poll.reset();//重置方便复用
}
}finally {
watchService.close();//关闭服务
}
具体封装为了FileTrigger监听文件更改
public class FileTrigger extends Thread{
private String triggerDir;
private WatchService watchService;
private FileChange fileChange;
@SneakyThrows
public FileTrigger(String triggerDir) {
this.triggerDir = triggerDir;
watchService = FileSystems.getDefault().newWatchService();//获取观察服务
}
public void setFileChange(FileChange fileChange) {
this.fileChange = fileChange;
}
@SneakyThrows
@Override
public void run() {
//注册文件夹(只能注册文件夹)并指定监听事件
Paths.get(triggerDir).register(watchService,StandardWatchEventKinds.ENTRY_MODIFY,
StandardWatchEventKinds.ENTRY_DELETE);
try {
while (!Thread.interrupted()){
WatchKey poll = watchService.take();//阻塞获取事件
if(fileChange!=null)fileChange.change(poll.pollEvents());//必须pollEvents否则事件不消失
poll.reset();//重置方便复用
}
}finally {
watchService.close();
}
}
}
做完这些我便想,监控文件更新自动更新R不就可以想安卓一样了吗
资源R类:https://blog.csdn.net/weixin_44598449/article/details/118309945
实际这里没有写什么代码直接修改的原来的MakeR使其更适应多次创建使用,并结合FileTrigger就完成了
实际动态替换字节码后原来的类文件并未直接释放
下面是我每秒热更100次方法区的内存占用
基本只需5s方法区就从2M达到了30M,然后便开始了FullGC,但是正常使用过程中肯定没有每秒热更100次的需求