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

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:527773
用户头像

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

关注

评论

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

前端必学必会-多媒体-本地存储-浏览器与服务器的交互-通信功能

我是哪吒

学习 程序员 面试 大前端 2月春节不断更

区块链还可以这么玩?“点亮莫高窟”背后的腾讯云区块链

CECBC

区块链

架构2期-大作业(一)

浮生一梦

大作业 2组 架构师训练营第2期

对话京东科技算法科学家吴友政:回望2020,NLP技术发展速度强劲

京东科技开发者

人工智能 自然语言处理

Spring Boot 微服务性能下降九成!使用 Arthas 定位根因

阿里巴巴云原生

Java 微服务 云原生 中间件 Arthas

几幅图拿下 ARP 协议

飞天小牛肉

Java 程序员 计算机网络 网络协议 2月春节不断更

欢度春节|新用户专属福利

InfoQ写作社区官方

热门活动

Serverless 场景下 Pod 创建效率优化

阿里巴巴云原生

Docker Serverless 容器 云原生 k8s

字节跳动面试必问:从外包月薪5K到阿里月薪15K,学习路线+知识点梳理

欢喜学安卓

android 程序员 面试 移动开发

架构师训练营第十一周作业

zamkai

从云数据迁移服务看MySQL大表抽取模式

华为云开发者联盟

MySQL JVM JDBC 数据迁移

产品经理训练营笔记-业务流程与产品文档(一)

.nil?

产品经理训练营

搜索引擎简述

跳蚤

从0到1实现一个简单计算器

codevald

Java 项目 计算器 动手实践

无意间发现 Google 代码模板,分享给大家!

C语言与CPP编程

c++ JavaScript objective-c 代码规范 Python 编码格式

架构师训练营第六周作业

跳蚤

执行、管理、领导做不好,都有懒的因素

刘华Kenneth

领导力 管理 软件开发

Ebean ORM框架介绍-1.增强注解

Barry的异想世界

Spring Boot jpa ORM Ebean

一文总结GaussDB通信原理知识

华为云开发者联盟

数据库 通信 框架 GaussDB 计算

缓存设计的好,服务基本不会倒

万俊峰Kevin

缓存 微服务 microservice Go 语言

Arthas 使用的各类方式

阿里巴巴云原生

Java 微服务 云原生 中间件 Arthas

停车、投票、领证,区块链如何在「智慧城市」建设中大显身手?

CECBC

区块链

中国移动工程师浅析:KubeEdge在国家工业互联网大数据中心的架构设计与应用

华为云开发者联盟

大数据 数据采集 工业智能体 边缘数据中心管理 EDCM

最好的IDEA debug长文?看完我佛了

YourBatman

eclipse debug IntelliJ IDEA 远程调试

前端开发:Node版本引起的报错问题

三掌柜

vue.js 大前端

字节跳动架构师讲解Android开发!2021年展望Android原生开发的现状,分享一点面试小经验

欢喜学安卓

android 程序员 面试 移动开发

架构2期-大作业(二)

浮生一梦

大作业 2组 架构师训练营第2期

2. 无门槛学会数据类型与输入、输出函数,滚雪球学 Python

梦想橡皮擦

Python python 爬虫 2月春节不断更 python入门

Kubernetes 原生 CI/CD 构建框架 Tekton 详解

字节跳动 Kubernetes 云原生 Tekton CI/CD

逼疯UE设计师,不可不知的提升产品用户体验的10个测试方法

华为云开发者联盟

产品 测试 UI 用户体验

排查指南 | 当 mPaaS 小程序真机扫码时提示 "应用更新错误(50002)"

蚂蚁集团移动开发平台 mPaaS

小程序 问题排查 mPaaS

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