Android Gradle学习(九)- java字节码指令集概述

331 阅读12分钟

一:概述

在Java中,字节码(Bytecode)是由Java编译器生成的中间代码,它是JVM(Java虚拟机)可以执行的代码。字节码指令是JVM执行引擎可以理解的一系列操作,它们代表了Java程序的各种基本操作,如算术运算、条件判断、对象创建等。由于各个平台实现了Java虚拟机,因此java具有跨平台特性。

字节码指令(Bytecode Instructions)是 JVM 执行引擎可直接识别和执行的操作指令,是 Java 字节码文件的核心内容。它们以二进制形式存储在.class 文件的 Code 属性中,用于完成数据加载、运算、对象操作、方法调用等各种操作。

二:字节码文件结构

Java 字节码文件(.class)是一种严格按照规范组织的二进制文件,其结构由《Java 虚拟机规范》明确定义。理解字节码文件结构有助于深入理解 JVM 的工作原理。

字节码文件的整体结构可概括为以下部分(按顺序排列):

2.1. 魔数(Magic Number)

  • 4 字节固定值:0xCAFEBABE(咖啡宝贝)
  • 作用:标识文件是否为有效的.class 文件,JVM 通过魔数判断文件类型

2.2. 版本号(Version)

  • 4 字节,分为 minor version(次版本号,前 2 字节)和 major version(主版本号,后 2 字节)
  • 示例:0x00000034 表示 JDK 8(主版本号 52)
  • 作用:确定该字节码文件兼容的 JVM 版本

2.3. 常量池(Constant Pool)

  • 字节码文件中最大的部分,包含类运行所需的各种常量和符号引用

  • 结构:

    • 1 字节的常量池计数器(constant_pool_count),值为常量池项数量 + 1
    • 常量池项(constant_pool):由 165535 个常量项组成,每项有特定类型(118 种)

2.4. 访问标志(Access Flags)

  • 2 字节,标识类的访问权限和属性
  • 常见标志:ACC_PUBLIC(0x0001)、ACC_FINAL(0x0010)、ACC_INTERFACE(0x0200)等
  • 示例:0x0021 表示 public final 类

2.5. 类索引、父类索引和接口索引集合

  • 类索引(this_class):2 字节,指向常量池中的类名常量
  • 父类索引(super_class):2 字节,指向常量池中的父类名常量(除 Object 类外都有值)
  • 接口索引集合:由接口计数器(interfaces_count)和接口索引表组成,存储实现的接口信息

2.6. 字段表集合(Fields)

  • 存储类的字段信息(成员变量)

  • 结构:

    • 2 字节的字段计数器(fields_count)
    • 字段表(field_info):每个字段包含访问标志、名称索引、描述符索引、属性表等

2.7. 方法表集合(Methods)

  • 存储类的方法信息

  • 结构与字段表类似:

    • 2 字节的方法计数器(methods_count)
    • 方法表(method_info):每个方法包含访问标志、名称索引、描述符索引、属性表等
    • 方法的字节码指令存储在属性表的 Code 属性中

2.8. 属性表集合(Attributes)

  • 存储类、字段、方法的附加信息

  • 常见属性:

    • Code:存储方法的字节码指令、操作数栈深度、局部变量表大小等
    • ConstantValue:用于常量字段的初始值
    • Exceptions:方法抛出的异常列表
    • LineNumberTable:字节码偏移量与源代码行号的映射(用于调试)
    • SourceFile:源文件名信息

三:字节码构建

先看一个简单的例子,新建一个Person.java

public class Person {
	
	private int age = 10;
	
	public void growUp() {
		int year = 1;
		age = age + year;
	}
}

调出命令面板,使用javac命令编译成class字节码,生成Person.class文件即为构建成功

javac Person.java

四:字节码格式

javap 提供了多个选项来控制输出内容的详细程度,常用的有:

  • -c:展示方法的字节码指令(最常用)
  • -v 或 -verbose:输出最详细的信息,包括常量池、访问标志、属性表等
  • -p 或 -private:显示所有类成员(包括 private 修饰的字段和方法),默认只显示 public 和 protected 成员。
  • -s:显示字段和方法的描述符(Descriptor),如方法参数和返回值的类型标识
  • -l:显示行号表和局部变量表信息(主要用于调试关联)。

使用javap命令查看Person.class字节码

javap -c Person

可以看到如下输出

Compiled from "Person.java"
public class Person {
  public Person();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: aload_0
       5: bipush        10
       7: putfield      #2                  // Field age:I
      10: return

  public void growUp();
    Code:
       0: iconst_1
       1: istore_1
       2: aload_0
       3: aload_0
       4: getfield      #2                  // Field age:I
       7: iload_1
       8: iadd
       9: putfield      #2                  // Field age:I
      12: return

可以看到其中有类名无参的默认构造方法以及growUp()方法,而方法体下方有Code方法体,冒号:左边的是字节码偏移量,右侧是具体的指令

五:字节码偏移量

这个数字代表了当前指令在方法字节码中的位置,以字节为单位。它主要有以下作用:

  1. 定位指令位置:用于标识每条指令在方法字节码中的具体位置,方便虚拟机执行时定位和读取指令。
  2. 异常处理和跳转:在涉及异常处理、循环跳转(如goto指令)等场景时,虚拟机需要通过偏移量来计算跳转目标。
  3. 调试信息关联:调试工具(如 IDE 的调试器)会利用偏移量将字节码指令与源代码行号关联起来,实现断点调试等功能。

Person构造方法为例

偏移量指令说明
0aload_0第一个指令,偏移量为0。读取this并入栈,指令长度为1
1invokespecial #1指令偏移量为1。#1表示读取常量池,指令长度为2,invokespecial指令长度为1,总指令长度为1+2=3
4aload_0指令偏移量为4。读取this并入栈,指令长度为1

六:指令集

指令有很多,大致分类如下

  • 加载指令(如iloadaload):将局部变量表中的数据压入操作数栈
  • 运算指令(如iaddimul):从栈顶弹出操作数,执行运算后将结果压回栈。
  • 存储指令(如istoreastore):将栈顶数据弹出并存入局部变量表。
  • 调用指令(如invokespecial):会将方法参数和返回地址等信息通过操作数栈传递。

以上面构造方法为例

指令含义
0:aload_0非静态方法,局部变量表第一个位置是this,这里表示读取this并入栈,该指令占1个字节
1: invokespecial #1#1表示常量池索引,即java/lang/Object."<init>":()V,占2个字节,invokespecial表示调用方法,这里表示调用this.<init>(),并将this出栈,该指令占1个字节
4: aload_0再次加载this入栈,占用1个字节
5: bipush 10将常量10入栈,指令和值各占用1个字节
7: putfiel #2#2为age:I,此处调用this.age=10
10: return返回指令,占用一个字节

七:操作数栈

在 JVM(Java 虚拟机)中,操作数栈(Operand Stack)  是方法执行时的一个重要内存区域,属于栈帧(Stack Frame)的一部分(每个方法调用时会创建一个栈帧,包含局部变量表、操作数栈、动态链接和返回地址)。

操作数栈的核心作用是临时存储指令执行过程中的操作数和中间结果,类似于 CPU 中的寄存器功能,支持指令之间的数据传递和运算。

7.1 操作数栈的工作方式:

  1. FILO 结构:遵循 "先进后出" 原则,指令通过入栈(push)和出栈(pop)操作来访问数据。
  2. 指令协作:大多数 JVM 指令需要通过操作数栈完成工作

7.2 局部变量表

在 JVM 中,局部变量表(Local Variable Table)  是栈帧(Stack Frame)的另一个核心组成部分,与操作数栈共同支撑着方法的执行。它主要用于存储方法执行过程中所需的局部变量,包括:

  • 方法的参数
  • 方法内部定义的局部变量(基本类型、对象引用等)

7.3 局部变量表的核心特点:

  1. 结构与索引局部变量表以变量槽(Slot)  为基本存储单元(1 个 Slot 占 4 字节),通过索引(从 0 开始)访问。

    • 实例方法中,索引 0 固定指向当前对象的引用(即this关键字)。
    • 方法参数按声明顺序依次占用后续索引(例如,第一个参数在索引 1,第二个在索引 2,依此类推)。
    • 局部变量则按定义顺序分配剩余的 Slot。

    示例:对于实例方法void foo(int a, Object b),局部变量表的初始索引分配为:0: this(当前对象)、1: a(int 类型)、2: b(对象引用)。

  2. 数据类型与 Slot 占用

    • 基本类型(booleanbytecharshortintfloatreferencereturnAddress)占用 1 个 Slot。
    • 长整型(long)和双精度浮点型(double)占用 2 个连续的 Slot(按第一个 Slot 的索引访问)。
  3. 生命周期局部变量表随栈帧的创建而分配,随栈帧的销毁而释放(方法执行结束时),因此它是线程私有的,不存在线程安全问题。

  4. 与字节码指令的交互局部变量表与操作数栈配合工作,通过特定指令完成数据传递:

    • 加载指令(如iload_<n>aload_<n>):将局部变量表中索引n的变量压入操作数栈。
    • 存储指令(如istore_<n>astore_<n>):将操作数栈顶的值弹出,存入局部变量表索引n的位置。

    示例:执行int x = 5;时:

    1. iconst_5:将常量 5 压入操作数栈 → 栈:[5]
    2. istore_1:将栈顶的 5 弹出,存入局部变量表索引 1 的位置(即变量x)。
  5. 编译期确定大小局部变量表所需的 Slot 数量在编译期就已确定(写入 class 文件的 Code 属性中),JVM 在方法调用时会根据这个数量分配内存,执行过程中不会动态改变。

八:常量池

在 Java 字节码文件(.class)中,常量池(Constant Pool)  是一个核心数据结构,用于存储类、方法、字段等相关的常量信息,是字节码文件中占比最大的部分之一。它本质上是一个 "符号表",保存了类运行时需要的各种字面量和符号引用。

8.1 常量池的主要作用:

  1. 存储常量信息:集中管理类中用到的各种常量,避免重复存储,节省空间。
  2. 符号引用载体:保存类、方法、字段的符号引用(如类名、方法名、参数类型等),在类加载的 "解析" 阶段会将这些符号引用转换为直接引用(内存地址)。
  3. 支撑字节码指令:字节码指令通过常量池索引(如#1#2)来引用常量池中的内容,例如invokespecial #1表示调用常量池索引 1 处的方法。

8.2 常量池包含的主要内容:

常量池中的元素称为 "常量项",每种常量项有特定的类型和结构,常见的包括:

  • 字面量:如字符串常量("hello")、基本类型常量(1233.14)等。
  • 类和接口的符号引用:如类的全限定名(java/lang/String)。
  • 字段的符号引用:包括字段所属类、字段名、字段描述符(如Ljava/lang/String;)。
  • 方法的符号引用:包括方法所属类、方法名、方法描述符(如(I)V表示参数为 int、返回值为 void)。
  • 接口方法的符号引用:与方法符号引用类似,但针对接口方法。
  • 其他信息:如名称和类型的描述符、方法句柄、方法类型等。

8.3 常量池的特点:

  1. 索引从 1 开始:常量池的索引值从 1 而不是 0 开始(索引 0 通常表示 "不引用任何常量")。
  2. 动态性:在类加载过程中,常量池会被解析为运行时常量池(Runtime Constant Pool),并可能在运行时动态添加新的常量(如String.intern()方法)。
  3. 内存分配:运行时常量池属于方法区(Method Area)的一部分,在 JDK 8 及以后,方法区的实现为元空间(Metaspace)。
  4. 常量池大小限制:每个.class 文件的常量池容量有限制(最多 65535 项),超过会导致编译失败。

8.4 示例理解:

在之前提到的字节码指令invokespecial #1中:

  • #1表示引用常量池索引 1 的常量项。
  • 这个常量项可能是一个 "方法符号引用",包含了要调用的方法信息(如java/lang/Object."<init>":()V,即 Object 类的无参构造方法)。
  • 在类加载时,JVM 会将这个符号引用解析为实际的方法内存地址,供执行引擎调用。

九:字节码指令执行过程

Person#growUp()为例

image.png

十:字节码查看插件

在Android Studio中有一款字节码查看插件,ASM Bytecode Outline Rebooted,只需要在编辑器页面邮件调出菜单show Bytecode Outline,即可查看java或者kotlin的字节码,ASMMified tab可以看到使用ASM生成这个类的代码。如果要确认如何修改字节码,可以通过修改源代码之后重新查看,通过前后语句对比可以知晓需要修改的内容。

image.png

image.png

十一:小结

本文旨在简单介绍字节码指令集,对此有个大致的概念,为后面修改字节码打下一个基础,若想详细学习字节码相关知识,可自行查阅相关文档。

参考

Java的 Class字节码文件结构和内容全面解析【两万字】

深入解析JVM指令手册:Java字节码操作全指南