用Javassist进行类转换

在介绍了Java类格式的基础知识和通过反射进行的运行时访问之后,是时候将本系列转向更高级的主题了。 本月我将开始本系列的第二部分,其中Java类信息只是应用程序要操纵的另一种数据结构形式。 我将其称为“整个主题领域” 课堂学习 。

我将开始使用Javassist字节码操作库进行类工作。 Javassist不是唯一使用字节码的库,但是它确实具有一个特别的功能,这使其成为尝试类工作的一个很好的起点:您可以使用Javassist来更改Java类的字节码,而无需实际学习任何知识。有关字节码或Java虚拟机(JVM)架构的信息。 在某些方面,这是好坏参半的祝福-我通常不提倡弄乱您不了解的技术-但与在单个指令级别工作的框架相比,它无疑使字节码操作更容易访问。

Javassist基础

Javassist使您可以检查,编辑和创建Java二进制类。 检查方面主要是通过反射API复制直接在Java中可用的内容,但是当您实际上在修改类而不是仅仅执行它们时,具有访问此信息的另一种方法很有用。 这是因为JVM设计在将原始类数据加载到JVM后没有提供任何访问权限。 如果要使用类作为数据,则需要在JVM之外进行。

Javassist使用javassist.ClassPool类来跟踪和控制您要操纵的类。 此类的工作原理与JVM类加载器非常相似,但重要的区别在于,类池使加载的类可通过Javassist API用作数据,而不是链接已加载的类以在应用程序中执行。 您可以使用从JVM搜索路径加载的默认类池,或者定义一个搜索您自己的路径列表的默认类池。 您甚至可以直接从字节数组或流中加载二进制类,并从头开始创建新类。

javassist.CtClass实例表示在类池中加载的类。 与标准Java java.lang.Class类一样, CtClass提供了用于检查类数据的方法,例如字段和方法。 不过,这仅仅是CtClass的开始,它还定义了向类添加新字段,方法和构造函数以及更改类名称,超类和接口的方法。 奇怪的是,Javassist不提供任何从类中删除字段,方法或构造函数的方法。

字段,方法和构造函数分别由javassist.CtFieldjavassist.CtMethodjavassist.CtConstructor实例表示。 这些类定义用于修改由该类表示的项的所有方面的方法,包括方法或构造函数的实际字节码主体。

所有字节码的来源

Javassist允许您完全替换方法或构造函数的字节码主体,或有选择地在现有主体的开头或结尾添加字节码(以及构造函数的其他几个变体)。 无论哪种方式,新的字节码都将以类似于Java的源代码语句或String块形式传递。 Javassist方法可以将您提供的源代码有效地编译为Java字节码,然后将其插入目标方法或构造函数的主体中。

Javassist接受的源代码与Java语言不完全匹配,但是主要区别只是添加了一些特殊的标识符,这些标识符用于表示方法或构造函数参数,方法返回值以及您可能希望在自己的代码中使用的其他项目。插入的代码。 这些特殊标识符都以$符号开头,因此它们不会干扰您在代码中所做的任何事情。

对传递给Javassist的源代码中的操作也有一些限制。 第一个限制是实际格式,必须是单个语句或块。 在大多数情况下,这并不是很大的限制,因为您可以将所需的任何语句序列放在一个块中。 这是一个使用特殊Javassist标识符作为前两个方法参数值的示例,以显示其工作原理:

{
  System.out.println("Argument 1: " + $1);
  System.out.println("Argument 2: " + $2);
}

对源代码的更大限制是,无法引用在要添加的语句或块之外声明的局部变量。 这意味着,例如,如果要在方法的开头和结尾处添加代码,通常将无法将信息从开头添加的代码传递到结尾添加的代码。 有很多方法可以解决此限制,但解决方法很麻烦-您通常需要找到一种将单独的代码插入合并到单个块中的方法。

用Javassist进行类工作

对于应用Javassist的示例,我将使用我经常直接在源代码中处理的任务:测量执行方法所花费的时间。 在源代码中这很容易做到; 您只需在方法开始时记录当前时间,然后在方法结束时再次检查当前时间,即可找到两个值之间的时差。 如果您没有源代码,通常很难获得这种时序信息。 这就是使用类工作方便的地方-它使您可以对任何方法进行这样的更改,而无需源代码。

清单1显示了一个(不好的)示例方法,该方法将用作我的计时实验的豚鼠: StringBuilder类的buildString方法。 这个方法构造一个String它一再追加一个字符的字符串的结尾,以创建更长的字符串-正好做任何Java性能专家会告诉你不要做任何要求的长度。 因为字符串是不可变的,所以这种方法意味着每次通过循环都会构造一个新的字符串,并从旧字符串中复制数据,并在最后添加一个字符。 最终结果是,此方法用于创建更长的字符串时,将产生越来越多的开销。

清单1.要计时的方法
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()方法,并添加了计时。 这并没有多大改变。 添加的代码仅将开始时间保存到本地变量,然后在方法结束时计算经过的时间并将其打印到控制台。

清单2.带有计时的方法
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如何允许您添加代码时,我提到添加的代码无法引用方法中其他地方定义的局部变量。 这个限制使我无法像在源代码中那样在Javassist中实现定时代码。 在这种情况下,我在开头添加的代码中定义了一个新的局部变量,并在结尾添加的代码中引用了该变量。

那么我可以使用其他什么方法来获得相同的效果呢? 好吧,我可以在类中添加一个新的成员字段,并使用它代替局部变量。 但是,这是一种有臭味的解决方案,并且受到一般使用的某些限制。 例如,考虑使用递归方法会发生什么。 每次方法调用自身时,上次调用保存的开始时间值将被覆盖并丢失。

幸运的是,这里有一个更清洁的解决方案。 我可以保持原始方法代码不变,只是更改方法名称,然后使用原始名称添加新方法。 此拦截器方法可以使用与原始方法相同的签名,包括返回相同的值。 清单3展示了这种方法的源代码版本:

清单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"来重命名现有方法,然后使用原始名称创建该方法的副本。 然后,用定时代码将对重命名的原始方法的调用包装起来,从而替换复制的方法的主体。

清单4.使用Javassist添加拦截器方法
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如何运行,报告执行时间,并且由于效率低下的字符串构造代码,该时间如何比构造的字符串的长度快得多。

清单5.运行程序
[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通过让您使用源代码而不是实际的字节码指令列表来简化类工作,做了出色的工作。 但是这种易用性带有一些缺点。 正如我在“所有字节码的源代码”中提到的那样,Javassist使用的源代码并非完全是Java语言。 除了识别代码中的特殊标识符外,Javassist还对代码执行比Java语言规范所需的宽松得多的编译时检查。 因此,如果您不小心,它将以某种方式从源生成字节码,可能会产生令人惊讶的结果。

作为示例,清单6显示了当我将拦截器代码中用于方法开始时间的局部变量的类型从long更改为int 。 Javassist接受源代码并将其转换为有效的字节码,但结果是浪费时间。 如果您尝试直接在Java程序中编译此分配,则会遇到编译错误,因为它违反了Java语言的规则之一:缩小分配要求强制转换。

清单6.在int中存储一个long
[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再次毫无保留地接受了源代码,但是当我尝试执行时,生成的字节码无法通过验证。

清单7.在int中存储一个字符串
[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不一定会捕获代码中的任何错误,并且错误的结果可能很难预测。

展望未来

除了我们在本文中介绍的内容之外,Javassist还有很多其他内容。 下个月,我们将更深入地研究Javassist提供的一些特殊钩子,这些钩子用于类的批量修改以及在运行时加载类时的即时修改。 这些功能使Javassist成为在您的应用程序中实现各方面的出色工具,因此请确保您掌握了此功能强大的工具的全文。


翻译自: https://www.ibm.com/developerworks/java/library/j-dyn0916/index.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值