⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 fanyilun.me 「Yilun Fan」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注**微信公众号:【芋道源码】**有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言将得到认真回复。甚至不知道如何读源码也可以请教噢
  4. 新的源码解析文章实时收到通知。每周更新一篇左右
  5. 认真的源码交流微信群。

1 Overview

对于Java 程序员来说,Java Intrumentation、Java agent这些技术可能平时接触的很少,听上去陌生但又好像在哪里见到过。实际上,我们日常应用的各种工具中,有很多都是基于他们实现的,例如常见的热部署(JRebel, spring-loaded)、各种线上诊断工具(btrace, Greys)、代码覆盖率工具(JaCoCo)等等。

本文会介绍 Java Instrumentation及其相关概念,会涉及到的名词包括

  • Java Intrumentation API
  • Java agent
  • Attach API
  • JVMTI
  • ……

简单的来看,如果需要通过Intrumentation操作或监控一个Java程序,相关的工具和流程如下:

Intrumentation操作或监控流程

下文会依次介绍图中的相关概念,并谈谈原理和具体的应用场景。

2 Java Instrumentation

Instrumentation是Java提供的一个来自JVM的接口,该接口提供了一系列查看和操作Java类定义的方法,例如修改类的字节码、向classLoader的classpath下加入jar文件等。使得开发者可以通过Java语言来操作和监控JVM内部的一些状态,进而实现Java程序的监控分析,甚至实现一些特殊功能(如AOP、热部署)。

Instrumentation的一些主要方法如下:

public interface Instrumentation {
/**
* 注册一个Transformer,从此之后的类加载都会被Transformer拦截。
* Transformer可以直接对类的字节码byte[]进行修改
*/
void addTransformer(ClassFileTransformer transformer);

/**
* 对JVM已经加载的类重新触发类加载。使用的就是上面注册的Transformer。
* retransformation可以修改方法体,但是不能变更方法签名、增加和删除方法/类的成员属性
*/
void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;

/**
* 获取一个对象的大小
*/
long getObjectSize(Object objectToSize);

/**
* 将一个jar加入到bootstrap classloader的 classpath里
*/
void appendToBootstrapClassLoaderSearch(JarFile jarfile);

/**
* 获取当前被JVM加载的所有类对象
*/
Class[] getAllLoadedClasses();
}

其中最常用的方法就是addTransformer(ClassFileTransformer transformer)了,这个方法可以在类加载时做拦截,对输入的类的字节码进行修改,其参数是一个ClassFileTransformer接口,定义如下:

/**
* 传入参数表示一个即将被加载的类,包括了classloader,classname和字节码byte[]
* 返回值为需要被修改后的字节码byte[]
*/
byte[]
transform( ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) throws IllegalClassFormatException;

addTransformer方法配置之后,后续的类加载都会被Transformer拦截。对于已经加载过的类,可以执行retransformClasses来重新触发这个Transformer的拦截。类加载的字节码被修改后,除非再次被retransform,否则不会恢复。

主流的JVM都提供了Instrumentation的实现,但是鉴于Instrumentation的特殊功能,并不适合直接提供在JDK的runtime里,而更适合出现在Java程序的外层,以上帝视角在合适的时机出现。因此如果想使用Instrumentation功能,「拿到Instrumentation实例,我们必须通过Java agent」

3 Java agent

Java agent是一种特殊的Java程序(Jar文件),它是Instrumentation的客户端。与普通Java程序通过main方法启动不同,agent并不是一个可以单独启动的程序,而必须依附在一个Java应用程序(JVM)上,与它运行在同一个进程中,通过Instrumentation API与虚拟机交互。

Java agent与Instrumentation密不可分,二者也需要在一起使用。因为Instrumentation的实例会作为参数注入到Java agent的启动方法中。

3.1 Java agent 的格式

Java agent以jar包的形式部署在JVM中,jar文件的manifest需要指定agent的类名。根据不同的启动时机,agent类需要实现不同的方法(二选一)。

/**
* 以vm参数的形式载入,在程序main方法执行之前执行
* 其jar包的manifest需要配置属性Premain-Class
*/
public static void premain(String agentArgs, Instrumentation inst);
/**
* 以Attach的方式载入,在Java程序启动后执行
* 其jar包的manifest需要配置属性Agent-Class
*/
public static void agentmain(String agentArgs, Instrumentation inst);

因此,如果想自己写一个java agent程序,只需定义一个包含premain或者agentmain的类,在方法中实现你的逻辑,然后在打包jar时配置一下manifest即可。可以参考如下的maven plugin配置:

<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<configuration>
<archive>
<manifestEntries>
<Premain-Class>**.**.InstrumentTest</Premain-Class>
<Agent-Class>**.**..InstrumentTest</Agent-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
</plugin>

3.2 Java agent 的加载

一个Java agent既可以在VM启动时加载,也可以在VM启动后加载:

  • 启动时加载:通过vm的启动参数-javaagent:**.jar来启动
  • 启动后加载:在vm启动后的任何时间点,通过attach api,动态地启动agent

如何通过attach api动态加载agent,请见下一小节

agent加载时,Java agent的jar包先会被加入到system class path中,然后agent的类会被system class loader加载。没错,这个system class loader就是所在的Java程序的class loader,这样agent就可以很容易的获取到想要的class。

对于VM启动时加载的Java agent,其premain方法会在程序main方法执行之前被调用,此时大部分Java类都没有被加载(“大部分”是因为,agent类本身和它依赖的类还是无法避免的会先加载的),是一个对类加载埋点做手脚(addTransformer)的好机会。如果此时premain方法执行失败或抛出异常,那么JVM的启动会被终止。

对于VM启动后加载的Java agent,其agentmain方法会在加载之时立即执行。如果agentmain执行失败或抛出异常,JVM会忽略掉错误,不会影响到正在running的Java程序。

3.3 举个例子

一个最简单的Java agent程序如下,该程序通过-javaagent参数附着在目标程序上启动,实现了在类加载时做拦截,修改字节码的功能。

public class InstrumentationExample {
// Java agent指定的premain方法,会在main方法之前被调用
public static void premain(String args, Instrumentation inst) {
// Instrumentation提供的addTransformer方法,在类加载时会回调ClassFileTransformer接口
inst.addTransformer(new ClassFileTransformer() {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer)
throws IllegalClassFormatException {
// 开发者在此自定义做字节码操作,将传入的字节码修改后返回
// 通常这里需要字节码操作框架
// ......
return transformResult;
}
});
}
}

以上面的代码文件,根据前一小节的要求打好jar包,就可以跟随宿主Java应用一起启动了。从执行的流程上来看,效果如下图所示:

跟随宿主Java应用一起启动

可以看出,通过Java agent我们可以注册类加载的回调方法,来实现通用的类加载拦截。

不过上述代码并没有给出transform方法的具体实现,我们举一个具体场景细化一下这个方法的实现:例如,我想要监听某个类,并对这个类的每个方法都做一层AOP,打印出方法调用的耗时。那么使用Instrumentation的解决方式,就是修改这个类的字节码,对每个方法作如下改动:

// 原方法
public void method1(){
dosomething();
}
↓ ↓ ↓ ↓ ↓
// 修改后的方法
public void method1(){
long stime = System.currentTimeMillis();
dosomething();
System.out.println("method1 cost:" + (System.currentTimeMillis() - stime) + " ms");
}

要想实现这种效果,我们需要在transform方法的实现中,对指定的类,做指定的字节码增强。通常来说,做字节码增强都需要使用到框架,比如ASM,CGLIB,Byte Buddy,Javassist。不过如果你喜欢,你可以直接用位运算操作byte[],不需要任何框架,例如JDK反射(method.invoke())的实现,就真的是用位操作拼装了一个类。

言归正传,操作字节码的高手可能更喜欢ASM,因为它提供的方法更底层,功能更强大更直白。对于字节码不熟悉的开发者,更适合javassist,它可以直接以Java代码方式直接修改方法体。我们以javassist为例,看看怎么实现上述的功能,完整代码如下:

public class InstrumentationExample {
// Java agent指定的premain方法,会在main方法之前被调用
public static void premain(String args, Instrumentation inst) {
// Instrumentation提供的addTransformer方法,在类加载时会回调ClassFileTransformer接口
inst.addTransformer(new ClassFileTransformer() {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer)
throws IllegalClassFormatException {
if (!"com/test/TestClass".equals(className)) {
// 只修改指定的Class
return classfileBuffer;
}

byte[] transformed = null;
CtClass cl = null;
try {
// CtClass、ClassPool、CtMethod、ExprEditor都是javassist提供的字节码操作的类
ClassPool pool = ClassPool.getDefault();
cl = pool.makeClass(new ByteArrayInputStream(classfileBuffer));
CtMethod[] methods = cl.getDeclaredMethods();
for (int i = 0; i < methods.length; i++) {
methods[i].instrument(new ExprEditor() {

@Override
public void edit(MethodCall m) throws CannotCompileException {
// 把方法体直接替换掉,其中 $proceed($$);是javassist的语法,用来表示原方法体的调用
m.replace("{ long stime = System.currentTimeMillis();" + " $_ = $proceed($$);"
+ "System.out.println(\"" + m.getClassName() + "." + m.getMethodName()
+ " cost:\" + (System.currentTimeMillis() - stime) + \" ms\"); }");
}
});
}
// javassist会把输入的Java代码再编译成字节码byte[]
transformed = cl.toBytecode();
} catch (Exception e) {
e.printStackTrace();
} finally {
if (cl != null) {
cl.detach();// ClassPool默认不会回收,需要手动清理
}
}
return transformed;
}
});
}
}

4 Attach API

上面提到,Java agent可以在JVM启动后再加载,就是通过Attach API实现的。当然,Attach API可不仅仅是为了实现动态加载agent,Attach API其实是跨JVM进程通讯的工具,能够将某种指令从一个JVM进程发送给另一个JVM进程。

加载agent只是Attach API发送的各种指令中的一种, 诸如jstack打印线程栈、jps列出Java进程、jmap做内存dump等功能,都属于Attach API可以发送的指令。

4.1 Attach API 用法

由于是进程间通讯,那代表着使用Attach API的程序需要是一个独立的Java程序,通过attach目标进程,与其进行通讯。下面的代码表示了向进程pid为1234的JVM发起通讯,加载一个名为agent.jar的Java agent。

// VirtualMachine等相关Class位于JDK的tools.jar
VirtualMachine vm = VirtualMachine.attach("1234"); // 1234表示目标JVM进程pid
try {
vm.loadAgent(".../agent.jar"); // 指定agent的jar包路径,发送给目标进程
} finally {
vm.detach();
}

vm.loadAgent之后,相应的agent就会被目标JVM进程加载,并执行agentmain方法。

4.2 Attach API 原理

按惯例,以Hotspot虚拟机,Linux系统为例。当external process执行VirtualMachine.attach时,需要通过操作系统提供的进程通信方法,例如信号、socket,进行握手和通信。其具体内部实现流程如下所示:

external process(attach发起的进程) target VM(目标JVM进程,假设pid为XXX)
1. 创建文件:.attach_pidXXX
2. 检查.java_pidXXX 文件是否存在,如果存在则跳过4
3. 向目标JVM发送SIGQUIT信号 →
4. 轮询等待.java_pidXXX 文件的创建(5秒超时) 1. JVM的Signal Dispatcher线程收到SIGQUIT信号
4. 轮询等待 ………… 2. 检查.attach_pidXXX 文件是否存在,若不存在则继续,否则忽略信号
4. 轮询等待 ………… 2. 创建一个新线程Attach Listener,专门负责接收各种attach请求指令
4. 轮询等待 ………… 3. 创建.java_pidXXX文件
4. 轮询等待 ………… 4. 开始监听socket(. java_pidXXX)
5. 尝试连接socket (.java_pidXXX )

上面提到了两个文件:

  • attach_pidXXX 后面的XXX代表pid,例如pid为1234则文件名为.attach_pid1234。该文件目的是给目标JVM一个标记,表示触发SIGQUIT信号的是attach请求。这样目标JVM才可以把SIGQUIT信号当做attach连接请求,再来做初始化。其默认全路径为/proc/XXX/cwd/.attach_pidXXX,若创建失败则使用/tmp/attach_pidXXX
  • java_pidXXX 后面的XXX代表pid,例如pid为1234则文件名为.java_pid1234。由于Unix domain socket通讯是基于文件的,该文件就是表示external process与target VM进行socket通信所使用的文件,如果存在说明目标JVM已经做好连接准备。其默认全路径为/proc/XXX/cwd/.java_pidXXX,若创建失败则使用/tmp/java_pidXXX VirtualMachine.attach动作类似TCP创建连接的三次握手,目的就是搭建attach通信的连接。而后面执行的操作,例如vm.loadAgent,其实就是向这个socket写入数据流,接收方target VM会针对不同的传入数据来做不同的处理。

5 JVM Tool Interface(JVMTI)

JVM Tool Interface(JVMTI)是JVM提供的native编程接口,开发者可以通过JVMTI向JVM监控状态、执行指令,其目的是开放出一套JVM接口用于 profile、debug、监控、线程分析、代码覆盖分析等工具。

JVMTI和Instumentation API的作用很相似,都是一套 「JVM操作和监控的接口,且都需要通过agent来启动」

  • Instumentation API需要打包成jar,并通过Java agent加载(-javaagent)
  • JVMTI需要打包成动态链接库(随操作系统,如.dll/.so文件),并通过JVMTI agent加载(-agentlib/-agentpath)
  • 既然都是agent,那么加载时机也同样有两种:启动时(Agent_OnLoad)和运行时Attach(Agent_OnAttach)。 不过相比于Instumentation API,JVMTI的功能强大的多,不知道高到哪里去了。它是实现Java调试器,以及其它Java运行态测试与分析工具的基础。JVMTI能做的事情包括:
  • 获取所有线程、查看线程状态、线程调用栈、查看线程组、中断线程、查看线程持有和等待的锁、获取线程的CPU时间、甚至将一个运行中的方法强制返回值……
  • 获取Class、Method、Field的各种信息,类的详细信息、方法体的字节码和行号、向Bootstrap/System Class Loader添加jar、修改System Property……
  • 堆内存的遍历和对象获取、获取局部变量的值、监测成员变量的值……
  • 各种事件的callback函数,事件包括:类文件加载、异常产生与捕获、线程启动和结束、进入和退出临界区、成员变量修改、gc开始和结束、方法调用进入和退出、临界区竞争与等待、VM启动与退出……
  • 设置与取消断点、监听断点进入事件、单步执行事件……

前面说的Instumentation API也是基于JVMTI来实现的,具体以addTransformer来说,通过Instrumentation注册的ClassFileTransformer,实际上是注册了JVMTI针对类文件加载事件(ClassFileLoadHook)的callback函数。这个callback函数长这个样子:

void JNICALL
ClassFileLoadHook(jvmtiEnv *jvmti_env,
JNIEnv* jni_env,
jclass class_being_redefined,
jobject loader,
const char* name,
jobject protection_domain,
jint class_data_len,
const unsigned char* class_data,
jint* new_class_data_len,
unsigned char** new_class_data)

注意到参数class_data和new_class_data分别对应了读入的原字节码数组,和提供的修改后的字节码数组的指针。这样,我们在方法的实现中就可以把修改后的类的字节码写回,实现 bytecode instrumentation。

InstumentationImpl的实现中,在这个callback函数里,对ClassFileTransformer的transform方法再进行一次回调。这样的一次封装,就做到了通过Java语言实现字节码拦截修改的能力。

6 相关技术的实际应用

6.1 btrace等诊断工具

6.1.1 btrace

btrace是一个安全的,动态追踪Java程序的工具。btrace可以跟踪到一个运行中的Java程序,监控到类和方法级别的状态信息。由于其api的限制,对目标程序源码无侵入性,不会影响到程序原有逻辑。

btrace的使用方式和内部原理如下图,使用者首先需要准备一份btrace脚本(btrace script),用来定义使用者想要追踪的位置和信息。接下来启动btrace client,启动参数包括目标JVM的pid用于attach、以及写好的btrace脚本文件。目标JVM会通过attach(或者启动时参数指定-javaagent)加载上Java agent,并通过socket与brace client建立连接。btrace脚本会被编译成字节码然后发送给目标JVM的agent,通过解析其语义,转换为对程序源码的改写,此处也是基于Instrumentation api完成的。

btrace等诊断工具

一份btrace脚本示例如下(来自官方文档https://github.com/btraceio/btrace/wiki),这份脚本会跟踪到javax.swing.*包下的所有class下的所有method,并在进入方法体时通过标准输出打印出类名和方法名。

package samples;
import com.sun.btrace.annotations.*;
import static com.sun.btrace.BTraceUtils.*;
/**
* This script traces method entry into every method of
* every class in javax.swing package! Think before using
* this script -- this will slow down your app significantly!!
*/
@BTrace public class AllMethods {
@OnMethod(
clazz="/javax\\.swing\\..*/",
method="/.*/"
)
public static void m(@ProbeClassName String probeClass, @ProbeMethodName String probeMethod) {
print(Strings.strcat("entered ", probeClass));
println(Strings.strcat(".", probeMethod));
}
}

这份例子仅仅是一个简单的例子,btrace追踪点的时机(对应例子里的@OnMethod)可以有很多,包括方法体进入/退出、方法调用与返回、行号、异常抛出、临界区进入和退出等等,追踪的内容(对应例子里的@ProbeClassName、@ProbeMethodName)除了提到的类名和方法名,还有对象的实例、入参和返回值、方法耗时等都可以作为参数注入到脚本方法的入参中。看得出,btrace脚本的语法强大且复杂,但是为了安全(不能修改程序自身逻辑)做了诸多的限制,例如不能新建对象、不能调用实例方法以及静态方法(BTraceUtils等特有方法除外)、不能使用循环、不能抛出和捕获异常等等。

6.1.2 Greys

从功能设计的角度上看,btrace在保证“安全”的前提下给予了用户尽可能多的功能,这也因此导致了其api和使用起来的复杂性。在实际生产环境的实践中,我更倾向于使用简单易用的工具,毕竟一些常用的功能基本可以覆盖绝大多数使用场景。例如:Greyshttps://github.com/oldmanpushcart/greys-anatomy/wiki/greys-pdf也是一个Java程序诊断工具(阿里内部叫Arthas,对其做了二次开发)其原理与btrace类似,区别在于用户不需要编写btrace脚本,直接通过命令行指令交互。因此它更像一个产品而不仅仅是工具,它提供了包括方法的出入参监控、类加载信息查看、调用堆栈查看、方法调用轨迹和耗时查看的功能。在实际线上问题诊断中,尤其是在无法debug的环境中定位问题,还是非常实用的。

举个例子,Greys可以以下面这种使用方式来监控某个方法的调用轨迹和内部耗时,参数包括了监控的类名表达式、方法名、追踪的路径表达式等。

ga?>ptrace -t *alibaba*Test printAddress --path=*alibaba*
Press Ctrl+D to abort.
Affect(class-cnt:10 , method-cnt:36) cost in 148 ms.
`---+pTracing for : thread_name="agent-test-address-printer" thread_id=0xb;is_daemon=false;priority=5;process=1004;
`---+[2,2ms]com.alibaba.AgentTest:printAddress(); index=1021;
+---+[1,1ms]com.alibaba.manager.DefaultAddressManager:newAddress(); index=1014;
| +---[1,1ms]com.alibaba.CountObject:<init>(); index=1012;
| `---[1,0ms]com.alibaba.Address:<init>(); index=1013;
+---+[2,1ms]com.alibaba.manager.DefaultAddressManager:toString(); index=1020;
| +---+[2,1ms]com.alibaba.manager.DefaultAddressManager:toStringPass1(); index=1019;
| | +---+[2,1ms]com.alibaba.manager.DefaultAddressManager:toStringPass2(); index=1017;
| | | +---[1,0ms]com.alibaba.Address:getAddressId(); index=1015;
| | | +---+[1,0ms]com.alibaba.manager.DefaultAddressManager:throwRuntimeException(); index=1016;
| | | | `---[1,0ms]throw:java.lang.RuntimeException
| | | `---[1,0ms]throw:java.lang.RuntimeException
| | +---[2,0ms]com.alibaba.AddressException:<init>(); index=1018;
| | `---[2,0ms]throw:com.alibaba.AddressException
| `---[2,0ms]throw:com.alibaba.AddressException
`---[2,0ms]throw:com.alibaba.AddressException

从Greys的原理来看,除了去掉了btrace脚本和Java Complier的部分以外,和btrace基本一样,毕竟都是Instrumentation的实际应用。在一些细节上,例如类加载的隔离还是值得研究学习的,可以直接从开源项目里拉到源码来看

6.2 热部署

6.2.1 IDE提供的HotSwap

使用eclipse或IntelliJ IDEA通过debug模式启动时,默认会开启一项HotSwap功能。用户可以在IDE里修改代码时,直接替换到目标程序的类里。不过这个功能只允许修改方法体,而不允许对方法进行增删改。

该功能的实现与debug有关。debug其实也是通过JVMTI agent来实现的,JVITI agent会在debug连接时加载到debugee的JVM中。debuger(IDE)通过JDI(Java debug interface)与debugee(目标Java程序)通过进程通讯来设置断点、获取调试信息。除了这些debug的功能之外,JDI还有一项redefineClass的方法,可以直接修改一个类的字节码。没错,它其实就是暴露了JVMTI的bytecode instrument功能,而IDE作为debugger,也顺带实现了这种HotSwap功能。

原理示意图如下,顺带着也把Java debug的原理也画了出来,毕竟知识都是相通的:)

IDE提供的HotSwap

由于是直接使用的JVM的原生的功能,其效果当然也一样:只能修改方法体,否则会弹出警告。例如eclipse会弹出””Hot Code Replace Failed”。不过优点在于简单实用,无需安装。

对了,如果你经常在生产环境debug的话,请在debug连接时不要修改本地代码,因为如果你只修改了方法体,那么你的本地代码修改能够直接被hotswap到线上去 :)

6.2.2 Tomcat的自动reload

Tomcat在配置Context(对应一个web应用,一个host下可以有多个context)时,有一个属性reloadable,当设置为true时,会监听其classpath下的类文件变动情况,当它有变动时,会自动重启所在的web应用(context)。

这里的重启,会先停止掉当前的Context,然后重新解析一遍xml,重新创建Webappclassloader,重新加载类。Tomcat的类加载机制分配给每个Context一个独立的类加载器,这样一来类的重新加载就成为了可能————直接使用新的类加载器重新加载一遍,避免了同一个类加载器不能重复加载一个类的限制。

把Tomcat的reload机制分类到热部署里的确有些牵强,我认为应该算作增量部署吧。不过这也算是热部署的实现思路之一,通过新的classloader重新全部加载一遍。缺陷也很明显:程序的状态可能丢失,耗时可能很长,而且如果应用只配置了一个Context那就和重启整个Tomcat没有太大差别了。

6.2.3 JRebel,spring-loaded,hotcode2等热部署工具

说到热部署,这些工具应该算得上最适合使用的了,这些热部署工具“突破”了只能修改方法体的JVM客观限制,实现了很多额外的功能例如增删改方法签名、增删改成员变量等等,尽最大可能让代码能够自由自在的热部署。目前了解到比较常见的有以下几种:

  • JRebelhttps://zeroturnaround.com/software/jrebel/:目前最常用的热部署工具,是一款收费的商业软件,因此在稳定性和兼容性上做的都比较好。
  • Spring-Loadedhttps://github.com/spring-projects/spring-loaded:Spring旗下的子项目,也是一款开源的热部署工具。
  • Hotcode2:阿里内部开发和使用的热部署工具,功能和上面基本一样,同时针对各种框架做了很多适配。

这类热部署工具的原理惊人的相似:首先都是通过Java agent,使用Instumentation API来修改已加载的类。既然Instumentation只能修改方法体,为什么这些工具突破了这个限制呢?实际上,这些工具在每个method call和field access的地方都做了一层代理,对于每次修改类,并不是直接retransformClasses,而是直接加载一个全新的类,由于方法调用和成员变量读写都被动态代理过,新修改的类自然能够成功“篡位”了。

举一个JRebel的简化版的例子,假设一个类一开始长这样:

public class C extends X {
int y = 5;
int method1(int x) {
return x + y;
}
void method2(String s) {
System.out.println(s);
}
}

那么这个类在加载时,就会被JRebel的agent转换掉:每个方法的方法体都变成了代理,其内容变成了调用某个具体实现类的同名方法。

public class C extends X {
int y = 5;
int method1(int x) {// 什么也不做,只把参数和方法名传递给名叫Runtime的代理
Object[] o = new Object[1];
o[0] = x;
return Runtime.redirect(this, o, "C", "method1", "(I)I");
}
void method2(String s) {
Object[] o = new Object[1];
o[0] = s;
return Runtime.redirect(this, o, "C", "method2", "(Ljava/lang/String;)V");
}
}

原代码的实现逻辑当然也不会丢掉,而是通过加载一个名叫C0的新类作为实现类。刚才通过Runtime.redirect的调用,会被路由到这个实现类的对应方法里。如果此时用户再次更新了类C的代码,那么会再重新加载一个C1类,然后C2,C3,C4,C5…

public abstract class C0 {
public static int method1(C c, int x) {
int tmp1 = Runtime.getFieldValue(c, "C", "y", "I");
return x + tmp1;
}
public static void method2(C c, String s) {
PrintStream tmp1 =
Runtime.getFieldValue(
null, "java/lang/System", "out", "Ljava/io/PrintStream;");
Object[] o = new Object[1];
o[0] = s;
Runtime.redirect(tmp1, o, "java/io/PrintStream;", "println","(Ljava/lang/String;)V");
}
}

通过这种方式,就可以在JVM既定的限制下,完成更自由的热部署。当然这种热部署行为,是需要做很多细节的兼容的,例如反射的各个方法都要做一些特殊的兼容处理,还有异常栈的获取不能真的把这些代理类透传出去……另外,由于很多类的行为是通过框架初始化的时候进行的,这些热部署工具还要对一些框架深度加工,来完成xml和注解的自动初始化,比如spring的配置xml、mybatis的sqlmap等。

6.2.4 Dynamic Code Evolution VM (DCE VM)

DCEVM是一款基于Java HotSpot(TM) VM修改的JVM,其目的就是允许对加载过的类无限制的修改(redefinition)。从技术的角度来讲,通过VM的修改实现热部署是最合理也是性能最好的方案。不过由于使用成本比较高,加之这个项目的推广程度不高,这种热部署方案并不常见。

文章目录
  1. 1. 1 Overview
  2. 2. 2 Java Instrumentation
  3. 3. 3 Java agent
    1. 3.1. 3.1 Java agent 的格式
    2. 3.2. 3.2 Java agent 的加载
    3. 3.3. 3.3 举个例子
  4. 4. 4 Attach API
    1. 4.1. 4.1 Attach API 用法
    2. 4.2. 4.2 Attach API 原理
  5. 5. 5 JVM Tool Interface(JVMTI)
  6. 6. 6 相关技术的实际应用
    1. 6.1. 6.1 btrace等诊断工具
      1. 6.1.1. 6.1.1 btrace
      2. 6.1.2. 6.1.2 Greys
    2. 6.2. 6.2 热部署
      1. 6.2.1. 6.2.1 IDE提供的HotSwap
      2. 6.2.2. 6.2.2 Tomcat的自动reload
      3. 6.2.3. 6.2.3 JRebel,spring-loaded,hotcode2等热部署工具
      4. 6.2.4. 6.2.4 Dynamic Code Evolution VM (DCE VM)