HotSwapClassloader在idea中无法热加载原因分析

[问题]

相同的代码在eclipse中运行正常,在idea中不能重启,不能热加载

[项目开源地址]

https://gitee.com/litongjava_admin/hotswap-classloader

[idea版本]

IntelliJ IDEA 2019.3.3 (Ultimate Edition)

Build #IU-193.6494.35, built on February 11, 2020

[必要设置]

已经开启了Build project automaitcally和compiler.automake.allow.when.app.running

[问题分析]

无论怎么修改代码,保存,都无法触发spring-boot重启,猜测是没有检测到文件更改

在com.litongjava.hotswap.classloader.HotSwapWatcher#doRun增加日志输出

image.png

完整代码如下

package com.litongjava.hotswap.classloader;

import java.io.File;
import java.io.IOException;
import java.nio.file.FileSystems;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardWatchEventKinds;
import java.nio.file.WatchEvent;
import java.nio.file.WatchEvent.Kind;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import com.litongjava.hotswap.kit.UndertowKit;
import com.litongjava.hotswap.server.RestartServer;

/**
 * 监听 class path 下 .class 文件变动,触发 UndertowServer.restart()
 */
public class HotSwapWatcher extends Thread {
  
  protected RestartServer server;
  
  // protected int watchingInterval = 1000;  // 1900 与 2000 相对灵敏
  protected int watchingInterval = 500;
  
  protected List<Path> watchingPaths;
  private WatchKey watchKey;
  protected volatile boolean running = true;
  
  public HotSwapWatcher(RestartServer server) {
    setName("HotSwapWatcher");
    // 避免在调用 deploymentManager.stop()、undertow.stop() 后退出 JVM 
    setDaemon(false);
    setPriority(Thread.MAX_PRIORITY);
    
    this.server = server;
    this.watchingPaths = buildWatchingPaths();


  }
  
  protected List<Path> buildWatchingPaths() {
    Set<String> watchingDirSet = new HashSet<>();
    String[] classPathArray = System.getProperty("java.class.path").split(File.pathSeparator);
    for (String classPath : classPathArray) {
      buildDirs(new File(classPath.trim()), watchingDirSet);
    }
    
    List<String> dirList = new ArrayList<String>(watchingDirSet);
    Collections.sort(dirList);
    
    List<Path> pathList = new ArrayList<Path>(dirList.size());
    System.out.println("观察的目录有:");
    for (String dir : dirList) {
      System.out.println(dir);
      pathList.add(Paths.get(dir));
    }
    
    return pathList;
  }
  
  private void buildDirs(File file, Set<String> watchingDirSet) {
    if (file.isDirectory()) {
      watchingDirSet.add(file.getPath());
      
      File[] fileList = file.listFiles();
      for (File f : fileList) {
        buildDirs(f, watchingDirSet);
      }
    }
  }
  
  public void run() {
    try {
      doRun();
    } catch (Throwable e) {
      throw new RuntimeException(e);
    }
  }
  
  protected void doRun() throws IOException {
    WatchService watcher = FileSystems.getDefault().newWatchService();
    System.out.println("获取到的文件观察器是:"+watcher);
    addShutdownHook(watcher);
    
    for (Path path : watchingPaths) {
      path.register(
          watcher,
          // StandardWatchEventKinds.ENTRY_DELETE,
          StandardWatchEventKinds.ENTRY_MODIFY,
          StandardWatchEventKinds.ENTRY_CREATE
      );
    }
    
    while (running) {
      try {
        // watchKey = watcher.poll(watchingInterval, TimeUnit.MILLISECONDS);  // watcher.take(); 阻塞等待
        // 比较两种方式的灵敏性,或许 take() 方法更好,起码资源占用少,测试 windows 机器上的响应
        watchKey = watcher.take();
        
        if (watchKey == null) {
          // System.out.println(System.currentTimeMillis() / 1000);
          continue ;
        }
      } catch (Throwable e) {            // 控制台 ctrl + c 退出 JVM 时也将抛出异常
        running = false;
        if (e instanceof InterruptedException) {  // 另一线程调用 hotSwapWatcher.interrupt() 抛此异常
          Thread.currentThread().interrupt();  // Restore the interrupted status
        }
        break ;
      }
      
      List<WatchEvent<?>> watchEvents = watchKey.pollEvents();
       for(WatchEvent<?> event : watchEvents) {
         Kind<?> kind = event.kind();
        String fileName = event.context().toString();
        
        System.out.println(watcher.toString()+"检测到文件修改"+kind.toString()+","+fileName);
        if (fileName.endsWith(".class")) {
          if (server.isStarted()) {
            server.restart();
            resetWatchKey();
            
            while((watchKey = watcher.poll()) != null) {
              // System.out.println("---> poll() ");
              watchKey.pollEvents();
              resetWatchKey();
            }
            
            break ;
          }
        }
      }
      
      resetWatchKey();
    }
  }
  
  private void resetWatchKey() {
    if (watchKey != null) {
      watchKey.reset();
      watchKey = null;
    }
  }
  
  /**
   * 添加关闭钩子在 JVM 退出时关闭 WatchService
   * 
   * 注意:addShutdownHook 方式添加的回调在 kill -9 pid 强制退出 JVM 时不会被调用
   *      kill 不带参数 -9 时才回调
   */
  protected void addShutdownHook(WatchService watcher) {
    Runtime.getRuntime().addShutdownHook(new Thread(() -> {
      try {
        watcher.close();
      } catch (Throwable e) {
        UndertowKit.doNothing(e);
      }
    }));
  }
  
  public void exit() {
    running = false;
    try {
      this.interrupt();
    } catch (Throwable e) {
      UndertowKit.doNothing(e);
    }
  }
  
//  public static void main(String[] args) throws InterruptedException {
//    HotSwapWatcher watcher = new HotSwapWatcher(null);
//    watcher.start();
//    
//    System.out.println("启动成功");
//    Thread.currentThread().join(99999999);
//  }
}

修改一个controller文件保存,发现输出如下

image.png

我修改的文件是com.litongjava.spring.boot.hello.HelloController216.java

但是却显示hello和boot的修改,boot和boot都是java文件的父目录

使用的文件监控技术是java的sun.nio.fs.WindowsWatchService

于是我猜测这是WindowsWatchService的一个小bug,于是我使用C#的FileSystemWatcher的技术监控文件的变化验证我的猜测

创建一个winform工程,窗口设计如下

image.png

组件如下

textPath:输入文件监控路径

btnWatchStart:开启监控,关闭监控按钮

btnClearLog:清除日志按钮

textLog:显示日志文本框

Form1.cs代码如下

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace winform_file_watcher {
  public partial class frmMain : Form {
    public frmMain() {
      InitializeComponent();
    }

    private void btnWatchStart_Click(object sender, EventArgs e) {
      FileWatchService f1 = new FileWatchService(@textPath.Text, textLog);
      if (btnWatchStart.Text.Equals("开启监控")) {
        f1.Start();
        btnWatchStart.Text = "关闭监控";
      } else {
        f1.Stop();
        btnWatchStart.Text = "开启监控";
      }
    }
    private void btnClearLog_Click(object sender, EventArgs e) {
      textLog.Text = "";
    }
  }
}

FileWatchService代码如下

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace winform_file_watcher {
  public class FileWatchService {
    private FileSystemWatcher _watcher;
    private TextBox _textLog;

    public FileWatchService(string path, TextBox textLog)  {
      try {
        this._watcher = new FileSystemWatcher();
        _watcher.Path = path;
        _watcher.NotifyFilter = NotifyFilters.FileName | NotifyFilters.Size | NotifyFilters.DirectoryName;
        _watcher.IncludeSubdirectories = true;
        _watcher.Created += new FileSystemEventHandler(FileWatcher_Created);
        _watcher.Changed += new FileSystemEventHandler(FileWatcher_Changed);
        _watcher.Deleted += new FileSystemEventHandler(FileWatcher_Deleted);
        _watcher.Renamed += new RenamedEventHandler(FileWatcher_Renamed);

      } catch (Exception ex) {
        _textLog.AppendText("Error:" + ex.Message);
      }
      _textLog = textLog;

    }

    public void Start() {
      this._watcher.EnableRaisingEvents = true;
      _textLog.AppendText("文件监控已经启动...\r\n");
    }

    public void Stop() {
      this._watcher.EnableRaisingEvents = false;
      this._watcher.Dispose();
      this._watcher = null;
      _textLog.AppendText("文件监控已关闭...\r\n");
    }

    protected void FileWatcher_Created(object sender, FileSystemEventArgs e) {
      _textLog.AppendText("新增:" + e.ChangeType + ";" + e.FullPath + ";" + e.Name+ "\r\n");

    }
    protected void FileWatcher_Changed(object sender, FileSystemEventArgs e) {
      _textLog.AppendText("变更:" + e.ChangeType + ";" + e.FullPath + ";" + e.Name+ "\r\n");
    }
    protected void FileWatcher_Deleted(object sender, FileSystemEventArgs e) {
      _textLog.AppendText("删除:" + e.ChangeType + ";" + e.FullPath + ";" + e.Name+ "\r\n");
    }
    protected void FileWatcher_Renamed(object sender, RenamedEventArgs e) {

      var message=string.Format("重命名: OldPath:{0} NewPath:{1} OldFileName{2} NewFileName:{3}", e.OldFullPath, e.FullPath, e.OldName, e.Name+ "\r\n");
      _textLog.AppendText(message);
    }

  }
}

启动监控服务对比

在eclipse环境修改代码使用java得到的监控变动如下

sun.nio.fs.WindowsWatchService@702d719a检测到文件修改ENTRY_MODIFY,HelloController216.class

在idea环境修改代码使用java得到的监控变动如下

sun.nio.fs.WindowsWatchService@39f03030检测到文件修改ENTRY_MODIFY,hello
sun.nio.fs.WindowsWatchService@39f03030检测到文件修改ENTRY_MODIFY,boot
sun.nio.fs.WindowsWatchService@39f03030检测到文件修改ENTRY_CREATE,hello
sun.nio.fs.WindowsWatchService@39f03030检测到文件修改ENTRY_MODIFY,hello

使用C#得到的监控变动如下

删除:Deleted;E:\dev_workspace\java\java-study\java-ee-spring-boot-study\java-ee-spring-boot-2.1.6-study\java-ee-spring-boot-2.1.6-hello\target\classes\com\litongjava\spring\boot\hello\HelloController216.class;com\litongjava\spring\boot\hello\HelloController216.class
删除:Deleted;E:\dev_workspace\java\java-study\java-ee-spring-boot-study\java-ee-spring-boot-2.1.6-study\java-ee-spring-boot-2.1.6-hello\target\classes\com\litongjava\spring\boot\hello;com\litongjava\spring\boot\hello
新增:Created;E:\dev_workspace\java\java-study\java-ee-spring-boot-study\java-ee-spring-boot-2.1.6-study\java-ee-spring-boot-2.1.6-hello\target\classes\com\litongjava\spring\boot\hello;com\litongjava\spring\boot\hello
新增:Created;E:\dev_workspace\java\java-study\java-ee-spring-boot-study\java-ee-spring-boot-2.1.6-study\java-ee-spring-boot-2.1.6-hello\target\classes\com\litongjava\spring\boot\hello\HelloController216.class;com\litongjava\spring\boot\hello\HelloController216.class
变更:Changed;E:\dev_workspace\java\java-study\java-ee-spring-boot-study\java-ee-spring-boot-2.1.6-study\java-ee-spring-boot-2.1.6-hello\target\classes\com\litongjava\spring\boot\hello\HelloController216.class;com\litongjava\spring\boot\hello\HelloController216.class

由C#得到的监控变更可知,idea在编译HelloController216.java时过程如下

image.png

[总结]

在idea中不能触发重启的原因是没有监控到class文件的改变

[解决办法]

如果使用idea开发,spring-boot使用controller对外提供重启的接口使用C#的winform程序监控文件的改变,监控到class文件改变发送请求到spring-boot的接口


评论区

redo100

2021-06-25 23:47

最简单的解决办法是同一个目录下有至少有2个.java文件,这样就能正常热加载;
不知道什么原因当目录只有一个.java文件的时候,idea总是把父文件夹删了再新建

李通

2021-06-26 00:11

@redo100 确实,你是怎么发现这个规律的,我刚才测试一下,成功了,我不用在写C#监控和回调了