Arthas 之热更新原理,并实现简易版热更新功能

热更新原理

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

Instrumentation

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

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

java -javaagent:jar Instrumentation_jar -jar xxx.jar

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

JDK6 针对这种情况作出了改进,增加 agent-main 方式。我们可以在应用启动之后,再运行 Instrumentation 程序。启动之后,只有连接上相应的应用,我们才能做出相应改动,这里我们就需要使用 Java 提供 attach API。

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 实现热更新功能。

实现热更新功能

实现 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,这样我们就完成了类的修改。

com.agent.AgentMain 的代码如下:

package com.agent;

import java.io.IOException;
import java.io.RandomAccessFile;
import java.lang.instrument.ClassDefinition;
import java.lang.instrument.Instrumentation;
import java.lang.instrument.UnmodifiableClassException;

import org.objectweb.asm.ClassReader;


public class AgentMain  {
	/**
	 * @param agentArgs 外部传入的参数,类似于 main 函数 args
	 * @param inst
	 */
	public static void agentmain(String agentArgs, Instrumentation inst) {
		// 从 agentArgs 获取外部参数
		System.out.println("start agentmain.");
		// 这里将会传入 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 (Exception e) {
			System.err.println("agentmain error.");
		}

	}

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

其中,ClassReader 类需要引入 Jar 包:

<dependency>
	<groupId>asm</groupId>
	<artifactId>asm</artifactId>
	<version>3.3.1</version>
</dependency>

配置 MANIFEST.MF

完成代码之后,我们还需要往 jar 包 MANIFEST.MF 写入以下属性。

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

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

<plugin>
	<artifactId>maven-assembly-plugin</artifactId>
	<version>3.1.0</version>
	<configuration>
		<finalName>hotswap-jdk</finalName>
		<appendAssemblyId>false</appendAssemblyId>
		<descriptorRefs>
			<descriptorRef>jar-with-dependencies</descriptorRef>
		</descriptorRefs>
		<archive>
			<manifestEntries>
				<Agent-Class>com.agent.AgentMain</Agent-Class>
				<Can-Redefine-Classes>true</Can-Redefine-Classes>
				<Can-Retransform-Classes>true</Can-Retransform-Classes>
			</manifestEntries>
			<manifest>
				<mainClass>com.main.JvmAttachMain</mainClass>
			</manifest>
		</archive>
	</configuration>
	<executions>
		<execution>
			<id>make-assembly</id>
			<phase>package</phase>
			<goals>
				<goal>single</goal>
			</goals>
		</execution>
	</executions>
</plugin>

使用 Attach API

以上完成热更新主要代码,接着使用 Attach API,连接目标虚拟机,触发热更新的代码。

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

com.main.JvmAttachMain 的代码如下:

package com.main;

import java.io.File;

import com.sun.tools.attach.VirtualMachine;


public class JvmAttachMain {

	public static void main(String[] args){
		String pid = "41556";
		String classPath = "E:\\RedefineSuccess.class";
		
		// 获取 Agent jar 路径
		String jarPath = System.getProperty("user.dir") + File.separator + "target" + File.separator + "hotswap-jdk.jar";
		System.out.println("this redefine jar path:" + jarPath);
		
		try {
			VirtualMachine vm = VirtualMachine.attach(pid);	// 待绑定的jvm进程的pid号
			// 运行最终 AgentMain 中方法
			vm.loadAgent(jarPath,classPath);
		} catch (Throwable e) {
			System.err.println("ERROR:" + e.getMessage());
			e.printStackTrace();
		}
		
	}
}

运行

  1. com.agent.AgentMain 打包成 hotswap-jdk.jar
  2. 运行测试类 RedefineSuccess.java
  3. 通过 jps 获取到进程 ID
  4. 将进程 ID 和反编译生成的 class 文件传入 com.main.JvmAttachMain
  5. 运行 com.main.JvmAttachMain

1598497095868

相关问题

编译引入 tools.jar

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

则需要 Maven 引入:

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

程序运行中 tools.jar

运行程序时抛出 java.lang.NoClassDefFoundError,主要原因还是系统未找到 tools.jar 导致。

1598497278943

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

java -Xbootclasspath/a:${java_home}\lib\tools.jar -jar hotswap-jdk.jar 22132 E:\RedefineSuccess.class