前言
项目在开发过程中, 需要不断调试并且更换生产或测试环境的包, 非常麻烦, 因此需要寻求一种不停机更新代码的方式, 了解到javaagent
模式可以实现.
javaAgent
是一种特殊的Java
程序,是Instrumentation
的客户端。它与普通Java
程序通过main
方法启动不同,JavaAgent
并不是一个可以单独启动的程序,它必须依附在一个Java
应用程序(JVM
)上,与主程序运行在同一个进程中,通过Instrumentation API
与虚拟机交互。
javaagent
有2种形式, 分别对应2个方法:
-
premain(args: String?, instrumentation: Instrumentation)
运行在
main
方法前,使用方法:将agent.jar
在应用运行前通过java -javaagent:<jar 路径>[=args] -jar server.jar
,绑定JVM
,实现class
文件的修改。 -
agentmain(args: String?, instrumentation: Instrumentation)
使用方法:希望修改的类正在运行状态,我们主动运行
attach
模块(这里先不要纠结什么是attach
模块, 后面小节有介绍。),获取目前所有运行的JVM
,通过过滤条件,找到希望修改的jvm-id
,然后通过如下方式, 将agent
注入,实现class
文件的修改。val attach = VirtualMachine.attach(${jvm-id}); attach.loadAgent("${agent_jar_path}", "${args}")
对比
- 作用时机不同: 需要根据实际的需求来进行取舍, 比如我们要进行热部署操作, 此时
premain
就不能满足要求, 因为在main
方法前执行,此时业务JVM
还没有加载我们的目标类, 自然无法很顺畅的进行重定义。 - 运行方式不同:
agentmain
不仅可以使用-javaagent:agent.jar[=args]
启动, 还可以通过VirtualMachine.attach()
进行附着 - 运行次数不同:由于运行方式的限制,
premain
只能运行一次, 而agentmain
可以多次运行
模块
为方便演示, 共设计3个maven
模块:
server
: 用来模拟真实业务, 使用springboot
框架
agent
:用来定义新的类的加载规则, 然后通过根模块将本模块附着(attach
)到业务模块中
attach
: 用于附着到业务模块上, 并且将新的类重新定义在业务模块中, 以达到运行时替换class
的目的.
需求
假设server
模块只有一个服务HelloWorldService
, 它只负责返回Hello World
字符串, 当我们访问localhost:8080/hi
时, 可以得到如上响应, 现要求不停机前提下, 改变它的输出为Hello JavaAgent
替换规则为:约定使用/tmp/classes/
作为搜索的根目录, 如果子文件夹classes
下有类文件, 则使用新类替换
/tmp/classes/
并不是死的, 而是通过jvm
参数传进来,比如启动时指定java -javaagent:<jar 路径>[=/tmp/agent/] -jar server.jar
,或者使用VirtualMachine
方式, 在loadAgent
方法中指定。
设计
agent
是我们的核心模块, 一般做如下设计, 我们仅需补充过滤规则和类转换规则(步骤③):
fun agentmain(args: String?, instrumentation: Instrumentation) {
agent(args, instrumentation)
}
fun premain(args: String?, instrumentation: Instrumentation) {
agent(args, instrumentation)
}
fun agent(args: String?, instrumentation: Instrumentation) {
// ①当前已加载的所有类
val allLoadedClasses = instrumentation.allLoadedClasses
// ②通过自定义的过滤规则得到的需要替换的类
val targetClasses = findTargetClasses(args, allLoadedClasses)
if (targetClasses.isEmpty()) {
println("no class found")
return
}
// ③添加类转换规则的具体实现
instrumentation.addTransformer({ loader, className, classBeingRedefined, protectionDomain, classfileBuffer ->
TODO("补充类加载或类定义的具体实现")
}, true)
// ④更新目标类到目标JVM
instrumentation.retransformClasses(*targetClasses)
}
// 定义了哪些类时需要替换的, 一般需要从已加载的类中进行过滤
// 方式有很多, 比如扫描/tmp/classes路径下的新类, 能找到则表示要替换, 或者通过args来指定也行
fun findTargetClasses(args: String?, allLoadedClasses: Array<Class<Any>>): Array<Class<*>> {
TODO("这里补充过滤规则")
}
上面的代码使用的是kotlin
, 步骤③中的匿名内部类不是很明显, 它实际上ClassFileTransformer
接口的实现, 要进行实现必须先熟悉甚至Instrumentation API
.后面小节会进行简单介绍。
MANIFEST.MF
仅做代码实现是不够的, 和main
方法一样, 当实际使用时, 需要在MANIFEST.MF
中指定启动的类(全类名),不同的是他们对应的KEY
不一样:
main
:Main-Class
premain
:Premain-Class
agentmain
:Agent-Class
不过这并不是javaagent
的全部, 还有常用的配套的定义:
Can-Redefine-Classes
: 是否可进行类定义, true或false, 一般都写true, 不然我们要它干啥🐶Can-Retransform-Classes
: 是否可进行类转换, true或false, 一般都写true, 不然我们要它干啥🐶
Instrumentation API 介绍
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。
* retransformClasses可以修改方法体,但是不能变更方法签名、增加和删除方法/类的成员属性
*/
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
接口,定义如下:
public interface ClassFileTransformer {
/**
* 类文件转换方法,重写transform方法可获取到待加载的类相关信息
*
* @param loader 定义要转换的类加载器;如果是引导加载器,则为 null
* @param className 类名,如:java/lang/Runtime
* @param classBeingRedefined 如果是被重定义或重转换触发,则为重定义或重转换的类;如果是类加载,则为 null
* @param protectionDomain 要定义或重定义的类的保护域
* @param classfileBuffer 类文件格式的输入字节缓冲区(不得修改)
* @return 字节码byte数组。
*/
byte[]
transform( ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer)
throws IllegalClassFormatException;
}
重写
transform
方法需要注意以下事项:
ClassLoader
如果是被Bootstrap ClassLoader
(引导类加载器)所加载那么loader参数的值是空。- 修改类字节码时需要特别注意插入的代码在对应的
ClassLoader
中可以正确的获取到,否则会报ClassNotFoundException
,比如修改java.io.FileInputStream
(该类由Bootstrap ClassLoader
加载)时插入了我们检测代码,那么我们将必须保证FileInputStream能够获取到我们的检测代码类。JVM
类名的书写方式路径方式:java/lang/String
而不是我们常用的类名方式:java.lang.String
。- 类字节必须符合
JVM
校验要求,如果无法验证类字节码会导致JVM
崩溃或者VerifyError
(类验证错误)。- 如果修改的是
retransform
类(修改已被JVM
加载的类),修改后的类字节码不得新增方法、修改方法参数、类成员变量。addTransformer
时如果没有传入retransform
参数(默认是false)就算MANIFEST.MF
中配置了Can-Redefine-Classes: true而且手动调用了retransformClasses方法也一样无法retransform。- 卸载transform时需要使用创建时的Instrumentation实例。
局限性
在运行时,我们可以通过Instrumentation
的redefineClasses
方法进行类重定义,在redefineClasses
方法上有一段注释需要特别注意:
* The redefinition may change method bodies, the constant pool and attributes.
* The redefinition must not add, remove or rename fields or methods, change the
* signatures of methods, or change inheritance. These restrictions maybe be
* lifted in future versions. The class file bytes are not checked, verified and installed
* until after the transformations have been applied, if the resultant bytes are in
* error this method will throw an exception.1.2.3.4.5.6.
这里面提到,我们不可以增加、删除或者重命名字段和方法,改变方法的签名或者类的继承关系。认识到这一点很重要,当我们通过 ASM
等字节码增强工具获取到增强的字节码之后,如果增强后的字节码没有遵守这些规则,那么调用redefineClasses
方法来进行类的重定义就会失败。
实现
agent
到这里我们对javaagent
有了一个初步的认识了, 可以着手实现上面的TODO
项了。
package com.yan
import java.lang.instrument.Instrumentation
import java.nio.file.Files
import java.nio.file.Paths
import java.util.logging.Logger
val logger: Logger = Logger.getLogger("agent")
fun agentmain(args: String, instrumentation: Instrumentation) {
agent(args, instrumentation)
}
fun premain(args: String, instrumentation: Instrumentation) {
agent(args, instrumentation)
}
fun agent(args: String, instrumentation: Instrumentation) {
// ①当前已加载的所有类
val allLoadedClasses = instrumentation.allLoadedClasses
// ②通过自定义的过滤规则得到的需要替换的类
val targetClasses = findTargetClasses(args, allLoadedClasses)
if (targetClasses.isEmpty()) {
println("no class found")
return
}
// ③添加类转换规则的具体实现
instrumentation.addTransformer({ loader, className, classBeingRedefined, protectionDomain, classfileBuffer ->
if (!classFilter(args, classBeingRedefined)) {
return@addTransformer classfileBuffer
}
val agentClassPath = Paths.get(args, "$className.class")
logger.info("start redefine class: $agentClassPath")
Files.newInputStream(agentClassPath).use {
return@addTransformer it.readBytes()
}
}, true)
// ④更新目标类到目标JVM
println(targetClasses.map { it.name })
instrumentation.retransformClasses(*targetClasses)
}
// 定义了哪些类时需要替换的, 一般需要从已加载的类中进行过滤
// 方式有很多, 比如扫描/tmp/classes路径下的新类, 能找到则表示要替换, 或者通过args来指定也行
fun findTargetClasses(args: String, allLoadedClasses: Array<Class<Any>>): Array<Class<Any>> {
return allLoadedClasses.filter { classFilter(args, it) }.toTypedArray()
}
fun classFilter(args: String, target: Class<*>): Boolean {
val name = target.name
val path = Paths.get(args, "${name.replace('.', '/')}.class")
return (name.startsWith("com.yan") // 只针对业务类, 一般使用包名的前缀进行过滤
&& Files.exists(path)) // 查看指定路径下有没有相关的类文件
}
attach
fun main() {
VirtualMachine.list().forEach {
val displayName = it.displayName()
if (!displayName.startsWith("com.yan")) {
return@forEach
}
val attach = VirtualMachine.attach(it.id())
attach.loadAgent("/home/yanwei/IdeaProjects/java-agent/out/artifacts/agent_jar/agent.jar", "/tmp/classes")
}
}
server
略
MANIFEST.MF
由于我们使用的是maven, 为了便于打包, 在pom.xml
中指定内容:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifestEntries>
<Agent-Class>com.yan.AgentMainKt</Agent-Class>
<Premain-Class>com.yan.AgentMainKt</Premain-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
</plugin>
测试
-
启动
server
,此时访问localhost:8080/hi
会输出Hello World
-
我们修改
server
代码, 将打印内容变更为Hello JavaAgent
, 重新编译mvn compile -pl server
-
将新的
classes
拷贝到/tmp/
下:cp ./server/target/classes/com/yan/service/impl/HelloServiceImpl.class /tmp/classes/com/yan/service/impl/
-
执行
attach
-
此时访问
localhost:8080/hi
会输出Hello JavaAgent