0%

Javassist学习笔记

序言

醉里挑灯看剑,梦回吹角连营。

整理Javassist相关知识 。

背景

比ASM更适合人类操纵字节码,使用API可以实现例如生成类、修改类的操作。

允许Java程序可以在运行时定义一个新的Class、在JVM加载时修改.class文件。

提供了2各层次的API:源码级别、字节码级别。

如果用户使用了源码级别的API,就可以在不了解Java字节码规范的情况下编辑class文件。整个API是基于Java语言词汇设计的。你甚至可以以源码文本形式指定插入字节码,javassist编译它是非常快的。

另一方面。字节码层次的API允许用户像其它编辑器一样直接编辑class文件。

API文档

读写字节码

Javassist是一个处理字节码的类库。Java字节码存储在一个叫做*.class的二进制文件中。每个class文件包含一个Java类或者接口。

javassist.CtClass 代表一个class文件的抽象类表示形式。一个CtClass(compile-time class编译时的类)是一个处理class文件的句柄,以下是一个简单的程序:

1
2
3
4
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("test.Rectangle");
cc.setSuperclass(pool.get("test.Point"));
cc.writeFile();

这段程序首先包含一个ClassPool对象,通过Javassist控制字节码的修改。

ClassPool对象是代表class文件的CtClass对象的容器。它根据构造一个CtClass对象的需求读取一个class文件,并记录被构建好的对象以供将来进行访问。 为了修改一个类的定义,用户必须首先从ClassPool对象的.get(className)方法获取一个CtClass引用。

在上述示例中,CtClass对象cc表示ClassPool中的类test.Rectangle,并且将其分配给变量ccClassPool对象由静态方法getDefault方法查找默认的系统检索path返回。格式为“包名.类名”。

从实现上来看,ClassPool是一个CtClass的哈希表,使用class name作为key。

ClassPool.get()方法通过检索这个哈希表找到一个CtClass对象关联指定的key。

如果CtClass对象没有找到,get()方法会读取class文件去构造一个CtClass对象,记录在哈希表中然后作为get()的返回值返回。

ClassPool中获取到的CtClass对象是可以被修改的。在上述示例中,它被修改了 test.Rectangle的父类变更为test.Point,这个修改将会在最后CtClass.writeFile()方法调用后反映在class文件中。

javassist提供了写到类文件的方法:

writeFile() 方法将CtClass对象转换到class文件并且将其写入本地磁盘。Javassist也提供了一个方法用于直接获取修改后的字节码:toBytecode():

1
byte[] b = cc.toBytecode();

也可以像这样直接加载CtClass:

1
Class clazz = cc.toClass();

toClass 请求当前线程的上下文类加载器去加载class文件,返回一个java.lang.Class对象。

例子:

定义一个新类

重新定义一个新的类,ClassPool.makeClass方法将会被调用:

1
2
3
4
// 定义一个新的类
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.makeClass("com.sec.Point");
System.out.println(cc.toClass()); // 输出class com.sec.Point

这个程序定义了一个Point类,未包含任何成员,成员方法可以通过使用CtClassaddMethod()方法传入一个CtMethod的factory方法创建的对象作为参数来追加。

1
2
3
4
5
6
7
8
9
10
// 定义一个新的类
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.makeClass("hello.make.Point");
//System.out.println(cc2.toClass().getMethods().length); // 9

// 追加方法
cc.addMethod(CtMethod.make("public void sayHello(){\n" +
" System.out.println(\"Hello!\");\n" +
" }",cc));
System.out.println(cc.toClass().getMethods().length); // 10

makeClass()方法不能创建一个新的接口,需要使用makeInterface()方法才可以创建一个新的接口。 接口中的成员方法可以通过CtMethodabstractMethod方法创建。

以上这个例子会报错:com.sec.Point class is frozen

Frozen冻结类

冻结类的含义

如果一个CtClass对象通过writeFile()toBytecodetoClass()方法被转换到class文件中,Javassist则会冻结这个CtClass对象。再对这个CtClass对象进行操作则是不允许的,当开发者尝试去修改一个已经被JVM加载过的class文件的时候会发出警告,因为JVM不允许重复加载一个class。

一个冻结的CtClass可以通过其defrost()方法解冻,解冻后可以允许对这个CtClass修改:

1
2
3
4
5
6
7
8
9
// 被冻结了,不能再修改(Exception in thread "main" java.lang.RuntimeException: com.sec.Point class is frozen)
// 解冻后可以修改
cc.toBytecode(); or cc.toClass();// 被冻结
cc.defrost();// 解冻
//以上两部顺序不能反,都是先解冻再修改。
System.out.println(cc.getFields().length);
cc.addField(CtField.make("private String name;", cc));// 解冻后允许修改
cc.writeFile();
System.out.println(cc.getFields().length);

修剪类 prune

如果ClassPool.doPruning被设置为true,那么当Javassist冻结一个CtClass对象时,Javassist就会对该对象中包含的数据结构进行修剪。为了减少内存消耗,修剪会丢弃该对象中不必要的属性(attribute_info结构)。例如,Code_attribute结构(方法体)会被丢弃。因此,当一个CtClass对象被修剪后,除了方法名、签名和注释外,无法访问方法的字节码。修剪后的CtClass对象不能再被解冻。ClassPool.doPruning 的默认值是 false。

要禁止修剪一个特定的CtClass,必须事先对该对象调用stopPruning(true)。

在调试时,你可能想暂时停止修剪和冻结,并将修改后的类文件写入磁盘驱动器。debugWriteFile()是一个方便的方法。它停止修剪,写入一个类文件,将其解冻,并再次开启修剪(如果最初是开启的)。

类搜索路径

默认的ClassPool.getDefault()检索路径和JVM底层路径一致(classpath)。如果一个程序运行在一个web应用程序比如JBoss、Tomcat中,ClassPool对象则可能搜索不到用户的类,因为这样的Web应用服务器使用多个类加载器以及系统类加载器。==在这种情况下,一个额外的classpath必须注册到ClassPool中==。假设pool引用了一个ClassPool对象:

1
2
//添加class查找路径search path
pool.insertClassPath(new ClassClassPath(this.getClass()));

这个语句注册了用于加载这个对象类的类路径。你可以使用任何Class对象作为参数来代替this.getClass()。Class对象已经被注册上了的表现就是它所在的class path被加载了。

你也可以注册一个目录的名称作为一个class查找路径。例如,以下代码添加了/usr/local/javalib到class查找路径中:

1
2
// 添加文件目录作为class查找路径
pool.insertClassPath("/usr/local/javalib");

还可以添加URL作为class查找路径:

1
2
3
4
// 添加URL作为class查找路径,第三个参数必须/开头、第四个参数必须.结尾
// 添加 "http://www.javassist.org:80/java/"
ClassPath cp = new URLClassPath("www.javassist.org", 80, "/java/", "org.javassist.");
pool.insertClassPath(cp);

上面操作添加了http://www.javassist.org:80/java/到class查找路径中。这个URL仅仅用来查找org.javassist.包的类。

例如:加载一个org.javassist.test.Main类,它的class文件是: http://www.javassist.org:80/java/org/javassist/test/Main.class

此外,你还可以直接给一个byte数组去构建一个CtClass对象,可以使用ByteArrayClassPath

1
2
3
4
5
6
// byte数组形式class path
ClassPool pool2 = ClassPool.getDefault();
byte[] arr = "org.byron4j".getBytes();
String name = "org.byron4j.Hello";
pool2.insertClassPath(new ByteArrayClassPath(name, arr));
CtClass ctClass = pool2.get(name);

CtClass对象ctClass表示字节数组b指定的class文件定义的类实例。ClassPool从给定的ByteArrayClassPath读取一个class文件。

如果调用get()并且给get()的类名等于name指定的类名,则ClassPool从给定的ByteArrayClassPath中读取类文件。

如果你不确定类的完全限定名,你可以使用ClassPoolmakeClass方法:

1
2
3
4
// makeClass
ClassPool pool3 = ClassPool.getDefault();
InputStream ins = new FileInputStream("/usr/local/javalib");
CtClass ctClass1 = pool3.makeClass(ins);

这里,makeClass() 返回从给定的输入流构建的 CtClass 对象。

你可以使用makeClass()将类文件急切地送入ClassPool对象。如果搜索路径包含一个大的jar文件,这可以提高性能。由于ClassPool对象按需读取类文件,它可能会重复搜索整个jar文件中的每一个类文件,makeClass()可以用来优化这种搜索。makeClass()构造的CtClass会被保存在ClassPool对象中,并且永远不会再读取类文件。

用户可以扩展class查找路径。可以定义一个实现ClassPath接口的新类,并将该类的一个实例交给ClassPool中的insertClassPath()。这种方式可以允许将非标准资源包含到class查找路径中。

ClassPool

一个ClassPool对象是CtClass对象的容器。一旦一个CtClass对象被创建,它将永远记录在ClassPool中。这是因为编译器在以后编译引用该CtClass所代表的类的源代码时,可能需要访问该CtClass对象。

例如,假设一个新的方法getter()被添加到代表Point类的CtClass对象中。之后,程序试图编译包括Point中对getter()的方法调用的源代码,并将编译后的代码作为方法的主体,将其添加到另一个类Line中。如果丢失了代表Point的CtClass对象,编译器就无法编译对getter()的方法调用。请注意,原来的类定义并不包括getter()。因此,为了正确地编译这样的方法调用,ClassPool必须在程序执行的所有时间都包含CtClass的所有实例。

避免内存不足

如果CtClass对象的数量变得大得惊人,ClassPool的这种规范可能会造成巨大的内存消耗(这种情况很少发生,因为Javassist试图通过各种方式减少内存消耗:冻结calss等方式)。为了避免这个问题,你可以明确地从ClassPool中删除一个不必要的CtClass对象。如果你在一个CtClass对象上调用detach(),那么这个CtClass对象就会从ClassPool中删除。例如

1
2
3
4
5
6
ClassPool classPool = ClassPool.getDefault();
CtClass cc = classPool.get("org.byron4j.cookbook.javaagent.Javassist2ClassPool");

// 调用该方法后,会将CtClass对象从ClassPool中移除
cc.writeFile();
cc.detach();

在detach()被调用后,你不能调用该CtClass对象上的任何方法。但是,你可以在ClassPool上调用get()来制作一个新的代表同一个类的CtClass实例。如果你调用get(),ClassPool会再次读取一个类文件,并新创建一个CtClass对象,这个对象由get()返回。

另一个想法是偶尔用新的ClassPool替换一个ClassPool,并丢弃旧的ClassPool。如果一个旧的ClassPool被垃圾回收,那么该ClassPool中包含的CtClass对象也会被垃圾回收。要创建一个新的ClassPool实例,请执行以下代码片段:

1
2
ClassPool cp = new ClassPool(true)//简单粗暴
// if needed, append an extra search path by appendClassPath()

这将创建一个ClassPool对象,它的行为就像ClassPool.getDefault()返回的默认ClassPool一样,getDefault()只是一个方便的方法。

注意,new ClassPool(true)是一个方便的构造函数,它可以构造一个ClassPool对象,并将系统搜索路径附加到它上面。调用该构造函数相当于下面的代码。

1
2
ClassPool cp = new ClassPool();
cp.appendSystemPath(); // or append another path by appendClassPath()

级联ClassPool

如果程序运行在Web应用服务器上,是需要创建ClassPool的多个实例;应该为每个类加载器(容器)创建一个ClassPool的实例。==程序应该通过不调用getDefault()而调用ClassPool的构造函数来创建ClassPool对象==。

多个ClassPool对象可以像java.lang.ClassLoader一样级联。比如说

1
2
3
4
// 级联ClassPool
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
5
// child classpool在委托之前加载类文件
ClassPool parent2 = ClassPool.getDefault();
ClassPool child2 = new ClassPool(parent2);
child2.appendSystemPath(); // 和默认同样的class查找路径
child2.childFirstLookup = true; // 改变child的行为

改变类名来定义新类

一个新的class可以被定义为一个已存在的类的副本。

1
2
3
ClassPool pool3 = ClassPool.getDefault();
CtClass cc3 = pool3.get("org.byron4j.cookbook.javaagent.Point");
cc3.setName("Pair");

这个程序首先包含类Point的CtClass对象,然后调用setName()方法为CtClass对象设置新的名称。

在这个调用之后,该CtClass对象所代表的类定义中所有出现的类名都由Point改为Pair。类定义的其他部分不会改变。

注意,CtClass中的setName()会改变ClassPool对象中的一条记录。从实现的角度来看,ClassPool对象是一个CtClass对象的哈希表,setName()改变了哈希表中与CtClass对象相关联的key。该键由原来的类名改为新的类名。

因此,如果以后再次调用ClassPool对象的get(“Point”),那么它再也不会返回变量cc3所指的CtClass对象。ClassPool对象又读取了一个类文件Point.class,它为类Point构造了一个新的CtClass对象。这是因为与名称Point相关联的CtClass对象已经不存在了。

1
2
3
4
5
6
7
8
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("org.byron4j.cookbook.javaagent.Point");
CtClass cc1 = pool.get("org.byron4j.cookbook.javaagent.Point");
cc.setName("Pair");
CtClass cc2 = pool.get("Pair");
CtClass cc3 = pool.get("org.byron4j.cookbook.javaagent.Point");
System.out.println(cc == cc2); // true;
System.out.println(cc3 == cc2); // false;

cc1和cc2指的是和cc一样的CtClass实例,而cc3没有。请注意,在执行cc.setName(“Pair”)后,cc和cc1引用的CtClass对象代表Pair类,所以cc2是去寻找Pair类而cc3还是去寻找Point类。

ClassPool 对象用于维护类和CtClass的一对一映射关系。javassist不允许两个不一样的CtClass表示同一个class,除非是两个独立的ClassPool创建的。

为了创建一个默认ClassPool实例(Clas.getDefault()返回的)的一个副本,可以使用以下代码片段:

1
ClassPool cp = new ClassPool(true);

这样一来,你拥有了两个ClassPool对象,可以从每一个ClassPool提供不同的CtClass对象表示同一个类。

1
2
3
4
5
ClassPool pool10 = ClassPool.getDefault();
CtClass ctClass10 = pool10.get("org.byron4j.cookbook.javaagent.Point");
ClassPool pool20 = new ClassPool(true);
CtClass ctClass20 = pool20.get("org.byron4j.cookbook.javaagent.Point");
System.out.println(pool10 == pool20); // false 不同的ClassPool中表示同一个类的CtClass对象

通过重命名一个冻结的CtClass来创建一个新的CtClass对象

一旦一个CtClass对象已经被writeFile()或者toBytecode()方法转到class文件,Javassist拒绝进一步修改该CtClass对象。因此,如果代表Point类的CtClass对象冻结后不能通过setName()修改它的名称。

1
2
3
4
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("org.byron4j.cookbook.javaagent.Point");
cc.writeFile();// 被冻结
cc.setName("Pair");// 错误

为了打破这个约束,可以使用ClassPool的getAndRename()方法:

1
2
3
4
5
ClassPool pool30 = ClassPool.getDefault();
CtClass ctClass30 = pool30.get("org.byron4j.cookbook.javaagent.Point");
ctClass30.writeFile();// 被冻结
//ctClass30.setName("Pair");// 冻结后不能使用--错误
pool30.getAndRename("org.byron4j.cookbook.javaagent.Point", "Pair");

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public CtClass getAndRename(String orgName, String newName)
throws NotFoundException
{
// 获取一个新的CtClass对象
CtClass clazz = get0(orgName, false);
if (clazz == null)
throw new NotFoundException(orgName);

if (clazz instanceof CtClassType)
((CtClassType)clazz).setClassPool(this);

// 设置新的名称
clazz.setName(newName); // indirectly calls
// classNameChanged() in this class
return clazz;
}

ClassLoader 类加载

如果事先知道必须修改什么类,那么修改类的最简单方法如下。

  1. 通过调用ClassPool.get()获得一个CtClass对象。
  2. 修改它
  3. 在该CtClass对象上调用writeFile()或toBytecode()来获取修改后的类文件。

如果一个类是否被修改是在加载时确定的,用户必须使Javassist与一个类加载器协作。Javassist可以与类加载器配合使用,这样就可以在加载时修改字节码。Javassist的用户可以定义自己版本的类加载器,但也可以使用Javassist提供的类加载器。

CtClass的toClass()方法

CtClass提供了一个方便的方法toClass(),它请求当前线程的上下文类加载器加载CtClass对象所代表的类。要调用这个方法,调用者必须有相应的权限,否则,可能会抛出一个SecurityException。

下面的程序显示了如何使用toClass():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Hello {
public void say() {
System.out.println("Hello");
}
}

public class Test {
public static void main(String[] args) throws Exception {
ClassPool cp = ClassPool.getDefault();
CtClass cc = cp.get("Hello");
CtMethod m = cc.getDeclaredMethod("say");
m.insertBefore("{ System.out.println(\"Hello.say():\"); }");
Class c = cc.toClass();
Hello h = (Hello)c.newInstance();
h.say();
}
}

Test.main()在Hello的say()的方法体中插入了对println()的调用。然后构造一个修改后的Hello类的实例,并在该实例上调用say()。

注意,==上面的程序取决于在调用toClass()之前,Hello类从未被加载==。如果不是这样,JVM就会在toClass()请求加载修改后的Hello类之前加载原来的Hello类。因此,加载修改后的Hello类会失败(会抛出LinkageError)。例如,如果Test中的main()是这样的。

1
2
3
4
5
6
public static void main(String[] args) throws Exception {
Hello orig = new Hello();
ClassPool cp = ClassPool.getDefault();
CtClass cc = cp.get("Hello");
:
}

那么原来的Hello类在main的第一行就被加载了,而调用toClass()会抛出一个异常,因为==类加载器不能同时加载两个不同版本的Hello类==。

如果程序运行在web容器中例如JBoss、Tomcat中, 上下文的类加载器使用toClass()方法可能并不适当。在这种情况下,你可能会看到一个不期望的异常ClassCastException。为了避免这种情况,你==必须明白清楚地给定一个适当的类加载器给toClass方法==。例如,如果bean是你的会话的bean对象:

1
2
CtClass cc = ...
Class c = cc.toClass(bean.getClass().getClassLoader());

提供toClass()是为了方便。如果你需要更复杂的功能,你应该编写自己的类加载器。

Java中的类加载

在Java中,多个类加载器可以共存,每个类加载器创建自己的命名空间。不同的类加载器可以加载具有相同类名的不同class文件,加载的两个类视为不同的类,这一个特性保证我们可以在一个JVM中运行多个应用程序即使这些程序包含相同类名的不同类实例。

注意:

==JVM不允许动态的重新加载一个类。一旦某个类加载器加载了某个类后,它就不能在运行时再重新加载一个新版本的类了。==

因此,你==不能在JVM加载类后,再去变更类的定义。==

但是,JPDA(Java平台调试架构)提供了有限的类重加载能力。

如果相同的class文件被不同的类加载器加载了,==JVM会使用相同的名称和定义创建两个不同的类==,这两个类会被看做是不同的。既然这两个类是不同的,所以一个类的实例就不能分配给另一个类类型的变量了。两个类之间的转码操作失败,并抛出一个ClassCastException。

例如,下面的代码片段就会抛出一个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类是由两个类加载器加载的。假设一个类加载器CL加载一个包括这个代码片段的类。由于这个代码片段引用了MyClassLoader、Class、Object和Box,CL也会加载这些类(除非它委托给另一个类加载器)。因此,变量b的类型就是CL加载的Box类。另一方面,myLoader也加载Box类。对象obj是myLoader加载的Box类的一个实例。因此,最后一条语句总是抛出一个ClassCastException,因为obj的类与作为变量b类型的Box类的不同版本。

多个类加载器形成一个树结构:

每个类加载器(引导加载器BootstrapClassLoader除外)都有一个父的类加载器(通常是加载了该子类加载器的类)。类加载请求可以沿着这个类加载器层级委托,一个类可能会被不是你请求的类加载器去加载。因此,被请求去加载一个类C的类加载器和实际加载这个类C的加载器可能不是同一个类加载器。以示区别,我们将前面的加载器称为C的启动器,后面的称为C的真实加载器

此外,如果请求加载类C的类加载器CL(C的发起者)委托给父类加载器PL,之后,类加载器CL则再也不会被请求去加载类C定义中引用的任何类。CL不是类C的引用的类的启动器,相反,PL成为了类C的引用的类的启动器,并且PL将会被请求去加载它们。类C的定义所引用的类是由C的真正的加载器加载

再看一个有些细微差异的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Point {    // 被PL加载
private int x, y;
public int getX() { return x; }
:
}

public class Box { // 引导器是CL,但是真实加载器是PL
private Point upperLeft, size;
public int getBaseX() { return upperLeft.x; }
:
}

public class Window { // 被CL加载
private Box box;
public int getBaseX() { return box.getBaseX(); }
}

假设一个类Window被一个类加载器CL加载了,则它的引导器和真实加载器都是CL。因为Window的定义引用了Box,所以JVM会请求CL加载Box。这里,假设CL将这个任务委托给父类加载器PL。加载Box的发起者是CL,但真正的加载器是PL。在这种情况下,Point的发起者不是CL,而是PL,因为它与Box的真正加载器相同。因此,CL永远不会被请求加载Point。

接下来,让我们考虑一个稍加修改的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Point {
private int x, y;
public int getX() { return x; }
:
}

public class Box { // 引导器是CL,但是真实加载器是PL
private Point upperLeft, size;
public Point getSize() { return size; }
:
}

public class Window { // 被CL加载
private Box box;
public boolean widthIs(int w) {
Point p = box.getSize();
return w == p.getX();
}
}

现在Window类的定义也引用了Point类,在这个案例中,CL在被请求加载Point时也将委托给PL。你必须避免存在两个不同的类加载器重复加载同一个类,二者中的其中一个必须委托给另外一个。 如果在Point加载的时候,CL没有委托给PL,widthIs()将会抛出一个ClassCastException。因为Box的真实加载器是PL,Box中引用的类Point类也会被PL加载。因此,getSize()方法返回值是PL加载的Point的一个实例,然而getSize()方法中的变量是CL加载的Point类型,JVM将它们视作不同的类型,所以会抛出类型不匹配的异常。

这种行为有些不方便但却是必要的,如果Point p = box.getSize();不会抛出异常,则Window的程序员就打破了Point类的封装性。例如,在PL加载的Point中,字段x是私有的。但是,如果CL用下面的定义加载Point(public代替private),Window类则可以直接访问x的值。

1
2
3
4
5
public class Point {
public int x, y; // not private
public int getX() { return x; }
:
}

使用javassist.Loader

Javassist提供了一个类加载器javassist.Loader,这个类加载器使用javassist.ClassPool对象读取class文件。

例如,javassist.Loader可用于使用javassist修改的指定的类:

1
2
3
4
5
6
7
8
9
ClassPool pool = ClassPool.getDefault();
// 使用ClassPool创建Loader
Loader cl = new Loader(pool);

CtClass ct = pool.get("org.byron4j.cookbook.javaagent.Rectangle");
ct.setSuperclass(pool.get("org.byron4j.cookbook.javaagent.Point"));

Class<?> c = cl.loadClass("org.byron4j.cookbook.javaagent.Rectangle");
Object o = c.newInstance();

这个程序修改了类Rectangle类,将其父类设置为Point类,然后程序加载了修改后的Rectangle类,并且创建了一个实例。

如果用户想在加载一个类的时候按需修改它,则用户可以添加一个javassist.Loader的事件监听器。当这个类加载器加载一个类的时候就会通知添加好的事件监听器。 事件监听器必须实现以下两个接口:

1
2
3
4
public interface Translator {
public void start(ClassPool pool)
public void onLoad(ClassPool pool, String classname)
}
  1. 当javassist.Loader中的addTranslator()将这个事件监听器添加到javassist.Loader对象中时,start()方法被调用。
  2. 方法onLoad()在javassist.Loader加载一个类之前被调用。onLoad()可以修改加载类的定义。
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
/**
* Loader的观察者
*/
public interface Translator {
/**
* 当对象附加到加载器对象时,加载器将调用该对象进行初始化。此方法可用于获取(用于缓存)一些将在Translator的onLoad()中访问的CtClass对象。
* @param pool
* @throws NotFoundException
* @throws CannotCompileException
*/
void start(ClassPool pool)
throws NotFoundException, CannotCompileException;

/**
* 当Loader加载一个类时,就会通知调用该方法。Loader会在onLoad()方法返回后调用
* pool.get(classname).toBytecode()
* 方法去读取class文件,classname可能是尚未创建的类的名称。
* 如果这样的话,<code>onLoad()</code>方法必须创建那个class,以便Loader可以在<code>onLoad()</code>方法返回后读取它。
* @param pool
* @param classname
* @throws NotFoundException
* @throws CannotCompileException
*/
void onLoad(ClassPool pool, String classname)
throws NotFoundException, CannotCompileException;
}

javassist.Loader对象的addTranslator()方法添加事件监听器的时候,start()方法就会被调用。 onLoad()方法会在javassist.Loader加载一个类之前被调用。

以下是这两种情况的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 添加事件监听器的时候,就会调用监听器的start方法
public void addTranslator(ClassPool cp, Translator t) throws NotFoundException, CannotCompileException {
source = cp;
translator = t;
t.start(cp);//<---
}

// 存在监听器,则在Loader的findClass方法中,先执行监听器的onLoad()方法,再通过.get(name).toBytecode()加载类
if (source != null) {
if (translator != null)
translator.onLoad(source, name);//1

try {
classfile = source.get(name).toBytecode();//2
}
catch (NotFoundException e) {
return null;
}
}

所以,translator.onLoad的方法中可以修改加载的类的定义。

例如,下面的事件监听器在加载之前将所有的类改为公共类。

1
2
3
4
5
6
7
8
9
10
public class MyTranslator implements Translator {
void start(ClassPool pool)
throws NotFoundException, CannotCompileException {}
void onLoad(ClassPool pool, String classname)
throws NotFoundException, CannotCompileException
{
CtClass cc = pool.get(classname);
cc.setModifiers(Modifier.PUBLIC);
}
}

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

要运行一个带有MyTranslator对象的应用程序类MyApp,需要写一个主类,如下所示:

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


public class Point {
public static void main(String[] args){
System.out.println("com.sec.Point#main invoked!");
}
}


public class Main2 {
public static void main(String[] args) throws Throwable {
Translator t = new MyTranslator();
ClassPool pool = ClassPool.getDefault();
Loader cl = new Loader();
cl.addTranslator(pool, t);
// cl.run方法会运行指定MyApp的main方法
cl.run("com.sec.Point", args);
}
}

% java Main2 arg1 arg2

注意:应用的类像Point是不能访问加载器的类如MyTranslator、ClassPool的,因为它们是被不同的加载器加载的。应用的类是由javassist.Loader加载,而其他的是由默认的JVM类加载器加载的。

javassist.Loader加载类的顺序和java.lang.ClassLoader不同。 ClassLoader首先将加载操作委托给父加载器,如果父加载器找不到它们才由自身尝试加载类。 反过来说,javassist.Loader在委托给父加载器之前尝试加载类。只有在以下情况才去委托父加载器:

  • 类不是由ClassPool.get()找到的
  • 类使用了delegateLoadingOf()去指定由父加载器加载。

这个搜索顺序允许Javassist加载修改过的类。然而,如果加载失败的话就会委托给父加载器去加载。一旦一个类由其父加载器加载了,这个类引用的其它类也会由其父加载器加载,则这些类就不会被当前类加载器修改了。 回想一下,类C中所有引用的类都是由类C的真实加载器负责加载的。如果你的程序不能加载一个修改过的类,你应该确保所有使用该类的类都已经被javassist.Loader加载了。

编写一个类加载器

一个使用Javassist的简单类加载器如下,继承ClassLoader。

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
import javassist.*;

public class SampleLoader extends ClassLoader {
private ClassPool pool;

public SampleLoader() throws NotFoundException {
this.pool = ClassPool.getDefault();
this.pool.insertClassPath("./target/classes");
}

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
CtClass cc;
try{
cc = pool.get(name);
//TODO
byte[] bb = cc.toBytecode();
return defineClass(name,bb,0,bb.length);
}catch (Exception e){
e.printStackTrace();
}
return null;
}


public static void main(String[] args) throws Exception {
SampleLoader sl = new SampleLoader();
Class<?> cl = sl.loadClass("com.sec.Point");
cl.getDeclaredMethod("main",new Class[]{String[].class}).invoke(null,new Object[]{args});
}
}

假设Point是一个应用程序,为了执行这个程序,首先指定./target/classes为class文件目录。构造器中insertClassPath()方法指定了目录名称./target/classes,你可以使用不同的目录名称来代替你想要加载的类路径地址。 执行该程序,类加载器会加载Point类(Point.class文件)并且调用其main方法。

这是使用javassist最简单的示例。然而,如果你想编写一个更加复杂的类加载器,你需要了解更多的java类加载的机制。例如,上面的程序将Point类在命名空间与SampleLoader命名空间分开了,因为这两个是由不同的类加载器加载的。因此,Point类不能直接访问SampleLoader类。

修改一个系统类

java.lang.String等系统类不能被系统类加载器以外的类加载器加载。因此,上图所示的SampleLoader或javassist.Loader不能在加载时修改系统类。

如果你的应用想那样去做的话(修改系统类),必须静态地修改系统类。

例如,添加一个新的属性字段给java.lang.String:

1
2
3
4
5
6
7
8
9
// 添加字段给系统类:java.lang.String
ClassPool pool = ClassPool.getDefault();
CtClass ctClass = pool.get("java.lang.String");
// 创建字段
CtField cf = new CtField(CtClass.intType, "hiddenValue", ctClass);
cf.setModifiers(Modifier.PUBLIC);
//添加字段
ctClass.addField(cf);
ctClass.writeFile(".");

这个程序产生一个文件”./java/lang/String.class”。

% java -Xbootclasspath/p:. MyApp arg1 arg2...

在运行时重新加载一个类

如果启动JVM时启动了JPDA,则一个类可以重加载。在JVM加载一个类后,旧的版本的类的定义可以卸载,新的版本可以重新加载。 换言之,类的定义可以在运行时动态修改。然而,一个新的类的定义必须与旧的类定义在某种程度上兼容。 JVM不允许两个版本之间更改模式。 它们拥有相同的方法、成员变量。

Javassist提供了一个方便的类,用于在运行时重载一个类。更多信息,请参见 javassist.tools.HotSwapper 的 API 文档。

自省 & 定制化

CtClass提供了自省的一些方法。Javassist的内省功能和Java反射API的内省功能兼容。 CtClass提供了getName()getSuperclass()getMethods()等等方法。CtClass也提供了修改类定义的方法,允许添加一个新的成员变量、构造器、方法,也可以检测方法体。 方法由CtMethod对象表示。CtMethod提供了修改方法定义的几个方法。注意:如果一个方法继承了某个类,则CtMethod表示为是在父类中声明的方法。一个CtMethod对象对应一个方法声明。

例如,如果类Point声明了一个方法move(),而Point类的的一个子类ColorPoint没有覆盖move()方法,这两个move()方法即Point中声名的和ColorPoint中继承的都由相同的CtMethod对象表示。如果修改这个CtMethod对象标表示的方法的定义,则修改会表现到这两个方法上。如果你仅仅想修改ColorPoint中的该方法,你首先必须给ColorPoint添加一个表示move()方法的CtMethod的副本,可以通过CtNewMethod.copy()方法获得。

Javassist不允许移除一个方法或者成员变量,但是允许变更方法名。所以如果一个方法不再需要了,应该重命名并且修改为私有的:调用CtMethod中的setName()setModifiers()方法。

Javassist不允许在一个已存在的方法中添加额外的参数,为了处理这样的变更,接受额外参数或者其他参数的新方法应该添加在同一个类中。例如,如果你想在一个方法中添加一个额外的参数:

1
void move(int newX, int newY) { x = newX; y = newY; }

改成:

1
2
3
4
void move(int newX, int newY, int newZ) {    
// do what you want with newZ.
move(newX, newY);
}

Javassist 也提供了更低层次的API可以直接编辑原生class文件。例如,CtClass的getClassFile()返回一个ClassFile对象表示一个原生class文件。 CtMethod中的getMethodInfo()方法返回一个MethodInfo对象表示在class文件中的一个method_info结构。 低层次的API使用了来自JVM规范的词汇。用户必须了解class文件和字节码。更多的细节,可以参考javassist.bytecode包

如果需要被修改的类包含以下以$开头的特殊标识符,则在运行时需要javassist.runtime包来支持。

在方法体的前部、后部插入代码

CtMethodCtConstructor提供了insertBefore(),insertAfter()addCatch()方法。他们都是在已存在的方法体中插入代码段,用户可以使用Java中的源代码文本方式编写代码段Javassist包含一个简单的Java编译器用于处理源文本,接收Java中的源文本并编译成字节码到方法体中。

插入代码段在指定行也是可以的(如果行号表在class文件中的话),CtMethodCtConstructorinsertAt()方法在源class文件中获取源文本和行号,它将编译源文本并且在指定行插入编译过的代码。

语句和代码块可以指的是字段和方法。特殊变量$0,$1,$2,…来访问方法参数。虽然允许在块中声明一个新的局部变量,但不允许访问方法中声明的局部变量。然而,insertAt()允许语句和代码块访问局部变量,如果这些变量在指定的行号处可用,并且目标方法是用-g选项编译的。

传递给方法insertBefore()insertAfter()addCatch()insertAt()的String对象是由Javassist中包含的编译器编译的。由于编译器支持语言扩展,所以几个以$开头的标识符具有特殊的意义。

image-20210217231334157

符号 含义
$0, $1, $2, … this and 方法的参数
$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,…代替原来的参数名进行访问。1元代表第一个参数,2元代表第二个参数,以此类推。这些变量的类型与参数类型相同。$0相当于this。如果方法是静态的,则$0不可用。

这些变量的用法如下。假设一个类Point:

1
2
3
4
class Point {
int x, y;
void move(int dx, int dy) { x += dx; y += dy; }
}

要在调用move()方法时打印dx和dy的值,请执行这个程序:

1
2
3
4
5
ClassPool pool = ClassPool.getDefault(); 
CtClass cc = pool.get("Point");
CtMethod m = cc.getDeclaredMethod("move");
m.insertBefore("{ System.out.println($1); System.out.println($2); }");
cc.writeFile();

修改后的Point类的定义是这样的。

1
2
3
4
5
6
7
8
9
10
class Point {
int x, y;
void move(int dx, int dy) {
{
System.out.println(dx);
System.out.println(dy);
}
x += dx; y += dy;
}
}

$1$2分别由dx和dy代替。

$1, $2, $3 …是可以更新的。如果变量值更改了,那么参数的值也会更新。

$args

变量$args表示装载所有参数的参数数组。该变量的类型是一个Object类的数组。如果一个参数类型是一个基本类型,比如int,那么参数值就会被转换为一个包装对象,比如java.lang.Integer来存储在$args中。因此,$args[0]相当于$1,除非第一个参数的类型是一个基本类型。请注意,$args[0]并不等同于​$0$0代表了this

如果一个Object数组被分配给$args,那么该数组的每个元素都被分配给每个参数。如果一个参数类型是基本类型,那么对应元素的类型必须是封装类型。在将值分配给参数之前,会将其从封装类型转换为基本类型。

$$

变量 $$ 是所有参数列表的缩写,用逗号分隔。 例如,如果方法 move() 的有 3 个参数,则

1
2
move($$) == move($1, $2, $3)
exMove($$, context) == exMove($1, $2, $3, context)

如果move()不接受任何参数,那么move($$)相当于move()。

请注意,$$使方法调用的通用符号与参数数量相关。它通常与后面的$proceed一起使用。

$cflow

$cflow的意思是 “控制流”。这个只读变量返回特定方法的递归调用深度。

假设下面所示的方法由CtMethod对象cm表示:

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

要使用$cflow,首先声明$cflow用于监控对方法fact()的调用。

1
2
CtMethod cm = ...;
cm.useCflow("fact");

useCflow()的参数是声明的$cflow变量的标识符。任何有效的Java名称都可以作为标识符。由于标识符也可以包含.,例如,my.Test.fact就是一个有效的标识符。

那么,$cflow(fact)表示cm指定的方法的递归调用的深度。当方法第一次被调用时,$cflow(fact)的值是0,而当方法内部被递归调用时,它的值是1。例如

1
2
cm.insertBefore("if ($cflow(fact) == 0)"
+ " System.out.println(\"fact \" + $1);");

翻译方法fact(),以便它显示参数。因为检查了 $cflow(fact) 的值,所以如果在 fact() 中递归调用,则方法 fact() 不会显示参数。

$cflow的值是当前线程当前最上面的栈帧下与指定方法cm相关联的栈帧数。在与指定方法cm不同的方法中也可以访问$cflow。

$r

$r 表示方法的结果类型(返回类型)。它用在 cast 表达式中作 cast 转换类型。 下面是一个典型的用法:

1
2
Object result = ... ;
$_ = ($r)result;

如果结果类型是一个基元类型,那么($r)遵循特殊的语义。首先,如果抛出表达式的操作数类型是基本类型,($r)就会作为一个普通的抛出操作数对结果类型进行操作。

另一方面,如果操作数类型是封装类型,($r)就会从封装类型转换到结果类型。例如,如果结果类型是int,那么($r)从java.lang.Integer转换为int。

如果结果类型是void,那么($r)不转换类型,它什么也不做。然而,如果操作数是对void方法的调用,那么($r)的结果是null。例如,如果结果类型是void,而foo()是一个void方法,则

1
$_ = ($r)foo();

$w

$w代表一个封装类型。它必须在一个cast表达式中作为cast转换类型使用。($w)从一个基本类型转换到相应的封装类型。下面的代码是一个例子。

1
Integer i = ($w)5;

选择的封装类型取决于 ($w) 后面的表达式类型。如果表达式的类型是double,那么包装器类型就是java.lang.Double.。

如果($w)后面的表达式类型不是基本类型,那么($w)什么都不做。

$_

CtMethod和CtConstructor中的insertAfter()将编译后的代码插入到方法的最后。在给insertAfter()的语句中,不仅有上面所示的变量如$0,$1,还可以有$_

变量$_表示方法的结果值。该变量的类型就是该方法的结果类型(返回类型)。如果结果类型是void,那么$_的类型是Object,$_的值是null。

虽然由insertAfter()插入的编译代码是在控件从方法中正常返回之前执行的,但它也可以在方法抛出异常时执行。为了在发生异常时执行它,insertAfter()的第二个参数asFinally必须为true。

如果抛出异常,由insertAfter()插入的编译代码将作为最后子句执行。在编译后的代码中,$_的值为0或空。在编译代码执行终止后,原来抛出的异常会重新抛给调用者。注意,$_的值永远不会被抛给调用者,而是被丢弃。

$sig

$sig的值是一个由java.lang.Class对象组成的数组,这些对象按照声明顺序表示形参类型

$type

$type的值是一个java.lang.Class对象,代表结果值的类型。如果这是一个构造函数,那么这个变量是指Void.class。

$class

$class 的值是一个 java.lang.Class 对象,代表声明编辑的方法所在的类。这代表了$0的类型。

addCatch

addCatch()插入方法体抛出异常时执行的代码,控制权会返回给调用者。 在插入的源代码中,异常用 $e 表示。

1
2
3
CtMethod m = ...;
CtClass etype = ClassPool.getDefault().get("java.io.IOException");
m.addCatch("{ System.out.println($e); throw $e; }", etype);

转换成对应的 java 代码如下:

1
2
3
4
5
6
try {
// the original method body
} catch (java.io.IOException e) {
System.out.println(e);
throw e;
}

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

修改方法体

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

在传递给 setBody() 的源代码中,以 $ 开头的标识符具有特殊含义:

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

$_不可用。

替换表达式

Javassist 只允许修改方法体中包含的表达式。

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

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

例如:

1
2
3
4
5
6
7
8
9
CtMethod cm = ... ;
cm.instrument(
new ExprEditor() {
public void edit(MethodCall m) throws CannotCompileException {
if (m.getClassName().equals("Point")
&& m.getMethodName().equals("move"))
m.replace("{ $1 = 0; $_ = $proceed($$); }");
}
});

上述代码,搜索由 cm 表示的方法体,并用使用下面的代码替换 Point 中的 move()调用:

1
{ $1 = 0; $_ = $proceed($$); }

因此 move() 的第一个参数总是0。注意,替换的代码不是一个表达式,而是一个语句或块。 它不能是或包含 try-catch 语句。

方法 instrument()搜索一个方法体。如果它找到了一个表达式,如方法调用、字段访问和对象创建,那么它就在给定的ExprEditor对象上调用edit()edit()的参数是一个代表找到的表达式的对象。edit()方法可以通过该对象检查和替换该表达式。

在edit()的参数上调用replace(),可以将给定的语句或块替换为表达式。如果给定的块是空块,也就是说,如果执行了replace(“{}”),那么表达式就会从方法体中删除。如果你想在表达式之前/之后插入一条语句(或一个代码块),应该向 replace()传递一个类似下面的代码块,无论表达式是方法调用、字段访问、对象创建,还是其他:

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

当是write access写访问:

$_ = $proceed();

当是read access读访问:

$proceed($$);

javassist.expr.MethodCall

一个MethodCall对象代表一个方法调用。MethodCall中的方法replace()为方法调用替换了一条语句或一个代码块。它接收代表被替换的语句或代码块的源文本,其中以$开头的标识符具有特殊意义,就像传递给insertBefore()的源文本一样。

符号 含义
$0 方法调用的目标对象。它不等于 this,它代表了调用者。 如果方法是静态的,则 $0 为 null
$1, $2 .. 方法的参数
$_ 方法调用的结果
$r 返回结果的类型,用于强制类型转换
$class 一个 java.lang.Class 对象,表示当前正在修改的类
$sig 类型为 java.lang.Class 的参数类型数组
$type 一个 java.lang.Class 对象,表示返回值类型
$class 一个 java.lang.Class 对象,表示当前正在修改的类
$proceed 调用表达式中方法的名称

这里的方法调用意味着由 MethodCall 对象表示的方法。

其他标识符如 $w$args$$ 也可用。

除非方法调用的返回类型为 void,否则返回值必须在源代码中赋给 $_$_的类型是表达式的结果类型。如果结果类型为 void,那么 $_ 的值将被忽略。

$proceed 不是字符串值,而是特殊的语法。 它后面必须跟一个由括号括起来的参数列表。

javassist.expr.ConstructorCall

ConstructorCall 表示构造函数调用,例如包含在构造函数中的 this() 和 super()。ConstructorCall 中的方法 replace() 可以使用语句或代码块来代替构造函数。它接收表示替换语句或块的源代码。和 insertBefore() 方法一样,传递给 replace 的源代码中,以 $ 开头的标识符具有特殊的含义。

符号 含义
$0 构造调用的目标对象。它等于 this
$1, $2, … 构造函数的参数
$class 一个 java.lang.Class 对象,表示当前正在修改的类
$sig 类型为 java.lang.Class 的参数类型数组
$proceed 调用表达式中构造函数的名称

其他标识符如 $w$args$$ 也可用。

由于任何构造函数必须调用超类的构造函数或同一类的另一个构造函数,所以替换语句必须包含构造函数调用,通常是对 $proceed() 的调用。

$proceed 不是字符串值,而是特殊的语法。 它后面必须跟一个由括号括起来的参数列表。

javassist.expr.FieldAccess

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

javassist.expr.NewExpr

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

javassist.expr.NewArray

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

javassist.expr.Instanceof

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

javassist.expr.Cast

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

javassist.expr.Handler

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

添加新方法和字段

添加新方法

Javassist 可以创建新的方法和构造函数。CtNewMethod 和 CtNewConstructor 提供了几个工厂方法来创建 CtMethod 或 CtConstructor 对象。make() 方法可以通过源代码来CtMethod 或 CtConstructor 对象。

例如:

1
2
3
CtClass point = ClassPool.getDefault().get("Point");
CtMethod m = CtNewMethod.make("public int xmove(int dx) { x += dx; }",point);
point.addMethod(m);

上面的代码向类 Point 添加了一个公共方法 xmove()。在这个例子中,x 是类 Point 的一个int 字段。

传递给 make() 和 setBody() 的源文本可以包括以$开头的标识符(除了$_)。 如果目标对象和目标方法名也被传递给 make() 方法,源文本中也可以包括 $proceed

例如:

1
2
3
4
CtClass point = ClassPool.getDefault().get("Point");
CtMethod m = CtNewMethod.make("public int ymove(int dy) { $proceed(0, dy); }",
point, "this", "move");
point.addMethod(m);

这个程序创建一个 ymove() 方法,定义如下:

1
public int ymove(int dy) { this.move(0, dy); }

注意,$proceed 已经被替换为 this.move。

Javassist 还提供了另一种添加新方法的方式。 你可以先创建一个抽象方法,然后给它一个方法体:

1
2
3
4
5
CtClass cc = ... ;
CtMethod m = new CtMethod(CtClass.intType, "move",new CtClass[] { CtClass.intType }, cc);
cc.addMethod(m);
m.setBody("{ x += $1; }");
cc.setModifiers(cc.getModifiers() & ~Modifier.ABSTRACT);

因为 Javassist 在类中添加了的方法是抽象的,所以在调用 setBody() 之后,必须将类显式地改回非抽象类(拥有方法体)。

相互递归的方法

如果一个方法调用了另一个没有被添加到类中的方法,Javassist就不能编译该方法。

(但是Javassist 可以编译一个以递归方式调用自己的方法。)

要将相互递归的方法添加到一个类中,你需要一个如下所示的技巧。假设你想把方法m()和n()添加到一个由cc表示的类中。

1
2
3
4
5
6
7
8
CtClass cc = ... ;
CtMethod m = CtNewMethod.make("public abstract int m(int i);", cc);
CtMethod n = CtNewMethod.make("public abstract int n(int i);", cc);
cc.addMethod(m);
cc.addMethod(n);
m.setBody("{ return ($1 <= 0) ? 1 : (n($1 - 1) * $1); }");
n.setBody("{ return m($1); }");
cc.setModifiers(cc.getModifiers() & ~Modifier.ABSTRACT);

你必须先创建两个抽象方法,并将它们添加到类中。然后设置它们的方法体,即使方法体包括互相递归的调用。 最后,必须将类更改为非抽象类。

添加一个字段

Javassist 还允许用户创建一个新字段。

1
2
3
CtClass point = ClassPool.getDefault().get("Point");
CtField f = new CtField(CtClass.intType, "z", point);//int z;
point.addField(f);

该程序向类 Point 添加一个名为 z 的字段。 int z;
如果必须指定添加字段的初始值,那么上面的程序必须修改为:

1
2
3
CtClass point = ClassPool.getDefault().get("Point");
CtField f = new CtField(CtClass.intType, "z", point);
point.addField(f, "0"); // initial value is 0

总结:先声明再初始化。

现在,方法 addField() 接收两个参数,第二个参数表示计算初始值的表达式。这个表达式可以是任意 Java 表达式,只要其结果与字段的类型匹配。 请注意,表达式不以分号结尾。

此外,上述代码可以重写为更简单代码:

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

删除成员

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

注解

CtClass,CtMethod,CtField 和 CtConstructor 提供 getAnnotations() 方法,用于读取注解。 它返回一个注解类型的对象。

例如,假设有以下注解:

1
2
3
4
public @interface Author {
String name();
int year();
}

下面是使用注解的代码:

1
2
3
4
@Author(name="Chiba", year=2005)
public class Point {
int x, y;
}

然后,可以使用 getAnnotations() 获取注解的值。 它返回一个包含注解类型对象的数组。

1
2
3
4
5
6
CtClass cc = ClassPool.getDefault().get("Point");
Object[] all = cc.getAnnotations();
Author a = (Author)all[0];
String name = a.name();
int year = a.year();
System.out.println("name: " + name + ", year: " + year);

这段代码输出:

1
name: Chiba, year: 2005

由于 Point 的注解只有 @Author,所以数组的长度是 1,all[0] 是一个 Author 对象。 注解成员值可以通过调用Author对象的 name() 和 year() 来获取。

要使用 getAnnotations(),注释类型(如 Author)必须包含在当前类路径中。它们也必须也可以从 ClassPool 对象访问。如果未找到注释类型的类文件,Javassist 将无法获取该注释类型的成员的默认值。

运行时支持类

在大多数情况下,使用 Javassist 修改类不需要运行 Javassist。 但是,Javassist 编译器生成的某些字节码需要运行时支持类,这些类位于 javassist.runtime 包中(有关详细信息,请阅读该包的API文档)。请注意,javassist.runtime 是修改的类时唯一可能需要使用的包。 修改类的运行时不会再使用其他的 Javassist 类。

导入包

源代码中的所有类名都必须是完整的(必须包含包名,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);//识别为java.awt.Point
cc.addField(f);

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

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

限制

在目前实现中,Javassist 中包含的 Java 编译器有一些限制:

  • J2SE 5.0 引入的新语法(包括枚举和泛型)不受支持。注释由 Javassist 的低级 API 支持。 参见 javassist.bytecode.annotation 包(以及 CtClass 和 CtBehavior 中的 getAnnotations())。对泛型只提供部分支持。更多信息,请参阅后面的部分;

  • 初始化数组时,只有一维数组可以用大括号加逗号分隔元素的形式初始化,多维数组还不支持;

  • 编译器不能编译包含内部类和匿名类的源代码。 但是,Javassist 可以读取和修改内部/匿名类的类文件;

  • 不支持带标记的 continue 和 break 语句;

  • 编译器没有正确实现 Java 方法调度算法。编译器可能会混淆在类中定义的重载方法(方法名称相同,查参数列表不同)。例如:

1
2
3
4
5
6
7
8
class A {} 
class B extends A {}
class C extends B {}

class X {
void foo(A a) { .. }
void foo(B b) { .. }
}

如果编译的表达式是 x.foo(new C()),其中 xX 的实例,编译器将产生对 foo(A) 的调用,尽管编译器可以正确地编译 foo((B) new C())

建议使用 # 作为类名和静态方法或字段名之间的分隔符。 例如,在常规 Java 中:

1
javassist.CtClass.intType.getName()

在 javassist.CtClass 中的静态字段 intType 指示的对象上调用一个方法 getName()。 在Javassist 中,用户也可以写上面的表达式,但是建议写成这样:

1
javassist.CtClass#intType.getName()

可以使编译器可以快速解析表达式。

字节码级API

Javassist 还提供了用于直接编辑类文件的低级级 API。 使用此 API之前,你需要详细了解Java 字节码和类文件格式,因为它允许你对类文件进行任意修改。

如果你只想生成一个简单的类文件,使用javassist.bytecode.ClassFileWriter就足够了。 它比javassist.bytecode.ClassFile更快而且更小。

获取ClassFile对象

javassist.bytecode.ClassFile 对象表示类文件。要获得这个对象,应该调用 CtClass 中的 getClassFile() 方法。你也可以直接从类文件构造 javassist.bytecode.ClassFile 对象。 例如:

1
2
3
BufferedInputStream fin
= new BufferedInputStream(new FileInputStream("Point.class"));
ClassFile cf = new ClassFile(new DataInputStream(fin));

这代码段从 Point.class 创建一个 ClassFile 对象。

ClassFile 对象可以写回类文件。

ClassFile 的 write() 将类文件的内容写入给定的 DataOutputStream。

也可以从头开始创建一个类文件

1
2
3
4
5
ClassFile cf = new ClassFile(false, "test.Foo", null);
cf.setInterfaces(new String[] { "java.lang.Cloneable" });
FieldInfo f = new FieldInfo(cf.getConstPool(), "width", "I"); f.setAccessFlags(AccessFlag.PUBLIC);
cf.addField(f);
cf.write(new DataOutputStream(new FileOutputStream("Foo.class")));

这段代码生成了一个类文件Foo.class,实现结果:

1
2
3
4
package test; 
class Foo implements Cloneable {
public int width;
}

添加和删除成员

ClassFile 提供了 addField(),addMethod() 和 addAttribute(),来向类添加字段、方法和类文件属性。

注意,FieldInfo,MethodInfo 和 AttributeInfo 对象包含了一个指向 ConstPool(常量池表)对象的链接。 ConstPool 对象必须是 ClassFile 对象和添加到该 ClassFile 对象的 FieldInfo(或MethodInfo 等)对象的共同对象。 换句话说,一个FieldInfo(或MethodInfo等)对象不能在不同的ClassFile 对象之间共享。

要从 ClassFile 对象中删除字段或方法,必须首先获取包含该类的所有字段的 java.util.List 对象。 getFields() 和 getMethods() 会返回这些列表。可以通过调用List对象的 remove() 来删除字段或方法。可以以类似的方式删除属性。在 FieldInfo 或 MethodInfo 中调用 getAttributes() 以获取属性列表,并从列表中删除一个属性。

遍历方法体

使用 CodeIterator 可以检查方法体中的每个字节码指令,要获得 CodeIterator 对象,参考以下代码:

1
2
3
4
ClassFile cf = ... ; 
MethodInfo minfo = cf.getMethod("move"); // we assume move is not overloaded.
CodeAttribute ca = minfo.getCodeAttribute();
CodeIterator i = ca.iterator();

CodeIterator 对象允许你逐个访问每个字节码指令。下面展示了一部分 CodeIterator 中声明的方法:

  • void begin() 移动到第一条指令
  • void move(int index) 移动到指定位置的指令
  • boolean hasNext() 是否有下一条指令
  • int next() 返回下一条指令的索引。注意,它不返回下一条指令的操作码
  • int byteAt(int index) 返回索引处的无符号8位整数。
  • int u16bitAt(int index) 返回索引处的无符号16位整数。
  • int write(byte [] code,int index) 在索引处写入字节数组。
  • void insert(int index,byte [] code) 在索引处插入字节数组。自动调整分支偏移量。

以下代码段打印了方法体中所有的指令:

1
2
3
4
5
6
CodeIterator ci = ... ;
while (ci.hasNext()) {
int index = ci.next();
int op = ci.byteAt(index);
System.out.println(Mnemonic.OPCODE[op]);
}

生成字节码序列

Bytecode 对象表示字节码指令序列。它是一个可扩展,可增长的字节码数组。
以下是示例代码段:

1
2
3
4
5
ConstPool cp = ...;    // constant pool table
Bytecode b = new Bytecode(cp, 1, 0);
b.addIconst(3);
b.addReturn(CtClass.intType);
CodeAttribute ca = b.toCodeAttribute();

这段代码产生以下序列的代码属性:

1
2
iconst_3
ireturn

还可以通过调用 Bytecode 中的 get() 方法来获取包含此序列的字节数组。获得的数组可以插入另一个代码属性中。

Bytecode 提供了许多方法来添加特定的指令,例如使用 addOpcode() 添加一个 8 位操作码,使用 addIndex() 用于添加一个索引。每个操作码的值定义在 Opcode 接口中。

addOpcode() 和添加特定指令的方法,会自动维持最大堆栈深度,除非控制流没有分支。最大堆栈深度这个值可以通过调用 Bytecode 的 getMaxStack() 方法来获得。它也反映在从 Bytecode对象构造的 CodeAttribute 对象上。要重新计算方法体的最大堆栈深度,可以调用 CodeAttribute 的 computeMaxStack() 方法。

可以使用Bytecode来构造一个方法。例如:

1
2
3
4
5
6
7
8
9
10
ClassFile cf = ...
Bytecode code = new Bytecode(cf.getConstPool());
code.addAload(0);
code.addInvokespecial("java/lang/Object", MethodInfo.nameInit, "()V");
code.addReturn(null);
code.setMaxLocals(1);

MethodInfo minfo = new MethodInfo(cf.getConstPool(), MethodInfo.nameInit, "()V");
minfo.setCodeAttribute(code.toCodeAttribute());
cf.addMethod(minfo);

这段代码制作了默认的构造函数,并将其添加到cf指定的类中,Bytecode对象首先被转换为CodeAttribute对象,然后添加到minfo指定的方法中。最后将该方法添加到类文件cf中。

注解 元标签

注释作为运行时不可见(或可见)的注记属性,存储在类文件中。

调用 getAttribute(AnnotationsAttribute.invisibleTag)方法,可以从 ClassFile,MethodInfo 或 FieldInfo 中获取注记属性。

更多信息,请参阅 javassist.bytecode.AnnotationsAttributejavassist.bytecode.annotation 包的 javadoc 手册。

Javassist还允许你通过更高级别的API访问注解。如果你想通过CtClass访问注解,在CtClass或CtBehavior中调用getAnnotations()。

泛型

Javassist 的低级别 API 完全支持 Java 5 引入的泛型。但是,高级别的API(如CtClass)不直接支持泛型。

Java的泛型是通过擦除技术实现的。在编译之后,所有的类型参数都会被丢掉。例如,假设你的源代码声明了一个参数化类型Vector

1
2
3
Vector<String> v = new Vector<String>();
:
String s = v.get(0);

编译后的字节码等价于以下代码:

1
2
3
Vector v = new Vector();
:
String s = (String)v.get(0);

所以当你写字节码变换器时,你可以直接放弃所有类型参数。

由于Javassist中嵌入的编译器不支持泛型,所以如果源代码是由Javassist编译的,例如通过CtMethod.make(),必须在调用者处插入一个显式类型转换。如果源代码是由普通的Java编译器(如javac)编译的,则无需进行类型转换。

例如,如果你有一个类:

1
2
3
4
public class Wrapper<T> {
T value;
public Wrapper(T t) { value = t; }
}

并想添加一个接口 Getter 到类 Wrapper

1
2
3
public interface Getter<T> {
T get();
}

那么你真正要添加的接口其实是Getter(将类型参数丢掉),最后你添加到 Wrapper 类的方法是这样的:

1
public Object get() { return value; }

注意,不需要类型参数。 由于 get 返回一个 Object,如果源代码是由 Javassist 编译的,那么在调用方需要进行显式类型转换。 例如,如果类型参数 T 是 String,则必须插入(String),如下所示:

1
2
Wrapper w = ...
String s = (String)w.get();

如果你需要在运行时通过反射使类型参数可以访问,你必须在类文件中添加通用签名。更多的细节,请参见CtClass中setGenericSignature方法的API文档(javadoc)。

可变参数

目前,Javassist 不直接支持可变参数。 因此,要使用 varargs 创建方法,必须显式设置方法修饰符。假设要定义下面这个方法:

1
public int length(int... args) { return args.length; }

使用 Javassist 应该是这样的:

1
2
3
4
CtClass cc = /* target class */;
CtMethod m = CtMethod.make("public int length(int[] args) { return args.length; }", cc);
m.setModifiers(m.getModifiers() | Modifier.VARARGS);
cc.addMethod(m);

参数类型int ...被更改为int []Modifier.VARARGS被添加到方法修饰符中。

要在由 Javassist 的编译器编译的源代码中调用此方法,需要这样写:

1
length(new int[] { 1, 2, 3 });

而不是这样:

1
length(1, 2, 3);