深入解析“Java 字节码 ” 之 「类文件结构」

Posted by tomas家的小拨浪鼓 on November 1, 2019

深入解析“Java 字节码 ” 之 「类文件结构」

本文主要是《深入探索 JVM》系列中『字节码篇』文章,主要系统的介绍了「类文件结构」。系列文章目录见:《 深入探索 JVM 》文集


『字节码』篇文章推荐:
深入解析“Java 字节码 ” 之 「类文件结构」
深入解析“Java 字节码 ” 之 「从案例深度解读 Java 字节码」
深入解析“Java 字节码 ” 之 「进一步探究 Java 方法的字节码实现」
深入解析“Java 字节码 ” 之 「从案例解读虚拟机属性表」
深入解析“Java 字节码 ” 之 「虚拟机字节码执行引擎」
深入解析“Java 字节码 ” 之 「动态代理的实现」


一,前言

“与平台无关”的理想最终实现在操作系统的应用层上:Sun公司以及其他虚拟机提供商发布了许多可以运行在各种不同平台上的虚拟机,这些虚拟机都可以载入和执行同一种平台无关的字节码,从而实现了程序的“一次编写,到处运行”。

各种不同平台的虚拟机所有平台都统一使用的程序存储格式——字节码(ByteCode)是构成平台无关性的基石。

实现语言无关性的基础仍然是虚拟机和字节码存储格式。Java虚拟机不和包括Java在内的任何语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联。

Java语言中的各种变量、关键字和运算符号的语义最终都是由多条字节码命令组合而成的,因此字节码命令所能提供的语义描述能力肯定会比Java语言本身更加强大。因此,有一些Java语言本身无法有效支持的语言特性不代表字节码本身无法有效支持,这也为其他语言实现一些有别于Java的语言特性提供了基础。


二,Class类文件的结构

任何一个Class文件都对应着唯一一个类或接口的定义信息,但反过来说,类或接口并不一定都得定义在文件里(譬如类或接口也可以通过类加载器直接生成)。

Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在。当遇到需要占用8位字节以上空间的数据项时,则会按照高位在前(大端模式)的方式分割成若干个8位字节进行存储。

Big-Endian和Little-Endian(x86等处理器使用“Little-Endian”顺序来存储数据)的定义如下:
1) Little-Endian就是低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。
2) Big-Endian就是高位字节排放在内存的低地址端,低位字节排放在内存的高地址端。
举一个例子,比如数字0x12 34 56 78在内存中的表示形式为:
1)大端模式:

低地址 -----------------> 高地址  
0x12  |  0x34  |  0x56  |  0x78  

2)小端模式:

低地址 ------------------> 高地址
0x78  |  0x56  |  0x34  |  0x12

可见,大端模式和字符串的存储模式类似。


2.1 Java 字节码数据类型

根据Java虚拟机规范的规定,Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:
①无符号数
        无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数
        无符号数可以用来描述:
        a)数字;
        b)索引引用;
        c)数量值;
        d)者按照UTF-8编码构成字符串值。
②表
        表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张表


2.2 Java 字节码整体结构

类型 名称 数量
u4 magic(魔数) 1
u2 minor_version(次版本号) 1
u2 major_version(主版本号) 1
u2 constant_pool_count(常量个数) 1  
cp_info constant_pool(常量池表) constant_pool_count - 1  
u2 access_flags(类的访问控制权限) 1
u2 this_class(类名) 1
u2 super_class(父类名) 1
u2 interfaces_count(接口个数) 1
u2 interfaces(接口名) interfaces_count
u2 fields_count(字段个数) 1
field_info fields(字段表) fields_count
u2 methods_count(方法个数) 1
method_info methods(方法表) methods_count  
u2 attributes_count(附加属性个数) 1
attribute_info attributes(附加属性表) attributes_count
ClassFile {
    u4             magic;
    u2             minor_version;
    u2             major_version;
    u2             constant_pool_count;
    cp_info        constant_pool[constant_pool_count-1];
    u2             access_flags;
    u2             this_class;
    u2             super_class;
    u2             interfaces_count;
    u2             interfaces[interfaces_count];
    u2             fields_count;
    field_info     fields[fields_count];
    u2             methods_count;
    method_info    methods[methods_count];
    u2             attributes_count;
    attribute_info attributes[attributes_count];
}


2.2.1 魔数

每个Class文件的头4个字节称为魔数(Magic Number),魔数值为固定值:0xCAFEBABE,它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。
很多文件存储标准中都使用魔数来进行身份识别,譬如图片格式,如gif或者jpeg等在文件头中都存有魔数。使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动。

2.2.2 版本号信息

紧接着魔数的4个字节存储的是Class文件的版本号:
第5和第6个字节是次版本号(Minor Version),
第7和第8个字节是主版本号(Major Version)。

高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件,即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的Class文件。

eg:『00 00 00 34』字节码换算成十进制,表示次版本号为 0,主版本号为52(52 —— jdk8;51 —— jdk7;…)。所以,该文件的版本号为:1.8.0。可以通过 java -version 命令来验证这一点。

minor version: 0
major version: 52
➜  classes git:(master) ✗ java -version
java version "1.8.0_144”    // 144 表示更新号,比如 jdk1.8.0u144
Java(TM) SE Runtime Environment (build 1.8.0_144-b01)
Java HotSpot(TM) 64-Bit Server VM (build 25.144-b01, mixed mode)


2.2.3 常量池

紧接着主版本号之后的就是常量池入口。一个 Java 类中定义的很多信息都是由常量池来维护和描述的,可以将常量池看作是 Class 文件的资源仓库,比如说 Java 类中定义的方法与变量信息,都是存储于常量池中。

  • 常量池中主要存放两大类常量:
    ① 字面量(Literal)
            字面量比较接近于Java语言层面的常量概念,如文本字符串、声明为final的常量值等。
    ② 符号引用(Symbolic References)
            符号引用则属于编译原理方面的概念,包括了下面三类常量:
            a)类和接口的全限定名(Fully Qualified Name)
            b)字段的名称和描述符(Descriptor)
            c)方法的名称和描述符

Java代码在进行Javac编译的时候,并不像C和C++那样有“连接”这一步骤,而是在虚拟机加载Class文件的时候进行动态连接。也就是说,在Class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。

  • 常量池的总体结构:
    Java 类所对应的常量池主要由:
    ① 常量池数量;
    ② 常量池数组(常量表) ;
    这两部分共同构成。
cp_info {
    u1 tag;
    u1 info[];
}

常量池数组紧跟在常量池数量之后。
常量池数组与一般的数组不同的是,常量池数组中不同的元素的类型、结构都是不同的,长度当然也就不同(也就是说,常量池数组中存放的元素类型会是不一样的,空间大小也会是不一样的);但是,每一种元素的第一个数据都是一个 u1 类型(该字节是一个标志位,占据 1 个字节)。JVM 在解析常量池时,会根据这个 u1 类型来获取元素的具体类型。值得注意的是,「常量池数组中元素的个数」 = 「常量池个数(constant_pool_count)」 - 「1(其中 0 暂时不使用)」。
目的是满足某些常量池索引值的数据在特定情况下需要表达『不引用任何一个常量池』的含义;根本原因在于,索引为 0 也是一个常量(保留常量),只不过它不位于常量表中,这个常量就对应 null 值;所以,常量池的索引从 1 而非 0 开始。

Class文件结构中只有常量池的容量计数是从1开始,对于其他集合类型,包括接口索引集合、字段表集合、方法表集合等的容量计数都与一般习惯相同,是从0开始的。


  • 常量池中的 14 种常量项的结构总表

① 11 种就是最为经典的常量池数据类型:

类型 标志 描述
CONSTANT_Utf8_info 1 UTF-8 编码的字符串
CONSTANT_Integer_info 3 整形字面量
CONSTANT_Float_info 4 浮点型字面量  
CONSTANT_Long_info 5 长整型字面量
CONSTANT_Double_info 6 双精度浮点型字面量
CONSTANT_Class_info 7 类或接口的符号引用  
CONSTANT_String_info 8 字符串类型字面量
CONSTANT_Fieldref_info 9 字段的符号引用
CONSTANT_Methodref_info 10 类中方法的符号引用  
CONSTANT_InterfaceMethodref_info 11 接口中方法的符号引用
CONSTANT_NameAndType_info 12 字段或方法的部分符号引用

② JDK1.7 之后加进来 3 种常量池类型:

类型 标志 描述
CONSTANT_MethodHandle_info 15 表示方法句柄
CONSTANT_MethodType_info 16 标识方法类型
CONSTANT_InvokeDynamic_info 18 表示一个动态方法调用点


完整常量池表结构:


其中,CONSTANT_Utf8_info:

类型 名称 数量
u1 tag 1
u2 length 1
u1 bytes length

length值说明了这个UTF-8编码的字符串长度是多少字节,它后面紧跟着的长度为length字节的连续数据是一个使用UTF-8缩略编码表示的字符串。

UTF-8缩略编码与普通UTF-8编码的区别是:从’\u0001’到’\u007f’之间的字符(相当于1~127的ASCII码)的缩略编码使用一个字节表示,从’\u0080’到’\u07ff’之间的所有字符的缩略编码用两个字节表示,从’\u0800’到’\uffff’之间的所有字符的缩略编码就按照普通UTF-8编码规则使用三个字节表示。

顺便提一下,由于Class文件中方法、字段等都需要引用CONSTANT_Utf8_info型常量来描述名称,所以CONSTANT_Utf8_info型常量的最大长度也就是Java中方法、字段名的最大长度。而这里的最大长度就是length的最大值,既u2类型能表达的最大值65535。所以Java程序中如果定义了超过64KB英文字符的变量或方法名,将会无法编译。


2.2.4 访问标志

在常量池结束之后,紧接着的两个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final等。

标志名称 标志值 含义
ACC_PUBLIC 0x0001 是否为 public 类型
ACC_FINAL 0x0010 是否被声明为 final,只有类可设置
ACC_SUPER 0x0020 是否允许使用 invokespecial 字节码的新语意,invokespecial 指令的语意在 JDK 1.0.2 之后编译出来的类这个标志都必须为真
ACC_INTERFACE 0x0200 标识这是一个接口
ACC_ABSTRACT 0x0400 是否为 abstract 类型,对于接口或者抽象来说,此标志值为真,其他类值为假
ACC_SYNTHETIC 0x1000 标识这个类并非由用户代码产生的
ACC_ANNOTATION 0x2000 标识这是一个注解
ACC_ENUM 0x4000 标识这是一个枚举

access_flags中一共有16个标志位可以使用,当前只定义了其中8个,没有使用到的标志位要求一律为0。


2.2.5 字段表集合

字段表(field_info)用于描述接口或者类中声明的变量:
① 字段(field)包括类级变量;
② 实例级变量。
✘:但不包括在方法内部声明的局部变量。

  • 字段表结构:
field_info {
    u2             access_flags;
    u2             name_index;
    u2             descriptor_index;
    u2             attributes_count;
    attribute_info attributes[attributes_count];
}

access_flags ———— 字段访问标志:

标志名称 标志值 含义
ACC_PUBLIC 0x0001 字段是否 public
ACC_PRIVATE 0x0002 字段是否 private
ACC_PROTECTED 0x0004 字段是否 protected
ACC_STATIC 0x0008 字段是否 static
ACC_FINAL 0x0010 字段是否 final
ACC_VOLATILE 0x0040 字段是否 volatile
ACC_TRANSIENT 0x0080 字段是否 transient
ACC_SYNTHETIC 0x1000 字段是否由编译器自动产生的
ACC_ENUM 0x4000 字段是否 enum

「name_index」:对常量池的引用,代表字段的简单名称。
「descriptor_index」:对常量池的引用,代表字段的描述符。

全限定名:例,“org/fenixsoft/clazz/TestClass”是这个类的全限定名,仅仅是把类全名中的“.”替换成了“/”而已,为了使连续的多个全限定名之间不产生混淆,在使用时最后一般会加入一个“;”表示全限定名结束。

简单名称:是指没有类型和参数修饰的方法或者字段名称,这个类中的inc()方法和m字段的简单名称分别是“inc”和“m”。

字段的描述符:描述字段的数据类型
方法的描述符:方法的参数列表(包括数量、类型以及顺序)和返回值。

注意,这里字段表中指向的都是CONSTANT_Utf8_info的常量引用;而非CONSTANT_Fieldref_info类型的常量引用。CONSTANT_Fieldref_info类型的常量引用会在方法的Code中被字节码指令所引用,作为字节码指令的参数!!


  • 描述符标识字符含义
标识字符 含义
B 基本类型 byte
C 基本类型 char
D 基本类型 double
F 基本类型 float
I 基本类型 int
J 基本类型 long
S 基本类型 short
Z 基本类型 boolean
V 特殊类型 void
L 对象类型,如Ljava/lang/Object;

对象类型用字符L加对象的全限定名来表示。
对于数组类型,每一维度将使用一个前置的“[”字符来描述。如“java.lang.String[][]”被记录为“[[Ljava/lang/String;”

用描述符来描述方法时,按照先参数列表,后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号“()”之内。如,方法int indexOf(char[]source,int sourceOffset,int sourceCount,char[]target,int targetOffset,int targetCount,int fromIndex)的描述符为“([CII[CIII)I”。

字段表都包含的固定数据项目到descriptor_index为止就结束了,不过在descriptor_index之后跟随着一个属性表集合用于存储一些额外的信息,字段都可以在属性表中描述零至多项的额外信息。如,字段“final static int m=123;”,那就可能会存在一项名称为ConstantValue的属性,其值指向常量123。

字段表集合中不会列出从超类或者父接口中继承而来的字段,但有可能列出原本Java代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。另外,在Java语言中字段是无法重载的,两个字段的数据类型、修饰符不管是否相同,都必须使用不一样的名称,但是对于字节码来讲,如果两个字段的描述符不一致,那字段重名就是合法的。


2.2.6 方法表集合

  • 方法表结构
method_info {
    u2             access_flags;
    u2             name_index;
    u2             descriptor_index;
    u2             attributes_count;
    attribute_info attributes[attributes_count];
}
  • 方法访问标志
标志名称 标志值 含义
ACC_PUBLIC 0x0001 方法是否为 public
ACC_PRIVATE 0x0002 方法是否为 private
ACC_PROTECTED 0x0004 方法是否为 protected
ACC_STATIC 0x0008 方法是否为 static
ACC_FINAL 0x0010 方法是否为 final
ACC_SYNCHRONIZED 0x0020 方法是否为 synchronized
ACC_BRIDGE 0x0040 方法是否是由编译器产生的桥接方法
ACC_VARARGS 0x0080 方法是否接受不定参数
ACC_NATIVE 0x0100 方法是否为 native
ACC_ABSTRACT 0x0400 方法是否为 abstract
ACC_STRICT 0x0800 方法是否为 strictfp
ACC_SYNTHETIC 0x1000 方法是否是由编译器自动产生的

方法的定义:可以通过访问标志、名称索引、描述符索引表达清楚。
方法里的Java代码:经过编译器编译成字节码指令后,存放在方法属性表集合中一个名为“Code”的属性里面。

与字段表集合相对应的,如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息。但同样的,有可能会出现由编译器自动添加的方法,最典型的便是类构造器“<clinit>”方法和实例构造器“<init>”方法。

在Java语言中,要重载(Overload)一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名。
Java语言中的特征签名:① 方法名称;② 参数顺序; ③ 参数类型;
因此Java语言里面是无法仅仅依靠返回值的不同来对一个已有方法进行重载的。

字节码的特征签名:① 方法名称;② 参数顺序; ③ 参数类型;④ 方法返回值;⑤ 受查异常表
也就是说,如果两个方法只有返回值不同,那么也是可以合法共存于同一个Class文件中的。

注意:类索引、父类索引、接口索引。都是对应常量表中的索引!!


2.2.6 属性表集合

在Class文件、字段表、方法表都可以携带自己的属性表集合,以用于描述某些场景专有的信息。

  • 属性表结构
attribute_info {
    u2 attribute_name_index;
    u4 attribute_length;
    u1 info[attribute_length];
}

对于每个属性,它的名称需要从常量池中引用一个CONSTANT_Utf8_info类型的常量来表示,而属性值的结构则是完全自定义的,只需要通过一个u4的长度属性去说明属性值所占用的位数即可。


  • 虚拟机规范定义的属性
属性名称 使用位置 含义
Code 方法表 Java 代码编译成的字节码指令
ConstantValue 字段表 final 关键字定义的常量值
Deprecated 类、方法表、字段表 被声明为 deprecated 的方法和字段
Exceptions 方法表 方法抛出的异常
EnclosingMethod 类文件 仅当一个类为局部类或匿名类时才能拥有这个属性,这个属性用于标识这个类所在的外围方法
InnerClasses 类文件 内部类列表
LineNumberTable Code 属性 Java 源码的行号与字节码指令的对应关系
LocalVariableTable Code 属性 方法的局部变量表
StackMapTable Code 属性 JDK 1.6 中新增的属性,供新的类型检查验证器(Type Checker)检查和处理目标方法的局部变量和操作数栈所需要的类型是否匹配
Signature 类、方法表、字段表 JDK 1.5 中新增的属性,这个属性用于支持泛型情况下的方法签名,在 Java 语言中,任何类、接口、初始化方法或成员的泛型签名如果包含了类型变量(Type Variables)或参数化类型(Parameterized Types),则 Signature 属性会为它记录泛型签名信息。由于 Java 的泛型采用擦除法实现,在为了避免类型信息被擦除后导致签名混乱,需要这个属性记录泛型中的相关信息
SourceFile 类文件 记录源文件名称
SourceDebugExtension 类文件 JDK 1.6 中新增的属性,SourceDebugExtension 属性用于存储额外的调试信息。譬如在进行 JPS 文件调试时,无法通过 Java 堆栈来定位到 JSP 文件的行号,JSR-45 规范为这些非 Java 语言编写,却需要编译成字节码并运行在 Java 虚拟机中的程序提供了一个进行调试的标准机制,使用 SourceDebugExtension 属性就可以用于存储这个标准所新加入的调试信息
Synthetic 类、方法表、字段表 标识方法或字段为编译器自动生成的
LocalVariableType JDK 1.5 中新增的属性,它使用特征签名代替描述符,是为了引入泛型语法之后能描述泛型参数化类型而添加
RuntimeVisibleAnnotations 类、方法表、字段表 JDK 1.5 中新增的属性,为动态注解提供支持。RuntimeVisibleAnnotations 属性用于指明哪些注解是运行时(实际上运行时就是进行反射调用)可见的
RuntimeInvisibleAnnotations 类、方法表、字段表 JDK 1.5 中新增的属性,与 RuntimeVisibleAnnotations 属性作用刚好相反,用于指明哪些注解是运行时不可见的
RuntimeVisibleParameterAnnotations 方法表 JDK 1.5 中新增的属性,作用与 RuntimeVisibleAnnotations 属性类似,只不过作用对象为方法参数
RuntimeInvisibleParameterAnnotations 方法表 JDK 1.5 中新增的属性,作用与 RuntimeInvisibleAnnotations 属性类似,只不过作用对象为方法参数
AnnotationDefault 方法表 JDK 1.5 中新增的属性,用于记录注解类元素的默认值
BootstrapMethods 类文件 JDK 1.7 中新增的属性,用于保存 invokedynamic 指令引用的引导方法限定符


ConstantValue属性:

ConstantValue_attribute {
    u2 attribute_name_index;
    u4 attribute_length;
    u2 constantvalue_index;
}

ConstantValue属性是一个定长属性,它的attribute_length数据项值必须固定为2。constantvalue_index数据项代表了常量池中一个字面量常量的引用,根据字段类型的不同,字面量可以是CONSTANT_Long_info、CONSTANT_Float_info、CONSTANT_Double_info、CONSTANT_Integer_info、CONSTANT_String_info常量中的一种。

字段类型 常量项类型
long CONSTANT_Long
float CONSTANT_Float
double CONSTANT_Double
int, short, char, byte, boolean CONSTANT_Integer
String CONSTANT_String

ConstantValue属性的作用是通知虚拟机自动为静态变量赋值。只有被static关键字修饰的变量(类变量)才可以使用这项属性。

对于非static类型的变量(也就是实例变量)的赋值是在实例构造器<init>方法中进行的;
而对于类变量(static类型变量),则有两种方式可以选择:
① 在类构造器<clinit>方法中
目前当满足如下条件时,Sun Javac编译器选择在类构造器<clinit>方法中对类变量(static类型变量)赋值:
        a)没有被final修饰
        b)非基本类型及字符串
② 使用ConstantValue属性。
目前当满足如下条件时,Sun Javac编译器选择使用ConstantValue属性:
        a)同时使用final和static来修饰一个变量(“常量”)
        b)变量的数据类型是基本类型或者java.lang.String

虚拟机规范中并没有强制要求字段必须设置了ACC_FINAL标志,只要求了有ConstantValue属性的字段必须设置ACC_STATIC标志而已,对final关键字的要求是Javac编译器自己加入的限制。