Skip to content
javassist_cover

Javassist使用手册

字节码操作技术的工具有很多,在项目中有用到javassist库,例如JustClipper项目,这里通过翻译Javassist官方技术文档,加强自己的理解和使用~

❤️官方英文文档地址

🥪Javassist使用篇

Javassist(Java Programming Assistant)使得Java字节码操纵变得简单。它是一个编辑Java字节码的类库;它使Java程序既能够在运行时定义新类,也能在JVM加载类文件时修改类文件。与其他相似的字节码编辑器不同的是,Javassist提供了两种级别的API,即源码级别和字节码级别。使用者利用源码级别的API可以再不了解Java字节码规范的情况下编辑类文件。整个API是用Java语言的词汇来设计的。你甚至可以指定插入的字节码的形式的源文本;Javassist动态地编译它。字节码级别的API允许用户像其他编辑器一样直接编辑类文件。

读写字节码

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

Javassist.CtClass类是类文件的抽象表示。一个CtClass(编译时的类)对象是处理类文件的句柄。下面的程序是一个非常简单的例子:

java
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表,以类名作为键。ClassPoolget()方法,根据指定的键搜索整个hash表寻找CtClass对象。如果CtClass对象没有找到,get()读取类文件构造新的CtClass对象,存储在hash表中,然后作为get()方法结果返回。

ClassPool中获取的CtClass对象是能够被修改的(具体如何修改将在之后呈现)。在上面的例子里,对其进行了修改,以便将test.Rectangle的超类更改为test.Point类。最终调用CtClass中的writeFile()时,此更改将反映在原始类文件上。

writeFile()CtClass对象翻译成类文件,并写进本地磁盘中。Javassist当然也提供了直接获取修改的字节码的方法。调用toBytecode()方法可以获取字节码:

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

当然,也可以直接加载CtClass:

java
Class clazz = cc.toClass();

toClass()请求当前线程的上下文类加载器加载由CtClass表示的类文件。它返回一个java.lang.Class对象,代表已被加载的类。更多详情请看下面这部分

定义一个新类

从头定义一个新类,使用ClassPoolmakeClass()方法:

java
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可以解冻,这样就允许修改类定义。例如,

java
CtClasss cc = ...;
    :
cc.writeFile();
cc.defrost();
cc.setSuperclass(...);  //  这样行得通,因为类没有被冻结。

调用defrost()后,CtClass对象又能够被修改了。

如果ClassPool.doPruning设置为trueJavassist会在冻结CtClass对象时,精简包含在该对象中的数据结构。为了减少内存消耗,精简过程会丢弃CtClass对象里非必要的属性(attribute_info结构)。例如Code_attribute结构(方法体)会被丢弃。因此,一个CtClass对象被精简后,除了方法名,签名和注解外,方法的字节码将无法访问。精简的CtClass对象也不能被解冻。ClassPool.doPruning的默认值是false

要不允许修剪特定的CtClass,必须预先在该对象上调用stopPruning()

java
CtClasss cc = ...;
cc.stopPruning(true);
    :
cc.writeFile();		// 转换成类文件。
// cc 不是精简的。

CtClass对象cc不是精简的,因此,它可以在writeFile()调用后被解冻。

注意:当调试时,你可能想要暂时停止精简和冻结,并且将修改的类文件写入磁盘。debugWriteFile()是达到此目的的一个便捷的方法。它停止精简,写一个类文件,解冻类文件并再次打开精简(如果该类文件最初是打开的)。

类搜索路径

静态方法ClassPool.getDefault()返回的默认ClassPool,搜索的路径与底层JVM(Java虚拟机)是一样的。 如果程序正在Web应用程序服务器(例如JBossTomcat)上运行,则ClassPool对象可能无法找到用户类,因为这样的Web应用程序服务器使用多个类加载器以及系统类加载器。 在这种情况下,必须将附加的类路径注册到ClassPool。假设池引用了一个ClassPool对象:

java
pool.insertClassPath(new ClassClassPath(this.getClass()));

该语句注册用于加载this引用的对象的类的类路径。 您可以使用任何Class对象代替this.getClass()作为参数。 注册用于加载该Class对象表示的类的类路径。用于加载Class对象所表示的类的类路径已经注册。

你可以注册一个目录名作为类搜索路径。例如,下面的代码添加了一个/usr/local/javalib目录到搜索路径:

java
ClassPool pool = ClassPool.getDefault();
pool.insertClassPath("/usr/local/javalib");

用户可以添加的搜索路径,不仅可以添加一个目录,还可以添加一个URL:

java
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,该类文件可以这样获取:

tex
http://www.javassist.org:80/java/org/javassist/test/Main.class

此外,你可以直接将字节数组传给ClassPool对象,并从数组构造CtClass对象。使用ByteArrayClassPath来达到此目的。例如:

java
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读取类文件。

如果你不知道全限定类名,你可以使用ClassPoolmakeClass()方法:

java
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接口的新类,并为ClassPoolinsertClassPath()提供该类的实例。这允许在搜索路径中包含非标准资源。

ClassPool

ClassPool对象是CtClass对象的容器。一旦创建了CtClass对象,它就会被永久地记录在ClassPool中。这是因为编译器稍后在编译引用由该CtClass表示的类的源代码时可能需要访问CtClass对象。

例如,假设一个新的方法getter()被添加到表示Point类的CtClass对象中。稍后,程序尝试编译源代码,包括Point中的getter()方法调用,并使用编译后的代码作为方法体,该方法体将被添加到另一个类Line。如果表示PointCtClass对象丢失,编译器就不能编译对getter()的方法调用。注意,原来的类定义不包括getter()。因此,要正确编译这样一个方法调用,ClassPool必须包含在程序执行的所有期间的CtClass的所有实例。

避免内存溢出

如果CtClass对象的数量惊人地大,ClassPool的这种规范可能会导致巨大的内存消耗(这种情况很少发生,因为Javassist试图以各种方式减少内存消耗)。为了避免这个问题,您可以显式地从ClassPool中删除一个不必要的CtClass对象。如果你在一个CtClass对象上调用detach(),那么该CtClass对象将从ClassPool中移除。例如:

java
CtClass cc = ... ;
cc.writeFile();
cc.detach();

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

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

java
ClassPool cp = new ClassPool(true);
// 如果需要,通过appendClassPath()附加一个额外的搜索路径。

这创建一个ClassPool对象,表现与由ClassPool.getDefault()返回的默认ClassPool一样。注意,ClassPool.getDefault()是为方便而提供的单例工厂方法。它以上面所示的方式创建一个ClassPool对象,尽管它保留了一个ClassPool的一个实例并重复使用。getDefault()返回的ClassPool对象没有特殊的角色。getDefault()是一个便利的方法。

请注意,new ClassPool(true)是一个便利的构造函数,它构造一个ClassPool对象并且附加了系统搜索路径。调用该构造函数相当于下面的代码:

java
ClassPool cp = new ClassPool();
cp.appendSystemPath();  // 或者通过appendClassPath()附加另一个的搜索路径。

级联的ClassPools

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

多个ClassPool对象可以像java.lang.ClassLoader那样级联。例如:

java
ClassPool parent = ClassPool.getDefault();
ClassPool child = new ClassPool(parent);
child.insertClassPath("./classes");

如果child.get()被调用,子ClassPool首先委派给父ClassPool。如果父ClassPool未能找到一个类文件,然后子ClassPool尝试在./classes目录下查找类文件。

如果child.childFirstLookup设置为true,子ClassPool在委派给父ClassPool之前会尝试查找类文件。例如:

java
ClassPool parent = ClassPool.getDefault();
ClassPool child = new ClassPool(parent);
child.appendSystemPath();         // 和默认的ClassPool一样相同的类路径
child.childFirstLookup = true;    // 改变子ClassPool的行为

改变一个类名称来定义一个新类

一个新类可以被定义为一个现有类的副本,下面的程序就是这样做的:

java
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("Point");
cc.setName("Pair");

该程序首先获取Point类的CtClass对象。然后,通过调用调用CtClass对象的setName()来给定一个新的名字Pair。在此调用之后,由该CtClass对象表示的类定义中出现的所有类名都从Point更改为Pair。类定义的其他部分不会改变。

注意,CtClasssetName()改变了ClassPool对象中的一条记录。从实现的角度看,ClassPool对象是CtClass对象的哈希表。setName()改变了哈希表中关联CtClass对象的键。该键从最初类名改变成新的类名。

因此,如果get("Point")之后再次通过ClassPool对象调用,它的返回值不再是cc变量指向的CtClass对象。ClassPool对象再次读取Point.class类文件,并且为类Point构造一个新的CtClass对象。这是因为与名称Point相关联的CtClass对象不再存在。见下面:

java
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 不相同。

cc1cc2指向和cc相同的CtClass实例,而cc3不是。注意,在cc.setName("Pair")执行后,cccc1指向的CtClass对象表示的是Pair类。

ClassPool对象用于维护类与CtClass对象之间的一对一映射。除非创建两个独立的ClassPool,否则Javassist不允许两个不同的CtClass对象表示同一个类。这是一致性程序转换的一个重要特性。

为了创建由ClassPool.getDefault()返回的默认ClassPool实例的另一个副本,执行下面的代码片段(该代码在上面见过):

java
ClassPool cp = new ClassPool(true);

如果已有两个ClassPool对象,然后可以从每个ClassPool中获取表示相同类文件的不同的CtClass对象。然后对CtClass对象进行不同的修改,来生成不同版本的类。

重命名冻结的类来定义一个新类

一旦CtClass对象通过writeFile()toBytecode()转成类文件,Javassist就拒绝该CtClass对象的进一步修改。因此,表示Point类的CtClass对象在被转成类文件后,不能定义Pair类为Point的副本,因为在Point上执行setName()会被拒绝。下面的代码片段是错误的:

java
ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("Point");
cc.writeFile();
cc.setName("Pair");    // 错误,因为writeFile()已被调用。

为了避免这种限制,应该调用ClassPoolgetAndRename()。例如:

java
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()

java
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()Hellosay方法体重插入一个println()调用。然后,它构造了修改的Hello类的一个实例并且调用该实例的say()方法。

注意,上面的程序依赖于这样一个事实,即在调用toClass()之前从未加载过Hello类。如果不是这样的,JVM将在toClass()请求加载修改的Hello类之前加载原先的Hello类。因此,加载修改的Hello类会失败(抛出LinkageError)。例如,如果Test的main()像这样:

java
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类。

如果程序正在运行在一些像JBossTomcat的应用服务器上,toClass()使用的上下文类加载器可能不合适。在这种情况下,你将看到一个意外的ClassCastException。为了避免这个异常,你必须显式给定toClass()一个合适的类加载器。例如,如果bean是你的会话bean对象,然后下面的代码是可行的:

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

你应该为toClass()提供加载了程序的类加载器(在上面的示例中,是bean对象的类)。

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

Java中的类加载

Java中,多个类加载器可以共存,并每个类加载器创建各自的命名空间。不同的类加载器可以用相同的类名加载不同的类文件。加载的两个类被视为不同的类。该特性赋予我们在单个JVM上运行多个应用程序,即使这些程序包含相同名称的不同的类。

注意:JVM不允许动态重新加载类。一旦一个类加载器加载了一个类,它不能在运行时重新加载修改后版本的类。因此,在JVM加载一个类后,你不能修改该类的定义。但是,JPDA(Java Platform Debugger Architecture)提供有限的重新加载类的能力。见章节

如果相同类文件被两个不同的类加载器加载,JVM使用相同的名称和定义生成两个不同的类。这两个类被视为不同的。由于这两个类不相同,一个类的实例不能赋值给另一个类的变量。两个类之间的强制转换操作失败并抛出ClassCastException

例如,下面的代码片段抛出一个异常:

java
MyClassLoader myLoader = new MyClassLoader();
Class clazz = myLoader.loadClass("Box");
Object obj = clazz.newInstance();
Box b = (Box)obj;    // 这总是抛出ClassCastException.

Box类被两个类加载器加载。假设一个类加载器CL加载包含该代码片段的类文件。由于此代码片段引用了MyClassLoaderClassObjectBox,所以CL也会加载这些类(除非它委托给另一个类加载器)。因此变量b的类型是由CL加载的Box类。另一方面,myLoader也加载Box类。对象objmyLoader加载的Box类的一个实例。因此,最后一条语句总是抛出ClassCastException,因为obj的类与用作变量b类型的Box类是不同的版本。

多个类加载器形成树形结构。除了引导(bootstrap)加载器之外,每个类加载器都有一个父类加载器,父类加载器通常加载该子类加载器的类。由于加载类的请求可以沿着类加载器的层次结构进行委托,类可能会被类加载器加载,而你不没有请求类加载。因此,被请求装入类C的类加载器可能与实际加载类C的加载器不同。为了区分,我们称前者加载器为C的启动器,称后者加载器为真正的加载器

此外,如果一个类加载器CL请求加载一个类C (C的启动器)委托给父类加载器PL,那么该类装入器CL永远不会被请求加载类C定义中引用的任何类。CL不是这些类的启动器。相反,父加载器PL成为它们的启动器,并被请求加载它们。C的定义所引用的类是由C的实际加载器加载的

要了解此行为,让我们考虑以下示例:

java
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的定义引用BoxJVM将会请求L加载Box。这里,假设L委派该任务给父类加载器PLBox的启动器是L但是实际的加载器是PL。在这种情况下,Point的启动器不是L而是PL,因为和Box一样的实际加载器。因此L不会被请求加载Point

接下来,让我们考虑一个轻微修改的例子:

java
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必须避免让两个类加载器双重加载同一个类。两个加载器中的一个必须委托给另一个。

如果加载PointL没有委托给PLwidthIs()将抛出ClassCastException。由于Box的实际加载器是PLBox中引用的Point也被PL加载。因此,getSize()的结果值是PL加载的Point实例,而widthIs()中变量p的类型是L加载的PointJVM将它们视为不同的类型,因此由于类型不匹配而抛出异常。

这种行为有些不方便,但却是必要的。如果下面的语句:

java
Point p = box.getSize();

没有抛出异常,那么Window的开发人员就可以打破Point对象的封装。例如,字段xPL加载的Point中是私有的。然而,如果L用以下定义加载Point, Window类可以直接访问x的值:

java
public class Point {
    public int x, y;    // not private
    public int getX() { return x; }
        :
}

要了解Java中类装入器的更多细节,下面的文章会有所帮助:

Sheng Liang and Gilad Bracha, "Dynamic Class Loading in the Java Virtual Machine", ACM OOPSLA'98, pp.36-44, 1998.

使用javassist.Loader

Javassist提供一个类加载器javassist.Loader。该类加载器使用javassist.ClassPool,用于读取类文件。

例如,javassist.Loader可用于加载使用Javassist修改的特定类。

java
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.Rectangletest.Rectangle的超类设置为test.point类。然后,该程序加载了修改的类,并创建了test.Rectangle类的新实例。

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

java
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()可以修改被加载类的定义。

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

java
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,编写一个主类,如下所示:

java
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);
  }
}

要运行此程序,请执行:

shell
% java Main2 arg1 arg2...

MyApp类和其他应用程序类由MyTranslator进行翻译。

注意,像MyApp这样的应用程序类不能访问像Main2MyTranslatorClassPool这样的加载器类,因为它们是由不同的加载器加载的。应用程序类由javassist.Loader加载,而像Main2这样的加载器类是默认的Java类加载器。

javassist.Loader以与java.lang.ClassLoader不同的顺序搜索类。ClassLoader首先将加载操作委托给父类加载器,然后仅在父类加载器找不到类时才尝试加载这些类。另一方面,javassist.Loadder在委托给父类加载器之前尝试加载类。它只在以下情况下委托:

  • 调用ClassPool对象的get()无法找到这些类
  • 这些类已经通过使用delegateLoadingOf()指定由父类加载器加载。

这个搜索顺序允许Javassist加载修改过的类。但是,如果由于某种原因无法找到修改过的类,它将委托给父类加载器。一旦一个类被父类加载器加载,在该类中引用的其他类也将被父类加载器加载,因此它们永远不会被修改。回想一下,类C中引用的所有类都是由C的实际加载器加载的。如果你的程序无法加载修改后的类,你应该确保使用该类的所有类是否都已被javassist.Loader加载。

写一个类加载器

一个简单的使用Javassist的类加载器如下:

java
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(如果需要)。然后做以下操作:

shell
% java SampleLoader

类加载器加载类MyApp(./class/MyApp.class)并且使用命令行参数调用MyApp.main()

这是使用Javassist最简单的方式。然而,如果你写一个更复杂的类加载器,你可能需要详细了解Java的类加载机制。例如,上面的程序将MyApp类放在与类SampleLoader所属的名称空间分开的名称空间中,因为这两个类是由不同的类加载器加载的。因此,MyApp类不能直接访问SampleLoader类。

修改一个系统类

java.lang.String这样的系统类不能由系统类加载器以外的类加载器加载。因此,上面展示的SampleLoaderjavassist.Loader不能在加载时修改系统类。

如果你的应用需要这样做,系统类必须被静态的修改。例如,下面程序给java.lang.String添加了一个新的字段hiddenValue

java
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,做以下操作:

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

假设MyApp的定义如下:

java
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.HotSwapperAPI文档。

内省和定制

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:

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

Point类中,您应该向Point类添加以下方法:

java
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文档。

在方法体的开头/结尾插入源文本

CtMethodCtConstructor提供了insertBefore()insertAfter()addCatch()方法。它们用于将代码片段插入现有方法的主体中。用户可以使用Java编写的源代码指定这些代码片段。Javassist包括一个用于处理源文本的简单Java编译器。它接收用Java编写的源文本,并将其编译为Java字节码,这些字节码将内联到方法体中。

也可以在行号指定的位置插入代码片段(如果行号表包含在类文件中)。在CtMethodCtConstructor中的insertAt()接受原始类定义的源文件中的源文本和行号。它编译源文本并在行号处插入编译后的代码。

方法insertBefore()insertAfter()addCatch()insertAt()接收表示语句或块的字符串对象。语句是一个单独的控制结构,如ifwhile或以分号(;)结尾的表达式。块是一组用大括号{}括起来的语句。因此,下面的每一行都是有效语句或块的示例:

java
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不可用。

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

java
class Point {
    int x, y;
    void move(int dx, int dy) { x += dx; y += dy; }
}

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

java
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()只接受单个语句或用大括号括起来的块。

修改后类点的定义是这样的:

java
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分别用dxdy替换。

$1$2$3…是可更新的。如果将一个新值赋给这些变量中的一个,那么由该变量表示的参数值也会更新。

$args

变量$args表示所有参数的数组。该变量的类型是Object类的数组。如果参数类型是基本类型,如int,则参数值被转换为包装对象,如java.lang.Integer,存储在$args中。因此,除非第一个形参的类型是基本类型,$args[0]等价于$1。注意$args[0]不等于$0$0表示this

如果Object的数组被赋值给$args,那么该数组的每个元素被赋值给每个参数。如果参数类型是基本类型,则相应元素的类型必须是包装类型。在将值赋给参数之前,将其从包装类型转换为基本类型。

$$

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

java
move($$)

等价于这样:

java
move($1, $2, $3)

如果move()不带任何参数,那么move($$)等价于move()

$$可以与另一种方法一起使用。如果你写一个表达式:

java
exMove($$, context)

那么这个表达式就等于:

java
exMove($1, $2, $3, context)

请注意,$$允许根据参数数量对方法调用进行通用标记。它通常与后面显示的$proceed一起使用。

cflow

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

假设下面显示的方法由一个CtMethod对象cm表示:

java
int fact(int n) {
    if (n <= 1)
        return n;
    else
        return n * fact(n - 1);
}

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

java
CtMethod cm = ...;
cm.useCflow("fact");

useCflow()的参数是声明的$cflow变量的标识符。任何有效的Java名称都可以用作标识符。因为标识符还可以包括点.,例如,"my.Test.fact"是一个有效的标识符。

然后,$cflow(fact)表示对cm指定的方法的递归调用的深度。当第一次调用该方法时,$cflow(fact)的值为0(零),而当在该方法内递归调用该方法时,它的值为1。例如:

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

转换方法fact(),使其显示参数。由于检查了$cflow(fact)的值,因此如果在fact()中递归调用fact()方法,则fact()方法不会显示该参数。

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

$r

$r表示方法的结果类型(返回类型)。它必须用作强制转换表达式中的强制转换类型。例如,通常用法:

java
Object result = ... ;
$_ = ($r)result;

如果结果类型是基本类型,则($r)遵循特殊语义。首先,如果强转表达式的操作数类型是基本类型,则($r)作为对结果类型的普通强转操作符。另一方面,如果操作数类型是包装类型,($r)将从包装类型转换为结果类型。例如,如果结果类型是int,那么($r)将从java.lang.Integer转换为int

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

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

是一个有效的语句。

强转操作符($r)在返回语句中也很有用。即使结果类型为void,下面的返回语句也是有效的:

java
return ($r)result;

这里,result是某个局部变量。由于指定了($r),结果值将被丢弃。这个返回语句被认为等同于没有结果值的返回语句:

java
return;

$w

$w表示包装类型。它必须用作强转表达式中的强转类型。($w)将基本类型转换为相应的包装类型。下面的代码是一个示例:

java
Integer i = ($w)5;

选择的包装类型取决于($w)后面的表达式的类型。如果表达式的类型是double,那么包装器类型就是java.lang.Double。如果($w)后面的表达式类型不是基本类型,则($w)不执行任何操作。

$_

CtMethodCtConstructorinsertAfter()在方法的末尾插入编译后的代码。在给insertAfter()的语句中,不仅上面显示的变量如$0,$1,…但是$_也是可用的。

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

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

如果抛出异常,insertAfter()插入的编译代码将作为finally子句执行。编译后的代码中$_的值为0null。编译后的代码执行结束后,最初抛出的异常被重新抛出给调用者。注意$_的值永远不会被抛出给调用者;它被抛弃了。

$sig

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

$type

$type的值是一个java.lang.Class对象,表示结果值的形式类型。如果这是一个构造函数,则该变量引用Void.class

$class

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

addCatch()

addCatch()将代码片段插入方法体中,以便在方法体抛出异常并将控制返回给调用者时执行该代码片段。在表示插入代码片段的源文本中,异常值使用特殊变量$e来引用。

例如,这个程序:

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

m表示的方法体转换成如下的形式:

java
try {
    the original method body
}
catch (java.io.IOException e) {
    System.out.println(e);
    throw e;
}

注意,插入的代码片段必须以throwreturn语句结束。

改变方法体

CtMethodCtConstructor提供了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对象,用户必须调用CtMethodCtClassinstrument()。例如:

java
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所表示的方法体,并用以下块替换类Pointmove()的所有调用:

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

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

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

edit()的参数上调用replace()来替换给定的语句或块。如果给定的块是空块,也就是说,如果执行replace("{}"),则从方法体中删除表达式。如果想要在表达式之前或之后插入一个语句(或者块)应该将如下代码块传递给replace()

java
{ before-statements;
  $_ = $proceed($$);
  after-statements; }

无论表达式是方法调用、字段访问、对象创建还是其他。第二个语句可以是:

java
$_ = $proceed();

如果表达式为读访问或写访问,则为

java
$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()和包含在构造函数体中的superConstructorCall中的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()方法接收表示用于对象创建的替换语句或块的源文本。

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

$0null。
$1,$2,...构造函数的参数。
$_对象创建的结果值。新创建的对象必须存储在这个变量中。
$r创建对象的类型。
$sig表示形式参数类型的java.lang.Class对象数组。
$type一个java.lang.Class对象,表示所创建对象的类。
$proceed执行原始对象创建的虚拟方法的名称。

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

javassist.expr.NewArray

NewArray对象表示用new操作符创建数组。如果找到了数组创建,ExprEditor中的edit()方法将接收这个对象。NewArray中的replace()方法接收表示用于创建数组的替换语句或块的源文本。

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

$0null。
$1,$2,...每个维度的大小。
$_数组创建的结果值。新创建的数组必须存储在这个变量中。
$r创建的数组类型。
$type一个java.lang.Class对象,表示创建的数组的类。
$proceed执行原始数组创建的虚方法的名称。

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

例如,如果数组创建为以下表达式

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

那么$1$2的值分别是3和4。$3是不可用的。

如果数组创建为以下表达式

java
String[][] s = new String[3][];

那么$1的值是3,但$2是不可用的。

javassist.expr.Instanceof

一个Instanceof对象表示一个instanceof表达式。如果找到一个instanceof表达式,ExprEditor中的edit()方法会接收这个对象。Instanceof中的replace()方法接收表示被替换语句或表达式块的源文本。

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

$0null。
$1原始instanceof操作符左侧的值。
$_表达式的结果值。$_的类型是boolean。
$rinstanceof操作符右侧的类型。
$type一个java.lang.Class对象,表示instanceof操作符右侧的类型。
$proceed执行原始instanceof表达式的虚方法的名称。它接受一个参数(类型为java.lang.Object),如果参数值是原始instanceof操作符右侧类型的实例,则返回true。否则,返回false。

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

javassist.expr.Cast

Cast对象表示显式类型转换的表达式。如果发现显式类型转换,ExprEditor中的方法edit()将接收此对象。Cast中的replace()方法接收表示被替换语句或表达式块的源文本。

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

$0null。
$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子句的开头。

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

$1catch子句捕获的异常对象。
$rcatch子句捕获的异常的类型。它用于强制转换表达式。
$w包装类型。它用于强制转换表达式。
$type一个java.lang.Class对象,表示catch子句捕获的异常的类型。

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

添加一个新的方法或字段

添加一个方法

Javassist允许用户从头创建一个方法和构造器。CtNewMethodCtNewConstructor提供了多个创建CtMethodCtConstructor的工厂静态方法。特别的,make()从给定的源文本创建一个CtMethod或者CtConstructor对象。

例如,该程序:

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

Point类添加了一个公有的方法xmove()。该例子中,xPoint类中的int字段。

传递给make()函数的源文本可以包含以$开头的标识符,但不能包含以$_开头的标识符,就像在setBody()函数中一样。如果make()指定了目标对象和目标方法名,它还可以包含$proceed。例如:

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

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

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

注意,$proceed已被this.move所取代。

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

java
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()

java
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也允许用户创建一个新的字段。

java
CtClass point = ClassPool.getDefault().get("Point");
CtField f = new CtField(CtClass.intType, "z", point);
point.addField(f);

该程序添加一个名为z的字段到类Point中。

如果添加的字段的初始值必须指定,上面的程序必须修改成:

java
CtClass point = ClassPool.getDefault().get("Point");
CtField f = new CtField(CtClass.intType, "z", point);
point.addField(f, "0");    //  初始值是0。

现在,addField()方法接收来两个参数。表示源文本的表达式用于计算初始值。只要表达式结果类型匹配字段的类型,源文本可以是任何的Java表达式。注意,表达式不要以分号(;)结尾。

此外,上面的代码可以重写成下面简单的代码:

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

注解

CtClassCtMethodCtFieldCtConstructor为读取注解提供了一个便利的getAnnotations()方法。它返回一个注解类型的对象。

例如,假设下面注解:

java
public @interface Author {
    String name();
    int year();
}

该注解可以用于下面:

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

然后,可以通过getAnnotations()方法获取注解的值。它返回注解类型对象数组。

java
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);

代码片段会打印:

java
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编译器可以解析Objectjava.lang.Object一样。

为了告诉编译器查找其他包(在解析类名时),调用ClassPoolimportPackage()。例如:

java
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包。因此,第三行将不会抛出异常。编译器可以识别Pointjava.awt.Point

注意,importPackage()不会影响ClassPool中的get()方法。编译器仅仅考虑导入的包。get()的参数必须总是全限定名。

局限性

在当前的实现中,Javassist中包含的Java编译器在编译器可接受的语言方面有几个限制。这些限制是:

  • J2SE 5.0(包含枚举和泛型)引入的新语法还不支持。注解在Javassist的低级API中支持。见javassist.bytecode.annotation包(以及CtClassCtBehavior中的getAnnotations())。泛型也只支持部分。详见后一节

  • 用大括号 {} 括起来的以逗号分隔的表达式列表的数组初始化方式不可用,除非数组维度为1。

  • 不支持内部类或匿名类。注意,这只是编译器的限制。它无法编译包含匿名类声明的源代码。Javassist能读取和修改内部类或匿名类的类文件。

  • 不支持标记的contiuebreak语句。

  • 编译器没有正确实现Java方法分派算法。如果类中定义的方法具有相同的名称但采用不同的参数列表,编译器可能会混淆。

    例如:

    java
    class 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中,

    java
    javassist.CtClass.intType.getName()

    调用javassist.CtClass中的静态字段intType的对象的getName()方法。在Javassist中,用户能够写上面的的表达式,但是下面的方式推荐:

    java
    javassist.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对象。例如:

java
BufferedInputStream fin = new BufferedInputStream(new FileInputStream("Point.class"));
ClassFile cf = new ClassFile(new DataInputStream(fin));

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

ClassFile对象可以写回成一个类文件。ClassFile中的write()将类文件的内容写入给定的DataOutputStream

您可以从头创建一个新的类文件。例如:

java
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,其中包含以下类的实现:

java
package test;
class Foo implements Cloneable {
    public int width;
}

添加和移除成员

ClassFile提供了addField()addMethod()来添加字段或方法(注意,构造函数在字节码级别被视为方法)。它还提供了adddattribute(),用于向类文件添加属性。

字段与属性的关系

字段通常指的是类中定义的变量,而属性通常指的是这些变量所具有的特性,例如它们的访问修饰符、数据类型、默认值等。因此,可以说属性是字段的元数据。

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

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

遍历方法体

要检查方法体中的每个字节码指令,CodeIterator是很有用的。操作步骤如下:

java
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) 在索引处插入字节数组。分支偏移量等自动调整。

下面的代码片段显示了方法主体中包含的所有指令:

java
CodeIterator ci = ... ;
while (ci.hasNext()) {
    int index = ci.next();
    int op = ci.byteAt(index);
    System.out.println(Mnemonic.OPCODE[op]);
}

生成字节码序列

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

java
ConstPool cp = ...;    // 常量池表
Bytecode b = new Bytecode(cp, 1, 0);
b.addIconst(3);
b.addReturn(CtClass.intType);
CodeAttribute ca = b.toCodeAttribute();

这将生成表示以下序列的字节码属性:

java
iconst_3
ireturn

还可以通过调用Bytecodeget()来获得包含此序列的字节数组。可以将获得的数组插入到另一个字节码属性中。

虽然Bytecode提供了许多向序列中添加特定指令的方法,但它提供了addOpcode()用于添加8位操作码,并提供了adddindex()用于添加索引。每个操作码的8位值在Opcode接口中定义。

addOpcode()和其他用于添加特定指令的方法自动维护最大操作数栈深度,除非控制流不包含分支。这个值可以通过在Bytecode对象上调用getMaxStack()来获得。它还反映在由Bytecode对象构造的CodeAttribute对象上。若要重新计算方法体的最大操作数栈深度,调用CodeAttributecomputeMaxStack()

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

java
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)

注解作为运行时不可见(或可见)的注解属性存储在类文件中。这些属性可以从ClassFileMethodInfoFieldInfo对象中获得。在这些对象上调用getAttribute(AnnotationsAttribute.invisibleTag)。更多详情,见javassist.bytecode.AnnotationsAttribute类的javadoc手册和javassist.bytecode.annotation包。

Javassist还允许您通过高级API访问注解。如果希望通过CtClass访问注解,请在CtClassCtBehavior中调用getnotations()

泛型

Javassist的低级API完全支持Java 5引入的泛型。另一方面,例如CtClass这样的高级别API并不直接支持泛型。但是,对于字节码转换来说,这不是一个严重的问题。

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

java
Vector<String> v = new Vector<String>();
  :
String s = v.get(0);

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

java
Vector v = new Vector();
  :
String s = (String)v.get(0);

因此,在编写字节码转换器时,您可以删除所有类型参数。因为嵌入到Javassist中的编译器不支持泛型,所以如果源代码是由Javassist编译的(例如通过CtMethod.make()),则必须在调用者站点插入显式类型转换。如果源代码是由普通的Java编译器(如javac)编译的,则不需要进行类型转换。

例如,你有一个类:

java
public class Wrapper<T> {
  T value;
  public Wrapper(T t) { value = t; }
}

并想要将接口Getter<T>添加到类Wrapper<T>:

java
public interface Getter<T> {
  T get();
}

然后,您真正需要添加的接口是Getter(类型参数<T>消失了),您还必须添加到包装器类的方法是这样一个简单的方法:

java
public Object get() { return value; }

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

java
Wrapper w = ...
String s = (String)w.get();

如果源代码是由普通Java编译器编译的,则不需要类型转换,因为它会自动插入类型转换。如果需要在运行时通过反射访问类型参数,则必须向类文件添加通用签名。更多细节,请参阅CtClass中的setGenericSignature方法的API文档(javadoc)。

可变参数

目前,Javassist不直接支持可变参数。因此创建一个可变参数的方法,必须显示设置一个方法修饰符。但是,这是简单的。假设想要创建下面的方法:

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

下面使用Javassist的代码将创建上面展示的方法:

java
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里的编译器编译的源代码中调用这个方法,您必须编写:

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

而不是使用可变参数机制的这个方法:

java
length(1, 2, 3);

J2ME

如果在J2ME执行环境修改一个类文件,你必须执行预校验。预校验基本上就是生成堆栈映射,它类似于JDK1.6中引入到J2SE中的堆栈映射表。Javassist仅在javassist.bytecode.MethodInfo.doPreverifytrue的情况下维护J2ME中的堆栈映射。

你可以手动为一个修改过的方法生成堆栈映射。对于由CtMethod对象m表示的给定方法,您可以通过调用以下方法生成堆栈映射:

java
m.getMethodInfo().rebuildStackMapForME(cpool); // CtMethod m

这里,cpool是一个ClassPool对象,可以通过在一个CtClass对象上调用getClassPool()来获得它。ClassPool对象负责从给定的类路径中查找类文件。要获取所有的CtMethod对象,请调用CtClass对象的getDeclaredMethods方法。

堆栈映射(stack map)是一种数据结构,用于描述方法在执行过程中的局部变量和操作数栈的状态。在Java虚拟机规范中,堆栈映射被用于类型检查和字节码验证。

在J2ME中,由于其资源受限的特点,需要进行预验证操作以确保代码在执行时不会引发错误。堆栈映射是预验证操作中的一个重要部分,它可以帮助检查方法的执行过程中是否存在类型不匹配或者栈溢出等问题。

装箱和拆箱

装箱和拆箱是Java的语法糖。装箱和拆箱没有对应的字节码。因此Javassist编译器不支持它们。例如,下面语句在Java中是正确的:

java
Integer i=3;

因为装箱是隐式执行的。但是,对于Javassist而言,必须明确的转换一个值类型从intInteger

java
Integer i =new Integer(3);

调试

CtClass.debugDump设置一个目录名称,之后所有通过avassist修改和生成的类文件都将被保存在这个目录下。如果不想保存,只需把CtClass.debugDump设置成nullCtClass.debugDump默认null值。 举例:

java
CtClass.debugDump = "./dump";

所有修改的类文件被保存在./dump下。