写点什么

Java 字节码忍者禁术

Introduction & Bytecode Primer

  • 2015-04-24
  • 本文字数:7365 字

    阅读完需:约 24 分钟

Java 语言本身是由 Java 语言规格说明(JLS)所定义的,而 Java 虚拟机的可执行字节码则是由一个完全独立的标准,即 Java 虚拟机规格说明(通常也被称为 VMSpec)所定义的。

JVM 字节码是通过 javac 对 Java 源代码文件进行编译后生成的,生成的字节码与原本的 Java 语言存在着很大的不同。比方说,在 Java 语言中为人熟知的一些高级特性,在编译过程中会被移除,在字节码中完全不见踪影。

这方面最明显的一个例子莫过于 Java 中的各种循环关键字了(for、while 等等),这些关键字在编译过程中会被消除,并替换为字节码中的分支指令。这就意味着在字节码中,每个方法内部的流程控制只包含 if 语句与 jump 指令(用于循环)。

在阅读本文前,我假设读者对于字节码已经有了基本的了解。如果你需要了解一些基本的背景知识,请参考《Java 程序员修炼之道》(Well-Grounded Java Developer)一书(作者为 Evans 与 Verburg,由 Manning 于 2012 年出版),或是来自于 RebelLabs 的这篇报告(下载PDF 需要注册)。

让我们来看一下这个示例,它对于还不熟悉的JVM 字节码的新手来说很可能会感到困惑。该示例使用了javap 工具,它本质上是一个Java 字节码的反汇编工具,在下载的JDK 或JRE 中可以找到它。在这个示例中,我们将讨论一个简单的类,它实现了Callable 接口:

复制代码
public class ExampleCallable implements Callable<double> {
public Double call() {
return 3.1415;
}
}</double>

我们可以通过对 javap 工具进行最简单形式的使用,对这个类进行反汇编后得到以下结果:

复制代码
$ javap kathik/java/bytecode_examples/ExampleCallable.class
Compiled from "ExampleCallable.java"
public class kathik.java.bytecode_examples.ExampleCallable
implements java.util.concurrent.Callable<java.lang.double> {
public kathik.java.bytecode_examples.ExampleCallable();
public java.lang.Double call();
public java.lang.Object call() throws java.lang.Exception;
}</java.lang.double>

这个反汇编后的结果看上去似乎是错误的,毕竟我们只写一个 call 方法,而不是两个。而且即使我们尝试手工创建这两个方法,javac 也会提示,代码中有两个具有相同名称和参数的方法,它们仅有返回类型的不同,因此这段代码是无法编译的。然而,这个类确确实实是由上面那个真实的、有效的 Java 源文件所生成的。

这个示例能够清晰地表明在使用 Java 中广为人知的一种限制:不可对返回类型进行重载,其实这只是 Java 语言的一种限制,而不是 JVM 字符码本身的强制要求。javac 确实会在代码中插入一些不存在于原始的类文件中的内容,如果你为此感到担忧,那大可放心,因为这种事每时每刻都在发生!每一位 Java 程序员最先学到的一个知识点就是:“如果你不提供一个构造函数,那么编译器会为你自动添加一个简单的构造函数”。在 javap 的输出中,你也能看到其中有一个构造函数存在,而它并不存在于我们的代码中。

这些额外的方法从某种程度上表明,语言规格说明的需求比 VM 规格说明中的细节更为严格。如果我们能够直接编写字节码,就可以实现许多“不可能”实现的功能,而这种字节码虽然是合法的,却没有任何一个 Java 编译器能够生成它们。

举例来说,我们可以创建出完全不含构造函数的类。Java 语言规格说明中要求每个类至少要包含一个构造函数,而如果我们在代码中没有加入构造函数,javac 会自动加入一个简单的 void 构造函数。但是,如果我们能够直接编写字节码,我们完全可以忽略构造函数。这种类是无法实例化的,即使通过反射也不行。

我们的最后一个例子已经接近成功了,但还是差一口气。在字节码中,我们可以编写一个方法,它将试图调用一个其它类中定义的私有方法。这段字节码是有效的,但如果任何程序打算加载它,它将无法正确地进行链接。这是因为在类型加载器中(classloader)的校验器会检测出这个方法调用的访问控制限制,并且拒绝这个非法访问。

介绍 ASM

如果我们打算在创建的代码中实现这些超越 Java 语言的行为,那就需要完全手动创建这样的一个类文件。由于这个类文件的格式是两进制的,因此可以选择使用某种类库,它能够让我们对某个抽象的数据结构进行操作,随后将其转换为字节码,并通过流方式将其写入磁盘。

具备这种功能的类库有多个选择,但在本文中我们将关注于 ASM。这是一个非常常见的类库,在 Java 8 分发包中有一个以内部 API 的形式提供的版本(其内容稍有不同)。对于用户代码来说,我们选择使用通用的开源类库,而不是 JDK 中提供的版本,毕竟我们不应当依赖于内部 API 来实现所需的功能。

ASM 的核心功能在于,它提供了一种 API,虽然它看上去有些神秘莫测(有时也会显得有些粗糙),但能够以一种直接的方式反映出字节码的数据结构。

我们看到的 Java 运行时是由多年之前的各种设计决策所产生的结果,而在后续各个版本的类文件格式中,我们能够清晰地看到各种新增的内容。

ASM 致力于尽量使构建的类文件接近于真实形态,因此它的基础 API 会分解为一系列相对简单的方法片段(而这些片段正是用于建模的二进制所关注的)。

如果程序员打算完全手动编写类文件,就必需理解类文件的整体结构,而这种结构是会随时改变的。幸运的是,ASM 能够处理多个不同 Java 版本中的类文件格式之间的细微差别,而 Java 平台本身对于可兼容性的高要求也侧面帮助了我们。

一个类文件依次包含以下内容:

  • 某个特殊的数字(在传统的 Unix 平台上,Java 中的特殊数字是这个历史悠久的、人见人爱的 0xCAFEBABE)
  • 正在使用中的类文件格式版本号
  • 常量
  • 访问控制标记(例如类的访问范围是 public、protected 还是 package 等等)
  • 该类的类型名称
  • 该类的超类
  • 该类所实现的接口
  • 该类拥有的字段(处于超类中的字段上方)
  • 该类拥有的方法(处于超类中的方法上方)
  • 属性(类级别的注解)

可以用下面这个方法帮助你记忆 JVM 类文件中的主要部分:

ASM 中提供了两个 API,其中最简单的那个依赖于访问者模式。在常见的形式中,ASM 只包含最简单的字段以及 ClassWrite 类(当已经熟悉了 ASM 的使用和直接操作字节码的方式之后,许多开发者会发现 CheckClassAdapter 是一个很实用的起点,作为一个 ClassVisitor,它对代码进行检查的方式,与 Java 的类加载子系统中的校验器的工作方式非常想像。)

让我们看几个简单的类生成的例子,它们都是按照常规的模式创建的:

  • 启动一个 ClassVisitor(在我们的示例中就是一个 ClassWriter)
  • 写入头信息
  • 生成必要的方法和构造函数
  • 将 ClassVisitor 转换为字节数组,并写入输出

示例

复制代码
public class Simple implements ClassGenerator {
// Helpful constants
private static final String GEN_CLASS_NAME = "GetterSetter";
private static final String GEN_CLASS_STR = PKG_STR + GEN_CLASS_NAME;
@Override
public byte[] generateClass() {
ClassWriter cw = new ClassWriter(0);
CheckClassAdapter cv = new CheckClassAdapter(cw);
// Visit the class header
cv.visit(V1_7, ACC_PUBLIC, GEN_CLASS_STR, null, J_L_O, new String[0]);
generateGetterSetter(cv);
generateCtor(cv);
cv.visitEnd();
return cw.toByteArray();
}
private void generateGetterSetter(ClassVisitor cv) {
// Create the private field myInt of type int. Effectively:
// private int myInt;
cv.visitField(ACC_PRIVATE, "myInt", "I", null, 1).visitEnd();
// Create a public getter method
// public int getMyInt();
MethodVisitor getterVisitor =
cv.visitMethod(ACC_PUBLIC, "getMyInt", "()I", null, null);
// Get ready to start writing out the bytecode for the method
getterVisitor.visitCode();
// Write ALOAD_0 bytecode (push the this reference onto stack)
getterVisitor.visitVarInsn(ALOAD, 0);
// Write the GETFIELD instruction, which uses the instance on
// the stack (& consumes it) and puts the current value of the
// field onto the top of the stack
getterVisitor.visitFieldInsn(GETFIELD, GEN_CLASS_STR, "myInt", "I");
// Write IRETURN instruction - this returns an int to caller.
// To be valid bytecode, stack must have only one thing on it
// (which must be an int) when the method returns
getterVisitor.visitInsn(IRETURN);
// Indicate the maximum stack depth and local variables this
// method requires
getterVisitor.visitMaxs(1, 1);
// Mark that we've reached the end of writing out the method
getterVisitor.visitEnd();
// Create a setter
// public void setMyInt(int i);
MethodVisitor setterVisitor =
cv.visitMethod(ACC_PUBLIC, "setMyInt", "(I)V", null, null);
setterVisitor.visitCode();
// Load this onto the stack
setterVisitor.visitVarInsn(ALOAD, 0);
// Load the method parameter (which is an int) onto the stack
setterVisitor.visitVarInsn(ILOAD, 1);
// Write the PUTFIELD instruction, which takes the top two
// entries on the execution stack (the object instance and
// the int that was passed as a parameter) and set the field
// myInt to be the value of the int on top of the stack.
// Consumes the top two entries from the stack
setterVisitor.visitFieldInsn(PUTFIELD, GEN_CLASS_STR, "myInt", "I");
setterVisitor.visitInsn(RETURN);
setterVisitor.visitMaxs(2, 2);
setterVisitor.visitEnd();
}
private void generateCtor(ClassVisitor cv) {
// Constructor bodies are methods with special name <init>
MethodVisitor mv =
cv.visitMethod(ACC_PUBLIC, INST_CTOR, VOID_SIG, null, null);
mv.visitCode();
mv.visitVarInsn(ALOAD, 0);
// Invoke the superclass constructor (we are basically
// mimicing the behaviour of the default constructor
// inserted by javac)
// Invoking the superclass constructor consumes the entry on the top
// of the stack.
mv.visitMethodInsn(INVOKESPECIAL, J_L_O, INST_CTOR, VOID_SIG);
// The void return instruction
mv.visitInsn(RETURN);
mv.visitMaxs(2, 2);
mv.visitEnd();
}
@Override
public String getGenClassName() {
return GEN_CLASS_NAME;
}
}</init>

这段代码使用了一个简单的接口,用一个单一的方法生成类的字节,一个辅助方法以返回生成的类名,以及一些实用的常量:

复制代码
interface ClassGenerator {
public byte[] generateClass();
public String getGenClassName();
// Helpful constants
public static final String PKG_STR = "kathik/java/bytecode_examples/";
public static final String INST_CTOR = "<init>";
public static final String CL_INST_CTOR = "<clinit>";
public static final String J_L_O = "java/lang/Object";
public static final String VOID_SIG = "()V";
}</clinit></init>

为了驾驭生成的类,我们需要使用一个 harness 类,它叫做 Main。Main 类提供了一个简单的类加载器,并且提供了一种反射式的方式对生成类中的方法进行回调。为了简便起见,我们将生成的类定入 Maven 的目标文件夹的正确位置,让 IDE 中的 classpath 能够顺利地找到它:

复制代码
public class Main {
public static void main(String[] args) {
Main m = new Main();
ClassGenerator cg = new Simple();
byte[] b = cg.generateClass();
try {
Files.write(Paths.get("target/classes/" + PKG_STR +
cg.getGenClassName() + ".class"), b, StandardOpenOption.CREATE);
} catch (IOException ex) {
Logger.getLogger(Simple.class.getName()).log(Level.SEVERE, null, ex);
}
m.callReflexive(cg.getGenClassName(), "getMyInt");
}

下面的类提供了一种方法,能够对受保护的 defineClass() 进行访问,这样一来我们就能够将一个字节数组转换为某个类对象,以便在反射中使用。

复制代码
private static class SimpleClassLoader extends ClassLoader {
public Class simpleDefineClass(byte[] clazzBytes) {
return defineClass(null, clazzBytes, 0, clazzBytes.length);
}
}
private void callReflexive(String typeName, String methodName) {
byte[] buffy = null;
try {
buffy = Files.readAllBytes(Paths.get("target/classes/" + PKG_STR +
typeName + ".class"));
if (buffy != null) {
SimpleClassLoader myCl = new SimpleClassLoader();
Class newClz = myCl.simpleDefineClass(buffy);
Object o = newClz.newInstance();
Method m = newClz.getMethod(methodName, new Class[0]);
if (o != null && m != null) {
Object res = m.invoke(o, new Object[0]);
System.out.println("Result: " + res);
}
}
} catch (IOException | InstantiationException | IllegalAccessException |
NoSuchMethodException | SecurityException |
IllegalArgumentException | InvocationTargetException ex) {
Logger.getLogger(Simple.class.getName()).log(Level.SEVERE, null, ex);
}
}

有了这个类以后,我们只要通过细微的改动,就可以方便地测试各种不同的类生成器,以此对字节码生成器的各个方面进行探索。

实现无构造函数的类的方式也很相似。举例来说,以下这种方式可以在生成的类中仅包含一个静态字段,以及它的 getter 和 setter(生成器不会调用 generateCtor() 方法):

复制代码
private void generateStaticGetterSetter(ClassVisitor cv) {
// Generate the static field
cv.visitField(ACC_PRIVATE | ACC_STATIC, "myStaticInt", "I", null,
1).visitEnd();
MethodVisitor getterVisitor = cv.visitMethod(ACC_PUBLIC | ACC_STATIC,
"getMyInt", "()I", null, null);
getterVisitor.visitCode();
getterVisitor.visitFieldInsn(GETSTATIC, GEN_CLASS_STR, "myStaticInt", "I");
getterVisitor.visitInsn(IRETURN);
getterVisitor.visitMaxs(1, 1);
getterVisitor.visitEnd();
MethodVisitor setterVisitor = cv.visitMethod(ACC_PUBLIC | ACC_STATIC, "setMyInt",
"(I)V", null, null);
setterVisitor.visitCode();
setterVisitor.visitVarInsn(ILOAD, 0);
setterVisitor.visitFieldInsn(PUTSTATIC, GEN_CLASS_STR, "myStaticInt", "I");
}
setterVisitor.visitInsn(RETURN);setterVisitor.visitMaxs(2,2);setterVisitor.visitEnd();

请留意一下该方法在生成时使用了 ACC_STATIC 标记,此外还请注意方法的参数是位于本地变量列表中的最前面的(这里使用的 ILOAD 0 模式暗示了这一点 —— 而在生成实例方法时,此处应该改为 ILOAD 1,这是因为实例方法中的“this”引用存储在本地变量表中的偏移量为 0)。

通过使用 javap,我们就能够确认在生成的类中确实不包括任何构造函数:

复制代码
$ javap -c kathik/java/bytecode_examples/StaticOnly.class
public class kathik.StaticOnly {
public static int getMyInt(); Code:
0: getstatic #11 // Field myStaticInt:I
3: ireturn
public static void setMyInt(int); Code:
0: iload_0
1: putstatic #11 // Field myStaticInt:I
4: return
}

使用生成的类

目前为止,我们是使用反射的方式调用我们通过 ASM 所生成的类的。这有助于保持这个示例的自包含性,但在很多情况下,我们希望能够将这些代码生成在常规的 Java 文件中。要实现这一点非常简单。以下示例将生成的类保存在 Maven 的目标目录下,写法很简单:

复制代码
$ cd target/classes
$ jar cvf gen-asm.jar kathik/java/bytecode_examples/GetterSetter.class kathik/java/bytecode_examples/StaticOnly.class
$ mv gen-asm.jar ../../lib/gen-asm.jar

这样一来我们就得到了一个 JAR 文件,可以作为依赖项在其它代码中使用。比方说,我们可以这样使用这个 GetterSetter 类:

复制代码
import kathik.java.bytecode_examples.GetterSetter;
public class UseGenCodeExamples {
public static void main(String[] args) {
UseGenCodeExamples ugcx = new UseGenCodeExamples();
ugcx.run();
}
private void run() {
GetterSetter gs = new GetterSetter();
gs.setMyInt(42);
System.out.println(gs.getMyInt());
}
}

这段代码在 IDE 中是无法通过编译的(因为 GetterSetter 类没有配置在 classpath 中)。但如果我们直接使用命令行,并且在 classpath 中指向正确的依赖,就可以正确地运行了:

复制代码
$ cd ../../src/main/java/
$ javac -cp ../../../lib/gen-asm.jar kathik/java/bytecode_examples/withgen/UseGenCodeExamples.java
$ java -cp .:../../../lib/gen-asm.jar kathik.java.bytecode_examples.withgen.UseGenCodeExamples
42

结论

在本文中,我们通过使用 ASM 类库中所提供的简单 API,学习了完全手动生成类文件的基础知识。我们也为读者展示了 Java 语言和字节码有哪些不同的要求,并且了解到 Java 中的某些规则其实只是语言本身的规范,而不是运行时所强制的要求。我们还看到,一个正确编写的手工类文件可以直接在语言中使用,与通过 javac 生成的文件没有区别。这一点也是 Java 与其它非 Java 语言,例如 Groovy 或 Scala 进行互操作的基础。

这方面的应用还有许多高级技巧,通过本文的学习,读者应该已经掌握了基本的知识,并且能够进一步深入研究 JVM 的运行时,以及如何对它进行各种操作的技术。

关于作者

Ben Evans是 Java/JVM 性能分析初创公司 jClarity 的 CEO。在业余时间他是伦敦 Java 社区的领导者之一并且是 Java 社区进程执行委员会的一员。之前的项目经验包括谷歌 IPO 的性能测试,金融交易系统,为 90 年代一些最大的电影编写备受好评的网站,以及其他。

查看英文原文: Secrets of the Bytecode Ninjas

2015-04-24 05:527912
用户头像

发布了 428 篇内容, 共 176.7 次阅读, 收获喜欢 38 次。

关注

评论

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

LabVIEW实现PCB电路板元器件匹配定位(实战篇—7)

不脱发的程序猿

计算机视觉 图像处理 LabVIEW PCB电路板元器件匹配定位

微信业务架构 & 学生管理系统架构

凌波微步

「架构实战营」

一起玩转LiteOS组件:TinyFrame

华为云开发者联盟

LiteOS 串口 LiteOS组件 TinyFrame

WorkPlus赋能数字政府迈入发展新阶段

WorkPlus

高效管理邮件的方式

NinetyH

工具软件 办公效率 邮件管理

ChaosCraft:和女朋友一起来 Hackathon 表演绝活丨滑滑蛋团队访谈

PingCAP

Centos7下Nginx编译安装与脚本安装的记录

edd

微信朋友圈架构设计

刘洋

#架构实战营

模块一作业--

Leo

「架构实战营」

小程序电商业务微服务拆分及基础设施选型

swallowluo

架构实战营 #架构实战营 「架构实战营」

华为云FusionInsight连续三次获得第一,加速释放数据要素价值

华为云开发者联盟

大数据 数据湖 云原生 FusionInsight 华为云

Android Studio开发flutter快捷键及文本显示技巧。

坚果

flutter 1月月更

模块六作业

novoer

「架构实战营」

JavaScript 之 Proxy

编程三昧

JavaScript 前端 Proxy 1月月更

「架构实战营」模块一作业

hxb

「架构实战营」

写了这么多年后端,你知道事务脚本模式吗?

蜜糖的代码注释

Java 互联网 后端

获奖作品公布,快来看看有没有你!

InfoQ写作社区官方

新春征文 热门活动

数据治理平台化的通用框架设计

Taylor

LabVIEW仪表盘识别(实战篇—6)

不脱发的程序猿

机器视觉 图像处理 LabVIEW 仪表盘识别

[架构实战营]-架构实训一

邹玉麒

「架构实战营」

架构实战营5期模块1作业

lovles

「架构实战营」

架构图 - 微信 & 学生管理系统

Ntropy

架构实战营

我的架构学习之始

浪飞

华山论“件”:Kafka、RabbitMQ、RocketMQ技能大比拼

华为云开发者联盟

kafka RocketMQ RabbitMQ 华为云 消息中间件

架构设计小试牛刀

Fingal

架构实战营

架构训练营模块一作业

苍狼

什么时候该减少质量投入?

QualityFocus

质量管理 软件测试 测试思维

音视频技术如何为元宇宙提供全真稳的全新体验之漫话腾讯云音视频 | 社区征文

liuzhen007

音视频 1月月更 新春征文

ReactNative进阶(三十六):ES8 中 async 与 await 使用方法详解

No Silver Bullet

Async React Native await 1月月更

复古冰雪传奇H5游戏详细图文架设教程

echeverra

游戏开发 游戏

git 使用总结

麦可

git 开发工具

Java字节码忍者禁术_Java_Ben Evans_InfoQ精选文章