讲过了 Java 类格式和利用反射进行的运行时访问后,本系列到了进入更高级主题的时候了。本月我将开始本系列的第二部分,在这里 Java 类信息只不过是由应用程序操纵的另外一种形式的数据结构而已。我将这个主题的整个内容称为 classworking。 html
我将以 Javassist 字节码操做库做为对 classworking 的讨论的开始。Javassist 不只是一个处理字节码的库,并且更由于它的另外一项功能使得它成为试验 classworking 的很好的起点。这一项功能就是:能够用 Javassist 改变 Java 类的字节码,而无需真正了解关于字节码或者 Java 虚拟机(Java virtual machine JVM)结构的任何内容。从某方面将这一功能有好处也有坏处 -- 我通常不提倡随便使用不了解的技术 -- 可是比起在单条指令水平上工做的框架,它确实使字节码操做更可具备可行性了。 java
Javassist 使您能够检查、编辑以及建立 Java 二进制类。检查方面基本上与经过 Reflection API 直接在 Java 中进行的同样,可是当想要修改类而不仅是执行它们时,则另外一种访问这些信息的方法就颇有用了。这是由于 JVM 设计上并无提供在类装载到 JVM 中后访问原始类数据的任何方法,这项工做须要在 JVM 以外完成。 数组
Javassist 使用javassist.ClassPool类跟踪和控制所操做的类。这个类的工做方式与 JVM 类装载器很是类似,可是有一个重要的区别是它不是将装载的、要执行的类做为应用程序的一部分连接,类池使所装载的类能够经过 Javassist API 做为数据使用。可使用默认的类池,它是从 JVM 搜索路径中装载的,也能够定义一个搜索您本身的路径列表的类池。甚至能够直接从字节数组或者流中装载二进制类,以及从头开始建立新类。 框架
装载到类池中的类由javassist.CtClass实例表示。与标准的 Javajava.lang.Class类同样,CtClass提供了检查类数据(如字段和方法)的方法。不过,这只是CtClass的部份内容,它还定义了在类中添加新字段、方法和构造函数、以及改变类、父类和接口的方法。奇怪的是,Javassist 没有提供删除一个类中字段、方法或者构造函数的任何方法。 函数
字段、方法和构造函数分别由javassist.CtField、javassist.CtMethod和javassist.CtConstructor的实例表示。这些类定义了修改由它们所表示的对象的全部方法的方法,包括方法或者构造函数中的实际字节码内容。 性能
全部字节码的源代码
Javassist 让您能够彻底替换一个方法或者构造函数的字节码正文,或者在现有正文的开始或者结束位置选择性地添加字节码(以及在构造函数中添加其余一些变量)。不论是哪一种状况,新的字节码都做为类 Java 的源代码声明或者String中的块传递。Javassist 方法将您提供的源代码高效地编译为 Java 字节码,而后将它们插入到目标方法或者构造函数的正文中。 优化
Javassist 接受的源代码与 Java 语言的并不彻底一致,不过主要的区别只是增长了一些特殊的标识符,用于表示方法或者构造函数参数、方法返回值和其余在插入的代码中可能用到的内容。这些特殊标识符以符号$开头,因此它们不会干扰代码中的其余内容。
对于在传递给 Javassist 的源代码中能够作的事情有一些限制。第一项限制是使用的格式,它必须是单条语句或者块。在大多数状况下这算不上是限制,由于能够将所须要的任何语句序列放到块中。下面是一个使用特殊 Javassist 标识符表示方法中前两个参数的例子,这个例子用来展现其使用方法:
{ System.out.println("Argument 1: " + $1); System.out.println("Argument 2: " + $2); }
对于源代码的一项更实质性的限制是不能引用在所添加的声明或者块外声明的局部变量。这意味着若是在方法开始和结尾处都添加了代码,那么通常不能将在开始处添加的代码中的信息传递给在结尾处添加的代码。有可能绕过这项限制,可是绕过是很复杂的 -- 一般须要设法将分别插入的代码合并为一个块。
做为使用 Javassist 的一个例子,我将使用一个一般直接在源代码中处理的任务:测量执行一个方法所花费的时间。这在源代码中能够容易地完成,只要在方法开始时记录当前时间、以后在方法结束时再次检查当前时间并计算两个值的差。若是没有源代码,那么获得这种计时信息就要困可贵多。这就是 classworking 方便的地方 -- 它让您对任何方法均可以做这种改变,而且不须要有源代码。
清单 1 显示了一个(很差的)示例方法,我用它做为个人计时试验的实验品:StringBuilder类的buildString方法。这个方法使用一种全部 Java 性能优化的高手都会叫您 不要使用的方法构造一个具备任意长度的String-- 它经过反复向字符串的结尾附加单个字符来产生更长的字符串。由于字符串是不可变的,因此这种方法意味着每次新的字符串都要经过一个循环来构造:使用从老的字符串中拷贝的数据并在结尾添加新的字符。最终的效果是用这个方法产生更长的字符串时,它的开销愈来愈大。
public class StringBuilder { private String buildString(int length) { String result = ""; for (int i = 0; i < length; i++) { result += (char)(i%26 + 'a'); } return result; } public static void main(String[] argv) { StringBuilder inst = new StringBuilder(); for (int i = 0; i < argv.length; i++) { String result = inst.buildString(Integer.parseInt(argv[i])); System.out.println("Constructed string of length " + result.length()); } } }
由于有这个方法的源代码,因此我将为您展现如何直接添加计时信息。它也做为使用 Javassist 时的一个模型。清单 2 只展现了buildString()方法,其中添加了计时功能。这里没有多少变化。添加的代码只是将开始时间保存为局部变量,而后在方法结束时计算持续时间并打印到控制台。
private String buildString(int length) { long start = System.currentTimeMillis(); String result = ""; for (int i = 0; i < length; i++) { result += (char)(i%26 + 'a'); } System.out.println("Call to buildString took " + (System.currentTimeMillis()-start) + " ms."); return result; }
来作 使用 Javassist 操做类字节码以获得一样的效果看起来应该不难。Javassist 提供了在方法的开始和结束位置添加代码的方法,别忘了,我在为该方法中加入计时信息就是这么作的。
不过,仍是有障碍。在描述 Javassist 是如何让您添加代码时,我提到添加的代码不能引用在方法中其余地方定义的局部变量。这种限制使我不能在 Javassist 中使用在源代码中使用的一样方法实现计时代码,在这种状况下,我在开始时添加的代码中定义了一个新的局部变量,并在结束处添加的代码中引用这个变量。
那么还有其余方法能够获得一样的效果吗?是的,我 能够在类中添加一个新的成员字段,并使用这个字段而不是局部变量。不过,这是一种糟糕的解决方案,在通常性的使用中有一些限制。例如,考虑在一个递归方法中会发生的事情。每次方法调用自身时,上次保存的开始时间值就会被覆盖而且丢失。
幸运的是有一种更简洁的解决方案。我能够保持原来方法的代码不变,只改变方法名,而后用原来的方法名增长一个新方法。这个 拦截器(interceptor)方法可使用与原来方法一样的签名,包括返回一样的值。清单 3 展现了经过这种方法改编后源代码看上去的样子:
private String buildString$impl(int length) { String result = ""; for (int i = 0; i < length; i++) { result += (char)(i%26 + 'a'); } return result; } private String buildString(int length) { long start = System.currentTimeMillis(); String result = buildString$impl(length); System.out.println("Call to buildString took " + (System.currentTimeMillis()-start) + " ms."); return result; }
经过 Javassist 能够很好地利用这种使用拦截器方法的方法。由于整个方法是一个块,因此我能够毫无问题地在正文中定义而且使用局部变量。为拦截器方法生成源代码也很容易 -- 对于任何可能的方法,只须要几个替换。
实现添加方法计时的代码要用到在 Javassist 基础中描述的一些 Javassist API。清单 4 展现了该代码,它是一个带有两个命令行参数的应用程序,这两个参数分别给出类名和要计时的方法名。main()方法的正文只给出类信息,而后将它传递给addTiming()方法以处理实际的修改。addTiming()方法首先经过在名字后面附加“$impl”重命名现有的方法,接着用原来的方法名建立该方法的一个拷贝。而后它用含有对通过重命名的原方法的调用的计时代码替换拷贝方法的正文。
public class JassistTiming { public static void main(String[] argv) { if (argv.length == 2) { try { // start by getting the class file and method CtClass clas = ClassPool.getDefault().get(argv[0]); if (clas == null) { System.err.println("Class " + argv[0] + " not found"); } else { // add timing interceptor to the class addTiming(clas, argv[1]); clas.writeFile(); System.out.println("Added timing to method " + argv[0] + "." + argv[1]); } } catch (CannotCompileException ex) { ex.printStackTrace(); } catch (NotFoundException ex) { ex.printStackTrace(); } catch (IOException ex) { ex.printStackTrace(); } } else { System.out.println("Usage: JassistTiming class method-name"); } } private static void addTiming(CtClass clas, String mname) throws NotFoundException, CannotCompileException { // get the method information (throws exception if method with // given name is not declared directly by this class, returns // arbitrary choice if more than one with the given name) CtMethod mold = clas.getDeclaredMethod(mname); // rename old method to synthetic name, then duplicate the // method with original name for use as interceptor String nname = mname+"$impl"; mold.setName(nname); CtMethod mnew = CtNewMethod.copy(mold, mname, clas, null); // start the body text generation by saving the start time // to a local variable, then call the timed method; the // actual code generated needs to depend on whether the // timed method returns a value String type = mold.getReturnType().getName(); StringBuffer body = new StringBuffer(); body.append("{\nlong start = System.currentTimeMillis();\n"); if (!"void".equals(type)) { body.append(type + " result = "); } body.append(nname + "($$);\n"); // finish body text generation with call to print the timing // information, and return saved value (if not void) body.append("System.out.println(\"Call to method " + mname + " took \" +\n (System.currentTimeMillis()-start) + " + "\" ms.\");\n"); if (!"void".equals(type)) { body.append("return result;\n"); } body.append("}"); // replace the body of the interceptor method with generated // code block and add it to class mnew.setBody(body.toString()); clas.addMethod(mnew); // print the generated code block just to show what was done System.out.println("Interceptor method body:"); System.out.println(body.toString()); } }
构造拦截器方法的正文时使用一个java.lang.StringBuffer来累积正文文本(这显示了处理String的构造的正确方法,与在StringBuilder的构造中使用的方法是相对的)。这种变化取决于原来的方法是否有返回值。若是它 有返回值,那么构造的代码就将这个值保存在局部变量中,这样在拦截器方法结束时就能够返回它。若是原来的方法类型为void,那么就什么也不须要保存,也不用在拦截器方法中返回任何内容。
除了对(重命名的)原来方法的调用,实际的正文内容看起来就像标准的 Java 代码。它是代码中的body.append(nname + "($$);\n")这一行,其中nname是原来方法修改后的名字。在调用中使用的$$标识符是 Javassist 表示正在构造的方法的一系列参数的方式。经过在对原来方法的调用中使用这个标识符,在调用拦截器方法时提供的参数就能够传递给原来的方法。
清单 5 展现了首先运行未修改过的StringBuilder程序、而后运行JassistTiming程序以添加计时信息、最后运行修改后的StringBuilder程序的结果。能够看到修改后的StringBuilder运行时会报告执行的时间,还能够看到由于字符串构造代码效率低下而致使的时间增长远远快于由于构造的字符串长度的增长而致使的时间增长。
[dennis]$ java StringBuilder 1000 2000 4000 8000 16000 Constructed string of length 1000 Constructed string of length 2000 Constructed string of length 4000 Constructed string of length 8000 Constructed string of length 16000 [dennis]$ java -cp javassist.jar:. JassistTiming StringBuilder buildString Interceptor method body: { long start = System.currentTimeMillis(); java.lang.String result = buildString$impl($$); System.out.println("Call to method buildString took " + (System.currentTimeMillis()-start) + " ms."); return result; } Added timing to method StringBuilder.buildString [dennis]$ java StringBuilder 1000 2000 4000 8000 16000 Call to method buildString took 37 ms. Constructed string of length 1000 Call to method buildString took 59 ms. Constructed string of length 2000 Call to method buildString took 181 ms. Constructed string of length 4000 Call to method buildString took 863 ms. Constructed string of length 8000 Call to method buildString took 4154 ms. Constructed string of length 16000
Javassist 经过让您处理源代码而不是实际的字节码指令清单而使 classworking 变得容易。可是这种方便性也有一个缺点。正如我在 全部字节码的源代码中提到的,Javassist 所使用的源代码与 Java 语言并不彻底同样。除了在代码中识别特殊的标识符外,Javassist 还实现了比 Java 语言规范所要求的更宽松的编译时代码检查。所以,若是不当心,就会从源代码中生成可能会产生使人感到意外的结果的字节码。
做为一个例子,清单 6 展现了在将方法开始时的拦截器代码所使用的局部变量的类型从long变为int时的状况。Javassist 会接受这个源代码并将它转换为有效的字节码,可是获得的时间是毫无心义的。若是试着直接在 Java 程序中编译这个赋值,您就会获得一个编译错误,由于它违反了 Java 语言的一个规则:一个窄化的赋值须要一个类型覆盖。
[dennis]$ java -cp javassist.jar:. JassistTiming StringBuilder buildString Interceptor method body: { int start = System.currentTimeMillis(); java.lang.String result = buildString$impl($$); System.out.println("Call to method buildString took " + (System.currentTimeMillis()-start) + " ms."); return result; } Added timing to method StringBuilder.buildString [dennis]$ java StringBuilder 1000 2000 4000 8000 16000 Call to method buildString took 1060856922184 ms. Constructed string of length 1000 Call to method buildString took 1060856922172 ms. Constructed string of length 2000 Call to method buildString took 1060856922382 ms. Constructed string of length 4000 Call to method buildString took 1060856922809 ms. Constructed string of length 8000 Call to method buildString took 1060856926253 ms. Constructed string of length 16000
取决于源代码中的内容,甚至可让 Javassist 生成无效的字节码。清单7展现了这样的一个例子,其中我将JassistTiming代码修改成老是认为计时的方法返回一个int值。Javassist 一样会毫无问题地接受这个源代码,可是在我试图执行所生成的字节码时,它不能经过验证。
[dennis]$ java -cp javassist.jar:. JassistTiming StringBuilder buildString Interceptor method body: { long start = System.currentTimeMillis(); int result = buildString$impl($$); System.out.println("Call to method buildString took " + (System.currentTimeMillis()-start) + " ms."); return result; } Added timing to method StringBuilder.buildString [dennis]$ java StringBuilder 1000 2000 4000 8000 16000 Exception in thread "main" java.lang.VerifyError: (class: StringBuilder, method: buildString signature: (I)Ljava/lang/String;) Expecting to find integer on stack
只要对提供给 Javassist 的源代码加以当心,这就不算是个问题。不过,重要的是要认识到 Javassist 没有捕获代码中的全部错误,因此有可能会出现没有预见到的错误结果。