写点什么

Java 深度历险(一)——Java 字节代码的操纵

  • 2010-12-20
  • 本文字数:5581 字

    阅读完需:约 18 分钟

【编者按】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 类的代码是保存在一个字符串中的。

复制代码
public class CompilerTest {
public static void main(String[] args) throws Exception {
String source = "public class Main { public static void main(String[] args) {System.out.println(\"Hello World!\");} }";
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null);
StringSourceJavaObject sourceObject = new CompilerTest.StringSourceJavaObject("Main", source);
Iterable< extends JavaFileObject> fileObjects = Arrays.asList(sourceObject);
CompilationTask task = compiler.getTask(null, fileManager, null, null, null, fileObjects);
boolean result = task.call();
if (result) {
System.out.println(" 编译成功。");
}
}
static class StringSourceJavaObject extends SimpleJavaFileObject {
private String content = null;
public StringSourceJavaObject(String name, String content) ??throws URISyntaxException {
super(URI.create("string:///" + name.replace('.','/') + Kind.SOURCE.extension), Kind.SOURCE);
this.content = content;
}
public CharSequence getCharContent(boolean ignoreEncodingErrors) ??throws IOException {
return content;
}
}
}

如果不能使用 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 来实现。

复制代码
private static double calculate(String expr) throws CalculationException  {
String className = "CalculatorMain";
String methodName = "calculate";
String source = "public class " + className
+ " { public static double " + methodName + "() { return " + expr + "; } }";
// 省略动态编译 Java 源代码的相关代码,参见上一节
boolean result = task.call();
if (result) {
ClassLoader loader = Calculator.class.getClassLoader();
try {
Class<?> clazz = loader.loadClass(className);
Method method = clazz.getMethod(methodName, new Class<?>[] {});
Object value = method.invoke(null, new Object[] {});
return (Double) value;
} catch (Exception e) {
throw new CalculationException(" 内部错误。");
}
} else {
throw new CalculationException(" 错误的表达式。");
}
}

上面的代码给出了使用动态生成的 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 类的字节代码内容。这些类库包括 ASM cglib serp BCEL 等。使用这些类库可以在一定程度上降低增强字节代码的复杂度。比如考虑下面一个简单的需求,在一个 Java 类的所有方法执行之前输出相应的日志。熟悉 AOP 的人都知道,可以用一个前增强(before advice)来解决这个问题。如果使用 ASM 的话,相关的代码如下:

复制代码
ClassReader cr = new ClassReader(is);
ClassNode cn = new ClassNode();
cr.accept(cn, 0);
for (Object object : cn.methods) {
MethodNode mn = (MethodNode) object;
if ("<init>".equals(mn.name) || "<clinit>".equals(mn.name)) {
continue;
}
InsnList insns = mn.instructions;
InsnList il = new InsnList();
il.add(new FieldInsnNode(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"));
il.add(new LdcInsnNode("Enter method -> " + mn.name));
il.add(new MethodInsnNode(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V"));
insns.insert(il);  mn.maxStack += 3;
}
ClassWriter cw = new ClassWriter(0);
cn.accept(cw);
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 类的转换。

复制代码
static class MethodEntryTransformer implements ClassFileTransformer {
public byte[] transform(ClassLoader loader, String className,
Class<?> classBeingRedefined, ?ProtectionDomain protectionDomain, byte[] classfileBuffer)
throws  IllegalClassFormatException {
try {
ClassReader cr = new ClassReader(classfileBuffer);
ClassNode cn = new ClassNode();
// 省略使用 ASM 进行字节代码转换的代码
ClassWriter cw = new ClassWriter(0);
cn.accept(cw);
return cw.toByteArray();
} catch (Exception e){
return null;
}
}
}

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

复制代码
public static void premain(String args, Instrumentation inst) {
inst.addTransformer(new MethodEntryTransformer());
}

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

总结

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

参考资料


感谢张凯峰对本文的审校。

给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家加入到 InfoQ 中文站用户讨论组中与我们的编辑和其他读者朋友交流。

2010-12-20 00:0039351

评论

发布
暂无评论
发现更多内容

飞步科技 x 焱融 YRCloudFile:大幅提升训练效率,开启智驾新纪元

焱融科技

自动驾驶 云计算 分布式 高性能 文件存储

7招!实现安全高效的流水线管理

阿里云云效

云计算 阿里云 运维 云原生 持续交付

群晖(Synology)NAS 后台安装 Docker 后配置 Mariadb / MySQL 配置端口

HoneyMoose

低代码实现探索(三十四)前台code逻辑

零道云-混合式低代码平台

评测有礼 | 飞桨黑客松第二期热身活动上线啦!

百度大脑

构建测试的体系化思维(进阶篇)

BY林子

测试体系 质量内建

投稿开奖丨云服务器ECS征文活动(1月)奖励公布

阿里云弹性计算

阿里云 云服务器 征文投稿开奖 玩转ECS

技术创想 | Cypress UI 自动化测试框架

领创集团Advance Intelligence Group

面由心生,由脸观心:基于AI的面部微表情分析技术解读

百度大脑

工业AI落地场景案例实战,飞桨EasyDL让工业更智能

百度大脑

浙江省人民医院:用宜搭助力党建改革工作,重构医院重大事项议事决策机制

一只大光圈

钉钉 低代码 钉钉宜搭 宜搭 宜搭数字化

Mac 配置 Flutter 安卓开发环境

岛上码农

flutter ios 安卓 移动开发 3月月更

群晖(Synology)NAS 安装 Mariadb 数据库启动错误

HoneyMoose

群晖(Synology)NAS 后台安装 Docker 后配置 Mariadb / MySQL

HoneyMoose

2021年信创产业融资分析报告

统小信uos

2022年软件开发趋势:远程工作已成主流

码语者

软件工程能力漫谈:比编码更重要的,是项目管理能力

百度开发者中心

CorelDRAW2022最新订阅版本下载

茶色酒

cdr2022

百度Q4财报:百度智能云2021年营收151亿元,同比大增64%

百度大脑

Powershell基础之脚本执行

喀拉峻

网络安全 安全 渗透测试

2021 盘点 | 券商 TOP 5 出炉,谁才是最“卷”的券商王者?

博睿数据

一撕得:全员参与低代码开发,全面实现企业数字化管理

一只大光圈

钉钉 低代码 数字化 宜搭 一撕得

昇思MindSpore再突破:蛋白质结构预测训练推理全流程开源,助力生物医药发展

Geek_32c4d0

mindspore 昇思 生物医药

快速入门!全国大学生智能汽车竞赛百度创意组首期直播宣讲来啦

百度大脑

半导体材料的国产替代,机遇与挑战并存!

IC男奋斗史

芯片行业思考 芯片技术 芯片上游

NFT音乐盲盒游戏系统开发方案

薇電13242772558

NFT

自动化测试指南

FunTester

敏捷 性能测试 自动化测试 测试框架 FunTester

无需嵌码的主动式监测:一种预先感知用户体验的最佳实践

博睿数据

【重磅发布】百度参编信通院《联邦学习场景应用研究报告(2022年)》

百度开发者中心

开发提效小技巧分享(二)

编程三昧

工具 gitee GitHub、 3月月更

中国AI的“底线思维”与安全锁

脑极体

Java深度历险(一)——Java字节代码的操纵_Java_成富_InfoQ精选文章