乐趣区

关于后端:自己动手写一个-java-热加载插件

背景

在 java 我的项目开发、测试过程中,须要重复批改代码,编译,部署,在一些大型项目中,整个编译个部署过程可能须要破费数分钟,甚至数十分钟。在前后端接口联调或者测试问题批改的时候可能只是批改一个参数,前端、后端、测试都须要期待数十分钟。如果 java 可能反对热加载,缩小不必要的工夫破费,同样也能领有像 nodejs 这样的开发效率。

问题剖析

要实现 java 代码热部署,首先须要理解 java 代码是如何运行的。

java 办法执行过程

一个办法的执行过程是非常复杂的,辨别动态解析和动静分派,为了不便了解能够大抵上认为是以下过程:

  1. 一个 java 对象在堆内存中会蕴含一个对应的类指针
  2. 通过类名 + 办法名 + 办法形容(参数、返回值)在对应类中寻找对应的办法
  3. 对于动态解析的状况能够间接失去一个办法援用地址,对于动静分派的办法,失去一个符号援用,并通过符号援用查找到指标办法地址
  4. 通过办法地址失去办法实例,将对应办法字节码指令压入栈帧执行

具体内容能够参考:< 深刻了解 Java 虚拟机 - 第 3 版 > 8.3 章节

总结:对象办法的调用会在对应 Class 查找到相干办法字节码,并加载到线程栈帧中执行,那么咱们只须要失去一个新的类,并且把新的类加载或者替换到办法区就可能实现热加载性能

如何在运行时取得一个 Java 类(字节码)

  • 应用 javac 或者 javax.tools 包下的 JavaCompiler 相干 api 编译源码
  • 应用 ASM, Javassist, Bytebuddy 等第三方类库生成字节码(具体用法网上有很多介绍)
  • 间接编写 java 字节码指令(如果不是 jvm 开发者,应该没有多少集体可能间接编写字节码指令来写一个程序,劝退、劝退)

通过什么办法能够在运行时加载类

java.lang.instrument.Instrumentation 提供了两个办法

  • redefineClasses: 提供一个类文件,从新定义一个类,能够扭转办法体、常量池和属性,不能增加、删除或重命名字段或办法。如果字节码有谬误,会抛出一个异样。
  • retransformClasses: 是更新一个类,这个办法不会触发类初始化,所以动态变量的值将放弃在调用前的状态。

官网 api 文档: https://docs.oracle.com/javas…

然而应用 Instrumentation 从新定义类还有一些有余

  1. jvm 为了平安对运行时从新定义的类做了限度,只能批改办法内逻辑、常量和属性(能够应用 dcevm jdk 解决)。
  2. 即便应用了 dcevm jdk 能够反对新增类、办法、字段,然而有些第三方框架在启动的时候会执行一些本人外部的初始化操作,例如 spring 启动时会扫描 bean 并实例化,应用 InstrumentationredefineClasses定义了新的 @Service 类之后并不会注册到 spring 中,这个就须要有一个机制告诉 spring 去加载新的 bean

dcevm jdk 是一个定制版的 jdk,可能反对类、办法、字段从新定义。我的项目主页:http://dcevm.github.io/

当初咱们曾经晓得能够应用 InstrumentationredefineClasses能够从新定义一个类,那么 Instrumentation 对象如何取得呢?

从 jdk 5 开始,能够应用 java 来编写 agent 实现,能够在 agent 类中定义 premain 或者 agentmain 办法取得 Instrumentation 实例。

  • premain

    在 main 办法启动之前执行,在 jvm 启动的时候通过 –javaagent 参数加载 agent 类

    // 优先级 1 大于 2
    [1] public static void premain(String agentArgs, Instrumentation inst);
    [2] public static void premain(String agentArgs);

    须要再 ManiFest 中指定Premain-Class: org.example.MyAgent

  • agentmain

    通过 Attach API 加载

    // 优先级 1 大于 2
    [1] public static void agentmain(String agentArgs, Instrumentation inst);
    [2] public static void agentmain(String agentArgs);

    须要再 ManiFest 中指定Agent-Class: org.example.MyAgent

示例

package org.example;

public class MyAgent {
    /**
     * 启动时加载
     */
    public static void premain(String args, Instrumentation inst) {System.out.println("premain");
    }

    /**
     * 运行时加载(attach api)*/
    public static void agentmain(String args, Instrumentation inst) {System.out.println("agentmain");
    }
}

maven 打包

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-assembly-plugin</artifactId>
    <version>3.3.0</version>
    <executions>
        <execution>
            <goals>
                <goal>single</goal>
            </goals>
            <phase>package</phase>
            <configuration>
                <descriptorRefs>
                    <descriptorRef>jar-with-dependencies</descriptorRef>
                </descriptorRefs>
                <archive>
                    <manifestEntries>
                        <Premain-Class>org.example.MyAgent</Premain-Class>
                        <Agent-Class>org.example.MyAgent</Agent-Class>
                    </manifestEntries>
                </archive>
            </configuration>
        </execution>
    </executions>
</plugin>

应用

  1. jvm 启动时加载

    # fat 指任意一个可运行 fat jar
    java -javaagent:myagent.jar fat
  2. 通过 Attach API 加载

    import java.io.IOException;
    import com.sun.tools.attach.*;
    
    public class AttachTest {public static void main(String[] args)
            throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {if (args.length <= 1) {System.out.println("Usage: java AttachTest <PID> /path/to/myagent.jar");
                return;
            }
            VirtualMachine vm = VirtualMachine.attach(args[0]);
            vm.loadAgent(args[1]);
        }
    }

确定实现计划

通过以上相干常识回顾并结合实际开发场景剖析,咱们能够初步得出以下实现步骤

  1. 监听我的项目源码文件(.java)变更

    • 通过 nio2 的 WatchService 监听目录文件变更
    • apache commons.io 包下的 FileAlterationListenerAdaptor 类曾经封装了文件监听相干解决逻辑,应用比拟不便
  2. 讲变更的源码文件编译成字节码文件(.class)

    • 能够通过 ide(如:IntelliJ IDEA)的主动编译性能生成 .class 文件
    • 通过 javac 或者 JavaCompiler 相干 api 编译
  3. 应用 Attach API 形式加载自定义 agent 类,通过参数将类名和字节码文件门路传递给指标 jvm 过程
  4. 从参数中取得字节码文件门路并取得 byte 流
  5. 自定义 agent 类取得 Instrumentation 对象,通过 redefineClasses 办法从新定义类

redefineClasses 办法只须要取得类名和字节码 byte 流就能够从新定义类,那么同样能够在近程服务器开一个 server,提供文件上传接口,将字节码文件上传到服务器,实现近程 jvm 过程热加载

架构设计

源码实现

相干代码实现曾经放到 github 中:https://github.com/fengjx/jav…,能够借鉴参考。

参考

  • https://tech.meituan.com/2019…
  • https://tech.meituan.com/2019…
  • https://tech.meituan.com/2020…
  • https://leokongwq.github.io/2…
  • https://blog.csdn.net/program…

扩大浏览

tomcat 如何实现 jsp 热加载

咱们都晓得 jsp 文件最终会编译成一个 Servlet 的实现类

来看下 tomcat 实现 jsp 热加载的几个要害源码

// JspCompilationContext.java

public void compile() throws JasperException, FileNotFoundException {createCompiler();
    // 判断文件是否变更
    if (jspCompiler.isOutDated()) {if (isRemoved()) {throw new FileNotFoundException(jspUri);
        }
        try {
            // 删除之前编译的文件
            jspCompiler.removeGeneratedFiles();
            // 设为 null 会创立新的 jspLoader(因为在同一个 ClassLoader 中,不容许反复加载 Class,所以须要创立一个新的 Classloader)jspLoader = null;
            // 将 jsp 编译成 Servlet
            jspCompiler.compile();
            jsw.setReload(true);
            jsw.setCompilationException(null);
        } catch (JasperException ex) {
            // Cache compilation exception
            jsw.setCompilationException(ex);
            if (options.getDevelopment() && options.getRecompileOnFail()) {
                // Force a recompilation attempt on next access
                jsw.setLastModificationTest(-1);
            }
            throw ex;
        } catch (FileNotFoundException fnfe) {
            // Re-throw to let caller handle this - will result in a 404
            throw fnfe;
        } catch (Exception ex) {
            JasperException je = new JasperException(Localizer.getMessage("jsp.error.unable.compile"),
                    ex);
            // Cache compilation exception
            jsw.setCompilationException(je);
            throw je;
        }
    }
}
// JspServletWrapper.java
public Servlet getServlet() throws ServletException {if (getReloadInternal() || theServlet == null) {synchronized (this) {if (getReloadInternal() || theServlet == null) {destroy();
                final Servlet servlet;
                try {InstanceManager instanceManager = InstanceManagerFactory.getInstanceManager(config);
                    // 通过新创建的 JasperLoader 创立 servlet 实例
                    servlet = (Servlet) instanceManager.newInstance(ctxt.getFQCN(), ctxt.getJspLoader());
                } catch (Exception e) {
                    Throwable t = ExceptionUtils
                            .unwrapInvocationTargetException(e);
                    ExceptionUtils.handleThrowable(t);
                    throw new JasperException(t);
                }
                servlet.init(config);
                if (theServlet != null) {ctxt.getRuntimeContext().incrementJspReloadCount();}
                theServlet = servlet;
                reload = false;
            }
        }
    }
    return theServlet;
}
// JspCompilationContext.java

public ClassLoader getJspLoader() {
    // 后面设置为 null,这里会创立一个新的 JasperLoader
    if(jspLoader == null) {
        jspLoader = new JasperLoader
                (new URL[] {baseUrl},
                        getClassLoader(),
                        rctxt.getPermissionCollection());
    }
    return jspLoader;
}

总体流程如下

  1. 定时扫描 jsp 文件目录比照文件批改工夫是否有变动
  2. 如果 jsp 文件批改工夫发送变动,将对应 Classloader(Jsploader)设置为 null
  3. 将 jsp 文件编程成 Servlet 类
  4. 从新创立一个新的 Classload 并加载新的 Servlet 类
  5. 通过新创建的 Classloader 创立 Servlet 实例
退出移动版