字节码工程

一则或许对你有用的小广告

欢迎加入小哈的星球 ,你将获得:专属的项目实战 / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

  • 新项目:《从零手撸:仿小红书(微服务架构)》 正在持续爆肝中,基于 Spring Cloud Alibaba + Spring Boot 3.x + JDK 17...点击查看项目介绍 ;
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/ ;

截止目前, 星球 内专栏累计输出 54w+ 字,讲解图 2476+ 张,还在持续爆肝中.. 后续还会上新更多项目,目标是将 Java 领域典型的项目都整一波,如秒杀系统, 在线商城, IM 即时通讯,权限管理,Spring Cloud Alibaba 微服务等等,已有 1900+ 小伙伴加入学习 ,欢迎点击围观

这篇博文是讨论字节码工程及其应用优点的系列文章的第一篇。字节码工程包括以类的形式创建新字节码和修改现有字节码。字节码工程有很多应用。它用于编译器、类重新加载、内存泄漏检测和性能监控的工具中。此外,大多数应用程序服务器使用字节代码库在运行时生成类。字节码工程的使用比你想象的要多。事实上,您可以找到捆绑在 JRE 中的流行字节码工程库,包括 BCEL ASM 。尽管它被广泛使用,但似乎很少有教授字节码工程的大学或学院课程。这是编程的一个方面,开发人员必须自己学习,对于那些不学习的人来说,它仍然是一种神秘的魔法。事实上,字节码工程库使学习这个领域变得容易,并且是深入了解 JVM 内部结构的门户。这些文章的目的是提供一个起点,然后记录一些高级概念,希望能启发读者发展自己的技能。

文档

任何学习字节码工程的人都应该随时掌握一些资源。第一个是 Java 虚拟机规范 (仅供参考,此页面有指向 语言 JVM 规范的链接)。第4章, 类文件格式 是必不可少的。有助于快速参考的第二个资源是 标题为 Java 字节码指令列表 的维基百科页面 。在字节码指令方面,它比 JVM 规范本身更加简洁和信息丰富。另一个对初学者有用的资源是字段类型的内部描述符格式表。该表直接取自 JVM 规范。

基本类型 字符 类型 解释
字节 有符号 字节
C 字符 Basic Multilingual 中的 Unicode 字符 代码
平面,使用 UTF-16 编码
双倍的 双精度浮点值
F 漂浮 单精度浮点值
整数 整数
长的 长整数
L<类名>; 参考 类 <ClassName> 的一个实例
小号 短的 签短
Z 布尔值 对或错
[ 参考 一维数组

大多数原始字段类型仅使用字段类型的第一个首字母在内部表示类型(即 I 表示 int,F 表示 float 等),但是, long J byte Z 。对象类型不直观。对象类型以字母 L 开头,以分号结尾。在这些字符之间是完全限定的类名,每个名称由正斜杠分隔。例如,字段类型 java.lang.Integer 的内部描述符是 Ljava/lang/Integer; .最后,数组维度由“[”字符表示。对于每个维度,插入一个“[”字符。例如,一个二维 int 数组将是
[[I ,而二维 java.lang.Integer 数组将是 [[Ljava/lang/Integer;

方法也有一个内部描述符格式。格式为 (<parameter types>)<return type> 。所有类型都使用上面的字段类型描述符格式。 void 返回类型由字母 V 表示。参数类型没有分隔符。这里有些例子:

  • public static final void main(String args[]) 的程序入口点方法是 ([Ljava/lang/String;)V
  • public Info(int index, java.lang.Object types[], byte bytes[]) 形式的构造函数是 (I[Ljava/lang/Object;[Z)V
  • 具有签名 int getCount() 的方法将是 ()I

说到构造函数,我还应该提到所有构造函数都有一个内部方法名称 <init> 。此外,源代码中的所有静态初始值设定项都放置在内部方法名称为 <clinit> 的 单个静态初始值设定项方法中。

软件

在我们讨论字节码工程库之前,JDK bin 目录中捆绑了一个必不可少的学习工具,称为 javap。 Javap 是一个程序,它将反汇编字节代码并提供文本表示。让我们看看它可以用以下代码的编译版本做什么:


 package ca.discotek.helloworld;

public class HelloWorld {

static String message =
        "Hello World!";

public static void main(String[] args) {
    try {
        System.out.println(message);
    }
    catch (Exception e) {
        e.printStackTrace();
    }
}

}

这是 javap -help 命令的输出:


 package ca.discotek.helloworld;

public class HelloWorld {

static String message =
        "Hello World!";

public static void main(String[] args) {
    try {
        System.out.println(message);
    }
    catch (Exception e) {
        e.printStackTrace();
    }
}

}

这是我们使用 javap 反汇编 HelloWorld 程序时的输出:


 package ca.discotek.helloworld;

public class HelloWorld {

static String message =
        "Hello World!";

public static void main(String[] args) {
    try {
        System.out.println(message);
    }
    catch (Exception e) {
        e.printStackTrace();
    }
}

}

您应该注意到输出行号信息的 -l 标志被故意省略了。 -verbose 标志输出其他相关信息,包括行号。如果两者都使用,行号信息将被打印两次。

以下是输出的概述:

行号 描述
2个 调用 javap 的命令行。有关参数的说明,请参阅上面的 javap -help 输出。
3个 字节码中包含的调试信息提供的源代码文件。
4个 班级签名
5个 字节码中包含的调试信息提供的源代码文件。
6-7 主要版本和次要版本。 50.0 表示该类是使用 Java 6 编译的。
8-54 类常量池
57-58 消息 字段的声明。
60 静态初始化方法的声明。
61 方法的内部方法描述符。
63 Stack=1 表示操作数堆栈上需要 1 个槽。 locals=0 表示不需要局部变量。
Args_size=0 是方法的参数数量。
64-66 字节码指令给String赋值 Hello World! 消息 字段。
67-77 如果使用调试信息编译,每个方法都会有一个 LineNumberTable 。每个条目的格式是
<源代码的行号>:<字节码中的起始指令偏移量> 。你会注意到 LineNumberTable
有重复的条目并且接缝乱序(即 6、5、6)。看起来可能不直观,但是编译器将字节码组装起来
指令将以基于堆栈的 JVM 为目标,这意味着它将经常不得不重新安排指令。
72 默认构造函数签名
73 默认构造函数内部方法描述符
75 Stack=1 表示操作数堆栈上需要 1 个槽。 locals=1 表示有一个局部变量。方法
参数被视为局部变量。在这种情况下,它是 args 参数。
Args_size=1 是方法的参数数量。
76-78 默认构造函数代码。只需调用超类 java.lang.Object 的默认构造函数。
79-80 尽管未显式定义默认构造函数,但 LineNumberTable 表明
默认构造函数与类签名所在的第 3 行相关联。
82-84 您可能会惊讶地看到 LocalVariableTable 中的一个条目,因为默认构造函数
没有定义局部变量,也没有参数。但是,所有非静态方法都将定义“this”局部
变量,这是在这里看到的。起始值和长度值表示方法中局部变量的范围。
起始值指示范围开始的方法的字节码数组中的索引和长度值
指示数组中范围结束的位置(即开始 + 长度 = 结束)。在构造函数中,“this”
从索引 0 开始。这对应于第 78 行的 a_load0 指令。长度为 5,涵盖了整个方法
最后一条指令在索引 4 处。 值指示它在方法中定义的顺序。 名称
attribute 是源代码中定义的变量名。 Signature 属性表示变量的类型。
您应该注意,添加局部变量表信息是为了调试目的。为内存块分配标识符
完全是为了帮助人类更好地理解程序。该信息可以从字节码中排除。
86 主要方法声明
87 主要方法内部描述符。
89 Stack=2 表示操作数堆栈上需要 2 个槽。 locals=2 表示需要两个局部变量
(来自 catch 块的 args 和异常 e )。 Args_size=1 是方法的参数数量 ( args )。
90-97 与打印消息和捕获任何异常相关联的字节代码。
98-100 字节码没有 try/catch 结构,但它有异常处理,这是在 Exception 表 中实现的。
表中的每一行都是一条异常处理指令。 from to 值表示指令的范围
异常处理适用。如果给定 类型 的指令出现在 from to 指令之间
(包括),执行将跳到 目标 指令索引。值 12 表示 catch 块的开始。
您还会注意到 invokevirtual 指令之后的 goto 指令,它会导致执行跳到最后
如果没有异常发生,方法的方法。
102-107 Main 方法的行号表,将源代码与字节码指令进行匹配。
109-112 主要方法的 LocalVariableTable ,它定义了 args 参数和 e 异常变量的范围。
114-117 JVM 使用 StackMapTable 条目来验证方法中定义的每个代码块的类型安全性。此信息
现在可以忽略。您的编译器或字节码工程库很可能会生成此字节码
为你。


字节码工程库

最流行的字节代码工程库是 BCEL SERP Javassist ASM 。所有这些库都有自己的优点,但总的来说,ASM 的速度和多功能性要好得多。除了网站上的文档之外,还有大量文章和博客条目讨论这些库。下面将提供链接和希望提供其他有用信息,而不是重复这些工作。

BCEL

BCEL(字节代码工程库)最明显的缺点是其不一致的支持。如果您查看 BCEL 新闻和状态页面 ,就会发现在 2001 年、2003 年、2006 年和 2011 年都有发布。10 年的四次发布并不令人鼓舞信心。但是,应该注意的是,似乎有第 6 版候选发布版本,可以 从 GitHub 下载 ,但不能从 Apache 下载。此外,下载的 RELEASE-NOTES.txt 文件中讨论的增强功能和错误修复是实质性的,包括对 Java 6、7 和 8 语言功能的支持。

BCEL 是外行字节码开发人员的自然起点,因为它享有 Apache 软件基金会的声望。通常,它可以服务于开发人员的目的。 BCEL 的好处之一是它有一个 API,用于 SAX 和 DOM 方法来解析字节码。然而,当字节码操作更加复杂时,BCEL 可能会因其 API 文档和社区支持而以失败告终。应该注意的是,BCEL 与 BCELifier 实用程序捆绑在一起,该实用程序解析字节代码并将输出 BCEL API Java 代码以生成解析的字节代码。如果您选择 BCEL 作为您的字节码工程库,这个实用程序将是无价的(但请注意 ASM 有一个等效的 ASMifier)。

SERP

SERP 是一个鲜为人知的图书馆。我对它的经验有限,但我确实发现它对于为字节码构建一个 Javadoc 风格的工具很有用。 SERP 是唯一可以为我提供程序计数器信息的 API,因此我可以将分支指令超链接到它们的目标。尽管 SERP 发布文档 表明支持 Java 8 的 invokedynamic 指令,但我不清楚它是否得到了作者的持续支持并且社区支持很少。作者还 讨论了它的局限性 ,包括速度、内存消耗和线程安全等问题。

Javasist

Javassist 是唯一提供一些 ASM 不支持的功能的库……而且它非常棒。 Javassist 允许您将 Java 代码插入到现有的字节代码中。您可以在方法主体之前插入 Java 代码或将其附加到方法主体之后。你
还可以将方法体包装在 try 块中并添加您自己的 catch 块(Java 代码)。您还可以用您自己的 Java 源代码替换整个方法体或其他较小的结构。最后,您可以向包含您自己的 Java 源代码的类添加方法。此功能非常强大,因为它允许 Java 开发人员无需深入了解底层字节码即可操作字节码。但是,此功能确实有其局限性。例如,如果您在 insertBefore() 代码块中引入变量,则以后无法在 insertAfter() 代码块中引用它们。此外,ASM 通常比 Javassist 更快,但 Javassist 的简单性带来的好处可能超过 ASM 的性能提升。 Javassists 一直受到 JBoss 作者的支持,并获得了很多社区支持。

ASM

ASM 拥有一切。它得到很好的支持,速度很快,几乎可以做任何事情。 ASM 具有用于解析字节码的 SAX 和 DOM 风格的 API。 ASM还有一个 ASMifier ,它可以解析字节码并生成相应的Java源代码,运行时会产生解析后的字节码。这是一个非常宝贵的工具。期望开发人员具有一些字节码知识,但是如果您添加局部变量等,ASM 可以为您更新帧信息。它的 commons 包中还有许多用于常见任务的实用程序类。此外,还非常详细地 记录了常见的字节码转换 。您还可以从 ASM 邮件列表 获得帮助。最后,像 StackOverflow 这样的论坛提供额外的支持。几乎可以肯定,您遇到的任何问题都已在 ASM 文档或 StackOverflow 线程中进行了讨论。

有用的链接


概括

不可否认,这篇博客文章并没有特别的指导意义。目的是给初学者一个开始的地方。以我的经验,最好的学习方法是在脑海中构思一个项目,将所学知识应用到该项目中。记录一些基本的字节码工程任务只会重复其他人的工作。我对逆向工程的兴趣培养了我的字节码技能。我宁愿不记录这些技能,因为它会对我的其他努力产生反作用(我构建了一个名为 Modifly 的商业字节码混淆器,它可以在运行时执行混淆转换)。但是,我愿意通过演示如何将字节码工程应用于类重新加载和内存泄漏检测(如果有兴趣,可能还有其他领域)来分享我所学到的知识。

系列预告片中的下一篇博客

即使您不使用 JRebel ,您也可能没有逃过他们的广告。 JRebel 的主页声称“立即重新加载代码更改。跳过构建和重新部署过程。JRebel 重新加载对 Java 类、资源和 90 多个框架的更改。”。你有没有想过他们是怎么做到的?在本系列的下一篇博文中,我将向您展示他们如何使用工作代码来做到这一点。

如果您喜欢这个博客,您可能希望 在 twitter 上关注 discotek.ca