NVIDIA 初创加速计划,免费加速您的创业启动 了解详情
写点什么

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:0039042

评论

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

ChatGPT如何助力DevOps|用例解读

SEAL安全

DevOps ChatGPT 企业号 3 月 PK 榜

Dubbo 正式支持 Spring 6 & Spring Boot 3

Java你猿哥

Java spring Spring Boot dubbo ssm

限时公开,2023 年阿里巴巴 Java 面试权威指南(全彩版)

架构师之道

Java 面试

干货分享|袋鼠云数栈离线开发平台在小文件治理上的探索实践之路

袋鼠云数栈

大数据 平台开发

什么是“语法糖”?Java中有哪些常见糖?

Java你猿哥

Java ssm Java工程师 语法糖

最新Ins图片保姆级保存方法来啦!你还在等什么!

frank

ins

弯道超车!阿里高工新产Java面试速成指南,面试骚操作都在里面了

Java你猿哥

Java 面试 面经 Java工程师 春招

批量上传iOS应用程序截图的实用技巧

PostgreSQL 技术内幕(六)Greenplum 排序算子

酷克数据HashData

玖章算术CEO叶正盛在杭州人工智能小镇AIGC论坛发表主题演讲

NineData

人工智能 代码开发 AIGC 玖章算术 NineData

最佳实践 | 用腾讯云智能语音打造智能对话机器人

牵着蜗牛去散步

腾讯云 腾讯 语音识别 语音合成 智能对话机器人

厉害了!阿里内部都用的Spring+MyBatis源码手册,实战理论两不误

Java你猿哥

spring 面试 Spring Boot mybatis 面经

太厉害了!腾讯T4大牛把《数据结构与算法》讲透了,带源码笔记

Java你猿哥

Java 数据结构 算法 数据结构算法 左程云

新一代移动动态研发模式及原理机制解析

Onegun

移动开发 热更新 动态更新

2周时间就掌握了Spring boot,原来是收藏了这样一份文档资料

三十而立

Java spring

是找茬? 还是装 B?阿里面试每轮必问的“Spring Boot”意义何在?

三十而立

在 Kubernetes 中部署应用交付服务(第 2 部分)

NGINX开源社区

nginx Kubernetes

应用健康度隐患刨析解决系列之数据库时区设置

京东科技开发者

数据库 优化 企业号 3 月 PK 榜 健康度

软件工程高效学 | 软件项目的开发模型

TiAmo

软件开发 模型开发

剥茧抽丝,细数模块化的前世今生

战场小包

前端 前端工程化 前端模块化

MySQL 语句中 where 条件后为什么写上1=1 , 是什么意思?

Java你猿哥

Java MySQL sql 后端 ssm

不懂就问:MySQL delete 表数据,磁盘空间为什么没有被释放?

Java你猿哥

Java MySQL 数据库 innodb Java工程师

三方对接「心得」与「体会」

Java 对外接口

龙蜥白皮书精选:龙蜥全面支持 Intel 第四代可扩展处理器 SPR 平台

OpenAnolis小助手

开源 Spr 操作系统 intel 龙蜥社区

消费级AR眼镜爆发将近:Rokid+无影突破算力,打造“第三块屏幕”

云布道师

无影

数据库 CI/CD 工具 -- Bytebase 介绍

Se7en

zookeeper的Leader选举源码解析

京东科技开发者

数据库 代码 企业号 3 月 PK 榜 选举机制

MPSK通信系统的设计与性能研究-8PSK

timerring

通信系统 8PSK

Spring知识点总结!已整理成142页离线文档(源码笔记+思维导图)

三十而立

Java

简单的文件搜索工具:Find Any File激活版

真大的脸盆

Mac Mac 软件 文件搜索 搜索工具 搜索软件

MySQL8.0 优化器介绍(一)

GreatSQL

MySQL greatsql greatsql社区

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