自己动手写一个 java 热加载插件

原创
2022/01/06 11:34
阅读数 547

原文:https://blog.fengjx.com/pages/86ec0a/

背景

在 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/javase/8/docs/api/java/lang/instrument/Instrumentation.html

但是使用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 和 gitee 中,可以借鉴参考。

参考

扩展阅读

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. 重新创建一个新的 Classloader 并加载新的 Servlet 类
  5. 通过新创建的 Classloader 创建 Servlet 实例
展开阅读全文
加载中
点击引领话题📣 发布并加入讨论🔥
0 评论
0 收藏
0
分享
返回顶部
顶部