likes
comments
collection
share

Javassist使用教程【译】

作者站长头像
站长
· 阅读数 13

说明

  1. 由于后续的 agent 相关文章要用到 Javassist,为了不和agent掺和所以这里把Javassist单独拿出来。
  2. 本文是对Javassist官网指南的翻译! ,原文:www.javassist.org/tutorial/tu…,作者:Shigeru Chiba (抛开民族ch来说,日本的这个教授开发的这个Javassist还是不错的毕竟没有ASM那么不易入手。)
  3. 由于我个人并没有把Javassist的所有功能和特性都使用一遍,所以有些功能我也不是很熟悉,翻译/解释不到位的地方就当混个脸熟或者说了解吧。
  4. 本文没有过多的个人情感在里边,99%都是翻译原文,另外个人水平有限翻译不正确的地方请多多海涵。当然或许也没人看哈哈。
  5. 更多的,本文是一篇 备忘/工具介绍 类文章。
  6. 注意:本文中有很多 $符号,不使用转义的话会被识别为数学符号排版很难受,所以全部使用 \转义了,如果见到$ 这种的 请忽略 \ 看成 $ 就好了。

1、读写字节码

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

javassist.CtClass是class文件的一个抽象代表。一个CtClass(编译期类)对象处理一个class文件。例如:

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

这个程序首先定义了个ClassPool对象,它控制着字节码的修改。ClassPool对象是CtClass对象的一个容器,它代表一个Class文件。它会读取Class(test.Rectangle)文件,然后构造并返回一个CtClass对象。为了修改一个类,用户必须用ClassPool对象的get() 方法来获取CtClass对象。上面展示的例子中,CtClass的实例cc代表类test.RectanleClassPool实例通过 getDefault() 方法实例化,它采用默认的搜索路径方式。

从实现的角度看,ClassPoolCtClass对象的一个Hash表,ClassPool使用全类名作为键。当使用classPool.get() 方法时,会搜索Hash表,根据类名找出相应的CtClass对象。如果该对象没找到,就会读取类文件,然后构造一个CtClass对象,将其存到Hash表中,并返回结果。

CtClass对象可以被修改(下边会详细介绍)。上面的例子中,它将test.Point作为自己的父类。在调用writeFile() 后,该修改就会更新到源class文件中。

writeFile()CtClass对象转化为一个Class文件,并把它写到本地磁盘上。Javassist也提供了一个方法,用于直接获取被修改的字节码。可以调用toBytecode() 方法获取:

byte[] b = cc.toBytecode();

你也可以直接加载CtClass:

Class clazz = cc.toClass();

toClass() 请求当前线程的上下文类加载器来加载CtClass代表的class文件,它返回java.lang.Class对象。更多细节见第三章。

1.1、 定义一个新的类(重要)

要定义一个新的类,必须使用ClassPool对象,调用其makeClass() 方法:

ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.makeClass("Point");

这段代码定义了一个类名为Point的类,它没有任何成员,Point的成员方法可以通过CtNewMethod声明的工厂方法make创建,然后通过CtClass的addMethod方法添加到Point类中。

makeClass() 不能创建一个新的接口,想创建一个接口需要用makeInterface() 。接口的成员方法是使用CtNewMethodabstractMethod() 来创建的 。注意接口的方法是抽象方法。

1.2、 冻结类(重要)

如果一个CtClass对象已经转化成了class文件,比如通过writeFile()toClass()toBytecode() , Javassist会冻结CtClass对象。之后对于CtClass对象的修改都是不允许的。这个是为了警告开发者,他们尝试修改的Class文件已经被加载了,JVM不允许再次加载该Class。

冻结的类可以如果想要修改,可以进行解冻,这样就允许修改了,如下:

CtClasss cc = ...;
    :
cc.writeFile();  // 会引起类冻结
cc.defrost();   // 解冻
cc.setSuperclass(...);    // OK 因为这个类已经被解冻了

defrost() 被调用之后,该CtClass对象可以再次被修改。

如果ClassPool.doPruning被设置为true,当CtClass被冻结时,Javassist会修剪它的数据结构。为了减少内存消耗,会删除那个对象中不需要的属性(attribute_info structures)。例如,Code_attribute结构(方法体)会被删除。因此,在CtClass对象被修剪之后,方法的字节码是不可访问的,除了方法名称,签名和注解。被修剪的CtClass对象不能再次被解冻(defrost)。ClassPool.doPruning 的默认是false.

CtClasss cc = ...;
cc.stopPruning(true);
    :
cc.writeFile();     // 转化为一个Class文件
// cc 没有被修剪.

CtClass对象cc没有被修剪。因此它还可以在调用writeFile() 之后调用defrost() 解冻。

在调试时,你可能想要临时停止修剪和冻结,并将修改后的字节码写回磁盘的类文件。使用debutWriteFile()方法可以方便的达到该目的。该方法停止修剪,写类文件,然后解冻CtClass对象,重新打开修剪开关(如果原来已经打开)

1.3、 类路径搜索(重要)

ClassPool.getDefault 默认会搜索JVM下面相同路径的类,并返回ClassPool。但是,如果一个程序运行在Web应用服务器上,像JBoss和Tomcat那种,ClassPool对象可能就找不到用户指定的类了,因为web应用服务使用了多个系统类加载器。这种情况下,需要给ClassPool注册一个额外的Class路径。如下:

pool.insertClassPath(new ClassClassPath(this.getClass()));  // 假设pool是ClassPool的一个实例

这句代码注册了一个类的类路径,这个类是this指向的那个类。你可以使用任意Class代替this.getClass()

你也可以注册一个文件夹作为类路径。例如,下面这段代码增添可以了文件夹 /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);

该代码增添了**www.javassist.org:80/java/** 到类文件搜索路径下。该URL仅仅搜索org.javassist. 包下的class文件。例如,要加载org.javassist.test.Main 这个类,javassist会从这个地址下获取该类文件:

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

此外,你也可以直接给ClassPool对象一个byte数组,然后用这个数组构建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()ClassPool会从ByteArrayClassPath中读取一个Class文件,指定的Class的名字就是上面的name变量。

如果你不知道这个类的全限定名,你可以使用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 对象提供一个Class文件。如果搜索路径包含了一个很大的jar包,这可以提高性能。因为ClassPool对象会一个一个找,它可能会重复搜索整个jar包中的每一个class文件。makeClass() 可以优化这个搜索。makeClass() 构造出来的类会保存在ClassPool对象中,你下次再用的时候,不会再次读Class文件。

2、ClassPool(重要)

2.1、 ClassPool简介(重要)

ClassPool对象是多个CtClass对象的容器。一旦CtClass对象被创建,它就会永远被记录在ClassPool对象中。这是因为编译器之后在编译源码的时候可能需要访问CtClass对象。

例如,假定有一个新方法getter() 被增添到了Point类的CtClass对象。稍后,程序会试图编译代码,它包含了对Point方法的getter() 调用,并会使用编译后代码作为一个方法的方法体。如果表示Point类的CtClass对象丢了的话,编译器就不能编译调用getter() 的方法了(注意:原始类定义中不包含getter() )。因此,为了正确编译这样一个方法调用,ClassPool在程序过程中必须始终包含所有的CtClass对象。

2.2、 避免内存溢出(重要)

某种特定的ClassPool可能造成巨大的内存消耗,导致OOM,比如CtClass对象变得非常的大(这个情况发生的很少,因为Javassist已经尝试用不同的方法减少内存消耗了,比如冻结类)。为了避免该问题,你可以从ClassPool中移除不需要的CtClass对象。只需要调用CtClassdetach() 方法就行了:

CtClass cc = ... ;
cc.writeFile();
cc.detach();  // 该CtClass已经不需要了,从ClassPool中移除

在调用detach() 之后,这个CtClass对象就不能再调用任何方法了。但是你可以依然可以调用classPool.get() 方法来创建(没有则创建)一个相同的类。如果你调用get()ClassPool会再次读取class文件,然后创建一个新的CtClass对象并返回。

另一种方式是new一个新的ClassPool,旧的就不要了。这样旧的ClassPool就会被垃圾回收,它的CtClass也会被跟着垃圾回收。可以使用以下代码完成:

ClassPool cp = new ClassPool(true);  // true代表使用默认路径
// 如果需要的话,可以用appendClassPath()添加一个额外的搜索路径。

上面这个new ClassPoolClassPool.getDefault() 的效果是一样。注意,ClassPool.getDefault() 是一个单例的工厂方法,它只是为了方便用户创建提供的方法。这两种创建方式是一样的,源码也基本是一样的,只不过ClassPool.getDefault() 是单例的。

注意,new ClassPool(true) 是一个很方便的构造函数,它构造了一个ClassPool对象,然后给他增添了系统搜索路径。它构造方法的调用就等同于下面的这段代码:

ClassPool cp = new ClassPool();
cp.appendSystemPath();  // 你也可以通过appendClassPath()增添其他路径

2.3、 级联ClassPool

如果一个程序运行在Web应用服务器上,你可能需要创建多个ClassPool实例。为每一个类加载器(ClassLoader)创建一个ClassPool(也就是容器)。这时程序在创建ClassPool对象的时候就不能再用getDefault() 了,而是要用ClassPool的构造函数。

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

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

如果调用了child.get() ,child的ClassPool首先会代理parent的ClassPool,如果parent的ClassPool中没有找到要找的类,才会试图到child中的 ./classes目录下找。

如果child.childFirstLookup设置为了true,child的ClassPool就会首先到自己路径下面找,之后才会到parent的路径下面找。

ClassPool parent = ClassPool.getDefault();
ClassPool child = new ClassPool(parent);
child.appendSystemPath();         // 这默认使用相同的类路径
child.childFirstLookup = true;    // 改变child的行为。

2.4、 更改类名的方式定义新类(重要)

一个“新类”可以从一个已经存在的类copy出来。可以使用以下代码:

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

这段代码首先获取了PointCtClass对象。然后调用setName() 方法给对象一个新的名字Pair。在这个调用之后,CtClass表示的类中的所有Point都会替换为Pair。类定义的其他部分不会变。

既然setName() 改变了ClassPool对象中的记录。从实现的角度看,ClassPool是一个hash表,setName() 改变了关联这个CtClass对象的key值。这个key值从原名称Point变为了新名称Pair

因此,如果之后调用get("Point") ,就不会再返回上面的cc引用的对象了。ClassPool对象会再次读取class文件,然后构造一个新的CtClass对象。这是因为Point这个CtClassClassPool中已经不存在了。请看下面代码:

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是不一样的,因为cc3是重新读取的class文件

cc1cc2引用的是相同的CtClass实例,与cc一样,cc3是另外一个实例对象。注意:cc.setName(“Pair”) 执行后,CtClass对象,cc和cc1引用的都都变成Pair类。

ClassPool对象用于维护CtClass对象和类之间的一一映射关系。Javassist不允许两个不同的CtClass对象代表相同的类,除非你用两个ClassPool。这个是程序转换一致性的重要特性。

要创建ClassPool的副本,可以使用下面的代码片段(这个上面已经提到过了):

ClassPool cp = new ClassPool(true);

如果你又两个ClassPool对象,那么你就可以从这两个对象中获取到相同class文件但是不同的CtClass对象。你可以对那两个CtClass进行不同方式的修改,然后生成两个版本的Class。

2.5、重命名冻结类的方式定义新类(重要)

一旦CtClass对象转化为Class文件后,比如writeFile() 或是 toBytecode() 之后,Javassist会拒绝CtClass对象进一步的修改。因此,在CtClass对象转为Class文件之后,你将不能再通过setNme() 的方式将该类拷贝成一个新的类了。比如,下面的这段错误代码:

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

为了解除这个限制,你应该调用ClassPoolgetAndRename() 方法。 例如:

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

如果调用了getAndRenameClassPool首先为了创建代表PairCtClass而去读取Point.class。然而,它在记录CtClass到hash表之前,会把CtClassPoint重命名为Pair。因此getAndRename() 可以在writeFile()toBytecode() 之后执行。

3、ClassLoader

如果一开始你就知道要修改哪个类,那么最简单的方式如下:

  • 1.调用ClassPool.get() 来获取一个CtClass对象。
  • 2.修改它
  • 3.调用writeFile()toBytecode() 来获取一个修改后的class文件

如果一个类是否要被修改是在加载时确定的,用户就必须让Javassist和类加载器协作。Javassist可以和类加载器一块儿使用,以便于可以在加载时修改字节码。用户可以自定义类加载器,也可以使用Javassist提供好的。

3.1、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");
        // 获取say方法
        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() 方法。

注意,上面这段程序有一个前提,就是Hello类在调用toClass() 之前没有被加载过。否则,在toClass() 请求加载被修改后的Hello类之前,JVM就会加载原始的Hello类。因此,加载被修改后的Hello类就会失败(抛出LinkageError)。例如:

public static void main(String[] args) throws Exception {
    Hello orig = new Hello();
    ClassPool cp = ClassPool.getDefault();
    CtClass cc = cp.get("Hello");
    Class c = cc.toClass();  // 这句会报错
}

main函数的第一行加载了Hello类,cc.toClass() 这行就会抛出异常。原因是类加载器不能同时加载两个不同版本的Hello类。

如果以上程序运行在JBoss或Tomcat等应用服务器,toClass() 方法用到的上下文类加载器可能与预期的不一致。在这种情况下,你可能会看到抛出ClassClassException异常。为了避免这种异常,你必须提供一个合适的类加载器给toClass()。例如,假设bean是你定义的某个类对象,那么这段代码:

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

你需要提供一个ClassLoader给toClass()方法,这个ClassLoader是用来加载你的程序的类加载器(比如上面例子,就是使用Bean对象对应的加载器)

toClass() 已经很方便了。你要是想更复杂的类加载器,你应该自定义类加载器。

3.2、 Java中的类加载

在Java中,多个类加载器可以共存,它们可以创建自己的命名空间。不同的类加载器能够加载有着相同类名的不同的类文件。被加载过的两个类会被视为不同的东西。这个特点可以让我们在一个JVM中运行多个应用程序,尽管它们包含了有着相同名称的不同的类。

JVM不允许动态重新加载一个类。一旦类加载加载过一个类之后,在运行期就不能在加载该类的另一个被修改过的版本。因此,你不能在JVM加载过一个类之后修改它的定义。但是,JPDA(Java Platform Debugger Architecture)提供了重新加载类的一些能力。详细请看3.6

如果两个不同的类加载器加载了一个相同的Class文件,那么JVM会生成两个不同的Class,虽然它们拥有相同的名字和定义。这两个Class会被视为两个不同的东西。因为这两个Class不是完全相同的,所以一个Class的实例不能赋值给另一个Class的变量。这两个类之间的类型转换会失败,抛出ClassCastException异常。

例如,下面这个代码片段就会抛出该异常:

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

Box类被两个类加载器所加载。假定CL(默认的类加载器)加载了这段代码片段。因为该代码中引用了MyClassLoader,Class,Object,所以CL也会加载这些类。因此,b 变量的类型是CL加载的Box。但是obj变量的类型是myLoader加载的Box,虽然都是Box,但是不一样。所以,最后一段代码一定会抛出ClassCastException,因为bobj是两个不同版本的Box(根本原因在于他们是被不同的类加载器加载的)。

多个类加载形成了一个树型结构。除了启动加载器之外,其他的类加载器都有一个父类加载,子类加载器通常由父类加载器加载。由于加载类的请求可以沿着加载器的层次结构进行委托(即双亲委派机制),所以你请求加载类 的加载器,并不一定真的是由这个加载器加载的,也可能换其他加载器加载了。因此(举例),请求加载类C的加载器可能不是真正加载类C的加载器。不同的是,我们将前面的加载器称为C的发起者(initiator),后面的加载器称为C实际的加载器(real loader)。

除此之外,如果类加载器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(); }
}

假定类加载器L加载Window类。加载Window的发起者和实际加载者都是L。因为Window的定义引用了类Box,所以JVM会让 L去加载Box类。这里,假定L将该任务委托给了父类加载器PL,所以加载Box的发起者是L,但实际加载者是PL。这种情况下,PL作为Box的实际加载者,就会去加载Box中定义中引用的Point类,所以Point的发起者和实际加载者都是PL。因此加载器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 {    // Window由加载器L加载
    private Box box;
    public boolean widthIs(int w) {  // 增加了方法,方法中有对Point类的引用。
        Point p = box.getSize();
        return w == p.getX();
    }
}

上面中,Window也引用了Point。这样,如果加载器L需要加载Point的话,L也必须委托给PL你必须避免让两个类加载器重复加载同一个类。两个加载器中的一个必须委托给另一个加载器。

如果当Point被加载时,L没有委托给PL,那么widthIs() 就会抛出ClassCastException。因为Window里的PointL加载的,而Box中的PointPL加载器加载的。你用box.getSize() 返回的PL.PointL的Point,那么就会JVM就会认为它们是不同的实例,进而抛出异常。

这样有些不方便,但是需要有这种限制。比如:

Point p = box.getSize();

如果这条语句没有抛出异常,那么Window的代码就有可能打破Point的封装。例如,PL加载的Pointx变量是private,但是L加载器加载的Pointx变量是public(下面的代码定义),那么就打破了封装定义。

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.

3.3、 使用javassist.Loader(重要)

Javassist提供了一个类加载器javasist.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.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.LoaderaddTranslator() 方法增添事件监听器时,start() 方法就会被调用。在javassist.Loader加载类之前,onLoad() 方法就会被调用。你可以在onLoad() 方法中修改要加载的类的定义。

例如,下面的事件监听器就在类被加载之前把它们都修改成public类。

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对象的application(带main方法,可以运行的)类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等,是由默认加载器加载的。

javassist.Loader搜索类的顺序和java.lang.ClassLoader.ClassLoader不同。JavaClassLoader首先会委托父加载器进行加载操作,父加载器找不到的时候,才会由子加载器加载。而javassist.Loader首先尝试加载类,然后才会委托给父加载器。只有在下面这些情况才会进行委托:

  • 调用get() 方法后在ClassPool对象中找不到
  • 使用delegateLoadingOf() 方法指定要父类加载器去加载

这个搜索顺序机制允许Javassist加载修改后的类。然而,如果它因为某些原因找不到修改后的类的话,就会委托父加载器去加载。一旦该类被父加载器加载,那么该类中引用的类也会用父加载器加载,并且它们不能再被修改了。回想下,之前类C的实际加载器加载了类C所有引用的类。如果你的程序加载一个修改过的类且失败了,那么你就得想想是否那些类是使用了被javassist.Loader加载的类。

3.4、自定义一个类加载器

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

import javassist.*;

public class SampleLoader extends ClassLoader {
    /* Call 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 must be there.
    }

    /* Finds a specified class.
     * The bytecode for that class can be modified.
     */
    protected Class findClass(String name) throws ClassNotFoundException {
        try {
            CtClass cc = pool.get(name);
            // modify the CtClass object here
            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文件到 ./class 目录下,该目录不能包含在类搜索路径下。否则,MyApp.class将会被默认的系统类加载器加载,也就是SampleLoader的父类加载器。你也可以把insertClassPath中的 ./class 放入构造函数的参数中,这样你就可以选择自己想要的路径了。 运行java程序:

> java SampleLoader

类加载器加载了类MyApp(./class/MyApp.class),并且调用了MyApp.main() ,并传入了命令行参数。

这是使用Javassist最简单的方式。如果你想写个更复杂的类加载器,你可能需要更多的java类加载机制的知识。例如,上面的程序把MyApp的命名空间和SampleLoader的命名空间是分开的,因为它们两个类是由不同的类加载器加载的。因此,MyApp不能直接访问SampleLoader类。

3.5、修改系统类

除了系统类加载器,系统类不能被其他加载器加载,比如java.lang.String。因此,上面的SampleLoaderjavassist.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 binary code 协议。

3.6、运行期重新加载一个类

启动JVM时,如果开启了JPDA(Java Platform Debugger Architecture),那么class就可以动态的重新加载了。在JVM加载一个类之后,旧的类可以被卸载,然后重新加载一个新版的类。意思就是,类的定义可以在运行期动态的修改。但是,新类一定要能和旧类相互兼容。JVM不允许两个版本存在模式的改变,它们需要有相同的方法和属性。

Javassist提供了一个很方便的类,用于在运行期改变类。想了解更多信息,可以看javassist.tools.HotSwapper的API文档

4、自省和定制

译者注: 此节比较重要,着重介绍了如何在方法中插入代码以及插入内容的介绍(比如各种\$开头的标识符,这些标识符的含义一定要搞清楚,这是重点)

简介:

CtClass 提供了自省的方法。Javassist的自省能力是能够兼容Java的反射API的。CtClass提供了getName()getSuperclass()getMethods() 等等方法。它也提供了修改类定义的方法。它允许增添一个新的属性,构造函数以及方法。甚至可以改变方法体。

CtMethod* 对象代表一个方法。CtMethod提供了一些修改方法定义的方法。注意,如果一个方法是从父类继承过来的,那么相同的 CtMethod对象也会代表父类中声明的方法。CtMethod对象会对应到每一个方法定义中。

例如,如果Point类声明了move() 方法,并且它的子类ColorPoint 没有重写move() 方法,那么PointColorPointmove() 方法会具有相同的CtMethod对象。如果用CtMethod修改了方法定义,那么该修改就会在两个类中都生效。如果你只想修改ColorPoint中的move() 方法,你必须要增添一个Point.move() 方法的副本到ColorPoint中去。可以使用CtNewethod.copy() 来获取CtMethod对象的副本。

Javassist不允许移除方法或者属性,但是允许你重命名它们。所以如果你不再需要方法或属性的时候,你应该将它们重命名或者把它们改成私有的,可以调用CtMethodsetName()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. 使用newZ参数做你想做的事。
    move(newX, newY);
}

Javassist也提供了一个底层API,用于直接编辑一个原生class文件。例如,CtClass中的getClassFile就会返回一个ClassFile对象,它代表了一个原生Class文件。CtMethod中的getMethodInfo() 会返回一个MethodInfo对象,它代表一个Class文件中的method_info结构。底层API使用了JVM的一些特定词汇,用户需要了解class文件和字节码的一些知识。更多详情,可以参考第五章。

只要标识符是\$开头的,那么在修改class文件的时候就需要javassist.runtime包用于运行时支持。那些特定的标识符会在下面进行说明。要是没有标识符,可以不需要javassist.runtime和其他的运行时支持包。更多详细内容,可以参考javassist.runtime包的API文档。

4.1、在方法的开头和结尾插入代码。

CtMethodCtConstructor提供了insertBefore() ,insertAfter() ,addCatch() 方法。它们被用于在已经存在的方法上面插入代码片段。用户可以把它们的代码以文本的形式写进去。Javassist包含了一个简单的编译器,可以处理这些源码文本。它能够把这些代码编译成字节码,然后将它们内嵌到方法体中。

往指定行插入代码也是有可能的(前提是class文件中包含行号表)。CtMethodCtConstructor中的insertAt() 就可以将代码插入指定行。它会编译代码文本,然后将其编译好的代码插入指定行。

insertBefore() ,insertAfter() ,addCatch() ,insertAt() 这些方法接受一个字符串来表示一个语句(statements)或代码块(block)。一句代码可以是一个控制结构,比如if、while,也可以是一个以分号(;)结尾的表达式。代码块是一组用 {} 括起来的语句。因此,下面的每一行代码都是一个合法的语句或代码块。

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

语句和代码块都可以引用属性或方法。如果方法使用 -g 选项(class文件中包含局部变量)进行编译,它们也可以引用自己插入方法的参数。否则,它们只能通过特殊的变量 \0,\1,$2... 来访问方法参数,下面有说明。虽然在代码块中声明一个新的局部变量是允许的,但是在方法中访问它们确是不允许的。然而,如果使用 -g 选项进行编译, 就允许访问。

传递到insertBefore() , insertAfter() 等方法中的String字符串会被Javassist的编译器编译。因为该编译器支持语言扩展,所以下面的这些以$开头的标识符就具有了特殊意义:

译者注: 大概就是这些标识符,我们先混个脸熟,下边一个个拿出来介绍。

标识符含义
$0, $1, $2, ...this 和实参, $0 代表this , $1 代表方法的第一个参数,$2代表第二个参数,以此类推
$args参数数组。 $args 的类型是 Object[]
$$所有实参,例如m($$)等同于m($1,$2,...)
$cflow(...)cflow变量
$r返回值类型。用于强制类型转换表达式
$w包装类型。用于强制类型转换表达式
$_结果值
$sigjava.lang.Class对象的数组,表示参数类型
$typejava.lang.Class对象的数组,表示结果类型

4.1.1 $0, $1, $2, ...(重要)

传递给目标方法的参数可以通过 $1,$2,... 访问,而 不是通过原先的参数名称!$1 代表第一个参数, $2代表第二个参数,以此类推。那些变量的类型和参数的类型是一样的。 $0代表this,如果是静态方法的话, $0不能用。

假定有一个Point类如下:

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

要想在调用move() 时打印dxdy的值,可以执行下面代码:

ClassPool pool = ClassPool.getDefault();
CtClass cc = pool.get("Point");
//获取到move方法的句柄
CtMethod m = cc.getDeclaredMethod("move");
//往move方法的前边添加打印语句,打印内容为move的第一个参数和第二个参数
m.insertBefore("{ System.out.println(\$1); System.out.println(\$2); }");
//使修改生效
cc.writeFile();

一定注意需要用 {} 括起来,如果只有一行语句,可以不用括。

修改后的Point类的定义长这个样子:

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 是可以被更新的,如果它们被赋予了新值,那么它们对应的变量也会被赋予新值

4.1.2 $args

变量 $arg 表示所有参数的一个数组。数组中的类型都是 Object 。如果参数是基本数据类型比如int,那么该参数就会被转换成包装类型比如java.lang.Integer,然后存储到 $args中。因此, $args[0] 就等于 $1 ,除非它是个基本类型(int不等于Integer)。注意, $args[0] 不等于 $0$0this

如果一个 Object 数组赋值给了 $args , 那么参数的每一个元素都会一一赋值。如果某个参数是基本类型,那么相应的元素必须是包装类型。该值会从包装类型自动拆箱转换成基本数据类型。

4.1.3 $$(重要)

$$ 是一个以逗号分隔参数的缩写。例如,如果move() 方法的参数是3个。那么:

move($$)

就等于:

move($1, $2, $3)

如果move() 没有接受任何参数,那么move($$) 就等于move()

$$ 也可以跟其他参数一起使用,比如你写这样一个表达式:

exMove($$, context)

这个表达式就等通于下面:

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

$$ 能够支持泛型表示。一般与 $procced一起使用,后面会说。

4.1.4 $cflow

$cflow 意思就是控制流(control flow)。该只读变量返回 特定方法进行递归调用时的深度。

假定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) 表示该方法递归调用时的深度。当该方法在方法内部递归调用时,第一次被调用时 $cflow(fact) 的值是0而不是1。例如:

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

fact加入了显示参数的代码。因为 $cflow(face) 被检查,所以如果在内部递归调用fact方法,则不会打印参数。

在当前线程的当前最顶层的堆栈帧下, $cflow的值是cm关联的指定方法的堆栈深度。 $cflow也能够在其他的方法下面访问。

4.1.6 $r (重要)

$r 代表方法的返回值类型。他必须用于强制转换表达式中的转换类型。例如,这是它的一个典型用法:

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

如果返回值类型是一个基本数据类型,那么 ($r) 就会遵循特殊的语义。首先,如果被转换对象的类型就是基本类型,那么 ($r) 就会省去基本类型到基本类型的转换。但是,如果被转换对象的类型是包装类型,那么 $r就会从包装类型转为基本数据类型。例如,如果返回值类型为int,那 ($r) 就会将其从java.lang.Integer转为int(也即拆箱)。

如果返回值类型为void,那么 ($r) 不会进行类型转换。 它什么都不做。然而,如果调用的方法返回值为void的话,那么 ($r) 的结果就是null。例如,如果foo() 方法的返回值为void,那么:

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

这是一个合法语句。

类型转换符 ($r)return语句中也是很有用的。即使返回值类型为void,下面的return语句也是合法的:

 return (\$r)result;

这里,result是某个本地变量。因为 ($r)void的,所以返回值就被丢弃了。return语句也被视为没有返回值,就等同于下面:

return;

4.1.7 $w

$w 表示一个包装类型。它必须用于强制类型转换表达式中。 ($w) 把一个基本数据类型转换为包装类型。例如:

Integer i = (\$w)5;

所用的包装类型(Integer)取决于 ($w) 后面表达式的类型。如果表达式类型为double,那么包装类型应为java.lang.Double

如果 ($w) 后面的表达式不是基本数据类型的话,那么 ($w) 将不起作用。

4.1.8 $_ (重要)

CtMethodCtConstructor中的insertAfter() 在方法最后插入代码时,不只是 $1,$2.. 这些可以用,你也可用 $_

$_ 表示方法的返回值。而该变量的类型取决于该方法的返回值类型。如果方法的返回值类型为void,那么 $_ 的值是null,类型为Object

只有方法不报错,运行正常的情况下,insertAfter() 中的代码才会运行。如果你想让方法在抛出异常的时候也能执行insertAfter() 中的代码,那你就把该方法的第二个参数asFinally设置为true.

如果方法中抛出了异常,那么insertAfter() 中的代码也会在finally语句中执行。这时 $_ 的值是0null。插入的代码执行完毕后,抛出的异常还会抛给原来的调用者。注意, $_ 的值不会抛给原来的调用者,它相当于没用了(抛异常的时候没有返回值)。

4.1.9 $sig

$sig是一个java.lang.Class对象的数组,数组的内容是按找参数顺序,记录每个参数的类型。

4.1.10 $type

$type 是一个java.lang.Class对象,它表示返回值类型。如果是构造函数,则它是Void.class的引用。

4.1.11 $class(重要)

$class 值是 java.lang.Class 对象,代表修改的方法所对应的那个类。 $class$0 的类($0是this)。

4.1.12 addCatch() (重要)

addCatch() 往方法体插入了的代码片段会在方法抛出异常的时候执行。在源码中,你可以用 $e 来表示抛出异常时候的异常变量。

例如,这段代码:

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

m代表的方法编译之后,就成了下面这样:

try {
    // 原本的代码
}
catch (java.io.IOException e) {
    System.out.println(e);
    throw e;
}

注意,插入的代码以throwreturn语句结尾。

4.2 修改方法体

4.2.0 setBody (重要)

译者注:修改方法提我一般喜欢直接使用setBody,至于后边的各种方式,仅做了解

CtMethodCtConstructor提供了setBody() 方法,该方法用于取代整个方法体。它们会把你提供的源码编译成字节码,然后完全替代之前方法的方法体。如果你传递的源码参数为null,那么被替换的方法体只会包含一条return语句。

setBody() 方法传递的源码中,以$开头的标识符会有一些特殊含义(这个跟上面是一样的):

标识符含义
$0, $1, $2, ...this 和实参
$args参数数组。 $args 的类型是 Object[]
$$所有实参,例如m($$)等同于m($1,$2,...)
$cflow(...)cflow变量
$r返回值类型。用于强制类型转换表达式
$w包装类型。用于强制类型转换表达式
$sig表示参数类型
$type表示结果类型
$class表示当前被编辑的类

注意,这里不能用 $_

4.2.1 修改现有的表达式

Javassist允许只修改方法体中的某一个表达式。javassist.expr.ExprEditor类用于替换方法体中的某一个表达式。用户可以定义ExprEditor的子类来说明表达式应该如何被修改。

使用ExprEditor对象,用户需要调用CtMethodCtClass中的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(\$\$); }");
        }
    });

该功能为:搜索方法体中,所有对Point类的move() 方法的调用,都将其替换为如下代码块:

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

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

instrument() 方法会搜索方法体。如果它找到了像是“方法调用、属性访问、对象创建”的表达式,那么它就会调用ExprEditor对象的edit() 方法。edit() 的参数就代表了被找到的那个表达式。edit() 方法可以通过该对象来检查和替换表达式。

调用MethodCall对象mreplace方法来将其替换为一个语句或代码块。如果给了的是 {} ,那么该表达式就会从方法体中移除。如果你想在该表达式的前后加一些逻辑,你可以这样写:

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

不管是方法调用,属性访问还是对象创建或者是其他,第二条语句都可以是:

\$_ = \$proceed();

如果表达式是个读访问,或者:

\$proceed(\$\$);

如果表达式是个写访问。

如果使用 -g 选项编译源码,那么在replace() 中也是可以直接使用局部变量的(前提是class文件中包含那个局部变量)。

4.2.2 javassist.expr.MethodCall

MethodCall对象代表一个方法的调用。它的replace() 方法会把方法调用替换成另一个语句或代码块。它接受一个源码文本来代表要替换的代码,文本中以$开头的表示符具有特殊的含义,就跟insertBefore() 的差不多。

标识符含义
$0方法调用的目标对象。 它不等于this,它是调用方this对象。 如果是静态方法, $0 是null.
$1, $2, ...方法调用的参数
$_方法调用的返回值
$r方法调用的返回值类型
$class表示声明该方法的类
$sig表示参数类型
$type表示结果类型
$proceed表达式中原始方法的名称

上面的“方法调用”的意思就是MethodCall代表的那个对象。

其他的标识符,像 $w$args,$$,也是可以用的。

除非返回类型是 void,否则,代码文本中你必须要给 $_ 赋值,而且类型要对的上。如果返回类型是void,那么 $_ 的类型是**Object,你也不用给他赋值。

$proceed不是一个 String,而是一个特殊的语法。它后面必须跟一个被括号 () 包围的参数列表。

4.2.3 javassist.expr.ConstructorCall

ConstructorCall对象代表一个构造函数的调用,像this() ,并且super() 包含在该构造方法体中。ConstructorCallreplace() 方法可以将一句语句或代码块替换掉原本的构造方法体。它接受源码文本代表要替换的代码,它之中的以$开头的标识符具有一些特殊含义,就像insertBefore中的那样:

标识符含义
$0构造方法调用的目标对象。它就等于this
$1, $2, ...构造方法调用的参数
$class表示声明该构造函数的类
$sig表示参数类型
$proceed表达式中原始方法的名称

这里,“构造函数调用”的意思就是ConstructorCall对象代表的那个方法。

$w,$args,$$ 等标识符也是可以用的

因为任何构造函数都要调用它的父类构造函数或是自己其他的构造函数,所以被替换的语句要包含一个构造函数的调用,通用使用 $proceed() .

$proceed不是一个String,而是一个特殊的语法。它后面必须跟一个被括号 () 包围的参数列表。

4.2.4 javassist.expr.FieldAccess

FieldAccess对象代表属性访问。如果找到了属性访问,那么ExprEditoredit() 就会接收到。FieldAccesssreplace() 方法接受一个源码文本,用于替换原本属性访问的代码。

在源码文本中,以$的标识符具有一些特殊的含义:

标识符含义
$0包含该变量的那个对象。 它不等与thisthis是访问该变量的那个方法对应的类对象。 如果变量为静态变量, $0为null
$1如果表达式是写访问,那么它代表将要被写入的值。 否则 $1不可用
$_如果表达式是读访问,它代表读取到的值。 否则,存储在$_的值将丢失。
$r如果表达式是读访问,它代表变量的类型。 否则, $rvoid
$class表示声明该属性的类
$type表示该变量的类型
$proceed表达式中原始方法的名称

$w,$args,$$ 等标识符也是可以用的。

如果表达式是读访问,必须要在源码文本中给 $_ 赋值,而且类型要对的上。

4.2.5 javassist.expr.NewExpr

NewExpr对象表示使用new关键字创建新对象(不包括数组创建)。如果找到了对象时,ExprEditoredit() 方法就会被执行。可以使用NewExprreplace() 方法来替换原本的代码。

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

标识符含义
$0null
$1,$2,...构造器的参数
$_创建对象的结果值。新创建的对象必须存储到这个变量中。
$r创建对象的类型
$sig表示参数类型
$typejava.lang.Class对象,表示创建对象的那个类的类型
$proceed表达式中原始方法的名称

$w,$args,$$ 等标识符也是可以用的。

4.2.6 javassist.expr.NewArray

NewArray代表使用new关键字创建数组。如果找到了数组的创建, ExprEditoredit() 方法就会被执行。NewArrayreplace() 方法能够替换创建数组的代码。

在源代码文本里,以$开头的标识符有以下特殊含义:

标识符含义
$0null
$1,$2,...每个维度的大小
$_数组创建的返回值。新创建的数组必须要存到该变量中
$r被创建的数组的类型
$typejava.lang.Class对象,表示被创建的数组的类
$proceed表达式中原始方法的名称

$w,$args,$$ 等标识符也是可以用的。

例如,如果按h照下面的方式创建数组:

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

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

如果数组是按照下面的方式创建的:

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

$1的值是3$2不可用。

4.2.7 javassist.expr.Instanceof

Instanceof对象代表了一个instanceof语句。如果instanceof语句被发现,ExprEditoredit() 就会被执行。Instanceofreplace() 方法会替换它原本的代码。

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

标识符含义
$0null
$1instanceof操作符左边变量的值
$_表达式的结果值。 $_ 的类型为boolean
$rinstanceof操作符右边的类型
$typejava.lang.Class对象,表示instanceof操作符右边的类型
$proceed表达式中原始方法的名称。 它接受一个参数(类型为java.lang.Object)。 如果类型对的上,返回true,否则为false

$w,$args,$$ 等标识符也是可以用的。

4.2.8 javassist.expr.Cast

Cast对象代表一个强制类型转换表达式。如果找到了强制类型转换的表达式,ExprEditoredit() 方法将会被执行。Castreplace() 方法会替换原来的代码。

在源代码文本里,以$开头的标识符有以下特殊含义:

标识符含义
$0null
$1被类型转换的那个变量的值
$_表达式结果的值。 $_ 的类型是被强制转换后的类型,就是 () 包起来的那个。
$r被强制转换后的类型,或者说是被 () 包起来的那个类型。
$typejava.lang.Class对象,表示与 $r相同的那个类型
$proceed表达式中原始方法的名称。 他接受一个java.lang.Object类型的参数,并在强制转换成功后返回它。

$w,$args,$$ 等标识符也是可以用的。

4.2.9 javassist.expr.Handler

Handler对象代表了try-catch语句中的catch语句。如果找到了catch语句,edit() 方法就会被执行。HandlerinsertBefore() 可以在catch语句的最开始插入代码。

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

标识符含义
$1catch语句捕获的异常对象
$r捕获异常的异常类型。用于强制类型转换
$w包装类型,用于强制类型转换
$typejava.lang.Class对象,表示catch捕获的异常对象的类型

如果给 $1 赋了新的异常对象,它会将其作为捕获异常传递给原始的 catch 语句。

4.3 增加新方法或新属性

4.3.1 增加新方法(重要)

Javassist允许用户从零开始创建一个新的方法和构造函数。CtNewMethodCtNewConstructor提供了几个工厂方法,它们都是静态方法 ,用于创建CtMethodCtConstructor对象。尤其是make() 方法,它可以直接传递源代码,用于创建CtMethodCtConstructor对象。

例如这个程序:

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

该代码给Point类增添了一个public方法xmove() 。其中xPoint类原本就有的一个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); }

这里面this.move替换了 $proceed

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

你给class增添过抽象方法之后,Javassist就会把这个类变成抽象类,所以在你调用setBody() 方法后,需要显式的把该class改变成非抽象类。

4.3.2 相互递归方法

如果一个类没有增添某一个方法,那么Javassist是不允许调用它的。要给一个类增添相互递归的方法,你需要先增添一个抽象方法。假定你增添m()n() 方法到cc代表的类中。

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

你必须首先把它们弄成两个抽象方法,然后增添到class中。然后你就能给他们增加方法体,方法体中也可以进行互相调用。最后你必须把类改成非抽象类,因为addMethod() 的时候,javassist自动把该类改成了抽象类。

4.3.3 增添属性(重要)

Javassist也允许用户创建一个新的属性:

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

这个程序给Point类增添一个名为z的属性。

如果增添的属性需要进行值初始化,则上面的程序就要改成这样:

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

Now,addField() 方法接受了第二个参数,它代表了计算初始值表达式的源码文本。该源码文本可以是任何Java表达式,前提是表达式的结果类型和属性类型匹配。注意,表达式不以分号(;)结尾,意思就是0后面不用跟分号。

除此之外,上面的代码也可用下面这个简单的代码代替:

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

4.3.4 删除属性

要删除属性或方法,可以调用CtClass中的removeField()removeMethod() 。也可以调用removeConstructor() 删除构造函数。

4.4 注解(重要)

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() 方法获取。

要使用getAnnotation() , 当前class路径下必须要包含注解类型,像Author它们也必须在ClassPool对象中可访问。如果注解类型的Class文件没有找到,Javassist就不能获取该注解类型成员的默认值。

4.5 运行时类支持

大多数情况下,由Javassist修改的类不需要Javassist去运行。然而,有些Javassist编译器生成的字节码需要运行时类支持,那些都在javassist.runtime包中(详细内容请参考该包的API文档)。注意,javassist.runtime包只负责管Javassist修改的类的运行时支持。其他Javassist修改后的类不会在运行时使用。

4.6 Import(重要)

源码中所有的类名都必须是完全限定的(它们必须导入包名)。然而,java.lang包时一个特例;例如,Javassist编译器可以解析Object也可以解析java.lang.Object.

为了告知编译器当解析类时搜索其他的包,可以调用ClassPoolimportPackage() 方法。 例如:

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() 不会影响ClassPoolget() 方法。编译器只有考虑导入的包。get() 方法的参数必须总是全限定名。

4.7 局限性(重要)

在当前实现中,Javassist的编译器存在几个局限性。这些局限性有:

  • J2SE 5.0中提到的语法(包括枚举和泛型)还没有得到支持。注解由Javassist的底层API支持。参见javassist.bytecode.annotation包(和getAnnotations() 以及CtBehavior )。泛型也只是部分支持。后面的章节有详细介绍。
  • 数组初始化的时候,以逗号分割,大括号 {} 包围的初始化方式还不支持。除非数组的长度时1.
  • 不支持内部类和匿名类。注意,这只是编译器的局限。它不能编译包含在匿名类定义中的源码。Javassist可以读取并修改内部/匿名类的类文件。
  • 不支持continuebreak关键字。
  • 编译器不能正确的实现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()) , xX的一个实例,编译器可能会生成一个对foo(A) 的调用,虽然编译器可以正确的编译foo((B)new C()) .

  • 建议用户使用 # 作为类名与静态方法或属性之间的分给。例如,在Java中:
javassist.CtClass.intType.getName()

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

javassist.CtClass#intType.getName()

这样,编译器就可以很快的解析这个表达式。

5、字节码API

简介

Javassist也提供了底层API用于直接编辑class文件。要使用了该API,你需要了解Java字节码和class文件格式的详细知识,这样你就可以利用API对class文件想怎么改就怎么改。

如果你只想生成一个简单的class文件,你可以使用javassist.bytecode.ClassFileWriter。它比javassist.bytecode.ClassFile快的多,虽然它的API最少。

5.1 获取 ClassFile 对象

一个javassist.bytecode.ClassFile对象代表一个Class文件。可以使用CtClass中的getClassFile() 获取该对象。

除此之外,你也可以用根据一个Class文件直接构造该javassist.bytecode.ClassFile对象。例如:

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

该代码片段创建了一个来自Point.classClassFile对象。

你也可以从零开始创建一个新文件。例如:

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

该代码生成了一个class文件Foo.class,它包含了以下实现:

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

5.2 增添或删除成员

ClassFile提供了addField()addMethod() ,用于增添属性或方法(注意在字节码中,构造函数被视为一个方法)。它也提供了addAttribute() 用于增添一个属性到class文件中。

注意,FiledInfo, MethodInfoAttributeInfo 对象包含了对ConstPool(常量池表)对象的引用。ConstPool对象必须是ClassFile对象和被增添到ClassFile对象的FieldInfo(或MethodInfo等)的公共对象。换句话说,一个FieldInfo(或MethodInfo等)对象不能在不同的ClassFile对象之间共享。

要从ClassFile对象中移除一个属性或方法,你必须先获取该类所有属性的java.util.List,可以使用getField()getMethod() ,它们都返回list。属性和方法都可以使用该List对象的remove() 方法进行移除。一个属性(Attribute)可以通过相同的方法进行移除。调用FieldInfoMethodInfogetAttribute() 来获取属性列表,然后从返回的list中移除它。

5.3 遍历方法体

要检查方法体中的每个字节码指令,CodeIterator是很有用的。要获取这个对象,可以这样做:

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

CodeIterator对象可以让你从开头到结尾一行一行的访问每一个字节码指令。下面是CodeIterator一部分的方法API:

  • void begin() : 移动到第一个指令
  • void move(int index) :移动到指定index位置的指令
  • boolean hasNext() :如果还有指令,则返回true
  • int next() :返回下一个指令的index。注意,他不会返回下一个指令的字节码。
  • int byteAt(int index) : 返回该位置的无符号8bit(unsigned 8bit)值
  • int u16bitAt(int index) : 返回该位置的无符号16bit(unsigned 16bit)值。
  • int write(byte[] code, int index) : 在该位置写byte数组。
  • void insert(int index, byte[] code) ,在该位置插入byte数组。分支偏移量等会自动调节。

下面这段代码基本包含了上面所介绍的所有API:

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

5.4 生成字节码序列

Bytecode对象代表一串字节码指令。它是一个可增长的bytecode数组。例如:

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

这将产生代码属性,即表示以下字节码序列:

iconst_3
ireturn

你也可以调用Bytecode中的get() 方法获取包含该序列的byte数组。获取到的数组可以插入到其他的代码属性中。

Bytecode提供了一些方法来增添特定的指令到字节码序列中。它提供了addOpcode() 用于增添8bit操作码,也提供了addIndex() 方法用于增添一个索引。每个操作码的8bit值都被定义在Opcode接口中。

addOpcode() 和其他用于增添特殊指令的方法,是自动维护最大堆栈深度,除非控制流不包括分支。可以通过Bytecode对象的getMaxStack() 值获取。它也会在Bytecode对象构造的CodeAttribute对象上反应出来。要重新计算方法体的堆栈深度,调用CodeAttributecomputeMaxStack() 方法。

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指定的class中。Bytecode对象首先被转换成了CodeAttribute对象,然后增添到了minfo指定的方法中。该方法最终被增添到了cf类文件中。

5.5 注解(Meta tags)

注解作为运行时不可见(或可见)的注解属性被存储在class文件中。它们的属性可以通过ClassFile,MethodInfoFieldInfo对象获取,调用那些对象的getAttribute(AnnotationsAttribute.invisibleTag) 方法。 更详细的内容参见javassist.bytecode.AnnotationsAttributejavassist.bytecode.annotation包的javadoc手册。

Javassist也让你通过顶层API访问注解。如果你想通过CtClass访问注解,可以调用getAnnotations() 方法。

6、泛型(重要)

Javassist的底层API完全支持了Java5中的泛型。另一方面,顶层API,例如 CtClass, 不能直接支持泛型。然而,这个对于字节码转换不是一个严重的问题。

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中的编译器不支持泛型,所以对于使用Javassis插入的代码,你必须插入一个显式的类型转换。例如通过 CtMethod.make() 插入的代码。如果源码是被正常的Java编译器编译的话,比如javac,你就不需要做类型转换了。

例如,如果你有这么一个类:

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

你想增添一个接口 Getter 到类 Wrapper 中:

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

那么你真正增添的是 Getter (类型参数被丢弃了),并且你必须向 Wrapper 类增添的方法就是下面这样一个简单的方法:

public Object get() { return value; }

注意,不需要类型参数。因为 get 返回的是 Object ,所以在调用方需要显示的增加类型转换。例如,如果类型参数TString, 那么 (String) 必须像下面这样被插入:

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

如果编译器是正常的Java编译器,那么不需要显式的指定类型转换,它会自动插入类型转换代码。

7、可变参数(int... args)(重要)

目前,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 });

不能使用Java原生的调用方式:

length(1, 2, 3);

8、J2ME

如果你要修改的文件是J2ME环境的,那么你必须执行预校验。预校验是生成堆栈映射(stack map)的基础,它与JDK1.6中的堆栈映射表很像。只有javassist.bytecode.MethodInfo.doPreverify为true的时候,Javassist才会为J2ME维护堆栈映射。

你也可以手工的为修改的方法生成一个堆栈映射。比如下面这个,m 是一个 CtMethod 对象,你可以调用下面方法来生成一个堆栈映射:

m.getMethodInfo().rebuildStackMapForME(cpool);

这里, cpool 是一个 ClassPool 对象, 可以通过调用CtClassgetClassPool() 方法获取。ClassPool 对象负责从给定路径找到class文件,这个前面章节已经说过了。要获取所有的CtMethod对象,可以调用CtClassgetDeclaredMethods方法。

9、拆箱和装箱(重要)

Java中的拆箱和装箱是个语法糖。是没有字节码的。所以Javassist的编译器不支持它们。例如,下面这个语句在Java中是合法的:

Integer i = 3;

因为装箱是暗中执行。 对于Javassist来说,然而,你必须显式的将int转换为Integer

Integer i = new Integer(3);

10、Debug

CtClass.debugDump 的值设置成一个目录,那么Javassist修改和生成的所有class文件都将会被保存在该目录下。要是不想弄,把 CtClass.debugDump 设置为null就行了。默认值也是null。

例如:

CtClass.debugDump = "./dump";

Javassist修改的所有class文件都将存储在 ./dump 目录下。