《Android 工程师进阶》笔记4:字节码层面分析 class 类文件结构

1,246 阅读8分钟

字节码层面分析 class 类文件结构

class 的来龙去脉

Java 能够实现"一次编译,到处运行”,具有良好的跨平台能力,是因为 Java 独具匠心的提供了一种可以在所有平台上都能使用的一种中间代码——字节码类文件(.class文件)。

class 文件

如果从纵观的角度来看 class 文件,class 文件里只有两种数据结构:无符号数和表。

  • 无符号数:属于基本的数据类型,以 u1、u2、u4、u8 来分别代表 1 个字节、2 个字节、4 个字节和 8 个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者字符串(UTF-8 编码)。
  • :表是由多个无符号数或者其他表作为数据项构成的复合数据类型,class文件中所有的表都以“_info”结尾。其实,整个 Class 文件本质上就是一张表。

class 文件结构

当 JVM 加载某个 class 文件时,JVM 就是根据上图中的结构去解析 class 文件,加载 class 文件到内存中,并在内存中分配相应的空间。具体某一种结构需要占用大多空间,可以参考下图:

class 文件中的无符号数和表格就相当于人类身体中的 H、O、C、N 等元素,而 class 结构图中的各项结构就相当于人类身体的各个器官,并且这些器官的组织顺序是有严格顺序要求的。

实例分析

import java.io.Serializable;

public class Test implements Serializable, Cloneable{

      private int num = 1;

      public int add(int i) {
          int j = 10;
          num = num + i;
          return num;
     }

}

通过 javac 将其编译,生成 Test.class 字节码文件。然后使用 16 进制编辑器打开 class 文件,显示内容如下所示:

  • 魔数 magic number

如上图所示,在 class 文件开头的四个字节是 class 文件的魔数,它是一个固定的值--0XCAFEBABE。魔数是 class 文件的标志,也就是说它是判断一个文件是不是 class 格式文件的标准, 如果开头四个字节不是 0XCAFEBABE, 那么就说明它不是 class 文件, 不能被 JVM 识别或加载。

  • 版本号

紧跟在魔数后面的四个字节代表当前 class 文件的版本号。前两个字节 0000 代表次版本号(minor_version),后两个字节 0034 是主版本号(major_version),对应的十进制值为 52,也就是说当前 class 文件的主版本号为 52,次版本号为 0。所以综合版本号是 52.0,也就是 jdk1.8.0

  • 常量池(重点)

紧跟在版本号之后的是一个叫作常量池的表(cp_info)。在常量池中保存了类的各种相关信息,比如类的名称、父类的名称、类中的方法名、参数名称、参数类型等,这些信息都是以各种表的形式保存在常量池中的。

常量池中的每一项都是一个表,其项目类型共有 14 种,如下表所示: 可以看出,常量池中的每一项都会有一个 u1 大小的 tag 值。tag 值是表的标识,JVM 解析 class 文件时,通过这个值来判断当前数据结构是哪一种表。

  • CONSTANT_Class_info 表

table CONSTANT_Class_info {
    u1  tag = 7;
    u2  name_index;
}
  • tag:占用一个字节大小。比如值为 7,说明是 CONSTANT_Class_info 类型表。

  • name_index:是一个索引值,可以将它理解为一个指针,指向常量池中索引为 name_index 的常量表。比如 name_index = 2,则它指向常量池中第 2 个常量。

  • CONSTANT_Utf8_info 表

table CONSTANT_utf8_info {
    u1  tag;
    u2  length;
    u1[] bytes;
}
  • tag:值为1,表示是 CONSTANT_Utf8_info 类型表。
  • length:length 表示 u1[] 的长度,比如 length=5,则表示接下来的数据是 5 个连续的 u1 类型数据。
  • bytes:u1 类型数组,长度为上面第 2 个参数 length 的值。

在常量池内部的表中也有相互之间的引用。

class 文件在常量池的前面使用 2 个字节的容量计数器,用来代表当前类中常量池的大小。 红色框中的 001d 转化为十进制就是 29,也就是说常量计数器的值为 29。其中下标为 0 的常量被 JVM 留作其他特殊用途,因此 Test.class 中实际的常量池大小为这个计数器的值减 1,也就是 28个。 第一个常量 0a 转化为 10 进制后为 10,通过查看常量池 14 种表格图中,可以查到 tag=10 的表类型为 CONSTANT_Methodref_info,因此常量池中的第一个常量类型为方法引用表。其结构如下:

CONSTANT_Methodref_info {
    u1 tag = 10;
    u2 class_index;        指向此方法的所属类
    u2 name_type_index;    指向此方法的名称和类型
}

也就是说在“0a”之后的 2 个字节指向这个方法是属于哪个类,紧接的 2 个字节指向这个方法的名称和类型。它们的值分别是:

  • 0006:十进制 6,表示指向常量池中的第 6 个常量。
  • 0015:十进制 21,表示指向常量池中的第 21 个常量。 至此,第 1 个常量就解读完毕了。

我们可以借助 javap 命令来帮助我们查看 class 常量池中的内容:

  • 访问标志(access_flags)

紧跟在常量池之后的常量是访问标志,占用两个字节。

我们定义的 Test.java 是一个普通 Java 类,不是接口、枚举或注解。并且被 public 修饰但没有被声明为 final 和 abstract,因此它所对应的 access_flags 为 0021(0X0001 和 0X0020 相结合)。

  • 类索引、父类索引与接口索引计数器

在访问标志后的 2 个字节就是类索引,类索引后的 2 个字节就是父类索引,父类索引后的 2 个字节则是接口索引计数器。如下图所示:

从图中可以看出,第 5 个常量和第 6 个常量均为 CONSTANT_Class_info 表类型,并且代表的类分别是“Test”和“Object”。再看接口计数器,因为接口计数器的值是 2,代表这个类实现了 2 个接口。查看在接口计数器之后的 4 个字节分别为:

  • 0007:指向常量池中的第 7 个常量,从图中可以看出第 7 个常量值为"Serializable"。
  • 0008:指向常量池中的第 8 个常量,从图中可以看出第 8 个常量值为"Cloneable"。

综上所述,可以得出如下结论:当前类为 Test 继承自 Object 类,并实现了“Serializable”和“Cloneable”这两个接口。

  • 字段表

紧跟在接口索引集合后面的就是字段表了,字段表的主要功能是用来描述类或者接口中声明的变量。这里的字段包含了类级别变量以及实例变量,但是不包括方法内部声明的局部变量。

字段访问标志

  • 方法表

  • 属性表

在之前解析字段和方法的时候,在它们的具体结构中我们都能看到有一个叫作 attributes_info 的表,这就是属性表。 属性表并没有一个固定的结构,各种不同的属性只要满足以下结构即可:

CONSTANT_Attribute_info{
    u2 name_index;
    u2 attribute_length length;
    u1[] info;
}

面试题:Java 中 String 字符串的长度有限制吗?

Java String最大长度分析

  • 编译器

我们在编写源代码的时候,经常会使用 String s = "abc" 这种方式去声明字符串。这种用字面量声明的字符串在 class 字节码文件中是以 CONSTANT_urf8_info 格式存储的。

一个字符串最大长度也就是 u2 所能代表的最大值2的16次方——65536个,但是需要使用2个字节来保存 null 值,因此一个字符串的最大长度为 65536 - 2 = 65534个字节 。

注意:上述解释中说的是字符串最大长度为65534个字节,并不代表一个字符串中就可以保存65534个字符。因为在utf-8编码下,一个数字和一个英文字母占一个字节,但是一个汉字却可以占用2~4个字节。因此如果使用字面量的方式声明中文字符串的长度会远远小于65534。

  • 运行期

String 内部是以 char 数组的 value 存储的,数组的长度是 int 类型的 count,那么 String 允许的最大长度就是 Integer.MAX_VALUE(2147483647) 了。Java 中一个 char 占2个字节,也就是16位。 因此运行时大概需要约4GB的内存才能存储最大长度的字符串。

总结

实际上平时我们不太会直接用一个 16 进制编辑器去打开一个 .class 文件。我们可以使用 javap 等命令或者是其他工具,来帮助我们查看 class 内部的数据结构。只不过自己亲手操作一遍是很有助于理解 JVM 的解析过程,并加深对 class 文件结构的记忆。