动态替换JVM中的Class字节码

将程序打包提测,然后测试小姐姐过来跟你说程序有bug的时候就不得不改完bug然后重新替换部署到测试服重新运行。这样不仅非常麻烦而且还浪费了很多时间。既然只是改了一小部分的代码,那我们试想一下能不能动态替换JVM中的的某个类的字节码,达到每次只要更新修改的代码就可以自动进行动态替换,不需要做打包和重启的工作。经过验证发现这种方案是可行的。

我们在本地开发环境编写代码的时候,只要IDE设置了自动刷新,就可以达到不需要重启项目就直接加载修改后的代码的效果。不需要重启项目可以继续调试,一直好奇是如何实现的,经过研究发现,这个功能其实是IDE使用了代理机制去替我们实现的。

无论是运行在Tomcat中的项目,还是像Spring Boot这样可以打成jar包运行的项目(内置了Tomcat),在本地开发环境编写的时候都会将class输出到指定目录,IDE实质上是通过检测代码的变化然后编译修改的代码到指定目录的class文件,代理检测到class变化,就把变化的class重新加载到JVM中,实现动态的更新程序。

只要自己编写一个实现网络传输的代理工具,就可以实现不打包不重启项目,直接远程热更新。

在JDK1.5中就引入了agentmain实现了静态代理,需要在启动目标程序时在命令行指定代理jar,这种方式一般不会在生产环境中使用。
在JDK1.6中又引入了premain,实现了动态代理,可以不用在目标程序启动时指定,而是以独立的代理程序获取目标程序的JVM的pid动态attach。

其实JVM提供了很多底层API和工具,可以对任意的ASM进行各种操作,只要你对JVM研究的足够深入,脑洞足够大,就可以实现任意你想实现的功能。就像“PHP是世界上最好的语言 ———《PHP官方文档》”,在JVM层可以对字节码进行操作就可以达到动态脚本语言的功能。

下面演示一下如何动态更新JVM中的class
1.新建一个普通的工程 AgentDemo
新建一个User类并重写toString方法返回User对象的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 用户信息实体类
* @author 蓝士钦
*/
public class User {
private String name;
private Integer age;

public User(String name, Integer age) {
this.name = name;
this.age = age;
}

@Override
public String toString() {
return "姓名:"+name +" 年龄:"+age;
}
}

在Main函数中使用循环每隔3秒打印一次User对象信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 程序入口类
* @author 蓝士钦
*/
public class Main {

public static void main(String[] args) {

while (true) {
System.out.println(new User("蓝士钦", 23));
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

将程序打包成jar包运行

1
java -jar AgentDemo.jar

程序每隔3秒输出一次User对象信息

接下来使用代理类在不重启目标程序的情况下动态替换User类,通过检测到User.class文件的更新将对应字节码从新加载到JVM中。
新建工程LoadAgent。代理程序需要两个文件,一个是用作代理的包含premain方法的java文件,另一个是MANIFEST.MF文件,用来声明premain方法所在的java文件的位置。
新建LoadAgent.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
package com.lanshiqin.agent;

import java.io.DataInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.lang.instrument.ClassDefinition;
import java.lang.instrument.Instrumentation;

/**
* 加载代理类,用来动态替换jvm中的class
*
* @author 蓝士钦
*/
public class LoadAgent {


public static void premain(String agentArgs, Instrumentation instrumentation) {
try {
// 监视的User.class所在的路径,此处为了示例演示,先直接硬编码,只监视指定目录下User.class的变化
File f = new File("/Users/lanshiqin/Desktop/out/User.class");
// 开启一个线程,用来监视文件变化
ClassFileWatcher watcher = new ClassFileWatcher(instrumentation, f);
watcher.start();
} catch (Exception e) {
e.printStackTrace();
}
}


/**
* Class文件监视线程类,用来检测class文件更新
*/
private static class ClassFileWatcher extends Thread {

private File classFile;
private long lastModified;
private Instrumentation instrumentation;
private boolean firstRun = true;

ClassFileWatcher(Instrumentation instrumentation, File classFile) {
this.classFile = classFile;
this.instrumentation = instrumentation;
lastModified = classFile.lastModified();
}

@Override
public void run() {
// 循环检测
while (true) {
// 判断是否第一次运行,或者文件最后的修改时间与上一次时间不一致时
if (firstRun || (lastModified != classFile.lastModified())) {
firstRun = false;
// 更新文件最后修改时间
lastModified = classFile.lastModified();
// 重新加载class
reDefineClass(instrumentation, classFile);
}
try {
// 每隔一秒休眠一次
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

}

/**
* 重新加载Class
* @param instrumentation jvm的监视对象
* @param classFile 类文件
*/
private static void reDefineClass(Instrumentation instrumentation, File classFile) {
byte[] reporterClassFile = new byte[(int) classFile.length()];
DataInputStream in;
try {
// 读入class文件实例化数据输入流
in = new DataInputStream(new FileInputStream(classFile));
in.readFully(reporterClassFile);
in.close();
// 通过读入的class数据输入流实例化类定义对象
ClassDefinition reporterDef = new ClassDefinition(Class.forName("com.lanshiqin.demo.User"), reporterClassFile);
// 使用jvm监视对象重新定义类
instrumentation.redefineClasses(reporterDef);
} catch (Exception e) {
e.printStackTrace();
}
}
}

在META-INF目录下的MANIFEST.MF文件中指定 Premain-Class为我们编写的LoadAgent

1
2
3
Manifest-Version: 1.0
Premain-Class: com.lanshiqin.agent.LoadAgent
Can-Redefine-Classes: true

Can-Redefine-Classes: true 是必须要声明的,否则在instrumentation.redefineClasses的时候会抛出异常。

完成后将代理程序打包成jar文件 LoadAgent.jar。

在执行目标程序AgentDemo时,通过-javaagent:xxx.jar指定代理jar,其中xxx为代理jar的文件名。

1
java -javaagent:LoadAgent.jar -jar AgentDemo.jar

发现控制台现在还是按照jar中的程序在不停的输出

编辑User类的toString方法,新增一行输出语句,修改返回的字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 用户信息实体类
* @author 蓝士钦
*/
public class User {
private String name;
private Integer age;

public User(String name, Integer age) {
this.name = name;
this.age = age;
}

@Override
public String toString() {
System.out.println("新增的输出语句");
return "我的名字是:"+name +" 今年:"+age +"岁";
}
}

手动将User.java 编译成User.class

1
javac User.java

手动将生成的User.class复制到指定目录下,观察此时控制台输出的内容

发现我们更新替换了User.class后,原先JVM中的User.class被实时重新加载了,并且最新修改的代码已经实时生效,很酷有没有😎。

上面的示例采用的是静态代理的方式,需要运行目标程序时通过指定参数来运行。
一般正式的环境下不会采用这种方式去运行,更多的我们可以考虑采用动态代理的方式。
使用独立运行的代理程序,获取目标JVM所在的pid,然后将代理jar动态attach到JVM上。
代理类中使用agentmain方法

1
2
3
public static void agentmain(String agentArgs, Instrumentation instrumentation){ 
// TODO 动态代理
}

在入口函数中通过目标JVM的pid,动态的将代理jar attach到目标JVM上。

1
2
VirtualMachine virtualMachine = VirtualMachine.attach(pid);
virtualMachine.loadAgent("LoadAgent.jar");

在MANIFEST.MF中指定 Agent-Class:为agentmain方法所在的代理类,指定Main入口函数所在的类

1
2
3
Manifest-Version: 1.0
Agent-Class: com.lanshiqin.agent.LoadAgent
Main-Class: com.lanshiqin.attach.AttachMain

也许你会觉得采用静态代理,开启一个线程去监听某个class变化的方式很low,确实很low。这里仅仅作为简单的示例演示。
我的想法是后续结合服务的做版本控制,比如git提交后使用webhooks触发代理程序将所有修改的class重新加载到JVM中
这篇文章参考了前人的思路,阿里其实已经开源了一个非常完备的解决方案JVM-Sandbox,不仅仅是动态更新JVM中的Class,而且对JVM做了很多高级的操作,比如动态非侵入AOP,有兴趣的可以查阅相关文章 https://mp.weixin.qq.com/s/Nn7Yl6UzRpWnSleKUss8Sw

阿里的JVM沙箱很强大,从中可以学到很多,但我并不需要那么复杂的功能。我想要的很简单,就是想Java程序能够像PHP这类动态脚本程序一样,写完直接刷新,可以不需要重启快速更新到测试服,在JVM层不需要再提什么打包部署,而是可以直接操作Class达到任意功能。

Tomcat的启动和Spring的启动都各自实现了自己的ClassLoad,他们通过破坏双亲委派模式,达到相对高级的操作方式。动态替换JVM中的Class还有很多要深入挖的地方,在后续的文章中会继续记录。

0%