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

摘自:https://www.cnblogs.com/goodAndyxublog/p/11880314.html

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

 

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

一、前言#

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

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

二、Arthas 热更新#

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

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

假设我们现在有一个 HelloService 类,逻辑如下,现在我们使用 Arthas 热更新代码,让其输出 hello arthas

Copy
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.1、jad 反编译代码#

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

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

2.2、修改反编译之后的代码#

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

2.3、查找 ClassLoader#

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

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

这里运行之后将会得到 ClassLoader 哈希值。

2.4、 mc 内存编译源代码#

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

Copy
$ 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.5、redefine 热更新代码#

运行 redefine 命令:

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

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

image.png

一般情况下,我们本地将会有源代码,上面的步骤我们可以进一步省略,我们可以先在自己 IDE 上改动代码,编译生成 class 文件。这样我们只需要运行 redefine 命令即可。也就是说实际上起到作用只是 redefine 。

三、 Instrumentation 与 attach 机制#

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

3.1 Instrumentation#

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

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

Copy
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 非常简单,内部只有两个主要的类,VirtualMachine 与 VirtualMachineDescriptor

VirtualMachine 代表一个 JVM 实例, 使用它提供 attach 方法,我们就可以连接上目标 JVM。

Copy
 VirtualMachine vm = VirtualMachine.attach(pid);

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

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

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

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

四、实现热更新功能#

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

4.1、实现 agent-main#

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

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

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

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

Copy
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 写入以下属性。

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

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

Copy
<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,连接目标虚拟机,触发热更新的代码。

Copy
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.2、运行#

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

Copy
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.3、调试技巧#

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

首先我们需要在 HelloService JVM 参数加入以下参数:

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

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

image.png

然后在 Agent-main 这个工程增加一个 remote 调试。

image.png

image.png

图中参数如下:

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

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

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

4.4、相关问题#

由于 Attach API 位于 tools.jar 中,而在 JDK8 之前 tools.jar 与我们常用JDK jar 包并不在同一个位置,所以编译与运行过程可能找不到该 jar 包,从而导致报错。

如果 maven 编译与运行都使用 JDK9 之后,不用担心下面问题。

maven 编译问题

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

image.png

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

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

或者使用下面依赖。

Copy
        <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.5、热更新存在一些限制#

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

五、彩蛋#

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

六、帮助#

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

欢迎关注我的公众号:程序通事,获得日常干货推送。如果您对我的专题内容感兴趣,也可以关注我的博客:studyidea.cn

其他平台.png

作者: good_andyxu

出处:https://www.cnblogs.com/goodAndyxublog/p/11880314.html

版权:本站使用「CC BY 4.0」创作共享协议,转载请在文章明显位置注明作者及出处。

 
标签: Java
原文地址:https://www.cnblogs.com/xichji/p/11884230.html