手动实现热加载

热加载相关知识,欢迎交流,指正错误。

1. 什么是热加载

热加载是指可以在不重启服务的情况下让更改的代码生效,热加载可以显著的提升开发以及调试的效率,它是基于 Java 的类加载器实现的,但是由于热加载的不安全性,一般不会用于正式的生产环境。

2. 热加载与热部署的区别

首先,不管是热加载还是热部署,都可以在不重启服务的情况下编译/部署项目,都是基于 Java 的类加载器实现的。

两者之间的区别:

  1. 在部署方式上:
  • 热部署是在服务器运行时重新部署项目。
  • 热加载是在运行时重新加载 class文件
  1. 在实现原理上:
  • 热部署是直接重新加载整个应用,耗时相对较高。
  • 热加载是在运行时重新加载 class文件,后台会启动一个线程不断检测你的类是否改变。
  1. 在使用场景上:
  • 热部署更多的是在生产环境使用。
  • 热加载则更多的是在开发环境上使用。线上由于安全性问题不会使用,难以监控。

3. 类加载五个阶段

  1. 加载阶段:找到类的静态存储结构,加载到虚拟机,定义数据结构。用户可以自定义类加载器。
  2. 验证阶段:确保字节码是安全的,确保不会对虚拟机的安全造成危害。
  3. 准备阶段:确定内存布局,确定内存遍历,赋初始值(注意:是初始值,也有特殊情况)。
  4. 解析阶段: 将符号变成直接引用。
  5. 初始化阶段:调用程序自定义的代码。规定有且仅有5种情况必须进行初始化。 > 1. new(实例化对象)、getstatic(获取类变量的值,被final修饰的除外,他的值在编译器时放到了常量池)、putstatic(给类变量赋值)、invokestatic(调用静态方法) 时会初始化 2. 调用子类的时候,发现父类还没有初始化,则父类需要立即初始化。 3. 虚拟机启动,用户要执行的主类,主类需要立即初始化,如 main 方法。 4. 使用 java.lang.reflect包的方法对类进行反射调用方法 是会初始化。 5. 当使用JDK 1.7的动态语言支持时, 如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、 REF_putStatic、 REF_invokeStatic的方法句柄, 并且这个方法句柄所对应的类没有进行过初始化, 则需要先触发其初始化。

要说明的是,类加载的 5 个阶段中,只有加载阶段是用户可以自定义处理的,而验证阶段、准备阶段、解析阶段、初始化阶段都是用 JVM 来处理的。

4. 实现类的热加载

4.1. 基本思路

由类加载的五个阶段可知,只有在加载阶段用户才可以自定义处理,因此如果由文件监视器实时监测class文件,若如class文件发生改变则将class文件重新加载到虚拟机,就可以简单实现类的热加载。

基本步骤:

  • 自定义类加载器
  • 指定需要热加载的类
  • 利用文件监视器实时监测class文件
  • class发生改变,重新加载

4.2. 自定义类加载器

设计 Java 虚拟机的团队把类的加载阶段放到的 JVM 的外部实现( 通过一个类的全限定名来获取描述此类的二进制字节流 )。这样就可以让程序自己决定如果获取到类信息。而实现这个加载动作的代码模块,我们就称之为 “类加载器”。

在 Java 中,类加载器也就是 java.lang.ClassLoader. 所以如果我们想要自己实现一个类加载器,就需要继承 ClassLoader

接下来看看ClassLoader中的主要方法:

 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
public abstract class ClassLoader {
    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        synchronized (getClassLoadingLock(name)) {
            // 首先,监测是否已经加载
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        //父加载器不为null的话调用父加载器的loadClass
                        c = parent.loadClass(name, false);
                    } else {
                        //父加载器为null 则调用 Bootstrap ClassLoader
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    //父加载器没有找到,则调用findclass
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

    protected final Class<?> findLoadedClass(String name) {
        if (!checkName(name))
            return null;
        // 检测这个class是不是已经加载过了
        return findLoadedClass0(name);
    }

    // 它能将class二进制内容转换成Class对象,如果不符合要求的会抛出各种异常
    protected final Class<?> defineClass(byte[] b, int off, int len) throws ClassFormatError {
        return defineClass(null, b, off, len, null);
    }

}

ClassLoader#defineClass可知,class文件可通过二进制形式转换为Class对象,并且在会在当前的ClassLoader中缓存该class已被加载,即通过ClassLoader#findLoadedClass方法检测出来。

自定义ClassLoader:

 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
/**
 * @author harrison
 * 自定义ClassLoader
 * 作用:将自己指定目录下  更新的class文件  动态加载到JVM中
 */
public class MyClassLoader extends ClassLoader {

    /**
     * 项目根目录
     */
    private final String rootPath;

    /**
     * 需要热加载的 class 记录, 因为有些类是不需要我们加载的  比如 String
     */
    private final List<String> clazzList;

    /**
     * 传入指定目录  热加载class文件
     * @param rootPath 项目的根目录,需要根据此目录来截取class文件路径  因为加载的是class文件, 其目录为: /projects/com/xx/X.class
     *                 但是classLoader需要根据ClassName加载,而className的格式为 com.xx.X,所以需要根据路径截取到com这一截
     * @param clazzPaths 需要热加载的类路径
     */
    public MyClassLoader(String rootPath, String... clazzPaths) {
        this.rootPath = rootPath;
        this.clazzList = new ArrayList<>();
        for(String clazzPath : clazzPaths){
            loadClassPath(new File(clazzPath));
        }
    }

    /**
     * 根据目录扫描项目里的class文件 并把文件加载进JVM
     * defineClass() 此方法为ClassLoader的方法
     * 此方法传入一个className 与 byte数组(byte数组是对应Class文件的二进制数据数组) 来将对应的Class文件加载进JVM, 并生成Class对象
     * @param file 出入扫描的目录
     */
    private void loadClassPath(File file) {
        if(file.isDirectory()){
            for(File f : Objects.requireNonNull(file.listFiles())){
                loadClassPath(f);
            }
        }else {
            String fileName = file.getName();
            String filePath = file.getPath();
            String endName = fileName.substring(fileName.lastIndexOf(Constants.DOT_STRING) + 1);
            if(!CLASS_SUFFIX.equals(endName)){
                return;
            }
            try (InputStream in = new FileInputStream(file)) {
                byte[] bytes = new byte[(int) file.length()];
                in.read(bytes);
                String className = filePath2ClassName(filePath);
                clazzList.add(className);
                // 将class文件生成class对象
                defineClass(className, bytes, 0, bytes.length);
                LogUtil.logger.info("{} 已加载进JVM", className);
            } catch (Exception e) {
                LogUtil.logger.error("error. ", e);
            }
        }
    }

    /**
     * 将文件路径替换为ClassName
     * @param filePath 文件路径
     * @return fileName
     */
    private String filePath2ClassName(String filePath) {
        String className = filePath.replace(rootPath, Constants.EMPTY_STRING)
                .replace(Constants.CUR_SYSTEM_FILE_SEPARATOR, Constants.DOT_STRING);
        className = className.substring(0, className.lastIndexOf(Constants.DOT_STRING));
        className = className.substring(1);
        return className;
    }

}

ClassLoader类中可以看到类加载采用“双亲委派机制”,并且自定义的MyClassLoader的父加载器默认为AppClassLoader,因此如果是这样的话,依旧采用的“双亲委派机制”来加载类,无法达到热加载的效果。故我们需要指定自定义的MyClassLoader来加载。这里涉及到全盘委托

全盘委托:首先要用哪个类加载器。

是利用当前方法(或者说当前类)的类加载器作为优先的类加载器。

例如:User对象调用setAddress()方法,而在setAddress()方法中对Address类进行实例化( new Address() ),则Address优先选择加载User的类加载器来加载Address,即如果UserAppClassLoader加载,则依据全盘委托Address也是优先选择AppClassLoader进行加载,但最终由哪个类加载器加载,还是由双亲委派机制决定。假设在User对象中实例化了一个String对象,则会优先选择AppClassLoader加载String,但是由于双亲委派机制最终StringBootstrap ClassLoader加载。

双亲委派机制:最终用哪个类加载器来加载类。

因此依据全盘委托我们自己自定义的优先选择类加载器,但是最终选择哪个类加载器还是不确定的。基于此我们可以让自定义的CLassLoader在加载class不经历双亲委派,那不就是优先选择谁,谁就是最终的类加载器么。

因此在加载给定目录下的类时,我们先把class文件读取成二进制数组,然后调用defineClass()就可以把class加载进JVM,且不经历双亲委派(如MyClassLoader#LoadClassPath方法所示)。

 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
public class Application extends FileAlterationListenerAdaptor {

    /**
     * 需要热加载类的根目录
     */
    public static String rootPath;

    public void start() {
        //类似于SpringBoot启动流程。。。
        init();
        // logic code
        new User().sayHello();
    }

    public void init(){
        LogUtil.logger.info("初始化项目。");
    }

    public static void run(Class<?> clazz) throws Exception{
        String rootPath = clazz.getResource("/").getPath().replace("%20", " ");
        rootPath = new File(rootPath).getPath();
        Application.rootPath = rootPath;
        startFileMoni(rootPath);
        MyClassLoader myClassLoader = new MyClassLoader(rootPath, rootPath + Constants.PACKAGE_PATH);
        start0(myClassLoader);
    }

    /**
     * Application 由自定义的类加载加载,因此在Application中实例化的对象都会优先选择自定义的类加载器
     * @param myClassLoader 自定义类加载器
     * @throws Exception ex
     */
    public static void start0(MyClassLoader myClassLoader) throws Exception{
        // loadClass(String name) 参数name是class的全限定名
        Class<?> clazz = myClassLoader.loadClass("cn.harrison.Application");
        Object o = clazz.newInstance();
        clazz.getMethod("start").invoke(o);
    }

    public static void close(){
        LogUtil.logger.info("关闭项目");
        //通知JVM销毁已失去引用的对象(执行finalize()方法)
        System.runFinalization();
        //通知JVM GC
        System.gc();
    }

    /**
     *  启动文件监听器
     * @param rootPath rootPath
     * @throws Exception ex
     */
    public static void startFileMoni(String rootPath) throws Exception{
        FileAlterationObserver observer = new FileAlterationObserver(rootPath);
        observer.addListener(new FileListener());
        FileAlterationMonitor monitor = new FileAlterationMonitor(500);
        monitor.addObserver(observer);
        monitor.start();
    }
}

Application类由自定义的ClassLoader加载,则在Application中实例化的对象都将有自定义的MyClassLoader加载。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class FileListener extends FileAlterationListenerAdaptor {

    @Override
    public void onFileChange(File file) {
        if (file.getName().contains(CLASS_SUFFIX)) {
            try {
                LogUtil.logger.info("{} 发生更改~", file.getName());
                // 热部署
                Application.close();
                MyClassLoader myClassLoader = new MyClassLoader(Application.rootPath, Application.rootPath + Constants.PACKAGE_PATH);
                Application.start0(myClassLoader);
            } catch (Exception e) {
                LogUtil.logger.error("error. ", e);
            }
        }
    }
}

文件监听器,class文件发生改变,则会重新加载class文件。

测试:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public class AppTest {

    public static void main(String[] args)  {
        try {
            Application.run(MyClassLoader.class);
        } catch (Exception e) {
            LogUtil.logger.error("error.", e);
        }
    }
}

经过测试发现,当User中的sayHello()方法改变时,重新编译,即可执行更改后的代码,证明实现了热加载。

上述source code可在Github上进行浏览。

5. 总结

  • CLassLoader中的三个重要的方法loadClass()findLoadedClass()defineClass()以及它们的作用。
  • 全盘委托 和 双亲委派, 以及二者之间的区别。
0%