javassist 学习记录

javassist 学习记录

Ko1sh1

之前看到过一些相关内容,最近稍微学了点基础记录了一下。

javassist 简介

Javassist (JAVA programming ASSISTant) 是在 Java 中编辑字节码的类库;它使 Java 程序能够在运行时定义一个新类, 并在 JVM 加载时修改类文件。

我们常用到的动态特性主要是反射,在运行时查找对象属性、方法,修改作用域,通过方法名称调用方法等。在线的应用不会频繁使用反射,因为反射的性能开销较大。其实还有一种和反射一样强大的特性,但是开销却很低,它就是Javassit。

与其他类似的字节码编辑器不同, Javassist 提供了两个级别的 API: 源级别和字节码级别。 如果用户使用源级 API, 他们可以编辑类文件, 而不知道 Java 字节码的规格。 整个 API 只用 Java 语言的词汇来设计。 您甚至可以以源文本的形式指定插入的字节码; Javassist 在运行中编译它。 另一方面, 字节码级 API 允许用户直接编辑类文件作为其他编辑器。

CtClass

可以把它理解成加强版的Class对象,需要从ClassPool中获得。

获得方法:CtClass cc = cp.get(ClassName)

AbstractClass.java

1
2
3
4
5
package Bean;

public abstract class AbstractClass {
public abstract void show();
}

InterfaceClass.java

1
2
3
4
5
package Bean;

public interface InterfaceClass {
void show2();
}

CtClass_Learn.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
import Bean.AbstractClass;
import Bean.InterfaceClass;
import javassist.*;

public class CtClass_Learn {
public static void main(String[] args) throws Exception {
ClassPool pool = ClassPool.getDefault();
pool.insertClassPath(new ClassClassPath(AbstractClass.class));
pool.insertClassPath(new ClassClassPath(InterfaceClass.class));
// // 新建类
// CtClass ctClass = pool.makeClass("Ko1sh1");
//
// // 设置父类为抽象类
// CtClass superClass = pool.get(AbstractClass.class.getName());
// ctClass.setSuperclass(superClass);

// 上两步可直接合成为:
CtClass ctClass = pool.makeClass("Ko1sh1",pool.get(AbstractClass.class.getName()));

// 创建抽象 show 方法并添加
CtMethod ctMethod = CtNewMethod.make("public void show(){String name=\"koishi\";System.out.println(name);}", ctClass);
ctClass.addMethod(ctMethod);

// 通过 CtClass 的方式获取接口并添加方法
CtClass interfaceCtClass = pool.makeInterface(InterfaceClass.class.getName());
CtMethod interface_method = CtNewMethod.abstractMethod(CtClass.voidType, "show2", null,null, interfaceCtClass);
interfaceCtClass.addMethod(interface_method);

// 再为原本的类添加一个接口
ctClass.addInterface(interfaceCtClass);

// 接口实现抽象方法的方式与抽象类的函数相同
CtMethod method = CtNewMethod.make("public void show2() { System.out.println(\"Implemented method\"); }", ctClass);
ctClass.addMethod(method);

// 保存class文件
// String savePath = "src/main/java/class_repository/Bean/class_repository";
// ctClass.writeFile(savePath);

// 生成实例化对象
// ctClass.toClass().newInstance();
Class instance_class = ctClass.toClass();
Object instance = instance_class.newInstance();
((AbstractClass)instance).show();
((InterfaceClass)instance).show2();

// 类冻结
try {
ctClass.toClass();
}catch (javassist.CannotCompileException e){
System.out.println("已调用 writeFile(), toClass(), toBytecode() 方法转换成一个类文件,此 CtClass 对象已被冻结,不允许再修改");

// 解冻
ctClass.defrost();
method = CtNewMethod.make("public void show3() { System.out.println(\"HAHA! I'm fine again\"); }", ctClass);
ctClass.addMethod(method);
try{
instance = ctClass.toClass().newInstance();
instance.getClass().getMethod("show3").invoke(instance);
}catch (javassist.CannotCompileException ex){
System.out.println("解冻后,即使可以修改class内容,但是也不能再重新实例化了");
}
}
}
}

对上面的代码选取部分进行解释:

1
2
3
ClassPool pool = ClassPool.getDefault();
pool.insertClassPath(new ClassClassPath(AbstractClass.class));
pool.insertClassPath(new ClassClassPath(InterfaceClass.class));

创建了 ClassPool 对象,并调用了 insertClassPath 方法。因为通过 ClassPool.getDefault() 获取的 ClassPool 使用 JVM 的类搜索路径。如果程序运行在 JBoss 或者 Tomcat 等 Web 服务器上,ClassPool 可能无法找到用户的类,因为 Web 服务器使用多个类加载器作为系统类加载器。在这种情况下,ClassPool 必须添加额外的类搜索路径(在该测试 java 文件中,可以不写这两行,因为默认加载器能找到)。

insertClassPath 也可以注册一个目录作为类搜索路径。下面的例子将 /classes 添加到类搜索路径中

1
pool.insertClassPath("./classes");

类搜索路径不但可以是目录,还可以是 URL :

1
2
3
ClassPool pool = ClassPool.getDefault();
ClassPath cp = new URLClassPath("www.javassist.org", 80, "/java/", "org.javassist.");
pool.insertClassPath(cp);

上述代码将 http://www.javassist.org:80/java/ 添加到类搜索路径。并且这个URL只能搜索 org.javassist 包里面的类。例如,为了加载 org.javassist.test.Main,它的类文件会从http://www.javassist.org:80/java/org/javassist/test/Main.class 获取。

随后新建了 Ko1sh1 类,同时设置其父类为 AbstractClass

1
CtClass ctClass = pool.makeClass("Ko1sh1",pool.get(AbstractClass.class.getName()));

与下面的代码等效

1
2
3
4
5
CtClass ctClass = pool.makeClass("Ko1sh1");

// 设置父类为抽象类
CtClass superClass = pool.get(AbstractClass.class.getName());
ctClass.setSuperclass(superClass);

javassist 中当 CtClass 对象调用 writeFile(), toClass(), toBytecode() 方法时,会转换成一个类文件,此 CtClass 对象已被冻结,不允许再修改,再次调用会产生报错。对于被冻结的 CtClass 对象,可以使用 defrost() 进行解冻,调用 defrost() 之后,此 CtClass 对象又可以被修改了(可以修改,但是不能再次加载进 JVM 中了,一个类只能被加载一次,比如实例化操作就无法再次进行了)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
        // 生成实例化对象
// ctClass.toClass().newInstance();
Class instance_class = ctClass.toClass();
Object instance = instance_class.newInstance();
((AbstractClass)instance).show();
((InterfaceClass)instance).show2();

// 类冻结
try {
ctClass.toClass();
}catch (javassist.CannotCompileException e){
System.out.println("已调用 writeFile(), toClass(), toBytecode() 方法转换成一个类文件,此 CtClass 对象已被冻结,不允许再修改");

// 解冻
ctClass.defrost();
method = CtNewMethod.make("public void show3() { System.out.println(\"HAHA! I'm fine again\"); }", ctClass);
ctClass.addMethod(method);
try{
instance = ctClass.toClass().newInstance();
instance.getClass().getMethod("show3").invoke(instance);
}catch (javassist.CannotCompileException ex){
System.out.println("解冻后,即使可以修改class内容,但是也不能再重新实例化了");
}
}

除此以外,ClassPool.doPruning 属性值为 true 时,在冻结 CtClass 时,会修剪 CtClass 的数据结构。为了减少内存的消耗,修剪操作会丢弃 CtClass 对象中不必要的属性。例如,Code_attribute 结构会被丢弃。修剪过的 CtClass 对象不能再次被解冻。ClassPool.doPruning 的默认值为 false。

也可通过下面的代码驳回裁剪

1
cc.stopPruning(true);

ClassPool

ClassPool 是 CtClass 对象的容器。因为编译器在编译引用 CtClass 代表的 Java 类的源代码时,可能会引用 CtClass 对象,所以一旦一个 CtClass 被创建,它就被保存在 ClassPool 中。简单来说,这就是个容器,存放的是CtClass对象。

如果 CtClass 对象的数量变得非常大(这种情况很少发生,因为 Javassist 试图以各种方式减少内存消耗),ClassPool 可能会导致巨大的内存消耗。 为了避免此问题,可以从 ClassPool 中显式删除不必要的 CtClass 对象。 如果对 CtClass 对象调用 detach(),那么该 CtClass 对象将被从 ClassPool 中删除。

1
2
3
CtClass cc = ... ;
cc.writeFile();
cc.detach();

在调用 detach() 之后,就不能调用这个 CtClass 对象的任何方法了。但是如果你调用 ClassPool 的 get() 方法,ClassPool 会再次读取这个类文件,创建一个新的 CtClass 对象。

由于 ClassPool.getDefault() 是为了方便而提供的单例工厂方法,它保留了一个ClassPool的单例并重用它。所以创建的新的 ClassPool 替换旧的 ClassPool,并将旧的 ClassPool 丢弃。除该方法以外,还可通过 new ClassPool(true) 构造一个 ClassPool 对象。

级联的 ClassPools

如果程序正在 Web 应用程序服务器上运行,则可能需要创建多个 ClassPool 实例; 应为每一个 ClassLoader 创建一个 ClassPool 的实例。 程序应该通过 ClassPool 的构造函数,而不是调用 getDefault() 来创建一个 ClassPool 对象。
多个 ClassPool 对象可以像 java.lang.ClassLoader 一样级联。 例如,

1
2
3
ClassPool parent = ClassPool.getDefault();
ClassPool child = new ClassPool(parent);
child.insertClassPath("./classes");

如果调用 child.get(),子 ClassPool 首先委托给父 ClassPool。如果父 ClassPool 找不到类文件,那么子 ClassPool 会尝试在 ./classes 目录下查找类文件。如果 child.childFirstLookup 设置为 true,那么子类 ClassPool 会在委托给父 ClassPool 之前尝试查找类文件。

1
2
3
4
ClassPool parent = ClassPool.getDefault();
ClassPool child = new ClassPool(parent);
child.appendSystemPath(); // the same class path as the default one.
child.childFirstLookup = true; // changes the behavior of the child.

拷贝一个已经存在的类来定义一个新的类

1
2
3
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("Point");
cc.setName("Pair");

这个程序首先获得类 Point 的 CtClass 对象。然后它调用 setName() 将这个 CtClass 对象的名称设置为 Pair。在这个调用之后,这个 CtClass 对象所代表的类的名称 Point 被修改为 Pair。类定义的其他部分不会改变。

因此,如果后续在 ClassPool 对象上再次调用 get(“Point”),则它不会返回变量 cc 所指的 CtClass 对象。 而是再次读取类文件 Point.class,并为类 Point 构造一个新的 CtClass 对象。 因为与 Point 相关联的 CtClass 对象不再存在。示例:

1
2
3
4
5
6
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("Point");
CtClass cc1 = pool.get("Point"); // cc1 is identical to cc.
cc.setName("Pair");
CtClass cc2 = pool.get("Pair"); // cc2 is identical to cc.
CtClass cc3 = pool.get("Point"); // cc3 is not identical to cc.

cc1 和 cc2 指向 CtClass 的同一个实例,而 cc3 不是。 注意,在执行 cc.setName(“Pair”) 之后,cc 和 cc1 引用的 CtClass 对象都表示 Pair 类。

除了上面的内容,还需要注意,一旦一个 CtClass 对象被 writeFile() 或 toBytecode() 转换为一个类文件,Javassist 会拒绝对该 CtClass 对象的进一步修改。因此,在表示 Point 类的 CtClass 对象被转换为类文件之后,不能将 Pair 类定义为 Point 的副本,在 Point 上执行 setName() 将会被拒绝。 以下代码段是错误的:

1
2
3
4
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("Point");
cc.writeFile();
cc.setName("Pair"); // wrong since writeFile() has been called.

为了避免这种限制,应该在 ClassPool 中调用 getAndRename() 方法。 例如:

1
2
3
4
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("Point");
cc.writeFile();
CtClass cc2 = pool.getAndRename("Point", "Pair");

如果调用 getAndRename(),ClassPool 首先读取 Point.class 来创建一个新的表示 Point 类的 CtClass 对象。 而且,它会在这个 CtClass 被记录到哈希表之前,将 CtClass 对象重命名为 Pair。因此,getAndRename() 可以在表示 Point 类的 CtClass 对象上调用 writeFile() 或 toBytecode() 后执行。

类加载器 (Class Loader)

在Java中,多个类加载器可以共存,每个类加载器创建自己的名称空间。不同的类加载器可以加载具有相同类名的不同类文件。加载的两个类被视为不同的类。此功能使我们能够在单个 JVM 上运行多个应用程序,即使这些程序包含具有相同名称的不同的类。

JVM 不允许动态重新加载类。一旦类加载器加载了一个类,它不能在运行时重新加载该类的修改版本。

如果相同的类文件由两个不同的类加载器加载,则 JVM 会创建两个具有相同名称和定义的不同的类。由于两个类不相同,一个类的实例不能被分配给另一个类的变量。两个类之间的转换操作将失败并抛出一个 ClassCastException。

1
2
3
4
MyClassLoader myLoader = new MyClassLoader();
Class clazz = myLoader.loadClass("Box");
Object obj = clazz.newInstance();
Box b = (Box)obj; // this always throws ClassCastException.

上述代码将报错,因为 Box 由默认的 classloader 加载,obj 是通过自定义的 classloader 加载的,强制转换将产生报错

javassist.Loader

Javassit 提供一个类加载器 javassist.Loader。它使用 javassist.ClassPool 对象来读取类文件。
例如,javassist.Loader 可以用于加载用 Javassist 修改过的类。

类加载前

FatherBean.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package Bean;

public class FatherBean {
private int id;

public FatherBean() {
}

public FatherBean(int id) {
this.id = id;
}

public int getId() {
return id;
}

public void setId(int id) {
this.id = id;
}
}

SimpleBean.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
package Bean;

public class SimpleBean {
private int age;
private String name;

public SimpleBean(int age, String name) {
this.age = age;
this.name = name;
}

public SimpleBean() {
}

public int getAge() {
return age;
}

public void setAge(int age) {
this.age = age;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}
}

javassistLoader_Learn.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import javassist.ClassPool;
import javassist.CtClass;
import javassist.Loader;

public class javassistLoader_Learn {
public static void main(String[] args) throws Exception{
ClassPool pool = ClassPool.getDefault();
Loader cl = new Loader(pool);

CtClass ct = pool.get("Bean.SimpleBean");
ct.setSuperclass(pool.get("Bean.FatherBean"));

Class c = cl.loadClass("Bean.SimpleBean");
Object instance = c.newInstance();

// 可以发现成功继承了父类,说明加载了被修改后的字节
System.out.println(instance.getClass().getSuperclass());
instance.getClass().getMethod("setId",int.class).invoke(instance,21);
System.out.println(instance.getClass().getMethod("getId").invoke(instance));
}
}

通过上面的代码,可以发现 javassist 的 Loader 加载的是修改后的字节码

类加载时

如果用户希望在加载时按需修改类,则可以向 javassist.Loader 添加事件监听器。当类加载器加载类时会通知监听器。事件监听器类必须实现以下接口:

1
2
3
4
5
6
public interface Translator {
public void start(ClassPool pool)
throws NotFoundException, CannotCompileException;
public void onLoad(ClassPool pool, String classname)
throws NotFoundException, CannotCompileException;
}

当事件监听器通过 addTranslator() 添加到 javassist.Loader 对象时,start() 方法会被调用。在 javassist.Loader 加载类之前,会调用 onLoad() 方法。可以在 onLoad() 方法中修改被加载的类的定义。

例如,下面的事件监听器在类加载之前,将类设为public,并将指定方法修改为 public static 修饰(注意,这里的只能去修改访问权限,不能修改 final 和 static 这种修饰,否则会出现报错,如果不写也会产生报错)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import javassist.*;

public class MyTranslator implements Translator {
public void start(ClassPool pool) throws NotFoundException, CannotCompileException {}
public void onLoad(ClassPool pool, String classname) throws NotFoundException, CannotCompileException {
System.out.println("Translating " + classname);
CtClass cc = pool.get(classname);
// 将类设为 public
cc.setModifiers(Modifier.PUBLIC);
// 将指定方法设为 public, 并且要写齐,public 和 static 都要写,否则会产生报错
cc.getDeclaredMethod("testMethod").setModifiers(Modifier.PUBLIC | Modifier.STATIC);

}
}

注意,onLoad() 不必调用 toBytecode() 或 writeFile(),因为 javassist.Loader 会调用这些方法来获取类文件。

写个测试类来测试 Translator 功能

TestApp.java 被加载对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;

public class TestApp {
private static String testMethod(){
return "test, test!";
}

public static void main(String[] args) throws Exception{
Method[] methods = TestApp.class.getDeclaredMethods();
for (Method method : methods) {
int modifiers = method.getModifiers();
if (Modifier.isPublic(modifiers)) {
System.out.println("Method " + method.getName() + " is public");
}

if (Modifier.isPrivate(modifiers)) {
System.out.println("Method " + method.getName() + " is private");
}
}
}
}

直接运行为:

1
2
Method main is public
Method testMethod is private

创建 Loader 来使用 Translator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import javassist.ClassPool;
import javassist.Loader;
import javassist.Translator;

public class Translator_Learn {
public static void main(String[] args) throws Throwable {
Translator t = new MyTranslator();
ClassPool pool = ClassPool.getDefault();
Loader cl = new Loader();
cl.addTranslator(pool, t);
// 这里的 args 就是触发 TestApp main 方法传入的 args
cl.run("TestApp", args);
}
}

运行结果为:

1
2
3
Translating G1_TestApp
Method testMethod is public
Method main is public

此外,还需要注意,TestApp 不能访问 Loader 类,如 Translator_Learn,MyTranslator 和 ClassPool,因为它们是由不同的加载器加载的。 TestApp 类由 javassist.Loader 加载,而加载器类(例如 Translator_Learn)是由默认的 Java 类加载器加载。

自定义类加载器

G2_SelfClassLoader_Learn.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
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import javassist.*;

import java.io.IOException;

public class G2_SelfClassLoader_Learn extends ClassLoader {


private ClassPool pool;

public G2_SelfClassLoader_Learn() throws NotFoundException {
pool = new ClassPool();
pool.insertClassPath("src/main/java/g2_classes"); // 指定 class 文件位置,该目录不能是程序的 class 输出位置,否则 JVM 会用默认的类加载器去加载该类。
}

// 调用 指定类 的 main 方法
public static void main(String[] args) throws Throwable {
G2_SelfClassLoader_Learn selfLoader = new G2_SelfClassLoader_Learn();
Class c = selfLoader.loadClass("G2_TestApp");
c.getDeclaredMethod("main", new Class[] { String[].class }).invoke(null, new Object[] { args });
}

protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
CtClass cc = pool.get(name);
// 在这里可以自己自定义去动态修改一些内容
// 比如像 CC 中常见的那样插入一个弹计算器方法,这样每个使用该加载器加载的类都会弹计算器
pool.insertClassPath(new ClassClassPath(AbstractTranslet.class));
String cmd = "java.lang.Runtime.getRuntime().exec(\"%s\");";
cmd = String.format(cmd, "calc");
cc.makeClassInitializer().insertBefore(cmd);
cc.setSuperclass(pool.get(AbstractTranslet.class.getName()));

// cc.setSuperclass(pool.get(AbstractTranslet.class.getName()));
byte[] b = cc.toBytecode();
return defineClass(name, b, 0, b.length);
} catch (NotFoundException | IOException | CannotCompileException e) {
e.printStackTrace();
return null;
}
}
}

G2_TestApp.java

1
2
3
4
5
6
7
8
public class G2_TestApp {
public G2_TestApp() {
}

public static void main(String[] args) {
System.out.println("hello, koishi and shruti!");
}
}

G2_TestApp 类是一个应用程序。 要执行此程序,首先将类文件放在 ./g2_classes 目录下,它目录不能是程序的 class 输出位置,否则 JVM 会用默认的类加载器去加载该类,它是我们自定义的 G2_SelfClassLoader_Learn 的父加载器。目录名 ./g2_classes 由构造函数中的 insertClassPath() 指定。然后运行,则其会去执行 G2_TestApp 的 main 方法。此外,在 defineclass 方法中,还可以使用 javassist 去动态的修改字节码。

修改系统的类

像 java.lang.String 这样的系统类只能被系统类加载器加载。因此,上面的 SampleLoader 或 javassist.Loader 在加载时不能修改系统类。系统类必须被静态地修改。下面的程序向 java.lang.String 添加一个新字段 hiddenValue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtField;
import javassist.Modifier;

public class G3_JavaOriginClass {
public static void main(String[] args) throws Throwable{
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("java.lang.String");
CtField f = new CtField(CtClass.intType, "hiddenValue", cc);
f.setModifiers(Modifier.PUBLIC);
cc.addField(f);
cc.writeFile("src/main/java");
}
}

再创建一个测试类:

1
2
3
4
5
6
7
public class G3_TestApp {
public static void main(String[] args) throws Exception{
System.out.println(String.class.getField("hiddenValue").getName());
// javac G3_TestApp
// java -Xbootclasspath/p:. G3_TestApp
}
}

运行测试类时通过 -Xbootclasspath/p 去指定引导类路径,使其能够加载到我们改好的 String 方法。

使用此技术来覆盖 rt.jar 中的系统类,则不需要再去手动修改。

在运行时重新加载类

如果 JVM 在启用 JPDA 的情况下启动,那么使用 HotSwapper 可以动态地重新加载其他类。

启动HotSwapperTest 的 VM options 添加如下配置项

1
-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8000

此外,HotSwapper 运行需要 tools.jar

1
2
3
4
5
6
7
<dependency>
<groupId>com.sun</groupId>
<artifactId>tools</artifactId>
<version>1.4.2</version>
<scope>system</scope>
<systemPath>${java.home}/../lib/tools.jar</systemPath>
</dependency>

测试类如下:

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
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;
import javassist.util.HotSwapper;

public class G4_HotSwapper {
static class Person{
public void say(){
System.out.println("whoami???");
}
}

public static void main(String[] args) throws Throwable{
Person person = new Person();

/* 创建线程循环调用Person类的 say 方法 */
new Thread(() -> {
while (true){
person.say();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();

/* Javassist 运行时修改 Person 类的 say 方法 */
ClassPool classPool = ClassPool.getDefault();
CtClass ctClass = classPool.get(person.getClass().getName());

CtMethod ctMethod = ctClass.getDeclaredMethod("say");
ctMethod.setBody("System.out.println(\"Oh, I'm Ko1sh1!\");");

/*
* HotSwapper热修改Person类,需要开启 JPDA,监听 8000 端口
* java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8000
*/
HotSwapper hs = new HotSwapper(8000);
hs.reload(person.getClass().getName(), ctClass.toBytecode());
}
}

结果如下:

1
2
3
4
5
6
7
whoami???
whoami???
whoami???
whoami???
Oh, I'm Ko1sh1!
Oh, I'm Ko1sh1!
Oh, I'm Ko1sh1!

可以发现成功修改了其内容

CtMethod 与 CtConstructor 使用

CtMethod 可以理解成加强版的Method对象。

获得方法:CtMethod m = cc.getDeclaredMethod(MethodName)

这个类提供了方法 setBodyinsertBeforeinsertAfterinsertAt 等方法,使我们可以便捷的修改方法体。

在方法体的开始/结尾处添加代码

CtMethod 和 CtConstructor 提供了 insertBefore(),insertAfter() 和 addCatch() 方法。 它们可以将用 Java 编写的代码片段插入到现有方法中。Javassist 包括一个用于处理源代码的简单编译器,它接收用 Java 编写的源代码,并将其编译成 Java 字节码,并内联方法体中。还可以向 CtMethod 和 CtConstructor 中的 insertAt() 方法提供源代码和原始类定义中的源文件的行号,就可以将编译后的代码插入到指定行号位置。

方法 insertBefore() ,insertAfter(),addCatch() 和 insertAt() 接收一个表示语句或语句块的 String 对象。一个语句是一个单一的控制结构,比如 if 和 while 或者以分号结尾的表达式。语句块是一组用大括号 {} 包围的语句。因此,以下每行都是有效语句或块的示例:

1
2
3
System.out.println("Hello");
{ System.out.println("Hello"); }
if (i < 0) { i = -i; }

此外,编译器支持语言扩展,以 $ 开头的几个标识符有特殊的含义:

符号 含义
$0, $1, $2, … $0 = this; $1 = args[0] ..... (如果方法是静态的,则 0 不可用)
$args 方法参数数组。它的类型为 Object[]
$$ 所有实参。例如, m($$) 等价于 m($1,$2,)
$cflow() cflow 变量
$r 返回结果的类型,用于强制类型转换
$w 包装器类型,用于强制类型转换
$_ 返回值
$sig 类型为 java.lang.Class 的参数类型数组
$type 一个 java.lang.Class 对象,表示返回值类型
$class 一个 java.lang.Class 对象,表示当前正在修改的类

$0, $1, $2

写个测试类简单看看这三个值的用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;

public class G5_CtMethod_Learn {
static class Test{
void add(int a, int b){
System.out.println(a + b);
}
}


public static void main(String[] args) throws Throwable{
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("G5_CtMethod_Learn$Test");
CtMethod m = cc.getDeclaredMethod("add");
m.insertBefore("{ System.out.println($0.getClass().getName());System.out.println(\"first num:\"+$1);\nSystem.out.println(\"second num:\"+$2);}");
cc.writeFile("javassist_learn/src/main/java/class_repository");

Class<?> aClass = cc.toClass();
Object instance = aClass.newInstance();
aClass.getDeclaredMethod("add", int.class, int.class).invoke(instance, 10, 20);
}
}

Test 类的字节变为下面这样

1
2
3
4
5
6
7
8
9
10
11
class G5_CtMethod_Learn$Test {
G5_CtMethod_Learn$Test() {
}

void add(int a, int b) {
System.out.println(this.getClass().getName());
System.out.println("first num:" + a);
System.out.println("second num:" + b);
System.out.println(a + b);
}
}

$args

方法参数数组。它的类型为 Object[],如果参数类型是基本数据类型(int,char等),则该参数值将转换为包装器对象(如 java.lang.Integer)存在 args 中。当第一个数据类型不是基本数据类型时,args[0] 即为 $1(不是 $0,因为 $0 是 this)

此外,javassist 不会进行装包和拆包,Integer 数据类型不能直接进行四则运算。

我们将之前的代码改为如下

1
2
3
4
5
6
7
8
9
10
        ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("G5_CtMethod_Learn$Test");
CtMethod m = cc.getDeclaredMethod("add");
// m.insertBefore("{ System.out.println($0.getClass().getName());System.out.println(\"first num:\"+$1);\nSystem.out.println(\"second num:\"+$2);}");
m.insertBefore("{ System.out.println(java.util.Arrays.toString($args));\nSystem.out.println($args[0]+$args[1]);}");
cc.writeFile("javassist_learn/src/main/java/class_repository");

Class<?> aClass = cc.toClass();
Object instance = aClass.newInstance();
aClass.getDeclaredMethod("add", int.class, int.class).invoke(instance, 10, 20);

生成的代码为:

1
2
3
4
5
void add(int a, int b) {
System.out.println(Arrays.toString(new Object[]{new Integer(a), new Integer(b)}));
System.out.println(String.valueOf((new Object[]{new Integer(a), new Integer(b)})[0]).concat(String.valueOf((new Object[]{new Integer(a), new Integer(b)})[1])));
System.out.println(a + b);
}

我们可以发现,首先 int 这种基本数据类型确实转换为了包装类,此外,当我们对 Integer 对象进行 + 运算时,转换的字节码则是转化为了字符串拼接,而不是整数的加运算。

$$,$proceed

变量 $$ 是所有参数列表的缩写,用逗号分隔。

将之前的代码改为如下

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
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;

public class G5_CtMethod_Learn {
static class Test{
void add(int a, int b){
System.out.println(a + b);
}
void sub(int a, int b){
System.out.println(Math.abs(a-b));
}
}


public static void main(String[] args) throws Throwable{
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("G5_CtMethod_Learn$Test");
CtMethod m = cc.getDeclaredMethod("add");
m.insertBefore("{ sub($$);}");

cc.writeFile("javassist_learn/src/main/java/class_repository");

Class<?> aClass = cc.toClass();
Object instance = aClass.newInstance();
aClass.getDeclaredMethod("add", int.class, int.class).invoke(instance, 10, 20);
}
}

生成的 class 文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
class G5_CtMethod_Learn$Test {
G5_CtMethod_Learn$Test() {
}

void add(int a, int b) {
this.sub(a, b);
System.out.println(a + b);
}

void sub(int a, int b) {
System.out.println(Math.abs(a - b));
}
}

可以与其他方法一起使用。

假如写:

1
exFunc($$, context)

等价于(由于 add 函数只有两个参数,所以 $$ 也只会生成两个,根据调用的函数参数数量确定)

1
exFunc($1, $2, context)

$proceed 表示的是调用原始方法,可以配合 $$ 表示原本方法

1
$proceed($$)

$cflow

$cflow 表示控制流。该变量是只读变量,会返回特定方法的递归调用的深度。

调用 $cflow 监视 fact() 方法的调用:

1
2
3
4
5
6
7
8
9
10
11
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("G5_CtMethod_Learn$Test");
CtMethod m = cc.getDeclaredMethod("fact");
m.useCflow("fact");
m.insertBefore("{ System.out.println(\"函数递归深度:\"+$cflow(fact));}");
cc.writeFile("javassist_learn/src/main/java/class_repository");


Class<?> aClass = cc.toClass();
Object instance = aClass.newInstance();
aClass.getDeclaredMethod("fact",int.class).invoke(instance,5);

为 Test 新建一个循环函数

1
2
3
4
5
6
int fact(int n) {
if (n <= 1)
return n;
else
return n * fact(n - 1);
}

运行输出中看到:

1
2
3
4
5
函数递归深度:0
函数递归深度:1
函数递归深度:2
函数递归深度:3
函数递归深度:4

$r,$_

$r 函数的返回值类型

$_ 返回值

$_ 用于在 CtMethod 中的 insertAfter() 和 CtConstructor() 在方法的末尾插入编译的代码(insertBefore 等函数使用会产生报错),支持使用 $1,$2,$_等内容。

1
2
3
4
5
6
7
8
9
10
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("G5_CtMethod_Learn$Test");
CtMethod m = cc.getDeclaredMethod("word");
m.insertAfter("{Object result = \"hahaha\";$_=($r)result;}");
cc.writeFile("javassist_learn/src/main/java/class_repository");


Class<?> aClass = cc.toClass();
Object instance = aClass.newInstance();
System.out.println(aClass.getDeclaredMethod("word").invoke(instance));

word函数

1
2
3
String word(){
return "1";
}

运行后输出,发现能成功改变

1
hahaha

$w

自动转换为对应的包装器类型,用于强制类型转换。当存在如下代码时

1
Integer i = ($w)5;

写入的class内容为:

1
Integer i = new Integer(5);

$sig

$sig 的值是一个 java.lang.Class 对象的数组,表示声明的形式参数类型。

1
m.insertBefore("{System.out.println(java.util.Arrays.toString($sig));}");

产生的class内容如下:

1
System.out.println(Arrays.toString(Desc.getParams("(II)")));

输出的是个 Class 数组

1
[int,int]

getParams 是 javassist.runtime.Desc 包下的,该方法接受一个方法描述符作为参数,然后返回一个字符串数组,其中包含了描述该方法参数类型的字符串。

该方法会解析方法描述符,提取出其中的参数类型信息,并将其转换为字符串数组返回。

  • B: byte 类型
  • C: char 类型
  • D: double 类型
  • F: float 类型
  • I: int 类型
  • J: long 类型
  • S: short 类型
  • Z: boolean 类型
  • L<full-classname>;: 对象引用类型,其中 <full-classname> 是类的完整路径名
  • [: 数组类型

所以上面转化的class文件中以下内容,实际上就是返回的是包含两个 int 类型的数组

1
Desc.getParams("(II)")

$type

$type 的值是一个 java.lang.Class 对象,表示函数返回值的类型。 如果这是一个构造函数,此变量返回 Void.class。

和上面的 $sig 差不多,但是其调用的方法是 Desc.getType,比如 Desc.getType(“V”) 表示的返回值类型是 void

$class

用于引用当前正在编辑的类的类型,通常在添加字段或方法时使用。与 $0 有点相似

$e,addCatch()

在插入的源代码中,异常用 $e 表示。

测试代码

1
2
3
4
5
6
7
8
public static void test4() throws Throwable {
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("G5_CtMethod_Learn$Test");
CtMethod m = cc.getDeclaredMethod("word");
CtClass etype = ClassPool.getDefault().get("java.io.IOException");
m.addCatch("{ System.out.println($e); throw $e; }", etype);
cc.writeFile("javassist_learn/src/main/java/class_repository");
}

写入的class 内容如下

1
2
3
4
5
6
7
8
String word() {
try {
return "1"; // 原本的内容
} catch (IOException var2) {
System.out.println(var2);
throw var2;
}
}

可以发现原本的类方法在 try 块中,而通过 addCatch 的内容添加在了 catch 块中。

还需要注意,插入的代码片段必须以 throw 或 return 语句结束。

修改方法体

CtMethod 和 CtConstructor 提供 setBody() 来替换整个方法体。他将新的源代码编译成 Java 字节码,并用它替换原方法体。 如果给定的源文本为 null,则替换后的方法体仅包含返回语句,返回零或空值,除非结果类型为 void。

在传递给 setBody() 的源代码中,以 $ 开头的标识符也具有特殊含义,处理方式与上面的提到的内容一致(但是 $_ 不可用)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;

public class G5_CtMethod_Learn_SetBody {
static class Test {
void add(int a, int b) {
System.out.println(a + b);
}
}

public static void main(String[] args) throws Throwable {
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("G5_CtMethod_Learn_SetBody$Test");
CtMethod m = cc.getDeclaredMethod("add");
m.setName("mul");
m.setBody("{System.out.println($1 * $2);}");
cc.writeFile("javassist_learn/src/main/java/class_repository");
Class<?> aClass = cc.toClass();
Object instance = aClass.newInstance();
aClass.getDeclaredMethod("mul", int.class, int.class).invoke(instance, 10, 20);
}
}

替换表达式

javassist.expr.MethodCall

Javassist 只允许修改方法体中包含的表达式。javassist.expr.ExprEditor 是一个用于替换方法体中的表达式的类。用户可以定义 ExprEditor 的子类来指定修改表达式的方式。

要运行 ExprEditor 对象,用户必须在 CtMethod 或 CtClass 中调用 instrument()。

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
static class Test {
void add(int a, int b) {
System.out.println(a + b);
}

int square(int a){
System.out.println(a);
return a*a;
}
}

public static void main(String[] args) throws Throwable {
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("G5_CtMethod_Learn_EditBody$Test");
CtMethod m = cc.getDeclaredMethod("square");
m.instrument(new ExprEditor() {
@Override
public void edit(MethodCall mc) throws CannotCompileException {
mc.replace("{ System.out.println(\""+mc.where().getName()+"调用"+mc.getClassName()+"类的方法: "+mc.getMethodName() +"\");$1 = 10; $_ = $proceed($$); }");
}
});
cc.writeFile("javassist_learn/src/main/java/class_repository");
Class<?> aClass = cc.toClass();
Test instance = (Test)aClass.getDeclaredConstructor().newInstance();
System.out.println(instance.square(5));
}

这里需要注意的是,这个方式修改的是调用的方法中存在的其他方法。比如上面代码寻找的是 square 方法,修改了 println 方法,也就是这个方法内的其他方法,$1 也是 println 的第一个参数,而不是 square 的第一个参数,这个需要注意一下;如果需要访问当前调用的方法名称,可以通过 .where().getName() 获取,比如上面的代码运行结果为:

1
2
3
square调用java.io.PrintStream类的方法: println
10 // $1 只修改了 println 的第一个参数的值,而不是修改的 a 的值
25 // 这个是 square 的返回值,可以发现实际上传入的 a 值并没有变

调用 edit() 参数的 replace() 方法可以将表达式替换为我们给定的语句。如果给定的语句是空块,即执行replace(“{}”),则将表达式删除。如果要在表达式之前或之后插入语句(或块),则应该将类似以下的代码传递给 replace():

1
2
3
{ *before-statements;*
$_ = $proceed($$);
*after-statements;* }

直接点说也就是不想改的部分记得照写。

上述代码中的 MethodCall 类的 replace 方法和之前接触的 CtMethod 方法中 $ 的作用是一样的($0 表示方法调用的目标对象。它不等于 this,它代表了调用者。 如果方法是静态的,则 $0 为 null)。

除了 MethodCall 类,ExprEditor 的 edit 其实有许多的重构方法。

image-20240308144738048

javassist.expr.ConstructorCall

ConstructorCall 表示构造函数调用,ConstructorCall 中的方法 replace() 可以使用语句或代码块来代替构造函数。它接收表示替换语句或块的源代码。以 $ 开头的标识符同样具有特殊的含义,具体同上。

由于任何构造函数必须调用超类的构造函数或同一类的另一个构造函数,所以替换语句必须包含构造函数调用,通常是对 $proceed() 的调用。否则会出现如下报错:

1
Constructor must call super() or this() before return

示例:

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
public static void main(String[] args) throws Throwable {
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("G5_CtMethod_Learn_EditBody$Test");
CtConstructor ctConstructor = cc.getDeclaredConstructor(null);
ctConstructor.instrument(new ExprEditor() {
@Override
public void edit(ConstructorCall cc) throws CannotCompileException {
cc.replace("{System.out.println(\"Hello Ko1sh1\");$proceed($$);}");
}
});
cc.writeFile("javassist_learn/src/main/java/class_repository");
cc.toClass().getDeclaredConstructor().newInstance();
}

static class Test {
public Test(){
}
void add(int a, int b) {
System.out.println(a + b);
}

int square(int a){
System.out.println(a);
return a*a;
}
}

javassist.expr.FieldAccess

FieldAccess 对象表示字段访问。 如果找到对应的字段访问操作,ExprEditor 中的 edit() 方法将接收到一个 FieldAccess 对象。FieldAccess 中的 replace() 方法接收替源代码来替换字段访问。

在源代码中,以 $ 开头的标识符具有特殊含义:

符号 含义
$0 表达式访问的字段。它不等于 this。this 表示调用表达式所在方法的对象。如果字段是静态的,则 $0 为 null
$1 如果表达式是写操作,则写的值将保存在 $1中,否则 $1 不可用
$_ 如果表达式是读操作,则结果值需要保存在 $_ 中的值,否则将舍弃 $_ 的值
$r 如果表达式是读操作,则 $r 表示读取的类型,否则 $r 为 void
$class 一个 java.lang.Class 对象,表示字段所在的类
$type 一个 java.lang.Class 对象,表示字段的类型
$proceed 执行原始字段访问的虚拟方法的名称

测试代码:

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
public static void main(String[] args) throws Throwable {
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("G5_CtMethod_Learn_EditBody$Test");
CtMethod m = cc.getDeclaredMethod("fieldTest");
m.instrument(new ExprEditor() {
@Override
public void edit(FieldAccess fa) throws CannotCompileException {
// 如果是写操作
if (fa.isWriter()) {
fa.replace("{System.out.println(\"写入的值是:\"+$1);}");
}
// 如果是读操作
if (fa.isReader()) {
fa.replace("{System.out.println($_=\""+fa.getFieldName()+"被读取\"); }");
}
}
});
cc.writeFile("javassist_learn/src/main/java/class_repository");
Test instance = (Test)cc.toClass().getDeclaredConstructor().newInstance();
instance.fieldTest();
}

static class Test {
private String name;
public Test(){
}
void add(int a, int b) {
System.out.println(a + b);
}

int square(int a){
System.out.println(a);
return a*a;
}
String fieldTest(){
this.name = "Ko1sh1";
return this.name;
}
}

javassist.expr.NewExpr

NewExpr 表示使用 new 运算符(不包括数组创建)创建对象的表达式。 如果发现创建对象的操作,NewEditor 中的 edit() 方法将接收到一个 NewExpr 对象。NewExpr 中的 replace() 方法接收替源代码来替换字段访问。

javassist.expr.NewArray

NewArray 表示使用 new 运算符创建数组。如果发现数组创建的操作,ExprEditor 中的 edit() 方法一个 NewArray 对象。NewArray 中的 replace() 方法可以使用源代码来替换数组创建操作。

$ 开头的符号存在部分不同含义

符号 含义
$0 null
$1, $1 每一维的大小
$_ 创建数组的返回值。一个新的数组对象存储在 $_ 中
$r 所创建的数组的类型

比如按下面的方式创建数组

1
String[][] s = new String[3][4];

那么$1、$2 分别是 3 和 4,$3 不可用。(如果创建的时候省略了最后一维的维度,那么最后一维也不可用)

javassist.expr.Instanceof

一个 InstanceOf 对象表示一个 instanceof 表达式。 如果找到 instanceof 表达式,则ExprEditor 中的 edit() 方法接收此对象。Instanceof 中的 replace() 方法可以使用源代码来替换 instanceof 表达式。

以$开头的标识符具有特殊含义

符号 含义
$0 null
$1 instanceof 运算符左侧的值
$_ 表达式的返回值。类型为 boolean
$r instanceof 运算符右侧的值
$type 一个 java.lang.Class 对象,表示 instanceof 运算符右侧的类型
$proceed 执行 instanceof 表达式的虚拟方法的名称。它需要一个参数(类型是 java.lang.Object)。如果参数类型和 instanceof 表达式右侧的类型一致,则返回 true。否则返回 false。

javassist.expr.Cast

Cast 表示 cast 表达式。如果找到 cast 表达式,ExprEditor 中的 edit() 方法会接收到一个 Cast 对象。 Cast 的 replace() 方法可以接收源代码来替换替换 cast 表达式。

符号 含义
$0 null
$1 显示类型转换的目标类型
$_ 表达式的结果值。$_ 的类型和被括号括起来的类型相同
$r 转换之后的类型,即被括号括起来的类型
$type 一个 java.lang.Class 对象,和 $r 的类型相同
$proceed 执行类型转换的虚拟方法的名称。它需要一个参数(类型是 java.lang.Object)。并在类型转换完成后返回它

javassist.expr.Handler

Handler 对象表示 try-catch 语句的 catch 子句。 如果找到 catch,ExprEditor 中的 edit() 方法会接收此对象。 Handler 中的 insertBefore() 方法会将收到的源代码插入到 catch 子句的开头。

在源文本中,以$开头的标识符具有意义:

符号 含义
$1 catch 分支获得的异常对象
$r catch 分支获得的异常对象的类型,用于强制类型转换
$w 包装类型,用于强制类型转换
$type 一个 java.lang.Class 对象,表示 catch 捕获的异常的类型

如果一个新的异常分配给 $1,它将作为捕获的异常传递给原始的 catch 子句。

CtField 添加字段

Javassist 还允许用户创建一个新字段。其中,可以通过 setModifiers 设置修饰类型,addField 的第二个参数表示计算初始值的表达式。这个表达式可以是任意 Java 表达式,只要其结果与字段的类型匹配。 请注意,表达式不以分号结尾。如不写第二个参数,则使用默认值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtField;

public class G6_CtFieldTest {
public static void main(String[] args) throws Throwable{
ClassPool pool = ClassPool.getDefault();
CtClass ctClass = pool.get("G6_CtFieldTest$Test");
CtField cf = new CtField(pool.get(int.class.getName()), "name", ctClass);
cf.setModifiers(javassist.Modifier.PRIVATE);
ctClass.addField(cf,"5+5");
ctClass.writeFile("javassist_learn/src/main/java/class_repository");
}
static class Test{
public Test(){
}
}
}

类变为:

1
2
3
4
5
6
class G6_CtFieldTest$Test {
private int name = 10;

public G6_CtFieldTest$Test() {
}
}

上面的方法也可以简写为:

1
2
3
CtClass ctClass = ClassPool.getDefault().get("G6_CtFieldTest$Test");
CtField f = CtField.make("public int z = 0;", ctClass);
point.addField(f);

删除成员

要删除字段或方法,可以使用 CtClass 的 removeField() 或 removeMethod() 方法。 一个CtConstructor 可以通过 CtClass 的 removeConstructor() 删除。

导包

需要导入的所有类名都必须是完整的(必须包含包名,java.lang 除外)。例如,Javassist 编译器可以解析 Object 以及 java.lang.Object。

ClassPool中 调用 importPackage() 可以告诉编译器在解析类名时搜索其他包。 例如,

1
2
3
4
5
ClassPool pool = ClassPool.getDefault();
pool.importPackage("java.awt");
CtClass cc = pool.makeClass("Test");
CtField f = CtField.make("public Point p;", cc);
cc.addField(f);

第二行导入了 java.awt 包。 因此,第三行不会抛出异常。 编译器可以将 Point 识别为java.awt.Point。

注意 importPackage() 不会影响 ClassPool 中的 get() 方法。只有编译器才考虑导入包。 get() 的参数必须是完整类名。

  • 标题: javassist 学习记录
  • 作者: Ko1sh1
  • 创建于 : 2024-03-08 20:35:04
  • 更新于 : 2024-03-08 21:33:49
  • 链接: https://ko1sh1.github.io/2024/03/08/blog_javassist 学习/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论