JVM 基础 (7) -- 字节码文件详解

1. 字节码文件

字节码是 JVM 的一套指令集规范,JVM 是 Java 语言跨平台的关键,而 JVM 与字节码文件具有绑定关系。简单来说:只要是字节码文件就可以在所有的 JVM 上运行,不管你是什么操作系统,而且不管是什么语言,只要你能编译成字节码文件,就可以在 JVM 上运行。

字节码文件是 Java 编译器编译 Java 源文件后的产物,是一种 8 位字节的二进制流文件,每一个类或接口都会被编译成一份独立的 .class 文件

2. 字节码文件的结构

class 文件中的信息是一项一项排列的, 每项数据都有它的固定长度, 有的占一个字节, 有的占两个字节, 还有的占四个字节或八个字节, 数据项的不同长度分别用 u1, u2, u4, u8 表示, 分别表示一种数据项在 class 文件中占据一个字节, 两个字节, 四个字节和八个字节。 可以把 u1, u2, u4, u8 看做 class 文件数据项的 “类型” 。

一个典型的字节码文件主要有十个部分(字节码文件按序排列着这些字段):

  1. MagicNumber
  2. Version
  3. Constant_pool
  4. Access_flag
  5. This_class
  6. Super_class
  7. Interfaces
  8. Fields
  9. Methods
  10. Attributes

数据结构表示如下:
在这里插入图片描述

1. magic

基本上大部分文件都有魔数,用来标识自己的文件格式。
在 class 文件开头的四个字节, 存放着 class 文件的魔数, 这个魔数用来标识该文件是一个 class 文件,他是一个固定的值: 0XCAFEBABE 。
也就是说他是判断一个文件是不是 class 格式的文件的标准, 如果开头四个字节不是 0XCAFEBABE, 那么就说明它不是 class 文件, 不能被 JVM 识别。

2. version

minor_version 和 major_version 分别表示该字节码文件的 次版本号和主版本号。
不同版本的 javac 编译器编译的 class 文件, 版本号可能不同, 而不同版本的 JVM 能识别的 class 文件的版本号也可能不同, 一般情况下, 高版本的 JVM 能识别低版本的 javac 编译器编译的 class 文件, 而低版本的 JVM 不能识别高版本的 javac 编译器编译的 class 文件。 如果使用低版本的 JVM 执行高版本的 class 文件, JVM 会抛出 java.lang.UnsupportedClassVersionError
在这里插入图片描述

3. constant_pool

常量池是 class 文件中的一项非常重要的数据。 常量池中存放了文字字符串, 常量值, 当前类的类名、字段名、方法名、各个字段和方法的描述符, 对当前类的字段和方法的引用信息, 当前类中对其他类的引用信息等等。 常量池中几乎包含类中的所有信息的描述, class 文件中的很多其他部分都是对常量池中的数据项的引用,比如后面要讲到的 this_class, super_class, field_info, attribute_info 等, 字节码指令中也存在对常量池的引用, 这个引用被当做字节码指令的一个操作数。此外,常量池中各个项也会相互引用。
常量池是一个类的结构索引,我们知道在程序中一个变量可以不断地被调用,要快速获取这个变量常用的方法就是通过索引变量。这种索引我们可以直观理解为“内存地址的虚拟”。我们把它叫做静态常量池的意思就是说这里维护着经过编译“梳理”之后的相对固定的数据索引,它是站在整个 JVM(进程)层面的共享池。
在这里插入图片描述
常量池中的数据也是一项一项的, 没有间隙的依次排放。常量池中各个数据项通过索引来访问, 有点类似数组, 只不过常量池中的第一项的索引为 1, 而不为 0, 如果 class 文件中的其他地方引用了索引为 0 的常量池项, 就说明它不引用任何常量池项。class 文件中的每一种数据项都有自己的类型, 相同的道理,常量池中的每一种数据项也有自己的类型。 常量池中的数据项的类型如下表:
在这里插入图片描述
每个数据项叫做一个 XXX_info 项,比如,一个常量池中一个 CONSTANT_Utf8 类型的项,就是一个 CONSTANT_Utf8_info 。除此之外, 每个 info 项中都有一个标志值(tag),这个标志值表明了这个常量池中的 info 项的类型是什么, 从上面的表格中可以看出,一个 CONSTANT_Utf8_info 中的 tag 值为 1,而一个 CONSTANT_Fieldref_info 中的 tag 值为 9 。

1. 字节码文件中的特殊字符串

Java 程序是动态链接的, 在动态链接的实现中, 常量池扮演者举足轻重的角色。 除了存放一些字面量之外, 常量池中还存放着以下几种符号引用:

  1. 类和接口的全限定名
  2. 字段的名称和描述符
  3. 方法的名称和描述符

在常量池中, 特殊字符串大量地出现。特殊字符串包括三种: 类的全限定名, 字段和方法的描述符, 特殊方法的方法名。

1. 类的全限定名

在常量池中, 一个类型的名字并不是我们在源文件中看到的那样, 也不是我们在源文件中使用的包名加类名的形式。 源文件中的全限定名和 class 文件中的全限定名不是相同的概念。 源文件中的全限定名是包名加类名, 包名的各个部分之间,包名和类名之间, 使用点号分割。 如 Object 类, 在源文件中的全限定名是 java.lang.Object 。 而 class 文件中的全限定名是将点号替换成 “/” 。 例如, Object 类在 class 文件中的全限定名是 java/lang/Object。 源文件中一个类的名字, 在 class 文件中是用全限定名表示的。

2. 描述符

我们知道在一个类中可以有若干个字段和方法, 那么一个字段或一个方法在 class 文件中是如何表述的呢? 答案是描述符,但是方法和字段的描述符并不会把方法和字段的所有信息全都描述出来, 毕竟描述符只是一个简单的字符串。
还有就是所有的类型在描述符中都有对应的字符或字符串。 比如, 每种基本数据类型都有一个大写字母做对应, void 也有一个大写字符做对应。 下表是 void 和基本数据类型在描述符中的对应:
在这里插入图片描述
基本类型和 void 在描述符中都有一个大写字符和他们对应, 那么引用类型(类、接口、枚举)在描述符中是如何对应的呢? 引用类型的对应字符串格式是(注意, 引用类型在描述符中使用一个字符串做对应): “L” + 类型的全限定名 + “;”

注意,这三个部分之间没有空格, 是紧密排列的。 如 Object 在描述符中的对应字符串是:Ljava/lang/Object;、 ArrayList 在描述符中的对应字符串是:Ljava/lang/ArrayList;、 自定义类型com.example.Person 在描述符中的对应字符串是:Lcom/example/Person;

我们知道, 在 Java 语言中数组也是一种类型, 一个数组的元素类型和他的维度决定了他的类型。

比如, 在 int[] a 声明中, 变量 a 的类型是 int[]、在 int[][] b 声明中, 变量 b 的类型是 int[][]、在 Object[] c 声明中, 变量 c 的类型是 Object[]

既然数组是类型, 那么在描述符中, 也应该有数组类型的对应字符串。在 class 文件的描述符中, 数组的类型中每个维度都用一个 [ 代表, 数组类型整个类型的对应字符串的格式如下:若干个“[” + 数组中元素类型的对应字符串

int[] 类型的对应字符串是:[Iint[][] 类型的对应字符串是:[[IObject[] 类型的对应字符串是:[Ljava/lang/Object;Object[][][] 类型的对应字符串是:[[[Ljava/lang/Object;

介绍完每种类型在描述符中的对应字符串, 下面就开始讲解字段和方法的描述符:
字段的描述符就是字段的类型所对应的字符或字符串。

如:int i 中, 字段 i 的描述符就是 IObject o 中, 字段 o 的描述符就是 Ljava/lang/Object;double[][] d 中, 字段 d 的描述符就是 [[D

方法的描述符比较复杂, 包括所有参数的类型列表和方法返回值。它的格式是这样的:(参数1类型 参数2类型 参数3类型 ...)返回值类型
其中, 不管是参数的类型还是返回值类型, 都是使用对应字符和对应字符串来表示的, 并且参数列表使用小括号括起来, 并且各个参数类型之间没有空格, 参数列表和返回值类型之间也没有空格。 如:
在这里插入图片描述

3. 特殊方法的方法名

首先要明确一下, 这里的特殊方法是指的类的构造方法和类型初始化方法。 构造方法就不用多说了, 至于类型的初始化方法, 对应到源码中就是静态初始化块。 也就是说, 静态初始化块, 在 class 文件中是以一个方法表述的, 这个方法同样有方法描述符和方法名。
类的构造方法的方法名使用字符串 <init> 表示, 而静态初始化方法的方法名使用字符串 <clinit> 表示。 除了这两种特殊的方法外, 其他普通方法的方法名和源文件中的方法名相同。

4. access_flag

保存了当前类的访问权限

5. this_class

保存了当前类的全局限定名在常量池里的索引

6. super_class

保存了当前类的父类的全局限定名在常量池里的索引

7. interfaces

保存了当前类实现的接口列表,包含两部分内容:interfaces_countinterfaces[interfaces_count]
interfaces_count 指的是当前类实现的接口数目,interfaces[] 是包含 interfaces_count 个接口的全局限定名的索引的数组。

8. fields

保存了当前类的成员列表,包含两部分的内容:fields_countfields[fields_count]
fields_count 是类变量和实例变量的字段的数量总和,fileds[] 是包含字段详细信息的列表。

9. methods

保存了当前类的方法列表,包含两部分的内容:methods_countmethods[methods_count]
methods_count 是该类或者接口显示定义的方法的数量,method[] 是包含方法信息的一个详细列表。

10. attributes

包含了当前类的 attributes 列表,包含两部分内容:attributes_countattributes[attributes_count]
class 文件的最后一部分是属性,它描述了该类或者接口所定义的一些属性信息。attributes_count 指的是 attributes 列表中包含的 attribute_info 的数量。
属性可以出现在 class 文件的很多地方,而不只是出现在 attributes 列表里。如果是 attributes 列表里的属性,那么它就是对整个 class 文件所对应的类或者接口的描述;如果出现在 fileds 的某一项里,那么它就是对该字段额外信息的描述;如果出现在 methods 的某一项里,那么它就是对该方法额外信息的描述。

3. 案例分析

//Hello.java 文件
public class Hello{
    private int test;
    public int test(){
          return test;
      }
  }

javac 编译:javac E:\Hello.java
得到字节码文件后用 winhex 等十六进制编辑器打开,得到:
在这里插入图片描述

1. magic

CA FE BA BE 代表该文件是一个字节码文件,我们平时区分文件类型都是通过后缀名来区分的,不过后缀名是可以随便修改的,所以仅靠后缀名不能真正区分一个文件的类型。区分文件类型的另个办法就是 magic 数字,JVM 就是通过 CA FE BA BE 来判断该文件是不是 class 文件

2. version

00 00 00 34 前两个字节 00 00是 minor_version,后两个字节 00 34 是 major_version 字段,对应的十进制值为 52,也就是说当前 class 文件的主版本号为 52,次版本号为 0
在这里插入图片描述

3. constant_pool

紧跟着 version 字段下来的两个字节是:00 12 代表常量池里包含的常量数目(constant_pool_count (u2)),因为字节码的常量池是从 1 开始计数的,这个常量池包含 17 个(0x0012-1)常量。

  1. 第一个常量 0a 00 04 00 0e
    紧接着 constant_pool_count 的第一个字节为 0a(即 tag=10)
    在这里插入图片描述
    可知,这表示的是一个 CONSTANT_Methodref 类型的数据项。CONSTANT_Methodref 的结构如下:
    在这里插入图片描述
    其中 class_index 表示该方法所属的类在常量池里的索引,name_and_type_index 表示该方法的名称和类型的索引。常量池里的变量的索引从 1 开始。那么这个 methodref 结构的数据如下:
    在这里插入图片描述

  2. 第二个常量 09 00 03 00 0F
    它的 tag 是 9,这表示的是一个 CONSTANT_Fieldref 类型的数据项,它的结构如下:
    在这里插入图片描述
    在这里插入图片描述

  3. 第三个常量 07 00 10
    它的 tag 为 7,这表示的是一个 CONSTANT_Class 类型的数据项,它的结构如下:
    在这里插入图片描述
    name_index 的值为 00 10,即指向常量池中第 16 个常量所表示的 Class 名称。

  4. 第四个常量 07 00 11
    同上,也是一个 CONSTANT_Class 类型的数据项,不过,指向的是第 17 个常量所表示的 Class 名称

  5. 第五个常量 01 00 04 74 65 73 74
    它的 tag 为 1,表示这是一个 CONSTANT_Utf8 类型的数据项,这种结构用 UTF-8 的一种变体来表示字符串,结构如下所示:
    在这里插入图片描述
    其中 length 表示该字符串的字节数,bytes 字段包含该字符串的二进制表示:
    在这里插入图片描述
    接下来的 8 个数据项都是字符串,这里就不具体分析了。

  6. 第十四个常量 0c 00 07 00 08
    它的 tag 为 12,表示这是一个 CONSTANT_NameAndType 类型的数据项,这个结构用来描述一个方法或者成员变量。具体结构如下:
    在这里插入图片描述
    其中 name_index 表示的是该变量或者方法的名称,这里的值是 00 07,表示指向第 7 个常量,即是 <init>,descriptor_index 指向该方法的描述符的引用,这里的值是 00 08,表示指向第 8 个常量,即是 ()V,由前面描述符的语法可知,这个方法是一个无参的,返回值为 void 的方法。
    综合两个字段,可以推出这个方法是 void <init>()。也即是指向这个 NameAndType 结构的 Methodref 的方法名为 void <init>(),也就是说第一个常量表示的是 void <init>() 方法,这个方法其实就是此类的默认构造方法。

  7. 第十五个常量也是一个 CONSTANT_NameAndType,表示的方法名为 int test(),第 2 个常量引用了这个 NameAndType,所以第二个常量表示的是 int test() 方法。

  8. 第 16 和 17 个常量也是字符串,可以按照前面的方法分析。

完整的常量池如下:
在这里插入图片描述
通过这样分析其实非常的累,JDK 提供了现成的工具可以直接解析此二进制文件,即 javap 工具(在 JDK 的 bin 目录下),我们通过 javap 命令来解析此 class 文件:javap -v -p -s -sysinfo -constants E:\Hello.class
在这里插入图片描述

4. access_flag (u2)

00 21 这两个字节的数据表示这个变量的访问标志位,JVM 对访问标示符的规范如下:
在这里插入图片描述
这个表里面无法直接查询到 00 21 这个值,原因是 0021 = 0020 + 0001,也就是表示当前 class 的 access_flag 是 ACC_PUBLIC|ACC_SUPER。ACC_PUBLIC 和代码里的 public 关键字相对应。ACC_SUPER 表示当用 invokespecial 指令来调用父类的方法时需要特殊处理。

5. this_class (u2)

00 03 是 this_class 指向 constant pool 的索引值,该值必须是 CONSTANT_Class_info 类型,这里是 3,即指向常量池中的第三项,即是 Hello

6. super_class (u2)

00 04 是 super_class 指向 constant pool 的索引值,它表示的是父类的名称,这里指向第四个常量,即是ava/lang/Object

7. interfaces

interfaces 包含 interfaces_count (u2)和 interfaces[] 两个字段。因为这里没有实现接口,所以就不存在 interfces 选项,所以这里的 interfaces_count 为 0(0000),所以后面的内容也对应为空。

8. fields

字段的访问标志:
在这里插入图片描述
在这里插入图片描述
每个成员变量对应一个 field_info 结构:
在这里插入图片描述
access_flags 为 00 02,即是 ACC_PRIVATE,name_index 指向常量池的第五个常量,为 test,escriptor_index 指向常量池的第 6 个常量为 I,三个字段结合起来,说明这个变量是 private int test
接下来的是 attribute 字段,用来描述该变量的属性,因为这个变量没有附加属性,所以 attributes_count 为0,attribute_info 为空。

9. methods

方法的访问标志:
在这里插入图片描述
00 02 00 01 00 07 00 08 00 01 00 09 ... 最前面的 2 个字节是 method_count (u2)。
method_count:00 02 为什么会有两个方法呢?我们明明只写了一个方法,这是因为 JVM 会自动生成一个<init> 方法,这个是类的默认构造方法。
接下来的内容是两个 method_info 结构:
在这里插入图片描述
前三个字段和 field_info 一样,可以分析出第一个方法是 public void ()
在这里插入图片描述
接下来是 attribute 字段,也即是这个方法的附加属性,这里的 attributes_count =1,也即是有一个属性。每个属性的都是一个 attribute_info 结构,如下所示:
在这里插入图片描述
JVM 预定义了部分 attribute,但是编译器自己也可以实现自己的 attribute 写入 class 文件里,供运行时使用。不同的 attribute 通过 attribute_name_index 来区分。JVM 规范里对以下 attribute 进行了预定义:
在这里插入图片描述
这里的 attribute_name_index 值为 00 09,表示指向第 9 个常量,即是 Code。Code Attribute 的作用是保存该方法的结构如所对应的字节码,具体的结构如下所示:
在这里插入图片描述
attribute_length 表示 attribute 所包含的字节数,这里为 00 00 00 1d,即是 29 个字节,不包含 attribute_name_index 和 attribute_length 字段。
max_stack 表示这个方法运行的任何时刻所能达到的操作数栈的最大深度,这里是 00 01,max_locals 表示方法执行期间创建的局部变量的数目,包含用来表示传入的参数的局部变量,这里是 00 01,接下来的 code_length 表示该方法的所包含的字节码的字节数以及具体的指令码,这里的字节码长度为 00 00 00 05,即后面的 5 个字节 2a b7 00 01 b1为对应的字节码指令的指令码。
参照下表可以将上面的指令码翻译成对应的助记符:
在这里插入图片描述
这即是该方法被调用时,虚拟机所执行的字节码。
接下来是 exception_table,这里存放的是处理异常的信息。
每个 exception_table 表项由 start_pc,end_pc,handler_pc,catch_type 组成。start_pc 和 end_pc 表示在 code 数组中的从 start_pc 到 end_pc 处(包含 start_pc,不包含 end_pc)的指令抛出的异常会由这个表项来处理,handler_pc 表示处理异常的代码的开始处,catch_type 表示会被处理的异常类型,它指向常量池里的一个异常类。当 catch_type 为 0 时,表示处理所有的异常,这个可以用来实现 finally 的功能。
不过,这段代码里没有异常处理,所以 exception_table_length 为 00 00,所以我们不做分析。
接下来是该方法的附加属性,attributes_count 为 00 01,表示有一个附加属性。
attribute_name_index 为 00 0a,指向第十个常量,为 LineNumberTable。这个属性用来表示 code 数组中的字节码和 java 代码行数之间的关系。这个属性可以用来在调试的时候定位代码执行的行数。LineNumberTable 的结构如下:
在这里插入图片描述
前面两个字段分别表示这个 attribute 的名称是 LineNumberTable 以及长度为 00 00 00 06。接下来的 00 01 表示 line_number_table_length,表示 line_number_table 有一个表项,其中 start_pc 为 00 00,line_number 为 00 00,表示第 0 行代码从 code 的第 0 个指令码开始。

后面的内容是第二个方法,具体就不再分析了。

10. attributes

最后剩下的内容是 attributes,这里的 attributes 表示整个 class 文件的附加属性,不过结构还是和前面的 attribute 保持一致。00 01 表示有一个 attribute。
attribute 结构如下:
在这里插入图片描述
attribute_name_index 为 00 0c,指向第 12 个常量,为 SourceFile,说明这个属性是 Source,attribute_length 为 00 00 00 02,sourcefile_index 为 00 0d,表示指向常量池里的第 13 个常量,为 Hello.java。
这个属性表明当前的 class 文件是从 Hello.java 文件编译而来。

4. 字节码修改技术

通过了解字节码,我们可以做些什么呢?
其实通过字节码能做很多平时我们无法完成的工作。比如,在类加载之前添加某些操作或者直接动态的生成字节。

ASM 是一个 Java 字节码操控框架。它能够以二进制形式修改已有类或者动态生成类。ASM 可以直接产生二进制 class 文件,也可以在类被加载入 Java 虚拟机之前动态改变类行为。ASM 从类文件中读入信息后,能够改变类行为,分析类信息,甚至能够根据用户要求生成新类。不过 ASM 在创建 class 字节码的过程中,操纵的级别是底层 JVM 的汇编指令级别,这要求 ASM 使用者要对 class 组织结构和 JVM 汇编指令有一定的了解。
目前字节码修改技术有 ASM,javassist,cglib,BCEL 等。cglib 就是基于封装的 ASM。Spring 就是使用 cglib 代理库。关于 cglib 的使用介绍,可以参考:CGLIB介绍与原理

Javassist 是一个开源的分析、编辑和创建 Java 字节码的类库。是由东京工业大学的数学和计算机科学系的 Shigeru Chiba (千叶 滋)所创建的。它已加入了开放源代码 JBoss 应用服务器项目,通过使用 Javassist 对字节码操作为 JBoss 实现动态 AOP 框架。javassist 是 jboss 的一个子项目,其主要的优点在于简单,而且快速。直接使用 java 编码的形式,而不需要了解虚拟机指令,就能动态改变类的结构,或者动态生成类。

参考:深入理解JVM之Java字节码(.class)文件详解

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值