Javassist使用手册
字节码操作技术的工具有很多,在项目中有用到javassist库,例如JustClipper项目,这里通过翻译Javassist官方技术文档,加强自己的理解和使用~
❤️官方英文文档地址
Javassist
(Java Programming Assistant
)使得Java字节码操纵变得简单。它是一个编辑Java
字节码的类库;它使Java
程序既能够在运行时定义新类,也能在JVM
加载类文件时修改类文件。与其他相似的字节码编辑器不同的是,Javassist
提供了两种级别的API
,即源码级别和字节码级别。使用者利用源码级别的API
可以再不了解Java
字节码规范的情况下编辑类文件。整个API
是用Java
语言的词汇来设计的。你甚至可以指定插入的字节码的形式的源文本;Javassist
动态地编译它。字节码级别的API
允许用户像其他编辑器一样直接编辑类文件。
读写字节码
Javassist
是一个处理Java字节码的类库。Java
字节码存储在二进制的类文件中。每个类文件都包含一个Java类或接口。
Javassist.CtClass
类是类文件的抽象表示。一个CtClass
(编译时的类)对象是处理类文件的句柄。下面的程序是一个非常简单的例子:
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("test.Rectangle");
cc.setSuperclass(pool.get("test.Point"));
cc.writeFile();
该程序首先获取了一个ClassPool
对象,用来在Javassist
中控制字节码修改。ClassPool
对象是CtClass
对象的容器,同时代表着一个类文件。它读取一个类文件满足构造一个CtClass
对象的需要,同时记录着已被构造的对象以备之后访问。为了修改一个类的定义,用户必须首先从ClassPool
对象获取代表该类的CtClass
对象的引用。ClassPool
中的get()
被用于此目的。就上面所示的程序而言,CtClass
对象代表一个test.Rectangle
类,该类从ClassPool
对象中获取并赋值个cc
变量。ClassPool
对象通过getDefault()
返回,表示搜索默认系统的搜索路径。
从实现角度看,ClassPool
是一个CtClass
对象的hash表,以类名作为键。ClassPool
的get()
方法,根据指定的键搜索整个hash
表寻找CtClass
对象。如果CtClass
对象没有找到,get()
读取类文件构造新的CtClass
对象,存储在hash
表中,然后作为get()
方法结果返回。
从ClassPool
中获取的CtClass
对象是能够被修改的(具体如何修改将在之后呈现)。在上面的例子里,对其进行了修改,以便将test.Rectangle
的超类更改为test.Point
类。最终调用CtClass
中的writeFile()
时,此更改将反映在原始类文件上。
writeFile()
将CtClass
对象翻译成类文件,并写进本地磁盘中。Javassist
当然也提供了直接获取修改的字节码的方法。调用toBytecode()
方法可以获取字节码:
byte[] b = cc.toBytecode();
当然,也可以直接加载CtClass
:
Class clazz = cc.toClass();
toClass()
请求当前线程的上下文类加载器加载由CtClass
表示的类文件。它返回一个java.lang.Class
对象,代表已被加载的类。更多详情请看下面这部分。
定义一个新类
从头定义一个新类,使用ClassPool
的makeClass()
方法:
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.makeClass("Point");
该程序定义了一个没有成员的Point类。使用CtNewMethod
里声明的工厂方法创建Point
的成员方法,并通过CtClass里的addMethod()
方法附加给Point类。
makeClass()
方法不能创建一个新的接口;ClassPool
里的makeInterface()
方法可以用来创建新的接口。使用CtNewMethod
里的abstractMethod()
方法创建接口的成员方法。注意的是,接口方法是一个抽象的方法。
冻结类
如果一个CtClass
对象通过writeFile()
,toClass()
或者是toBytecode()
转换成类文件,Javassist
就会冻结该CtClass
对象。不允许对该CtClass
对象进行进一步的修改。这是为了在开发人员试图修改已经加载的类文件时警告他们,因为JVM
不允许重新加载类。
冻结的CtClass
可以解冻,这样就允许修改类定义。例如,
CtClasss cc = ...;
:
cc.writeFile();
cc.defrost();
cc.setSuperclass(...); // 这样行得通,因为类没有被冻结。
调用defrost()后,CtClass
对象又能够被修改了。
如果ClassPool.doPruning
设置为true
,Javassist
会在冻结CtClass
对象时,精简包含在该对象中的数据结构。为了减少内存消耗,精简过程会丢弃CtClass
对象里非必要的属性(attribute_info
结构)。例如Code_attribute
结构(方法体)会被丢弃。因此,一个CtClass
对象被精简后,除了方法名,签名和注解外,方法的字节码将无法访问。精简的CtClass
对象也不能被解冻。ClassPool.doPruning
的默认值是false
。
要不允许修剪特定的CtClass
,必须预先在该对象上调用stopPruning()
:
CtClasss cc = ...;
cc.stopPruning(true);
:
cc.writeFile(); // 转换成类文件。
// cc 不是精简的。
CtClass
对象cc
不是精简的,因此,它可以在writeFile()
调用后被解冻。
注意:当调试时,你可能想要暂时停止精简和冻结,并且将修改的类文件写入磁盘。debugWriteFile()
是达到此目的的一个便捷的方法。它停止精简,写一个类文件,解冻类文件并再次打开精简(如果该类文件最初是打开的)。
类搜索路径
静态方法ClassPool.getDefault()
返回的默认ClassPool
,搜索的路径与底层JVM
(Java虚拟机)是一样的。 如果程序正在Web应用程序服务器(例如JBoss
和Tomcat
)上运行,则ClassPool
对象可能无法找到用户类,因为这样的Web
应用程序服务器使用多个类加载器以及系统类加载器。 在这种情况下,必须将附加的类路径注册到ClassPool
。假设池引用了一个ClassPool
对象:
pool.insertClassPath(new ClassClassPath(this.getClass()));
该语句注册用于加载this
引用的对象的类的类路径。 您可以使用任何Class
对象代替this.getClass()
作为参数。 注册用于加载该Class
对象表示的类的类路径。用于加载Class
对象所表示的类的类路径已经注册。
你可以注册一个目录名作为类搜索路径。例如,下面的代码添加了一个/usr/local/javalib
目录到搜索路径:
ClassPool pool = ClassPool.getDefault();
pool.insertClassPath("/usr/local/javalib");
用户可以添加的搜索路径,不仅可以添加一个目录,还可以添加一个URL:
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
此外,你可以直接将字节数组传给ClassPool
对象,并从数组构造CtClass
对象。使用ByteArrayClassPath
来达到此目的。例如:
ClassPool cp = ClassPool.getDefault();
byte[] b = a byte array;
String name = class name;
cp.insertClassPath(new ByteArrayClassPath(name, b));
CtClass cc = cp.get(name);
获取的CtClass
对象代表b所指定类文件中定义的类。如果调用get()
并且给get()
的类名等于name
指定的类名,则ClassPool
从给定的ByteArrayClassPath
读取类文件。
如果你不知道全限定类名,你可以使用ClassPool
的makeClass()
方法:
ClassPool cp = ClassPool.getDefault();
InputStream ins = an input stream for reading a class file;
CtClass cc = cp.makeClass(ins);
makeClass()
方法返回从给定输入流构造的CtClass
对象。你可可以使用makeClass()
急切地将类文件提供给ClassPool
对象。如果搜索路径包含一个大的jar文件,这可能会提高性能。由于ClassPool
对象根据需要读取类文件,它可能会在整个jar文件中重复搜索每个类文件。makeClass()
可用于优化此搜索。由makeClass()
构造的CtClass
保存在ClassPool
对象中,并且类文件不再被读取。
用户可以扩展类搜索路径。他们可以定义一个实现ClassPath
接口的新类,并为ClassPool
的insertClassPath()
提供该类的实例。这允许在搜索路径中包含非标准资源。
ClassPool
ClassPool
对象是CtClass
对象的容器。一旦创建了CtClass
对象,它就会被永久地记录在ClassPool
中。这是因为编译器稍后在编译引用由该CtClass
表示的类的源代码时可能需要访问CtClass
对象。
例如,假设一个新的方法getter()
被添加到表示Point
类的CtClass
对象中。稍后,程序尝试编译源代码,包括Point
中的getter()
方法调用,并使用编译后的代码作为方法体,该方法体将被添加到另一个类Line
。如果表示Point
的CtClass
对象丢失,编译器就不能编译对getter()
的方法调用。注意,原来的类定义不包括getter()
。因此,要正确编译这样一个方法调用,ClassPool
必须包含在程序执行的所有期间的CtClass
的所有实例。
避免内存溢出
如果CtClass
对象的数量惊人地大,ClassPool
的这种规范可能会导致巨大的内存消耗(这种情况很少发生,因为Javassist
试图以各种方式减少内存消耗)。为了避免这个问题,您可以显式地从ClassPool
中删除一个不必要的CtClass
对象。如果你在一个CtClass
对象上调用detach()
,那么该CtClass
对象将从ClassPool
中移除。例如:
CtClass cc = ... ;
cc.writeFile();
cc.detach();
在detach()
被调用之后,你不能调用CtClass
对象的任何方法了。但是,您可以在ClassPool
上调用get()
来创建表示相同类的CtClass
的新实例。如果调用get()
, ClassPool
将再次读取一个类文件,并新建一个由get()
返回的CtClass
对象。
另一个想法是偶尔用一个新的ClassPool
对象替换,并丢弃旧的ClassPool
。如果旧的ClassPool
被垃圾收集,那么包含在该ClassPool
中的CtClass
对象也被垃圾收集。要创建一个ClassPool
的新实例,执行以下代码片段:
ClassPool cp = new ClassPool(true);
// 如果需要,通过appendClassPath()附加一个额外的搜索路径。
这创建一个ClassPool
对象,表现与由ClassPool.getDefault()
返回的默认ClassPool
一样。注意,ClassPool.getDefault()
是为方便而提供的单例工厂方法。它以上面所示的方式创建一个ClassPool
对象,尽管它保留了一个ClassPool
的一个实例并重复使用。getDefault()
返回的ClassPool
对象没有特殊的角色。getDefault()
是一个便利的方法。
请注意,new ClassPool(true)
是一个便利的构造函数,它构造一个ClassPool
对象并且附加了系统搜索路径。调用该构造函数相当于下面的代码:
ClassPool cp = new ClassPool();
cp.appendSystemPath(); // 或者通过appendClassPath()附加另一个的搜索路径。
级联的ClassPools
如果程序运行在一个web
应用服务器上,有必要创建多个ClassPool
实例。应该为每个类加载器(即容器)创建一个ClassPool
实例。程序应该通过调用ClassPool
的构造函数而不是getDefault()
来创建一个ClassPool
对象。
多个ClassPool
对象可以像java.lang.ClassLoader
那样级联。例如:
ClassPool parent = ClassPool.getDefault();
ClassPool child = new ClassPool(parent);
child.insertClassPath("./classes");
如果child.get()
被调用,子ClassPool
首先委派给父ClassPool
。如果父ClassPool
未能找到一个类文件,然后子ClassPool
尝试在./classes
目录下查找类文件。
如果child.childFirstLookup
设置为true
,子ClassPool
在委派给父ClassPool
之前会尝试查找类文件。例如:
ClassPool parent = ClassPool.getDefault();
ClassPool child = new ClassPool(parent);
child.appendSystemPath(); // 和默认的ClassPool一样相同的类路径
child.childFirstLookup = true; // 改变子ClassPool的行为
改变一个类名称来定义一个新类
一个新类可以被定义为一个现有类的副本,下面的程序就是这样做的:
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("Point");
cc.setName("Pair");
该程序首先获取Point
类的CtClass
对象。然后,通过调用调用CtClass
对象的setName()
来给定一个新的名字Pair
。在此调用之后,由该CtClass
对象表示的类定义中出现的所有类名都从Point
更改为Pair
。类定义的其他部分不会改变。
注意,CtClass
的setName()
改变了ClassPool
对象中的一条记录。从实现的角度看,ClassPool
对象是CtClass
对象的哈希表。setName()
改变了哈希表中关联CtClass
对象的键。该键从最初类名改变成新的类名。
因此,如果get("Point")
之后再次通过ClassPool
对象调用,它的返回值不再是cc
变量指向的CtClass
对象。ClassPool
对象再次读取Point.class
类文件,并且为类Point
构造一个新的CtClass
对象。这是因为与名称Point相关联的CtClass
对象不再存在。见下面:
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("Point");
CtClass cc1 = pool.get("Point"); // cc1 与 cc 相同。
cc.setName("Pair");
CtClass cc2 = pool.get("Pair"); // cc2 与 cc 相同。
CtClass cc3 = pool.get("Point"); // cc3 与 cc 不相同。
cc1
和cc2
指向和cc
相同的CtClass
实例,而cc3
不是。注意,在cc.setName("Pair")
执行后,cc
和cc1
指向的CtClass
对象表示的是Pair
类。
ClassPool
对象用于维护类与CtClass
对象之间的一对一映射。除非创建两个独立的ClassPool
,否则Javassist
不允许两个不同的CtClass
对象表示同一个类。这是一致性程序转换的一个重要特性。
为了创建由ClassPool.getDefault()
返回的默认ClassPool
实例的另一个副本,执行下面的代码片段(该代码在上面见过):
ClassPool cp = new ClassPool(true);
如果已有两个ClassPool
对象,然后可以从每个ClassPool
中获取表示相同类文件的不同的CtClass
对象。然后对CtClass
对象进行不同的修改,来生成不同版本的类。
重命名冻结的类来定义一个新类
一旦CtClass
对象通过writeFile()
或 toBytecode()
转成类文件,Javassist
就拒绝该CtClass
对象的进一步修改。因此,表示Point
类的CtClass
对象在被转成类文件后,不能定义Pair
类为Point
的副本,因为在Point
上执行setName()
会被拒绝。下面的代码片段是错误的:
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("Point");
cc.writeFile();
cc.setName("Pair"); // 错误,因为writeFile()已被调用。
为了避免这种限制,应该调用ClassPool
的 getAndRename()
。例如:
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
对象从Point
重命名为Pair
。因此,getAndRename()
可以在表示Point
类的CtClass
对象上调用writeFile()
或toBytecode()
之后执行。
类加载器
如果提前知道该修改什么类,修改类的最简单方式如下:
- 1.通过
ClassPool.get()
获取CtClass
对象 - 2.对
CtClass
对象进行修改 - 3.调用
CtClass
对象的writeFile()
或toBytecode()
来获取修改的类文件
如果不确定在加载时是否要修改类,用户必须让Javassist
与类加载器协作。Javassist
能够与一个类加载器一起使用,因此字节码能够在加载时被修改。Javassist
用户既可以定义自己版本的类加载器也可以使用Javassist
提供的类加载器。
CtClass的toClass方法
CtClass
提供了一个便利toClass
的方法,该方法请求当前线程的上下文类加载器来加载由CtClass
对象表示的类。为了调用该方法,调用者必须获得适当的许可;否则,可能会抛出SecurityException
。
以下程序展示如何使用toClass()
:
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()像这样:
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
类。
如果程序正在运行在一些像JBoss
和Tomcat
的应用服务器上,toClass()
使用的上下文类加载器可能不合适。在这种情况下,你将看到一个意外的ClassCastException
。为了避免这个异常,你必须显式给定toClass()
一个合适的类加载器。例如,如果bean
是你的会话bean
对象,然后下面的代码是可行的:
CtClass cc = ...;
Class c = cc.toClass(bean.getClass().getClassLoader());
你应该为toClass()
提供加载了程序的类加载器(在上面的示例中,是bean
对象的类)。
提供toClass()
是为了方便。如果需要更复杂的功能,应该编写自己的类装入器。
Java中的类加载
在Java
中,多个类加载器可以共存,并每个类加载器创建各自的命名空间。不同的类加载器可以用相同的类名加载不同的类文件。加载的两个类被视为不同的类。该特性赋予我们在单个JVM
上运行多个应用程序,即使这些程序包含相同名称的不同的类。
注意:JVM
不允许动态重新加载类。一旦一个类加载器加载了一个类,它不能在运行时重新加载修改后版本的类。因此,在JVM
加载一个类后,你不能修改该类的定义。但是,JPDA
(Java Platform Debugger Architecture)提供有限的重新加载类的能力。见章节。
如果相同类文件被两个不同的类加载器加载,JVM
使用相同的名称和定义生成两个不同的类。这两个类被视为不同的。由于这两个类不相同,一个类的实例不能赋值给另一个类的变量。两个类之间的强制转换操作失败并抛出ClassCastException
。
例如,下面的代码片段抛出一个异常:
MyClassLoader myLoader = new MyClassLoader();
Class clazz = myLoader.loadClass("Box");
Object obj = clazz.newInstance();
Box b = (Box)obj; // 这总是抛出ClassCastException.
Box
类被两个类加载器加载。假设一个类加载器CL
加载包含该代码片段的类文件。由于此代码片段引用了MyClassLoader
、Class
、Object
和Box
,所以CL
也会加载这些类(除非它委托给另一个类加载器)。因此变量b
的类型是由CL
加载的Box
类。另一方面,myLoader
也加载Box
类。对象obj
是myLoader
加载的Box
类的一个实例。因此,最后一条语句总是抛出ClassCastException
,因为obj
的类与用作变量b
类型的Box
类是不同的版本。
多个类加载器形成树形结构。除了引导(bootstrap
)加载器之外,每个类加载器都有一个父类加载器,父类加载器通常加载该子类加载器的类。由于加载类的请求可以沿着类加载器的层次结构进行委托,类可能会被类加载器加载,而你不没有请求类加载。因此,被请求装入类C
的类加载器可能与实际加载类C
的加载器不同。为了区分,我们称前者加载器为C
的启动器,称后者加载器为真正的加载器。
此外,如果一个类加载器CL
请求加载一个类C (C
的启动器)委托给父类加载器PL
,那么该类装入器CL
永远不会被请求加载类C
定义中引用的任何类。CL
不是这些类的启动器。相反,父加载器PL
成为它们的启动器,并被请求加载它们。类C
的定义所引用的类是由C
的实际加载器加载的。
要了解此行为,让我们考虑以下示例:
public class Point { // 通过PL加载
private int x, y;
public int getX() { return x; }
:
}
public class Box { // 启动器是L,但真正的加载器是PL
private Point upperLeft, size;
public int getBaseX() { return upperLeft.x; }
:
}
public class Window { // 通过加载器L加载
private Box box;
public int getBaseX() { return box.getBaseX(); }
}
假设Window
类是由类加载器L
加载的。Window
的启动器和实际加载器都是L
。因为Window
的定义引用Box
,JVM
将会请求L
加载Box
。这里,假设L
委派该任务给父类加载器PL
。Box
的启动器是L
但是实际的加载器是PL
。在这种情况下,Point
的启动器不是L
而是PL
,因为和Box
一样的实际加载器。因此L
不会被请求加载Point
。
接下来,让我们考虑一个轻微修改的例子:
public class Point {
private int x, y;
public int getX() { return x; }
:
}
public class Box { // 启动器是L,但真正的加载器是PL
private Point upperLeft, size;
public Point getSize() { return size; }
:
}
public class Window { // 类加载器L加载
private Box box;
public boolean widthIs(int w) {
Point p = box.getSize();
return w == p.getX();
}
}
现在,Window
的定义也引用Point
。在这种情况下,如果请求类加载器L
加载Point
,它也必须委托给PL
。必须避免让两个类加载器双重加载同一个类。两个加载器中的一个必须委托给另一个。
如果加载Point
时L
没有委托给PL
,widthIs()
将抛出ClassCastException
。由于Box
的实际加载器是PL
,Box
中引用的Point
也被PL
加载。因此,getSize()
的结果值是PL
加载的Point
实例,而widthIs()
中变量p
的类型是L
加载的Point
。JVM
将它们视为不同的类型,因此由于类型不匹配而抛出异常。
这种行为有些不方便,但却是必要的。如果下面的语句:
Point p = box.getSize();
没有抛出异常,那么Window
的开发人员就可以打破Point
对象的封装。例如,字段x
在PL
加载的Point
中是私有的。然而,如果L
用以下定义加载Point
, Window
类可以直接访问x
的值:
public class Point {
public int x, y; // not private
public int getX() { return x; }
:
}
要了解Java中类装入器的更多细节,下面的文章会有所帮助:
使用javassist.Loader
Javassist
提供一个类加载器javassist.Loader
。该类加载器使用javassist.ClassPool
,用于读取类文件。
例如,javassist.Loader
可用于加载使用Javassist
修改的特定类。
import javassist.*;
import test.Rectangle;
public class Main {
public static void main(String[] args) throws Throwable {
ClassPool pool = ClassPool.getDefault();
Loader cl = new Loader(pool);
CtClass ct = pool.get("test.Rectangle");
ct.setSuperclass(pool.get("test.Point"));
Class c = cl.loadClass("test.Rectangle");
Object rect = c.newInstance();
:
}
}
该程序修改了类Test.Rectangle
。test.Rectangle
的超类设置为test.point
类。然后,该程序加载了修改的类,并创建了test.Rectangle
类的新实例。
如果用户希望在加载类时按需修改该类,则可以向javassist.Loader
添加事件监听器。当类加载器加载类时,将通知添加的事件监听器。事件监听器类必须实现以下接口:
public interface Translator {
public void start(ClassPool pool)
throws NotFoundException, CannotCompileException;
public void onLoad(ClassPool pool, String classname)
throws NotFoundException, CannotCompileException;
}
当事件监听器通过javassist.Loader
中的addTranslator()
添加至javassist.Loader
对象中时,start()
会被调用。在javassist.Loader
加载类之前调用onLoad()
方法。onLoad()
可以修改被加载类的定义。
例如,下面的事件监听器在加载所有类之前将它们更改为公共类。
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
,编写一个主类,如下所示:
import javassist.*;
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", args);
}
}
要运行此程序,请执行:
% java Main2 arg1 arg2...
MyApp
类和其他应用程序类由MyTranslator
进行翻译。
注意,像MyApp
这样的应用程序类不能访问像Main2
、MyTranslator
和ClassPool
这样的加载器类,因为它们是由不同的加载器加载的。应用程序类由javassist.Loader
加载,而像Main2
这样的加载器类是默认的Java
类加载器。
javassist.Loader
以与java.lang.ClassLoader
不同的顺序搜索类。ClassLoader
首先将加载操作委托给父类加载器,然后仅在父类加载器找不到类时才尝试加载这些类。另一方面,javassist.Loadder
在委托给父类加载器之前尝试加载类。它只在以下情况下委托:
- 调用
ClassPool
对象的get()
无法找到这些类 - 这些类已经通过使用
delegateLoadingOf()
指定由父类加载器加载。
这个搜索顺序允许Javassist
加载修改过的类。但是,如果由于某种原因无法找到修改过的类,它将委托给父类加载器。一旦一个类被父类加载器加载,在该类中引用的其他类也将被父类加载器加载,因此它们永远不会被修改。回想一下,类C
中引用的所有类都是由C
的实际加载器加载的。如果你的程序无法加载修改后的类,你应该确保使用该类的所有类是否都已被javassist.Loader
加载。
写一个类加载器
一个简单的使用Javassist
的类加载器如下:
import javassist.*;
public class SampleLoader extends ClassLoader {
/* 调用 MyApp.main().
*/
public static void main(String[] args) throws Throwable {
SampleLoader s = new SampleLoader();
Class c = s.loadClass("MyApp");
c.getDeclaredMethod("main", new Class[] { String[].class })
.invoke(null, new Object[] { args });
}
private ClassPool pool;
public SampleLoader() throws NotFoundException {
pool = new ClassPool();
pool.insertClassPath("./class"); // MyApp.class 必须在该路径下。
}
/* 查找指定的类
* 该类的字节码可以被修改
*/
protected Class findClass(String name) throws ClassNotFoundException {
try {
CtClass cc = pool.get(name);
// 在这里修改CtClass对象
byte[] b = cc.toBytecode();
return defineClass(name, b, 0, b.length);
} catch (NotFoundException e) {
throw new ClassNotFoundException();
} catch (IOException e) {
throw new ClassNotFoundException();
} catch (CannotCompileException e) {
throw new ClassNotFoundException();
}
}
}
MyApp
类是一个应用程序。为了执行该程序,首先将该类文件放在./class
目录下,不得包含在类搜索路径中。否则,MyApp.class
将通过默认的系统类加载器加载,该加载器是SampleLoader
的父加载器。目录名称./class
由构造函数中的insertClassPath()
指定。你可以选择一个其他名称,而不是./class
(如果需要)。然后做以下操作:
% java SampleLoader
类加载器加载类MyApp
(./class/MyApp.class
)并且使用命令行参数调用MyApp.main()
。
这是使用Javassist
最简单的方式。然而,如果你写一个更复杂的类加载器,你可能需要详细了解Java的类加载机制。例如,上面的程序将MyApp
类放在与类SampleLoader
所属的名称空间分开的名称空间中,因为这两个类是由不同的类加载器加载的。因此,MyApp
类不能直接访问SampleLoader
类。
修改一个系统类
像java.lang.String
这样的系统类不能由系统类加载器以外的类加载器加载。因此,上面展示的SampleLoader
或javassist.Loader
不能在加载时修改系统类。
如果你的应用需要这样做,系统类必须被静态的修改。例如,下面程序给java.lang.String
添加了一个新的字段hiddenValue
:
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(".");
该程序产生一个文件“./java/lang/String.class
”。
要使用修改后的String
类运行程序MyApp
,做以下操作:
% java -Xbootclasspath/p:. MyApp arg1 arg2...
假设MyApp
的定义如下:
public class MyApp {
public static void main(String[] args) throws Exception {
System.out.println(String.class.getField("hiddenValue").getName());
}
}
如果修改的String类被正确加载,MyApp
打印hiddenValue
。
注意:不应该部署使用这种技术来覆盖rt.jar
中的系统类的应用程序,因为这样做会违反Java 2 Runtime Environment二进制代码许可。
运行时重新加载类
如果JVM
启动时启用了JPDA
(Java Platform Debugger Architecture),类是动态可加载的。JVM在加载一个类后,老版本的类定义能够被卸载并且新的类定义能够重新加载。也就是说,在运行时,类定义可以被动态修改。但是,新的类定义必须在某种程度上与旧的类定义兼容。JVM不允许在两个版本间的更改Schema。它们有相同的方法和字段。
Javassist
提供了一个方便的类,用于在运行时重新加载类。有关更多信息,请参阅javassist.tools.HotSwapper
的API
文档。
内省和定制
CtClass
提供了用于内省的方法。Javassist
的内省能力与Java
反射API
的内省能力是兼容的。CtClass
提供了getName()
、getSuperclass()
、getMethods()
等方法。CtClass
还提供了修改类定义的方法。它允许添加新的字段、构造函数和方法。也可以插桩方法体。
方法由CtMethod
对象表示。CtMethod
提供了几种修改方法定义的方法。请注意,如果一个方法是从一个超类继承来的,那么表示继承的方法的CtMethod
对象就表示在那个超类中声明的方法。一个CtMethod
对象对应于每个方法声明。
例如,如果类Point
声明了方法move()
,并且Point
的子类ColorPoint
没有覆盖move()
,那么在Point中声明的和在ColorPoint
中继承的两个move()
方法将由相同的CtMethod
对象表示。如果这个CtMethod
对象表示的方法定义被修改,那么修改会反映在两个方法上。如果您只想修改ColorPoint中的move()
方法,那么首先必须向ColorPoint
添加一个代表Point
中的move()
的CtMethod
对象的副本。可以通过CtNewMethod.copy()
获得CtMethod
对象的副本。
Javassist
不允许删除方法或字段,但它允许更改名称。因此,如果一个方法不再需要,它应该被重命名,并通过调用在CtMethod
中声明的setName()
和setModifiers()
将其更改为一个私有方法。
Javassist
也不允许向现有的方法添加额外的参数。相反,接收额外参数和其他参数的新方法应该添加到同一个类中。例如,如果你想给一个方法添加一个额外的int
形参newZ
:
void move(int newX, int newY) { x = newX; y = newY; }
在Point
类中,您应该向Point
类添加以下方法:
void move(int newX, int newY, int newZ) {
// do what you want with newZ.
move(newX, newY);
}
Javassist
还提供了用于直接编辑原始类文件的低级API
。例如,CtClass
中的getClassFile()
返回一个表示原始类文件的ClassFile
对象。CtMethod
中的getMethodInfo()
返回一个MethodInfo
对象,表示类文件中包含的method_info
结构。低级API
使用来自Java虚拟机规范的词汇表。用户必须具有关于类文件和字节码的知识。有关更多细节,用户应该看到javassist.bytecode
包。
只有在使用了以$
开头的特殊标识符时,Javassist
修改的类文件才需要Javassist.runtime
包来提供运行时支持。下面描述了这些特殊的标识符。在没有这些特殊标识符的情况下修改的类文件在运行时不需要Javassist.runtime
包或任何其他Javassist
包。有关更多细节,请参阅javassist.runtime
包的API
文档。
在方法体的开头/结尾插入源文本
CtMethod
和CtConstructor
提供了insertBefore()
、insertAfter()
和addCatch()
方法。它们用于将代码片段插入现有方法的主体中。用户可以使用Java编写的源代码指定这些代码片段。Javassist
包括一个用于处理源文本的简单Java编译器。它接收用Java
编写的源文本,并将其编译为Java
字节码,这些字节码将内联到方法体中。
也可以在行号指定的位置插入代码片段(如果行号表包含在类文件中)。在CtMethod
和CtConstructor
中的insertAt()
接受原始类定义的源文件中的源文本和行号。它编译源文本并在行号处插入编译后的代码。
方法insertBefore()
、insertAfter()
、addCatch()
和insertAt()
接收表示语句或块的字符串对象。语句是一个单独的控制结构,如if
和while
或以分号(;)
结尾的表达式。块是一组用大括号{}
括起来的语句。因此,下面的每一行都是有效语句或块的示例:
System.out.println("Hello");
{ System.out.println("Hello"); }
if (i < 0) { i = -i; }
语句和块可以引用字段和方法。如果方法是用-g
选项编译的(在类文件中包含一个局部变量属性),它们还可以引用所插入的方法的参数。否则,它们必须通过下面描述的特殊变量$0,$1,$2,…
来访问方法参数。不允许访问方法中声明的局部变量,但允许在块中声明一个新的局部变量。但是,insertAt()
允许语句和块访问局部变量,前提是这些变量在指定的行号处可用,并且目标方法是用-g
选项编译的。
传递给insertBefore()
、insertAfter()
、addCatch()
和insertAt()
方法的String对象由Javassist
中包含的编译器编译。由于编译器支持语言扩展,一些以$
开头的标识符具有特殊的含义:
$0 , $1 , $2 , ... | this和实际的参数 |
---|---|
$args | 参数的数组。$arg 的类型是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
不可用。
这些变量的用法如下。假设一个类点:
class Point {
int x, y;
void move(int dx, int dy) { x += dx; y += dy; }
}
要在调用move()
方法时打印dx
和dy
的值,执行这个程序:
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();
请注意,传递给insertBefore()
的源文本被大括号{}
包围。insertBefore()
只接受单个语句或用大括号括起来的块。
修改后类点的定义是这样的:
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,则:
move($$)
等价于这样:
move($1, $2, $3)
如果move()
不带任何参数,那么move($$)
等价于move()
。
$$
可以与另一种方法一起使用。如果你写一个表达式:
exMove($$, context)
那么这个表达式就等于:
exMove($1, $2, $3, context)
请注意,$$
允许根据参数数量对方法调用进行通用标记。它通常与后面显示的$proceed
一起使用。
cflow
$cflow
表示“控制流”。这个只读变量返回对特定方法的递归调用的深度。
假设下面显示的方法由一个CtMethod
对象cm
表示:
int fact(int n) {
if (n <= 1)
return n;
else
return n * fact(n - 1);
}
要使用$cflow
,首先声明$cflow
用于监视对方法fact()
的调用:
CtMethod cm = ...;
cm.useCflow("fact");
useCflow()
的参数是声明的$cflow
变量的标识符。任何有效的Java
名称都可以用作标识符。因为标识符还可以包括点.
,例如,"my.Test.fact
"是一个有效的标识符。
然后,$cflow(fact)
表示对cm
指定的方法的递归调用的深度。当第一次调用该方法时,$cflow(fact)
的值为0
(零),而当在该方法内递归调用该方法时,它的值为1。例如:
cm.insertBefore("if ($cflow(fact) == 0)"
+ " System.out.println(\"fact \" + $1);");
转换方法fact()
,使其显示参数。由于检查了$cflow(fact)
的值,因此如果在fact()
中递归调用fact()
方法,则fact()方法不会显示该参数。
$cflow
的值是在当前线程的当前最顶层栈帧下指定方法cm
关联的栈帧数。$cflow
也可以在不同于指定方法cm
的方法中访问。
$r
$r
表示方法的结果类型(返回类型)。它必须用作强制转换表达式中的强制转换类型。例如,通常用法:
Object result = ... ;
$_ = ($r)result;
如果结果类型是基本类型,则($r)
遵循特殊语义。首先,如果强转表达式的操作数类型是基本类型,则($r)
作为对结果类型的普通强转操作符。另一方面,如果操作数类型是包装类型,($r)
将从包装类型转换为结果类型。例如,如果结果类型是int
,那么($r)将从java.lang.Integer
转换为int
。
如果结果类型为void
,则($r)
不转换类型;它什么也不做。但是,如果操作数是对void
方法的调用,则($r)
的结果为null
。例如,如果结果类型为void
并且foo()
是一个void
方法,那么:
$_ = ($r)foo();
是一个有效的语句。
强转操作符($r)
在返回语句中也很有用。即使结果类型为void
,下面的返回语句也是有效的:
return ($r)result;
这里,result
是某个局部变量。由于指定了($r)
,结果值将被丢弃。这个返回语句被认为等同于没有结果值的返回语句:
return;
$w
$w
表示包装类型。它必须用作强转表达式中的强转类型。($w)
将基本类型转换为相应的包装类型。下面的代码是一个示例:
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()
插入的编译代码将作为finally
子句执行。编译后的代码中$_
的值为0
或null
。编译后的代码执行结束后,最初抛出的异常被重新抛出给调用者。注意$_
的值永远不会被抛出给调用者;它被抛弃了。
$sig
$sig
的值是java.lang.Class
对象的数组,这些对象按照声明顺序表示形式参数类型。
$type
$type
的值是一个java.lang.Class
对象,表示结果值的形式类型。如果这是一个构造函数,则该变量引用Void.class
。
$class
$class
的值是一个java.lang.Class
对象,表示在其中声明编辑方法的类。这表示$0
的类型。
addCatch()
addCatch()
将代码片段插入方法体中,以便在方法体抛出异常并将控制返回给调用者时执行该代码片段。在表示插入代码片段的源文本中,异常值使用特殊变量$e
来引用。
例如,这个程序:
CtMethod m = ...;
CtClass etype = ClassPool.getDefault().get("java.io.IOException");
m.addCatch("{ System.out.println($e); throw $e; }", etype);
将m
表示的方法体转换成如下的形式:
try {
the original method body
}
catch (java.io.IOException e) {
System.out.println(e);
throw e;
}
注意,插入的代码片段必须以throw
或return
语句结束。
改变方法体
CtMethod
和CtConstructor
提供了setBody()
来替换整个方法体。它们将给定的源文本编译成Java
字节码,并用它代替原始方法体。如果给定的源文本为null
,则替换的方法体只包含一个return
语句,该语句返回零或 null
,除非结果类型为void
。
在给定给setBody()
的源文本中,以$
开头的标识符具有特殊含义:
$0 , $1 , $2 , ... | this和实际的参数 |
---|---|
$args | 参数的数组。$arg 的类型是Object[]。 |
$$ | 实际的参数。 |
$cflow( ...) | cflow 变量 |
$r | 结果类型。用于强转表达式。 |
$w | 包装类型。用于强转表达式。 |
$sig | 表示形式参数类型的java.lang.Class 对象数组。 |
$type | 表示表示形式类型的java.lang.Class 对象。 |
$class | 表示声明当前包含当前编辑的方法的类($0的类型)的java.lang.Class 对象。 |
注意,$_
不可用。
将源文本替换为现有表达式
Javassist
只允许修改方法体中包含的表达式。javassist.expr.ExprEditor
是一个用于替换方法体中的表达式的类。用户可以定义ExprEditor
的子类来指定如何修改表达式。
要运行ExprEditor
对象,用户必须调用CtMethod
或CtClass
的instrument()
。例如:
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 = 0; $_ = $proceed($$); }
因此move()
的第一个参数总是0
。请注意,替换的代码不是表达式,而是语句或块。它不能是或包含try-catch
语句。
方法instrument()
搜索方法体。如果它找到一个表达式,比如方法调用,字段访问和对象创建,那么它就在给定的ExprEditor
对象上调用edit()
。edit()
的参数是一个表示找到的表达式的对象。edit()
方法可以通过该对象检查和替换表达式。
在edit()
的参数上调用replace()
来替换给定的语句或块。如果给定的块是空块,也就是说,如果执行replace("{}")
,则从方法体中删除表达式。如果想要在表达式之前或之后插入一个语句(或者块)应该将如下代码块传递给replace()
:
{ before-statements;
$_ = $proceed($$);
after-statements; }
无论表达式是方法调用、字段访问、对象创建还是其他。第二个语句可以是:
$_ = $proceed();
如果表达式为读访问或写访问,则为
$proceed($$);
如果instrument()
搜索的方法是用-g
选项编译的(类文件包含一个局部变量属性),则目标表达式中可用的局部变量在传递给replace()
的源文本中也可用。
javassist.expr.MethodCall
MethodCall
对象表示一个方法调用。MethodCall
中的replace()
方法替换方法调用的语句或块。它接收表示被替换语句或块的源文本,其中以$
开头的标识符与传递给insertBefore()
的源文本一样具有特殊含义。
$0 | 方法调用的目标对象。不等同于this,表示调用方this对象。如果方法是静态的,则$0为null。 |
---|---|
$1, $2, ... | 方法调用的参数。 |
$_ | 方法调用的结果值。 |
$r | 方法调用的结果类型。 |
$class | 一个java.lang.Class对象,表示声明该方法的类。 |
$sig | 表示形式参数类型的java.lang.Class对象数组。 |
$type | 表示形式结果类型的java.lang.Class对象。 |
$proceed | 表达式中最初调用的方法的名称。 |
这里的方法调用是指由MethodCall
对象表示的方法调用。
其他标识符,如$w
、$args
和$$
也可用。
除非方法调用的结果类型为void
,否则必须在源文本中为$_
赋值,并且$_
的类型为结果类型。如果结果类型为void
,则$_
的类型为Object
,并且分配给$_
的值将被忽略。
$proceed
不是一个String
值,而是一个特殊语法。它必须后跟一个由圆括号()
包围的参数列表。
javassist.expr.ConstructorCall
ConstructorCall
对象表示构造函数调用,例如this()
和包含在构造函数体中的super
。ConstructorCall
中的replace()
方法替代构造函数调用的语句或块。它接收表示被替换语句或块的源文本,其中以$
开头的标识符与传递给insertBefore()
的源文本一样具有特殊含义。
$0 | 构造函数调用的目标对象。等同于this。 |
---|---|
$1, $2, ... | 构造函数调用的参数。 |
$class | 一个java.lang.Class对象,表示声明构造函数的类。 |
$sig | 表示形式参数类型的java.lang.Class对象数组。 |
$proceed | 表达式中最初调用的构造函数的名称。 |
这里的构造函数调用是指由ConstructorCall
对象表示的构造函数调用。
其他标识符,如$w
、$args
和$$
也可用。
由于任何构造函数都必须调用父类的构造函数或同一类的另一个构造函数,因此替换语句必须包含构造函数调用,通常是对$proceed()
的调用。
$proceed
不是一个String
值,而是一个特殊语法。它必须后跟一个由圆括号()
包围的参数列表。
javassist.expr.FieldAccess
FieldAccess
对象表示字段访问。如果找到了字段访问权限,ExprEditor
中的edit()
方法将接收该对象。FieldAccess
中的replace()
方法接收表示字段访问的替换语句或块的源文本。
在源文本中,以$
开头的标识符具有特殊含义:
$0 | 包含表达式访问的字段的对象。不等于this。this表示调用包含表达式的方法的对象。如果字段是静态的,则$0为null。 |
---|---|
$1 | 如果表达式是写访问,将存储在字段中的值。否则,$1不可用。。 |
$_ | 如果表达式为读访问,则字段访问的结果值。否则,存储在$_中的值将被丢弃。 |
$r | 如果表达式是读访问,则字段的类型。否则,$r为void。 |
$class | 一个java.lang.Class对象,表示声明字段的类。 |
$type | 表示字段类型的java.lang.Class对象。 |
$proceed | 执行原始字段访问的虚拟方法的名称。 |
其他标识符,如$w
、$args
和$$
也可用。
如果表达式是读访问,则必须在源文本中为$_
赋值。$_
的类型是字段的类型。
javassist.expr.NewExpr
NewExpr
对象表示使用new
操作符创建对象(不包括创建数组)。如果找到对象创建,ExprEditor
中的edit()
方法将接收此对象。NewExpr
中的replace()
方法接收表示用于对象创建的替换语句或块的源文本。
在源文本中,以$
开头的标识符具有特殊含义:
$0 | null。 |
---|---|
$1,$2,... | 构造函数的参数。 |
$_ | 对象创建的结果值。新创建的对象必须存储在这个变量中。 |
$r | 创建对象的类型。 |
$sig | 表示形式参数类型的java.lang.Class对象数组。 |
$type | 一个java.lang.Class对象,表示所创建对象的类。 |
$proceed | 执行原始对象创建的虚拟方法的名称。 |
其他标识符,如$w
、$args
和$$
也可用。
javassist.expr.NewArray
NewArray
对象表示用new
操作符创建数组。如果找到了数组创建,ExprEditor
中的edit()
方法将接收这个对象。NewArray
中的replace()
方法接收表示用于创建数组的替换语句或块的源文本。
在源文本中,以$
开头的标识符具有特殊含义:
$0 | null。 |
---|---|
$1,$2,... | 每个维度的大小。 |
$_ | 数组创建的结果值。新创建的数组必须存储在这个变量中。 |
$r | 创建的数组类型。 |
$type | 一个java.lang.Class对象,表示创建的数组的类。 |
$proceed | 执行原始数组创建的虚方法的名称。 |
其他标识符,如$w
、$args
和$$
也可用。
例如,如果数组创建为以下表达式
String[][] s = new String[3][4];
那么$1
和$2
的值分别是3和4。$3
是不可用的。
如果数组创建为以下表达式
String[][] s = new String[3][];
那么$1
的值是3,但$2
是不可用的。
javassist.expr.Instanceof
一个Instanceof
对象表示一个instanceof
表达式。如果找到一个instanceof
表达式,ExprEditor
中的edit()
方法会接收这个对象。Instanceof
中的replace()
方法接收表示被替换语句或表达式块的源文本。
在源文本中,以$
开头的标识符具有特殊含义:
$0 | null。 |
---|---|
$1 | 原始instanceof操作符左侧的值。 |
$_ | 表达式的结果值。$_的类型是boolean。 |
$r | instanceof操作符右侧的类型。 |
$type | 一个java.lang.Class对象,表示instanceof操作符右侧的类型。 |
$proceed | 执行原始instanceof表达式的虚方法的名称。它接受一个参数(类型为java.lang.Object),如果参数值是原始instanceof操作符右侧类型的实例,则返回true。否则,返回false。 |
其他标识符,如$w
、$args
和$$
也可用。
javassist.expr.Cast
Cast
对象表示显式类型转换的表达式。如果发现显式类型转换,ExprEditor
中的方法edit()
将接收此对象。Cast
中的replace()
方法接收表示被替换语句或表达式块的源文本。
在源文本中,以$
开头的标识符具有特殊含义:
$0 | null。 |
---|---|
$1 | 被显式强制转换的值。 |
$_ | 表达式的结果值。$_的类型与显式强制转换后的类型相同,即被()包围的类型。 |
$r | 在显式强制转换之后或者用()包围的类型。 |
$type | 表示与$r相同类型的java.lang.Class对象。 |
$proceed | 执行原始类型强制转换的虚方法的名称。它接受java.lang.Object类型的一个参数,并在原始表达式指定的显式类型转换之后返回它。 |
其他标识符,如$w
、$args
和$$
也可用。
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
子句。
添加一个新的方法或字段
添加一个方法
Javassist
允许用户从头创建一个方法和构造器。CtNewMethod
和CtNewConstructor
提供了多个创建CtMethod
或CtConstructor
的工厂静态方法。特别的,make()
从给定的源文本创建一个CtMethod
或者CtConstructor
对象。
例如,该程序:
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
。例如:
CtClass point = ClassPool.getDefault().get("Point");
CtMethod m = CtNewMethod.make(
"public int ymove(int dy) { $proceed(0, dy); }",
point, "this", "move");
这个程序创建了一个方法ymove()
,定义如下:
public int ymove(int dy) { this.move(0, dy); }
注意,$proceed
已被this.move
所取代。
Javassist
提供了另一种添加新方法的方法。你可以先创建一个抽象方法,然后给它一个方法体:
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
能够编译自我调用的递归方法)。为了添加相互递归方法到一个类中,你需要下面的技巧。假设你想要向类cc
中添加方法m()
和n()
:
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);
你必须创建两个抽象的方法并添加到类中。然后你可以添加方法的方法体,即使方法体包含方法的互相调用。最后,你必须将类更改为非抽象类,因为如果添加了抽象方法,addMethod()
会自动将类更改为抽象类。
添加一个字段
Javassist
也允许用户创建一个新的字段。
CtClass point = ClassPool.getDefault().get("Point");
CtField f = new CtField(CtClass.intType, "z", point);
point.addField(f);
该程序添加一个名为z
的字段到类Point
中。
如果添加的字段的初始值必须指定,上面的程序必须修改成:
CtClass point = ClassPool.getDefault().get("Point");
CtField f = new CtField(CtClass.intType, "z", point);
point.addField(f, "0"); // 初始值是0。
现在,addField()
方法接收来两个参数。表示源文本的表达式用于计算初始值。只要表达式结果类型匹配字段的类型,源文本可以是任何的Java
表达式。注意,表达式不要以分号(;)
结尾。
此外,上面的代码可以重写成下面简单的代码:
CtClass point = ClassPool.getDefault().get("Point");
CtField f = CtField.make("public int z = 0;", point);
point.addField(f);
移除一个成员
为了移除一个字段或者一个方法,调用CtClass
中的removeField()
或者removeMethod()
方法。CtClass
中的removeConstructor()
可以移除一个CtConstructor
。
注解
CtClass
,CtMethod
,CtField
和CtConstructor
为读取注解提供了一个便利的getAnnotations()
方法。它返回一个注解类型的对象。
例如,假设下面注解:
public @interface Author {
String name();
int year();
}
该注解可以用于下面:
@Author(name="Chiba", year=2005)
public class Point {
int x, y;
}
然后,可以通过getAnnotations()
方法获取注解的值。它返回注解类型对象数组。
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);
代码片段会打印:
name: Chiba, year: 2005
因为Point
的注解仅仅只有@Author
,数组all
的长度是1,并且all[0]
是Author
对象。注解的成员值可以通过调用Author
对象的name()
和year()
获取。
为了使用getAnnotations()
,像Author
这样的注解类型必须包含在当前类路径中。注解类型必须从ClassPool中访问。如果注解类型的类文件没有找到,Javassist
不能获取注解类型的成员的默认值。
运行时支持类
大多数情况,通过Javassist
修改的类运行不要求依赖Javassist
。但是,一些Javassist
编译器生成的字节码需要运行时支持的类,这些类在javassist.runtime
包中(详情阅读该包的API
参考)。注意,javassist.runtime
包仅仅是通过Javassist修改的类运行可能需要的包。其他Javassist
类,修改的类在运行时不再使用。
导入
源代码中的所有类名必须全是限定(必须包含包名)。但是,java.lang
包是个意外。例如,Javassist
编译器可以解析Object
和java.lang.Object
一样。
为了告诉编译器查找其他包(在解析类名时),调用ClassPool
的importPackage()
。例如:
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
中包含的Java
编译器在编译器可接受的语言方面有几个限制。这些限制是:
J2SE 5.0
(包含枚举和泛型)引入的新语法还不支持。注解在Javassist
的低级API
中支持。见javassist.bytecode.annotation
包(以及CtClass
和CtBehavior
中的getAnnotations()
)。泛型也只支持部分。详见后一节。用大括号
{
和}
括起来的以逗号分隔的表达式列表的数组初始化方式不可用,除非数组维度为1。不支持内部类或匿名类。注意,这只是编译器的限制。它无法编译包含匿名类声明的源代码。
Javassist
能读取和修改内部类或匿名类的类文件。不支持标记的
contiue
和break
语句。编译器没有正确实现Java方法分派算法。如果类中定义的方法具有相同的名称但采用不同的参数列表,编译器可能会混淆。
例如:
javaclass A {} class B extends A {} class C extends B {} class X { void foo(A a) { .. } void foo(B b) { .. } }
如果编译的表达式是
x.foo(new C())
,x是X的实例。尽管编译器可以正确编译foo((B)new C())
,但编译器可能会产生对foo(A)
的调用。推荐用户使用
#
作为类名和方法名或者字段名间的分割符。例如,在常规Java
中,javajavassist.CtClass.intType.getName()
调用
javassist.CtClass
中的静态字段intType的对象的getName()
方法。在Javassist
中,用户能够写上面的的表达式,但是下面的方式推荐:javajavassist.CtClass#intType.getName()
这样可以让编译器更快的解析表达式。
字节码级API
Javassist
也提供直接编辑类文件的底层API
。要使用该级别的API
,您需要详细了解Java
字节码和类文件格式,而这一级别的API
允许您对类文件进行任何类型的修改。
如果仅仅想要生成一个简单的类文件,javassist.bytecode.ClassFileWriter
或许是最好的API
选择。它虽然体积小,但是它比javassist.bytecode.ClassFile
要快的多。
获取ClassFile对象
javassist.bytecode.ClassFile
对象表示一个类文件。要获取这个对象,应该调用CtClass
中的getClassFile()
。
否则,您可以直接从类文件构造一个javassist.bytecode.ClassFile
对象。例如:
BufferedInputStream fin = new BufferedInputStream(new FileInputStream("Point.class"));
ClassFile cf = new ClassFile(new DataInputStream(fin));
这段代码从Point.class
创建了一个ClassFile
对象。
ClassFile
对象可以写回成一个类文件。ClassFile
中的write()
将类文件的内容写入给定的DataOutputStream
。
您可以从头创建一个新的类文件。例如:
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
,其中包含以下类的实现:
package test;
class Foo implements Cloneable {
public int width;
}
添加和移除成员
ClassFile
提供了addField()
和addMethod()
来添加字段或方法(注意,构造函数在字节码级别被视为方法)。它还提供了adddattribute()
,用于向类文件添加属性。
字段与属性的关系
字段通常指的是类中定义的变量,而属性通常指的是这些变量所具有的特性,例如它们的访问修饰符、数据类型、默认值等。因此,可以说属性是字段的元数据。
注意,FieldInfo
、MethodInfo
和AttributeInfo
对象包含到ConstPool
(常量池表)对象的链接。ConstPool
对象必须是ClassFile
对象和添加到该ClassFile
对象的FieldInfo
(或MethodInfo
等)对象的公共对象。换句话说,FieldInfo
(或MethodInfo
等)对象不能在不同的ClassFile
对象之间共享。
要从ClassFile
对象中删除字段或方法,必须先获得包含类的所有字段的java.util.List
对象。getFields()
和getMethods()
返回列表。可以通过在List
对象上调用remove()
来删除字段或方法。可以用类似的方式删除属性。调用FieldInfo
或MethodInfo
的getAttributes()
来获取属性列表,并从列表中删除一个属性。
遍历方法体
要检查方法体中的每个字节码指令,CodeIterator是很有用的。操作步骤如下:
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()
如果有更多的指令则返回true。int next()
返回下一条指令的索引。注意,它不会返回下一条指令的操作码。int byteAt(int index)
返回索引处的无符号8位值。int u16bitAt(int index)
返回无符号的16位值。int write(byte[] code,int index)
在索引处写入字节数组。void insert(int index,byte[] code)
在索引处插入字节数组。分支偏移量等自动调整。
下面的代码片段显示了方法主体中包含的所有指令:
CodeIterator ci = ... ;
while (ci.hasNext()) {
int index = ci.next();
int op = ci.byteAt(index);
System.out.println(Mnemonic.OPCODE[op]);
}
生成字节码序列
Bytecode
对象表示一个字节码指令序列。它是一个可增长的字节码数组。下面是一个示例代码片段:
ConstPool cp = ...; // 常量池表
Bytecode b = new Bytecode(cp, 1, 0);
b.addIconst(3);
b.addReturn(CtClass.intType);
CodeAttribute ca = b.toCodeAttribute();
这将生成表示以下序列的字节码属性:
iconst_3
ireturn
还可以通过调用Bytecode
的get()
来获得包含此序列的字节数组。可以将获得的数组插入到另一个字节码属性中。
虽然Bytecode
提供了许多向序列中添加特定指令的方法,但它提供了addOpcode()
用于添加8位操作码,并提供了adddindex()
用于添加索引。每个操作码的8位值在Opcode
接口中定义。
addOpcode()
和其他用于添加特定指令的方法自动维护最大操作数栈深度,除非控制流不包含分支。这个值可以通过在Bytecode
对象上调用getMaxStack()
来获得。它还反映在由Bytecode
对象构造的CodeAttribute
对象上。若要重新计算方法体的最大操作数栈深度,调用CodeAttribute
的computeMaxStack()
。
Bytecode
可以用来构造方法。例如:
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
中。
注解(Meta tags)
注解作为运行时不可见(或可见)的注解属性存储在类文件中。这些属性可以从ClassFile
、MethodInfo
或FieldInfo
对象中获得。在这些对象上调用getAttribute(AnnotationsAttribute.invisibleTag)
。更多详情,见javassist.bytecode.AnnotationsAttribute
类的javadoc
手册和javassist.bytecode.annotation
包。
Javassist
还允许您通过高级API
访问注解。如果希望通过CtClass
访问注解,请在CtClass
或CtBehavior
中调用getnotations()
。
泛型
Javassist
的低级API
完全支持Java 5
引入的泛型。另一方面,例如CtClass
这样的高级别API
并不直接支持泛型。但是,对于字节码转换来说,这不是一个严重的问题。
Java的泛型是通过擦除技术实现的。编译之后,所有类型参数都被删除。例如,假设您的源代码声明了一个参数化类型Vector<String>
:
Vector<String> v = new Vector<String>();
:
String s = v.get(0);
编译后的字节码等价于下面的代码:
Vector v = new Vector();
:
String s = (String)v.get(0);
因此,在编写字节码转换器时,您可以删除所有类型参数。因为嵌入到Javassist
中的编译器不支持泛型,所以如果源代码是由Javassist
编译的(例如通过CtMethod.make()
),则必须在调用者站点插入显式类型转换。如果源代码是由普通的Java编译器(如javac
)编译的,则不需要进行类型转换。
例如,你有一个类:
public class Wrapper<T> {
T value;
public Wrapper(T t) { value = t; }
}
并想要将接口Getter<T>添加到类Wrapper<T>:
public interface Getter<T> {
T get();
}
然后,您真正需要添加的接口是Getter
(类型参数<T>
消失了),您还必须添加到包装器类的方法是这样一个简单的方法:
public Object get() { return value; }
注意,不需要类型参数。由于get
返回一个对象,所以如果源代码是由Javassist
编译的,则需要在调用方站点进行显式类型转换。例如,如果类型参数T是String,则(String)必须按如下方式插入:
Wrapper w = ...
String s = (String)w.get();
如果源代码是由普通Java编译器编译的,则不需要类型转换,因为它会自动插入类型转换。如果需要在运行时通过反射访问类型参数,则必须向类文件添加通用签名。更多细节,请参阅CtClass
中的setGenericSignature
方法的API
文档(javadoc
)。
可变参数
目前,Javassist
不直接支持可变参数。因此创建一个可变参数的方法,必须显示设置一个方法修饰符。但是,这是简单的。假设想要创建下面的方法:
public int length(int... args){ return args.length; }
下面使用Javassist
的代码将创建上面展示的方法:
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
里的编译器编译的源代码中调用这个方法,您必须编写:
length(new int[] { 1, 2, 3 });
而不是使用可变参数机制的这个方法:
length(1, 2, 3);
J2ME
如果在J2ME
执行环境修改一个类文件,你必须执行预校验。预校验基本上就是生成堆栈映射,它类似于JDK1.6
中引入到J2SE
中的堆栈映射表。Javassist
仅在javassist.bytecode.MethodInfo.doPreverify
为true
的情况下维护J2ME
中的堆栈映射。
你可以手动为一个修改过的方法生成堆栈映射。对于由CtMethod
对象m
表示的给定方法,您可以通过调用以下方法生成堆栈映射:
m.getMethodInfo().rebuildStackMapForME(cpool); // CtMethod m
这里,cpool
是一个ClassPool
对象,可以通过在一个CtClass
对象上调用getClassPool
()来获得它。ClassPool
对象负责从给定的类路径中查找类文件。要获取所有的CtMethod
对象,请调用CtClass
对象的getDeclaredMethods
方法。
堆栈映射(stack map)是一种数据结构,用于描述方法在执行过程中的局部变量和操作数栈的状态。在Java虚拟机规范中,堆栈映射被用于类型检查和字节码验证。
在J2ME中,由于其资源受限的特点,需要进行预验证操作以确保代码在执行时不会引发错误。堆栈映射是预验证操作中的一个重要部分,它可以帮助检查方法的执行过程中是否存在类型不匹配或者栈溢出等问题。
装箱和拆箱
装箱和拆箱是Java
的语法糖。装箱和拆箱没有对应的字节码。因此Javassist
编译器不支持它们。例如,下面语句在Java
中是正确的:
Integer i=3;
因为装箱是隐式执行的。但是,对于Javassist
而言,必须明确的转换一个值类型从int
到Integer
:
Integer i =new Integer(3);
调试
将CtClass.debugDump
设置一个目录名称,之后所有通过avassist
修改和生成的类文件都将被保存在这个目录下。如果不想保存,只需把CtClass.debugDump
设置成null
。CtClass.debugDump
默认null
值。 举例:
CtClass.debugDump = "./dump";
所有修改的类文件被保存在./dump下。