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

4,129 阅读8分钟

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

【答案】String 的长度是有限制的。

  • 编译器的限制:字符串的 UTF-8 编码值的字节数不能超过 65535,字符串的长度不能超过 65534
  • 运行时的限制:字符串的长度不能超过 2^31-1,占用的内存数不能超过虚拟机能够提供的最大值。
  • 长度为 2^31-1 的字符串所占用的空间大小为:4G。

Java 提供了一种在所有平台上都能使用的一种中间代码--字节码类文件(*.class文件)

  • 有了字节码,无论哪种平台只要安装了虚拟机都可以直接运行字节码
  • 有了字节码,解除了 Java 虚拟机和 Java 语言之间的耦合

一、Class中的数据结构

从纵观角度看,class 文件里只有两种数据结构:无符号数

【无符号数】

  • 属于基本的数据类型。
  • 以 u1、u2、u4、u8 来分别代表 1 个字节、2 个字节、4 个字节和 8 个字节的无符号数。
  • 无符号数可以用来描述数字、索引引用、数量值或字符串(UTF-8编码)。

【表】

  • 表是有多个无符号数或其他表作为数据项构成的复合数据类型。
  • class 文件中所有的表都以 “_info” 结尾。
  • 整个 Class 文件本质上就是一张表。

表和无符号之间的关系图

表和无符号之间的关系

可用下面的伪代码表示

// 无符号数
byte[] u1 = new byte[1];
byte[] u2 = new byte[2];
byte[] u4 = new byte[4];
byte[] u8 = new byte[8];

// 表
class _table {
    // 表中可以引用无符号数
    u1 tag;
    u4 index;
    
    // 表中可以引用其他表
    method_info table;
}

二、Class文件结构

无符号数和表组成了 class 中的各个结构。

这些结构按照 预先规定好的顺序 紧密的从前向后排列,相邻的项之间没有任何间隙。

class 文件结构如下

魔数版本号常量池访问标识类/父类/接口字段描述集合字段描述集合属性描述集合

当 JVM 加载某个 class 文件时,JVM 就是根据上图的结构进行解析 class 文件到内存中,并在内存中分配相应的空间。

每种结构所占用的空间大小如下表:

字段名称数据类型数量
magic number魔数u41
major version主版本号u21
minor version副版本号u21
constant_pool_count常量池大小u21
constant_pool常量池cp_infocountant_pool_count - 1
access_flag访问标识u21
this_class当前类索引u21
super_class父类索引u21
interfaces_count接口索引集合大小u21
interfaces接口索引集合u2interfaces_count
fields_count字段索引集合大小u21
fields字段索引集合field_infofields_count
methods_count方法索引集合大小u21
methods方法索引集合method_infomethods_count
attributes_count属性索引集合大小u21
attributes属性索引集合attribute_infoattributes_count

示例

public class ClassHexNormal implements Serializable, Cloneable {

    private int num = 1;

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

将上述代码编译成 .class 文件,使用 16 进制编辑器打开:

16进制字节码文件

下面我们通过上图来一步步解析字节码文件:

1、魔数 magic numebr

魔数

在 class 文件开头的四个字节是 class 文件的魔数,它是一个固定值 0XCAFEBABE

魔数是 class 文件的标志,它是判断一个文件是不是 class 格式文件的标准。

2、版本号

版本号

前两个字节 0000 代表 次版本号 minor_version。后两个字节 0034 是 主版本号 major_version,对应的十进制值为 52。

所以当前 class 文件的主版本号为 52,次版本号为 0,所以综合版本号是 52.0,也就是 jdk1.8.0。

3、常量池(重点)

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

常量池中的每一项都是一个表,其项目类型共有14种:

表名标识位描述
CONSTANT_uft8_info1UTF-8编码字符串表
CONSTANT_Integer_info3整型常量表
CONSTANT_Float_info4浮点常量表
CONSTANT_Long_info5长整型常量表
CONSTANT_Double_info6双精度浮点型常量表
CONSTANT_Class_info7类、接口引用表
CONSTANT_String_info8字符串常量表
CONSTANT_Fieldref_info9字段引用表
CONSTANT_Methodref_info10类的方法引用表
CONSTANT_InterfaceMethodref_info11接口的方法引用表
CONSTANT_NameAndType_info12字段或方法的名称和类型表
CONSTANT_MethodHandle_info15方法句柄表
CONSTANT_MethodType_info16方法类型表
CONSTANT_InvokeDynamic_info18动态方法调用表

以 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_Uft8_info 表为例:

table CONSTANT_uft8_info {
    u1 tag;
    u2 length;
    u1[] bytes;
}

【tag】值为 1,表示 CONSTANT_Utf8_info 类型表。

【length】表示 u1[] 的长度,比如 length=5,则表示接下来的数据是 5 个连续的 u1 类型数据。

【bytes】u1 类型数组,长度为上面第 2 个参数 length 的值。

【注意】

在 java 代码中声明的 String 字符串最终在 class 文件中的存储格式是 CONSTANT_utf8_info。因此一个字符串最大长度也就是 u2 所能表达的最大值 65536 个。但是需要使用2个字节来保存 null 值,因此一个字符串的最大长度为 65536-2 = 65534。

常量池内部的表中也有表与表之间的相互引用,如下图:

表与表之间的关系

16进制中的常量池大小

常量池

class 文件在常量池的前面使用 2个字节 的容量计数器,用来代表当前类中常量池的大小。

上图中 0017 转化为十进制是 29,也就是说常量计数器的值为 23。其中下标为 0 的常量被 JVM 留作其他特殊用途,因此当前 class 中实际的常量池大小为这个计数器的值减 1,也就是 22 个。

常量池第一个常量

常量池第一个参数

上图中 0A 转化为10进制后为 10。说明对应常量池 14 种表格图中的 10,也就是 CONSTANT_Methodref_info 表(类的方法引用表)。所以常量池中的第一个常量类型为 方法引用表

该方法的表结构如下:

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

也就是说 0A 之后的 2 个字节指向此方法的所属类,在之后的 2 个字节表示该方法的名称和类型。如下图:

方法的类、名称、类型

【0004】十进制是 4,指向常量池中的第 4 个常量。

【0011】十进制是 17,指向常量池中的第 17 个常量。

这里只解析了第一个常量,后面还有 21 个常量,也是与上面说的类似,第一个参数对应 14 种类型的下标,之后再看具体的表结构;如果u1表示一个字节,u2 表示后面 2 个字节,以此类推。

借助 javap 命令

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

javap -v Test.class
// 借助命令查看22个常量
Constant pool:
    // 下标为1的指向 下标为4 和 下标为 17的常量
   #1 = Methodref          #4.#17         // java/lang/Object."<init>":()V
   // 下标为2的指向下标为3和18的常量
   #2 = Fieldref           #3.#18         // ClassHexNormal.num:I
   #3 = Class              #19            // ClassHexNormal
   #4 = Class              #20            // java/lang/Object
   #5 = Class              #21            // java/io/Serializable
   #6 = Class              #22            // java/lang/Cloneable
   #7 = Utf8               num
   #8 = Utf8               I
   #9 = Utf8               <init>
  #10 = Utf8               ()V
  #11 = Utf8               Code
  #12 = Utf8               LineNumberTable
  #13 = Utf8               add
  #14 = Utf8               (I)I
  #15 = Utf8               SourceFile
  #16 = Utf8               ClassHexNormal.java
  #17 = NameAndType        #9:#10         // "<init>":()V
  #18 = NameAndType        #7:#8          // num:I
  #19 = Utf8               ClassHexNormal
  #20 = Utf8               java/lang/Object
  #21 = Utf8               java/io/Serializable
  #22 = Utf8               java/lang/Cloneable

有上可知,下标为 1 的常量表示 Object()方法。

4、访问标志 access_flags

紧跟在常量池之后的常量时访问标志,占用两个字节。访问标志代表类或接口的访问信息

比如:该 class 文件是类还是接口,是否被定义成 public,是否是 abstract,如果是了是否被声明成 final 等。

访问标志如下:

访问标志描述
ACC_PUBLIC0x0001public类型
ACC_FINAL0x0010被声明为final类型的类
ACC_SUPER0x0020是否允许使用invokespecial字节码指令的新语义,JDK1.0.2之后编译出来的类的这个标志默认为真
ACC_INTERFACE0x0200标志这是一个接口类型
ACC_ABSTRACT0x0400抽象类或接口类型
ACC_ANNOTATION0x2000注解
ACC_ENUM0x4000枚举

上面定义的类 ClassHexNormal.java 是一个普通 Java 类,不是接口、枚举、注解。并且被 public 修饰,但没有被声明为 final 和 abstract,因此它对应的 access_flags 为 0021 (0x0001和0x0020结合)

5、类索引、父类索引、接口索引计数器

标志后的2个字节是 类索引;类索引后的2个字节是 父类索引;父类索引后的2个字节是 接口索引计数器

6、字段表

紧跟在接口索引结合后面的就是字段表;字段表的主要功能是用来 描述类或接口中声明的变量

这里的字段包含类级别变量以及实例变量,不包括方法内部声明的局部变量。

【注意事项】

  1. 字段表集合中不会列出从父类或者父接口中继承而来的字段。
  2. 内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。
7、方法表

字段表之后跟着的就是 方法表常量方法表常量 也是以一个计数器开始的,因为一个类中的方法数量是不固定的。

后面数据依次类推,这里不再举例说明。