Java 字节码

Java作为业界应用最为广泛的语言之一,深得众多软件厂商和开发者的推崇,更是被包括Oracle在内的众多JCP成员积极地推动发展。但是对于Java语言的深度理解和运用,毕竟是很少会有人涉及的话题。InfoQ中文站特地邀请IBM高级工程师成富为大家撰写这个《Java深度历险》专栏,旨在就Java的一些深度和高级特性分享他的经验。

在一般的Java应用开发过程中,开发人员使用Java的方式比较简单。打开惯用的IDE,编写Java源代码,再利用IDE提供的功能直接运行Java 程序就可以了。这种开发模式背后的过程是:开发人员编写的是Java源代码文件(.java),IDE会负责调用Java的编译器把Java源代码编译成平台无关的字节代码(byte code),以类文件的形式保存在磁盘上(.class)。Java虚拟机(JVM)会负责把Java字节代码加载并执行。Java通过这种方式来实现其“编写一次,到处运行(Write once, run anywhere)” 的目标。Java类文件中包含的字节代码可以被不同平台上的JVM所使用。Java字节代码不仅可以以文件形式存在于磁盘上,也可以通过网络方式来下载,还可以只存在于内存中。JVM中的类加载器会负责从包含字节代码的字节数组(byte[])中定义出Java类。在某些情况下,可能会需要动态的生成 Java字节代码,或是对已有的Java字节代码进行修改。这个时候就需要用到本文中将要介绍的相关技术。首先介绍一下如何动态编译Java源文件。

动态编译Java源文件

在一般情况下,开发人员都是在程序运行之前就编写完成了全部的Java源代码并且成功编译。对有些应用来说,Java源代码的内容在运行时刻才能确定。这个时候就需要动态编译源代码来生成Java字节代码,再由JVM来加载执行。典型的场景是很多算法竞赛的在线评测系统(如PKU JudgeOnline),允许用户上传Java代码,由系统在后台编译、运行并进行判定。在动态编译Java源文件时,使用的做法是直接在程序中调用Java编译器。

JSR 199引入了Java编译器API。如果使用JDK 6的话,可以通过此API来动态编译Java代码。比如下面的代码用来动态编译最简单的Hello World类。该Java类的代码是保存在一个字符串中的。

 1 public class CompilerTest {
 2    public static void main(String[] args) throws Exception {
 3       String source = "public class Main { public static void main(String[] args) {System.out.println(\"Hello World!\");} }";
 4       JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
 5       StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null);
 6       StringSourceJavaObject sourceObject = new CompilerTest.StringSourceJavaObject("Main", source);
 7       Iterable< extends JavaFileObject> fileObjects = Arrays.asList(sourceObject);
 8       CompilationTask task = compiler.getTask(null, fileManager, null, null, null, fileObjects);
 9       boolean result = task.call();
10       if (result) {
11          System.out.println("编译成功。");
12       }
13    }
14
15    static class StringSourceJavaObject extends SimpleJavaFileObject {
16
17       private String content = null;
18       public StringSourceJavaObject(String name, String content) ??throws URISyntaxException {
19          super(URI.create("string:///" + name.replace(‘.‘,‘/‘) + Kind.SOURCE.extension), Kind.SOURCE);
20          this.content = content;
21       }
22
23       public CharSequence getCharContent(boolean ignoreEncodingErrors) ??throws IOException {
24          return content;
25       }
26    }
27 }

如果不能使用JDK 6提供的Java编译器API的话,可以使用JDK中的工具类com.sun.tools.javac.Main,不过该工具类只能编译存放在磁盘上的文件,类似于直接使用javac命令。

另外一个可用的工具是Eclipse JDT Core提供的编译器。这是Eclipse Java开发环境使用的增量式Java编译器,支持运行和调试有错误的代码。该编译器也可以单独使用。Play框架在内部使用了JDT的编译器来动态编译Java源代码。在开发模式下,Play框架会定期扫描项目中的Java源代码文件,一旦发现有修改,会自动编译 Java源代码。因此在修改代码之后,刷新页面就可以看到变化。使用这些动态编译的方式的时候,需要确保JDK中的tools.jar在应用的 CLASSPATH中。

下面介绍一个例子,是关于如何在Java里面做四则运算,比如求出来(3+4)*7-10的值。一般的做法是分析输入的运算表达式,自己来模拟计算过程。考虑到括号的存在和运算符的优先级等问题,这样的计算过程会比较复杂,而且容易出错。另外一种做法是可以用JSR 223引入的脚本语言支持,直接把输入的表达式当做JavaScript或是JavaFX脚本来执行,得到结果。下面的代码使用的做法是动态生成Java源代码并编译,接着加载Java类来执行并获取结果。这种做法完全使用Java来实现。

 1 private static double calculate(String expr) throws CalculationException  {
 2    String className = "CalculatorMain";
 3    String methodName = "calculate";
 4    String source = "public class " + className
 5       + " { public static double " + methodName + "() { return " + expr + "; } }";
 6       //省略动态编译Java源代码的相关代码,参见上一节
 7    boolean result = task.call();
 8    if (result) {
 9       ClassLoader loader = Calculator.class.getClassLoader();
10       try {
11          Class<?> clazz = loader.loadClass(className);
12          Method method = clazz.getMethod(methodName, new Class<?>[] {});
13          Object value = method.invoke(null, new Object[] {});
14          return (Double) value;
15       } catch (Exception e) {
16          throw new CalculationException("内部错误。");
17       }
18    } else {
19       throw new CalculationException("错误的表达式。");
20    }
21 }

上面的代码给出了使用动态生成的Java字节代码的基本模式,即通过类加载器来加载字节代码,创建Java类的对象的实例,再通过Java反射API来调用对象中的方法。

Java字节代码增强

Java 字节代码增强指的是在Java字节代码生成之后,对其进行修改,增强其功能。这种做法相当于对应用程序的二进制文件进行修改。在很多Java框架中都可以见到这种实现方式。Java字节代码增强通常与Java源文件中的注解(annotation)一块使用。注解在Java源代码中声明了需要增强的行为及相关的元数据,由框架在运行时刻完成对字节代码的增强。Java字节代码增强应用的场景比较多,一般都集中在减少冗余代码和对开发人员屏蔽底层的实现细节上。用过JavaBeans的人可能对其中那些必须添加的getter/setter方法感到很繁琐,并且难以维护。而通过字节代码增强,开发人员只需要声明Bean中的属性即可,getter/setter方法可以通过修改字节代码来自动添加。用过JPA的人,在调试程序的时候,会发现实体类中被添加了一些额外的 域和方法。这些域和方法是在运行时刻由JPA的实现动态添加的。字节代码增强在面向方面编程(AOP)的一些实现中也有使用。

在讨论如何进行字节代码增强之前,首先介绍一下表示一个Java类或接口的字节代码的组织形式。

类文件 {
   0xCAFEBABE,小版本号,大版本号,常量池大小,常量池数组,
   访问控制标记,当前类信息,父类信息,实现的接口个数,实现的接口信息数组,域个数,
   域信息数组,方法个数,方法信息数组,属性个数,属性信息数组
}

如上所示,一个类或接口的字节代码使用的是一种松散的组织结构,其中所包含的内容依次排列。对于可能包含多个条目的内容,如所实现的接口、域、方法和属性等,是以数组来表示的。而在数组之前的是该数组中条目的个数。不同的内容类型,有其不同的内部结构。对于开发人员来说,直接操纵包含字节代码的字节数组的话,开发效率比较低,而且容易出错。已经有不少的开源库可以对字节代码进行修改或是从头开始创建新的Java类的字节代码内容。这些类库包括ASMcglibserpBCEL等。使用这些类库可以在一定程度上降低增强字节代码的复杂度。比如考虑下面一个简单的需求,在一个Java类的所有方法执行之前输出相应的日志。熟悉AOP的人都知道,可以用一个前增强(before advice)来解决这个问题。如果使用ASM的话,相关的代码如下:

 1 ClassReader cr = new ClassReader(is);
 2 ClassNode cn = new ClassNode();
 3 cr.accept(cn, 0);
 4 for (Object object : cn.methods) {
 5    MethodNode mn = (MethodNode) object;
 6    if ("<init>".equals(mn.name) || "<clinit>".equals(mn.name)) {
 7       continue;
 8    }
 9    InsnList insns = mn.instructions;
10    InsnList il = new InsnList();
11    il.add(new FieldInsnNode(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"));
12    il.add(new LdcInsnNode("Enter method -> " + mn.name));
13    il.add(new MethodInsnNode(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V"));
14    insns.insert(il);  mn.maxStack += 3;
15 }
16 ClassWriter cw = new ClassWriter(0);
17 cn.accept(cw);
18 byte[] b = cw.toByteArray();

ClassWriter就可以获取到包含增强之后的字节代码的字节数组,可以把字节代码写回磁盘或是由类加载器直接使用。上述示例中,增强部分的逻辑比较简单,只是遍历Java类中的所有方法并添加对System.out.println方法的调用。在字节代码中,Java方法体是由一系列的指令组成的。而要做的是生成调用System.out.println方法的指令,并把这些指令插入到指令集合的最前面。ASM对这些指令做了抽象,不过熟悉全部的指令比较困难。ASM提供了一个工具类ASMifierClassVisitor,可以打印出Java类的字节代码的结构信息。当需要增强某个类的时候,可以先在源代码上做出修改,再通过此工具类来比较修改前后的字节代码的差异,从而确定该如何编写增强的代码。

对类文件进行增强的时机是需要在Java源代码编译之后,在JVM执行之前。比较常见的做法有:

  • 由IDE在完成编译操作之后执行。如Google App Engine的Eclipse插件会在编译之后运行DataNucleus来对实体类进行增强。
  • 在构建过程中完成,比如通过Ant或Maven来执行相关的操作。
  • 实现自己的Java类加载器。当获取到Java类的字节代码之后,先进行增强处理,再从修改过的字节代码中定义出Java类。
  • 通过JDK 5引入的java.lang.instrument包来完成。

java.lang.instrument

由于存在着大量对Java字节代码进行修改的需求,JDK 5引入了java.lang.instrument包并在JDK 6中得到了进一步的增强。基本的思路是在JVM启动的时候添加一些代理(agent)。每个代理是一个jar包,其清单(manifest)文件中会指定一个代理类。这个类会包含一个premain方法。JVM在启动的时候会首先执行代理类的premain方法,再执行Java程序本身的main方法。在 premain方法中就可以对程序本身的字节代码进行修改。JDK 6中还允许在JVM启动之后动态添加代理。java.lang.instrument包支持两种修改的场景,一种是重定义一个Java类,即完全替换一个 Java类的字节代码;另外一种是转换已有的Java类,相当于前面提到的类字节代码增强。还是以前面提到的输出方法执行日志的场景为例,首先需要实现java.lang.instrument.ClassFileTransformer接口来完成对已有Java类的转换。

 1 static class MethodEntryTransformer implements ClassFileTransformer {
 2    public byte[] transform(ClassLoader loader, String className,
 3      Class<?> classBeingRedefined, ?ProtectionDomain protectionDomain, byte[] classfileBuffer)
 4      throws  IllegalClassFormatException {
 5         try {
 6            ClassReader cr = new ClassReader(classfileBuffer);
 7            ClassNode cn = new ClassNode();
 8            //省略使用ASM进行字节代码转换的代码
 9            ClassWriter cw = new ClassWriter(0);
10            cn.accept(cw);
11            return cw.toByteArray();
12         } catch (Exception e){
13            return null;
14         }
15    }
16 }

有了这个转换类之后,就可以在代理的premain方法中使用它。

1 public static void premain(String args, Instrumentation inst) {
2    inst.addTransformer(new MethodEntryTransformer());
3 }

把该代理类打成一个jar包,并在jar包的清单文件中通过Premain-Class声明代理类的名称。运行Java程序的时候,添加JVM启动参数-javaagent:myagent.jar。这样的话,JVM会在加载Java类的字节代码之前,完成相关的转换操作。

总结

操纵Java字节代码是一件很有趣的事情。通过它,可以很容易的对二进制分发的Java程序进行修改,非常适合于性能分析、调试跟踪和日志记录等任务。另外一个非常重要的作用是把开发人员从繁琐的Java语法中解放出来。开发人员应该只需要负责编写与业务逻辑相关的重要代码。对于那些只是因为语法要求而添加的,或是模式固定的代码,完全可以将其字节代码动态生成出来。字节代码增强和源代码生成是不同的概念。源代码生成之后,就已经成为了程序的一部分,开发人员需要去维护它:要么手工修改生成出来的源代码,要么重新生成。而字节代码的增强过程,对于开发人员是完全透明的。妥善使用Java字节代码的操纵技术,可以更好的解决某一类开发问题。

参考资料

时间: 2024-11-10 03:15:20

Java 字节码的相关文章

java字节码忍者禁术

Java语言本身是由Java语言规格说明(JLS)所定义的,而Java虚拟机的可执行字节码则是由一个完全独立的标准,即Java虚拟机规格说明(通常也被称为VMSpec)所定义的. JVM字节码是通过javac对Java源代码文件进行编译后生成的,生成的字节码与原本的Java语言存在着很大的不同.比方说,在Java语言中为人熟知的一些高级特性,在编译过程中会被移除,在字节码中完全不见踪影. 这方面最明显的一个例子莫过于Java中的各种循环关键字了(for.while等等),这些关键字在编译过程中会

通过Java字节码发现有趣的内幕之String篇(上)(转)

原文出处: jaffa 很多时候我们在编写Java代码时,判断和猜测代码问题时主要是通过运行结果来得到答案,本博文主要是想通过Java字节码的方式来进一步求证我们已知的东西.这里没有对Java字节码知识进行介绍,如果想了解更多的Java字节码或对其感兴趣的朋友可以先阅读字节码基础:JVM字节码初探. String字面量可以通过’==’判断两个字符串是否相同,是因为大家都知道’==’是用来判断两个对象的值引用地址是否一致,两个值一样的字符串字面量定义是否指向同一个值内存地址呢?答案是肯定的. 1

如何调教java字节码

本文地址:http://www.cnblogs.com/herbix/p/3541093.html java字节码是直接在在jvm上运行的代码.和简单易懂的java程序不同,java字节码是类似于汇编的指令串,不过比汇编的指令集要小很多,java字节码可优化的余地没有那么大,想直接编写字节码也要比编写汇编容易许多. <JAVA虚拟机规范 java SE 7>这本书上详细介绍了制作一个java虚拟机的过程,包括类文件的结构.指令集.还有一些约束等等,其余的部分是由虚拟机的编写者决定的.既然我们的

Java字节码 小结

Reference javap 基本使用方法 深入理解java字节码 从Java代码到字节码 Java字节码.class文件案例分析 字节码 核心概念 Class文件是8位字节流,按字节对齐.之所以称为字节码,是由于每条指令都仅仅占领一个字节.全部的操作码和操作数都是按字节对齐的. 数据结构 Java虚拟机规范中规定.Class文件格式採用一种相似C语言结构体的伪结构来存储,它仅仅有两种数据类型 无符号数(基本数据类型) 主要用于描写叙述数字.索引引用.数量值.或UTF-8编码构成的字符串: u

JAVA字节码修改异常分析

源class反编译后代码如下: public boolean isExpiring() { if ((this.timestamp == null) || (this.timestamp.length() <= 0)) { return true; } boolean isExpiring = false; try { SimpleDateFormat df = new SimpleDateFormat( SSOAuthConfig.getAuthDataDateFormart()); Date

Java字节码基础[转]

原文链接:http://it.deepinmind.com/jvm/2014/05/24/mastering-java-bytecode.html Java是一门设计为运行于虚拟机之上的编程语言,因此它需要一次编译,处处运行(当然也是一次编写,处处测试).因此,安装到你系统上的JVM是原生的程序,而运行在它之上的代码是平台无关的.Java字节码就是你写的源代码的中间表现形式,也就是你的代码编译后的产物.你的class文件就是字节码. 简单点说,字节码就是JVM使用的代码集,它在运行时可能会被JI

JAVA字节码解析

Java字节码指令 Java 字节码指令及javap 使用说明 ### java字节码指令列表 字节码 助记符 指令含义 0x00 nop 什么都不做 0x01 aconst_null 将null推送至栈顶 0x02 iconst_m1 将int型-1推送至栈顶 0x03 iconst_0 将int型0推送至栈顶 0x04 iconst_1 将int型1推送至栈顶 0x05 iconst_2 将int型2推送至栈顶 0x06 iconst_3 将int型3推送至栈顶 0x07 iconst_4

关于java字节码框架ASM的学习

一.什么是ASM ASM是一个java字节码操纵框架,它能被用来动态生成类或者增强既有类的功能.ASM 可以直接产生二进制 class 文件,也可以在类被加载入 Java 虚拟机之前动态改变类行为.Java class 被存储在严格格式定义的 .class文件里,这些类文件拥有足够的元数据来解析类中的所有元素:类名称.方法.属性以及 Java 字节码(指令).ASM从类文件中读入信息后,能够改变类行为,分析类信息,甚至能够根据用户要求生成新类.asm字节码增强技术主要是用来反射的时候提升性能的,

【转】在Eclipse里查看Java字节码

要理解 Java 字节码,比较推荐的方法是自己尝试编写源码对照字节码学习.其中阅读 Java 字节码的工具必不可少.虽然javap可以以可读的形式展示出.class 文件中字节码,但每次改动源码都需调用命令行并不方便.这里介绍一个可以辅助阅读 Java 字节码的 Eclipse 插件:bytecode outline. bytecode outline 插件用可读的方式展现了 Eclipse 的 Java 编辑器或类文件的字节码内容.它使用了 ASM 框 架的部分组建来实现对字节码的展示(ASM

从Java源码到Java字节码

Java最主流的源码编译器,javac,基本上不对代码做优化,只会做少量由Java语言规范要求或推荐的优化:也不做任何混淆,包括名字混淆或控制流混淆这些都不做.这使得javac生成的代码能很好的维持与原本的源码/AST之间的对应关系.换句话说就是javac生成的代码容易反编译. Java Class文件含有丰富的符号信息.而且javac默认的编译参数会让编译器生成行号表,这些都有助于了解对应关系. 关于Java语法结构如何对应到Java字节码,在JVM规范里有相当好的例子:Chapter 3.