手把手教你实现热更新功能,带你了解 Arthas 热更新背后的原理

文章来源:https://studyidea.cn/java-hotswaphtml

1、前言

一天下午正在摸鱼的时候,测试小姐姐走了过来求助,说是须要改动测试环境 mock 应用。可是这个应用一时半会又找不到源代码存在何处。可是测试小姐姐的活仍是必定要帮,忽然想起了 Arthas 能够热更新应用代码,按照网上的步骤,反编译应用代码,加上须要改动的逻辑,最后热更新成功。对此,测试小姐姐很满意,并表示下次会少提 Bug。java

嘿嘿,之前一直对热更新背后原理很好奇,借着这个机会,研究一下热更新的原理。git

2、Arthas 热更新

咱们先来看下 Arthas 是如何热更新的。github

详情参考:阿里巴巴Arthas实践--jad/mc/redefine线上热更新一条龙api

假设咱们如今有一个 HelloService 类,逻辑以下,如今咱们使用 Arthas 热更新代码,让其输出 hello arthasapp

public class HelloService {

    public static void main(String[] args) throws InterruptedException {

        while (true){
            TimeUnit.SECONDS.sleep(1);
            hello();
        }
    }

    public static void hello(){
        System.out.println("hello world");
    }

}

2.一、jad 反编译代码

首先运行 jad 命令反编译 class 文件获取源代码,运行命令以下:。框架

jad --source-only com.andyxh.HelloService > /tmp/HelloService.java

2.二、修改反编译以后的代码

拿到源代码以后,使用 VIM 等文本编辑工具编辑源代码,加入须要改动的逻辑。dom

2.三、查找 ClassLoader

而后使用 sc 命令查找加载修改类的 ClassLoader,运行命令以下:jvm

$ sc -d  com.andyxh.HelloService | grep classLoaderHash
 classLoaderHash   4f8e5cde

这里运行以后将会获得 ClassLoader 哈希值。socket

2.四、 mc 内存编译源代码

使用 mc 命令编译上一步修改保存的源代码,生成最终 class 文件。

$ mc -c 4f8e5cde  /tmp/HelloService.java  -d /tmp
Memory compiler output:
/tmp/com/andyxh/HelloService.class
Affect(row-cnt:1) cost in 463 ms.

2.五、redefine 热更新代码

运行 redefine 命令:

$ redefine /tmp/com/andyxh/HelloService.class
redefine success, size: 1

热更新成功以后,程序输出结果以下:

image.png

通常状况下,咱们本地将会有源代码,上面的步骤咱们能够进一步省略,咱们能够先在本身 IDE 上改动代码,编译生成 class 文件。这样咱们只须要运行 redefine 命令便可。也就是说实际上起到做用只是 redefine

3、 Instrumentation 与 attach 机制

Arthas 热更新功能看起来很神奇,实际上离不开 JDK 一些 API,分别为 instrument API 与 attach API。

3.1 Instrumentation

Java Instrumentation 是 JDK5 以后提供接口。使用这组接口,咱们能够获取到正在运行 JVM 相关信息,使用这些信息咱们构建相关监控程序检测 JVM。另外, 最重要咱们能够替换修改类的,这样就实现了热更新。

Instrumentation 存在两种使用方式,一种为 pre-main 方式,这种方式须要在虚拟机参数指定 Instrumentation 程序,而后程序启动以前将会完成修改或替换类。使用方式以下:

java -javaagent:jar Instrumentation_jar -jar xxx.jar

有没有以为这种启动方式很熟悉,仔细观察一下 IDEA 运行输出窗口。

image.png

另外不少应用监控工具,如:zipkin、pinpoint、skywalking。

这种方式只能在应用启动以前生效,存在必定的局限性。

JDK6 针对这种状况做出了改进,增长 agent-main 方式。咱们能够在应用启动以后,再运行 Instrumentation 程序。启动以后,只有链接上相应的应用,咱们才能作出相应改动,这里咱们就须要使用 Java 提供 attach API。

3.2 Attach API

Attach API 位于 tools.jar 包,能够用来链接目标 JVM。Attach API 很是简单,内部只有两个主要的类,VirtualMachineVirtualMachineDescriptor

VirtualMachine 表明一个 JVM 实例, 使用它提供 attach 方法,咱们就能够链接上目标 JVM。

VirtualMachine vm = VirtualMachine.attach(pid);

VirtualMachineDescriptor 则是一个描述虚拟机的容器类,经过该实例咱们能够获取到 JVM PID(进程 ID),该实例主要经过 VirtualMachine#list 方法获取。

for (VirtualMachineDescriptor descriptor : VirtualMachine.list()){

            System.out.println(descriptor.id());
        }

介绍完热更新涉及的相关原理,接下去使用上面 API 实现热更新功能。

4、实现热更新功能

这里咱们使用 Instrumentation agent-main 方式。

4.一、实现 agent-main

首先须要编写一个类,包含如下两个方法:

public static void agentmain (String agentArgs, Instrumentation inst);          [1]
public static void agentmain (String agentArgs);            [2]

上面的方法只须要实现一个便可。若两个都实现, [1] 优先级大于 [2],将会被优先执行。

接着读取外部传入 class 文件,调用 Instrumentation#redefineClasses,这个方法将会使用新 class 替换当前正在运行的 class,这样咱们就完成了类的修改。

public class AgentMain {
    /**
     *
     * @param agentArgs 外部传入的参数,相似于 main 函数 args
     * @param inst
     */
    public static void agentmain(String agentArgs, Instrumentation inst) {
        // 从 agentArgs 获取外部参数
        System.out.println("开始热更新代码");
        // 这里将会传入 class 文件路径
        String path = agentArgs;
        try {
            // 读取 class 文件字节码
            RandomAccessFile f = new RandomAccessFile(path, "r");
            final byte[] bytes = new byte[(int) f.length()];
            f.readFully(bytes);
            // 使用 asm 框架获取类名
            final String clazzName = readClassName(bytes);

            // inst.getAllLoadedClasses 方法将会获取全部已加载的 class
            for (Class clazz : inst.getAllLoadedClasses()) {
                // 匹配须要替换 class
                if (clazz.getName().equals(clazzName)) {
                    ClassDefinition definition = new ClassDefinition(clazz, bytes);
                    // 使用指定的 class 替换当前系统正在使用 class
                    inst.redefineClasses(definition);
                }
            }

        } catch (UnmodifiableClassException | IOException | ClassNotFoundException e) {
            System.out.println("热更新数据失败");
        }


    }

    /**
     * 使用 asm 读取类名
     *
     * @param bytes
     * @return
     */
    private static String readClassName(final byte[] bytes) {
        return new ClassReader(bytes).getClassName().replace("/", ".");
    }
}

完成代码以后,咱们还须要往 jar 包 manifest 写入如下属性。

## 指定 agent-main 全名
Agent-Class: com.andyxh.AgentMain
## 设置权限,默认为 false,没有权限替换 class
Can-Redefine-Classes: true

咱们使用 maven-assembly-plugin,将上面的属性写入文件中。

<plugin>
    <artifactId>maven-assembly-plugin</artifactId>
    <version>3.1.0</version>
    <configuration>
        <!--指定最后产生 jar 名字-->
        <finalName>hotswap-jdk</finalName>
        <appendAssemblyId>false</appendAssemblyId>
        <descriptorRefs>
            <!--将工程依赖 jar 一块打包-->
            <descriptorRef>jar-with-dependencies</descriptorRef>
        </descriptorRefs>
        <archive>
            <manifestEntries>
                <!--指定 class 名字-->
                <Agent-Class>
                    com.andyxh.AgentMain
                </Agent-Class>
                <Can-Redefine-Classes>
                    true
                </Can-Redefine-Classes>
            </manifestEntries>
            <manifest>
                <!--指定 mian 类名字,下面将会使用到-->
                <mainClass>com.andyxh.JvmAttachMain</mainClass>
            </manifest>
        </archive>
    </configuration>
    <executions>
        <execution>
            <id>make-assembly</id> <!-- this is used for inheritance merges -->
            <phase>package</phase> <!-- bind to the packaging phase -->
            <goals>
                <goal>single</goal>
            </goals>
        </execution>
    </executions>
</plugin>

到这里咱们就完成热更新主要代码,接着使用 Attach API,链接目标虚拟机,触发热更新的代码。

public class JvmAttachMain {
    public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {
        // 输入参数,第一个参数为须要 Attach jvm pid 第二参数为 class 路径
        if(args==null||args.length<2){
            System.out.println("请输入必要参数,第一个参数为 pid,第二参数为 class 绝对路径");
            return;
        }
        String pid=args[0];
        String classPath=args[1];
        System.out.println("当前须要热更新 jvm pid 为 "+pid);
        System.out.println("更换 class 绝对路径为 "+classPath);
        // 获取当前 jar 路径
        URL jarUrl=JvmAttachMain.class.getProtectionDomain().getCodeSource().getLocation();
        String jarPath=jarUrl.getPath();

        System.out.println("当前热更新工具 jar 路径为 "+jarPath);
        VirtualMachine vm = VirtualMachine.attach(pid);//7997是待绑定的jvm进程的pid号
        // 运行最终 AgentMain 中方法
        vm.loadAgent(jarPath, classPath);
    }
}

在这个启动类,咱们最终调用 VirtualMachine#loadAgent,JVM 将会使用上面 AgentMain 方法使用传入 class 文件替换正在运行 class。

4.二、运行

这里咱们继续开头使用的例子,不过这里加入一个方法获取 JVM 运行进程 ID。

public class HelloService {

    public static void main(String[] args) throws InterruptedException {
        System.out.println(getPid());
        while (true){
            TimeUnit.SECONDS.sleep(1);
            hello();
        }
    }

    public static void hello(){
        System.out.println("hello world");
    }

    /**
     * 获取当前运行 JVM PID
     * @return
     */
    private static String getPid() {
        // get name representing the running Java virtual machine.
        String name = ManagementFactory.getRuntimeMXBean().getName();
        System.out.println(name);
        // get pid
        return name.split("@")[0];
    }

}

首先运行 HelloService,获取当前 PID,接着复制 HelloService 代码到另外一个工程,修改 hello 方法输出 hello agent,从新编译生成新的 class 文件。

最后在命令行运行生成的 jar 包。

image.png

HelloService 输出效果以下所示:

image.png

源代码地址:https://github.com/9526xu/hotswap-example

4.三、调试技巧

普通的应用咱们能够在 IDE 直接使用 Debug 模式调试程序,可是上面的程序没法直接使用 Debug。刚开始运行的程序碰到不少问题,无奈之下,只能选择最原始的办法,打印错误日志。后来查看 arthas 的文档,发现上面一篇文章介绍使用 IDEA Remote Debug 模式调试程序。

首先咱们须要在 HelloService JVM 参数加入如下参数:

-Xrunjdwp:transport=dt_socket,server=y,address=8001

此时程序将会被阻塞,直到远程调试程序链接上 8001 端口,输出以下:

image.png

而后在 Agent-main 这个工程增长一个 remote 调试。

image.png

image.png

图中参数以下:

-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8001

Agent-main 工程打上断点,运行远程调试, HelloService 程序将会被启动。

最后在命令行窗口运行 Agent-main 程序,远程调试将会暂停到相应断点处,接下来调试就跟普通 Debug 模式同样,再也不叙述。

4.四、相关问题

因为 Attach API 位于 tools.jar 中,而在 JDK8 以前 tools.jar 与咱们经常使用JDK jar 包并不在同一个位置,因此编译与运行过程可能找不到该 jar 包,从而致使报错。

若是 maven 编译与运行都使用 JDK9 以后,不用担忧下面问题。

maven 编译问题

maven 编译过程可能发生以下错误。

image.png

解决办法为在 pom 下加入 tools.jar 。

<dependency>
            <groupId>jdk.tools</groupId>
            <artifactId>jdk.tools</artifactId>
            <scope>system</scope>
            <version>1.6</version>
            <systemPath>${java.home}/../lib/tools.jar</systemPath>
        </dependency>

或者使用下面依赖。

<dependency>
            <groupId>com.github.olivergondza</groupId>
            <artifactId>maven-jdk-tools-wrapper</artifactId>
            <version>0.1</version>
            <scope>provided</scope>
            <optional>true</optional>
        </dependency>

程序运行过程 tools.jar 找不到

运行程序时抛出 java.lang.NoClassDefFoundError,主要缘由仍是系统未找到 tools.jar 致使。

image.png

在运行参数加入 -Xbootclasspath/a:${java_home}/lib/tools.jar,完整运行命令以下:

image.png

4.五、热更新存在一些限制

并非全部改动热更新都将会成功,当前使用 Instrumentation#redefineClasses 仍是存在一些限制。咱们仅只能修改方法内部逻辑,属性值等,不能添加,删除方法或字段,也不能更改方法的签名或继承关系。

5、彩蛋

写完热更新代码,收到一封系统邮件提示 xxx bug 待修复。恩,说好的少提 Bug 呢 o(╥﹏╥)o。

6、帮助

1.深刻探索 Java 热部署
2.Instrumentation 新功能

欢迎关注个人公众号:程序通事,得到平常干货推送。若是您对个人专题内容感兴趣,也能够关注个人博客:studyidea.cn

其余平台.png

相关文章
相关标签/搜索