java跨平台的实现是基于JVM虚拟机的,编写的java源码,编译后会生成一种 .class 文件,称为字节码文件。java虚拟机就是负责将字节码文件翻译成特定平台下的机器码然后运行。为了保证Class文件在多个平台的通用性,java官方制定了严格的Class文件格式。了解Class文件结构,有利于我们反编译 .class 文件或在程序编译期间修改字节码做代码注入。
首先先创建一个java类:
public class HelloWorld {
private static int num = 0;
public String name = "HelloWorld";
public static void main(String[] args) {
String[] strs = {"bigkai1", "bigkai2"};
for (int i = 0; i < 10; i++) {
num++;
if(i == 5) continue;
System.out.println("HelloWorld!");
}
}
}
复制代码
然后进去当前类目录下执行 javac 命令生成类文件:
$ javac HelloWorld.java 复制代码
我们便可以看到在java文件下生成了一个 HelloWorld.class 文件,使用类文件解析器 classpy 打开该文件,可以看到文件的整体结构:
Class文件的整体结构为:
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];
}
复制代码
我将Class文件的结构做了一个简单的图示:
在JVM中,Class文件使用的是类C语言进行描述的,统一使用无符号整数作为基本数据类型:单字节 u1 、2字节 u2 、4字节 u4 、8字节 u8 。
下面就对文件各部分一一进行解析。
魔数(Magic Number)是Class文件的标识符,它是一个4字节的整数,只有当前四个字节为 0xCAFEBABE (可以记忆为 咖啡宝贝 的英译)时,虚拟机才会认为这是一个Class文件。这种开头固定标识符的做法在很多地方用到过,比如 zip的压缩文件 。
查看我们的Class文件,是否有这个标识符:
当我人为地将 CA FE BA BE 修改为 CA FE BA BA 时,让虚拟机对类文件加载 ,虚拟机在校验文件时会抛出以下错误:
在魔数的后面,就是Class的版本号,它一共有两种:小版本号( minor_version )和大版本号( major_version )。它们组合起来表示当前Class文件是由哪个版本的JDK编译产生的。以下是截取自java官网的版本图:
对照此图,我们可以通过版本号查看对应的jdk版本:
在我的Class文件中,版本号为 0x0037 ,换算为十进制为 55 ,即对应jdk11。
对于major_version为56或以上的类文件,minor_version必须为0或65535。
对于major_version在45到55之间的类文件,minor_version可以是任何值。
当我人为的将大版本号修改为 0x0039 ,即对应jdk14版本,然后加载类文件,由于我的jdk版本是11,虚拟机只能向下兼容,所以会报错:
常量池是Class文件中内容最重要的组成之一,常量池大体分为静态常量池和运行时常量池,静态常量池存放在Class文件中,运行时常量池指的是将Class文件加载进内容后,保存了常量池的方法区。这里我们解析的是静态常量池。
静态常量池的每个表项的格式为:
cp_info {
u1 tag;
u1 info[];
}
复制代码
tag表示指示条目所表示的常量类型。共有17种常数:
我对生成的Class文件常量池第一项进行分析:
可以看出它的 tag 是 0A ,根据上表得出它是一个 CONSTANT_Methodref ,该结构为:
CONSTANT_Methodref_info {
u1 tag;
u2 class_index;
u2 name_and_type_index;
}
复制代码
然后根据它后面的 0x000C ,得出 class_index 在常量池中第12项
class_index 的值为常量池的索引,表示具有字段或方法作为成员的类或接口类型。
然后又往后读取两个字节 0x001C ,它表示常量池中字段或方法的名称和描述符的索引值。
我们从 class_index 查看它所在的类:
可以看到它的 tag 是7,指示的是 CONOSTANT_CLASS ,结构为:
CONSTANT_Class_info {
u1 tag;
u2 name_index;
}
复制代码
它的 name_index 指示的是类的名字,我们接着看 0x0028 对应的项:
可以看出它是 CONSTANT_Utf8 ,结构为:
CONSTANT_Utf8_info {
u1 tag;
u2 length;
u1 bytes[length];
}
复制代码
它的长度为 0x0010 = 16 ,所以往后一直读16个字节,得出它的名字为: java/lang/Object 。
接着我们再看它的 name_and_type_index 指向,它对应表项28:
tag =12表示这是 CONSTANT_NameAndType 类型,结构为:
CONSTANT_NameAndType_info {
u1 tag;
u2 name_index;
u2 descriptor_index;
}
复制代码
name_index 已经知道是什么意思了,我们直接来看 descriptor_index ,它的作用是表示一个有效的字段描述符或方法描述符:
不同字母对应的字段描述符为:
对于方法描述符,它有参数描述符和返回描述符,对于返回描述符,只是增加了一个 V ,它对应返回值为 void 。
在常量池后面,就是类访问标记,它是一个 u2 类型的字节,表示该类的访问信息,映射的访问修饰符如下:
每一种类型的表示都是通过设置访问标记中的特定位来表示的从图中可以看出我的Class文件为 0x0021 :
那么可以知道类的访问修饰符为 ACC_PUBLIC|ACC_SUPER ( 0x0021 = 0x0020 + x0001 )。
在访问标记的后面,就是该类的类别 this_class 、父类类别(所有的类最上层父类都是 Object ) super_class 以及实现的接口数量 interface_count 、接口类别 interface_index 。
查看我的Class文件:
该类的类别是11,父类是12,接口数量为0,然后根据索引在常量池中查找相关信息:
看到它们都是 CONSTANT_Class 类型,根据后两个字节查看它们的名字:
它们都是 CONSTANT_Utf8 类型,按照对应的结构读取之后分别是 HelloWorld , java/lang/Object 。则可知该类名字为 HelloWorld ,它的父类是 java.lang.Object ,它并没有实现接口。
在类信息后面就是字段信息,分别由字段数量( fields_count )和字段表( fields_info )组成,字段数量是一个 u2 类型,主要看下字段信息表的结构:
field_info {
u2 access_flags; // 字段访问标记
u2 name_index; // 字段名
u2 descriptor_index; // 描述符
u2 attributes_count; // 字段属性数量
attribute_info attributes[attributes_count]; // 字段属性表
}
复制代码
字段访问标记:类似类的访问标记,计算方式也和类的差不多。
字段名:指向常量池索引。
描述符:用于描述字段类型,指向常量池索引,字段的类型有:
字段属性数量:记录字段的属性个数,属性是字段的额外信息,比如初始化值、注释等。
字段属性:存放属性的具体内容。
以我生成的Class文件为例:
一共有两个字段,第一个字段字节码表示为 00 0A 00 0D 0E 00 00 ,访问标记为 ACC_PRIVATE | ACC_STATIC ( 00 0A = 00 02 + 00 08 ),字段名为 00 0D ,描述符是 00 0E ,属性数量是 00 00 。
关于属性表的内容放到方法中描述。
Class文件的方法由方法数量和方法内容两部分组成,方法数量是一个 u2 类型的数据,后面就是方法信息,方法信息的结构为:
method_info {
u2 access_flags; // 访问标记
u2 name_index; // 方法名
u2 descriptor_index; // 描述符
u2 attributes_count; // 属性数量
attribute_info attributes[attributes_count]; // 属性内容
}
复制代码
方法的访问标记就比字段的访问标记多得多了:
name_index 是方法名的索引, descriptor_index 表示方法的签名(参数、返回值等),方法描述符在常量池中的表现为 (参数1参数2)返回值 。
主要关注 attribute_info ,它的结构为:
attribute_info {
u2 attribute_name_index; // 属性名
u4 attribute_length; // 属性长度
u1 info[attribute_length]; // 属性
}
复制代码
属性有多种:
简单看一下常用的属性:
对于下面属性,有些不是运行时必须的属性,可以在Javac中分别使用 -g : none 或 -g :vars 选项来取消或要求生成这项信息。
Code属性存放方法的字节码等信息,是方法的执行主体,Code属性的结构体为:
Code_attribute {
u2 attribute_name_index; // 属性名——固定为Code
u4 attribute_length; // 属性长度(不包括前面6个字节)
u2 max_stack; // 操作数栈最大深度
u2 max_locals; // 局部变量最大个数
u4 code_length; // 方法字节码长度
u1 code[code_length]; // 字节码内容
u2 exception_table_length; // 异常处理表长度
/*
从方法字节码的start_pc偏移量开始到end_pc偏移量为止的代码中,如果遇到了catch_type所指定的异常,那么代码就跳转到handler_pc位置执行。
*/
{ u2 start_pc;
u2 end_pc;
u2 handler_pc;
u2 catch_type;
} exception_table[exception_table_length]; // 异常处理表内容
u2 attributes_count; // 属性个数
attribute_info attributes[attributes_count]; // 属性内容
}
复制代码
ConstantValue属性的作用是通知虚拟机自动为静态变量赋值。只有被 static 关键字修饰的变量(类变量)才可以使用这项属性。它的结构为:
ConstantValue_attribute {
u2 attribute_name_index; // 固定ConstantValue
u4 attribute_length; // 固定2
u2 constantvalue_index; // 常量池的有效索引
}
复制代码
如果在 field_info 结构的 access_flags 项中设置了 ACC_STATIC 标志,那么 field_info 结构所表示的字段将被赋给它的 ConstantValue 属性所表示的值,作为声明该字段的类或接口的初始化的一部分,这以操作发生在调用类或接口的类或接口初始化方法之前。
Signature是JDK1.5时发布的,出现于类、属性表和方法表结构的属性表中。任何类、接口、初始化方法或成员的泛型签名如果包含了类型变量(Type Variables)或参数化类型(Parameterized Types),则Signature属性会为他记录泛型签名信息。它的结构为:
Signature_attribute {
u2 attribute_name_index; // 固定Signature
u4 attribute_length; // 固定2
/*
如果该签名属性是类文件结构的属性,则该索引处的常量池项必须是表示类签名的常量信息结构);
如果该签名属性是方法信息结构的属性,则必须是方法签名;否则,必须是字段签名。
*/
u2 signature_index; // 常量池有效索引。
}
复制代码
之所以要专门使用这样一个属性去记录泛型类型,是因为Java语言的泛型采用的是擦除法实现的伪泛型,在字节码(Code属性)中,泛型信息编译(类型变量、参数化类型)之后都统统被擦除掉。使用擦除法的好处是实现简单(主要修改 Javac 编译器,虚拟机内部只做了很少的改动)、非常容易实现 Backport ,运行期也能够节省一些类型所占的内存空间。但坏处是运行期就无法像C#等有真泛型支持的语言那样,将泛型类型与用户定义的普通类型同等对待,例如运行期做反射时无法获得到泛型信息。 Signature 属性就是为了弥补这个缺陷而增设的,现在java的反射API能够获取泛型类型,最终的数据来源也就是这个属性。
LineNumberTable 用于记录字节码偏移量和行号的对应关系,不是运行时必须的属性,但默认生成到Class文件之中,如果选择不生成 LineNumberTable 属性,对程序运行产生的最主要的影响就是当抛出异常时,堆栈中将不会显示出错的行号,并且在调试程序的时候,也无法按照源码行来设置断点。 LineNumberTable 属性的结构体为 :
LineNumberTable_attribute {
u2 attribute_name_index; // 固定为LineNumberTable
u4 attribute_length; // 属性长度
u2 line_number_table_length; // 表项长度
{ u2 start_pc; // 字节码偏移量
u2 line_number; // 行号
} line_number_table[line_number_table_length]; // 表项内容
}
复制代码
LocalVariableTable 属性为局部变量表,不是运行时必须的属性,但默认会生成到Class文件之中如果没有生成这项属性,当前其他人引用这个方法时,所有的参数名称都将会丢失,IDE将会使用如 arg0 、 arg1 之类的占位符代替原有的参数名。它的结构体如下:
LocalVariableTable_attribute {
u2 attribute_name_index; // 固定LocalVariableTable
u4 attribute_length; // 表项长度
u2 local_variable_table_length;
{ u2 start_pc; // 字节码偏移量
u2 length; // 长度
u2 name_index; // 局部变量名
u2 descriptor_index; // 局部变量描述符
u2 index; // 局部变量在当前栈帧的局部变量表中的槽位
} local_variable_table[local_variable_table_length]; // 表项内容
}
复制代码
它是JDK1.6引入的一个属性,位于 Code 属性的属性表,该接口存在若干个栈映射帧的数据,这个属性不是运行时必需的,仅做Class的类型校验。它会在虚拟机类加载的字节码验证阶段被新类型检查验证器(Type Checker)使用,目的在于代替以前比较消耗性能的基于数据流分析的类型推导验证器。它的结构如下:
StackMapTable_attribute {
u2 attribute_name_index; // 固定StackMapTable
u4 attribute_length; // 表项长度
u2 number_of_entries; // 栈映射帧属性
stack_map_frame entries[number_of_entries]; // 栈映射帧具体内容
}
复制代码
stack_map_frame 的结构为:
union stack_map_frame {
/*
same_frame {
u1 frame_type = SAME; // 0-63
}
*/
same_frame; // 表示当前代码所在位置和上一个比较位置的局部变量表是否相同,并且操作数栈为空
/*
same_locals_1_stack_item_frame {
u1 frame_type = SAME_LOCALS_1_STACK_ITEM; // 64-127
verification_type_info stack[1];
}
*/
same_locals_1_stack_item_frame; // 表示当前帧和上一帧有相同的局部变量,并且操作数栈中变量的数量为1
/*
same_locals_1_stack_item_frame_extended {
u1 frame_type = SAME_LOCALS_1_STACK_ITEM_EXTENDED; // 247-
u2 offset_delta;
verification_type_info stack[1];
}
*/
same_locals_1_stack_item_frame_extended; // 表示当前帧和上一帧有相同的局部变量,操作数栈中变量的数量为1,并且offset_delta超过same_locals_1_stack_item_frame
/*
chop_frame {
u1 frame_type = CHOP; // 248-250
u2 offset_delta;
}
*/
chop_frame; // 表示操作数栈为空,当前局部变量表比前一帧少K(K=2510frrame_type)个局部变量
/*
same_frame_extended {
u1 frame_type = SAME_FRAME_EXTENDED; // 251-
u2 offset_delta;
}
*/
same_frame_extended; // 表示当前代码所在位置和上一个比较位置的局部变量表是否相同,并且操作数栈为空,支持的offset_delta更大
/*
append_frame {
u1 frame_type = APPEND; // 252-254
u2 offset_delta;
verification_type_info locals[frame_type - 251];
}
*/
append_frame; // 表示当前帧比上一帧多了K(K=frame_type-251)个局部变量,且操作数栈为空
/*
full_frame {
u1 frame_type = FULL_FRAME; // 255
u2 offset_delta;
u2 number_of_locals; // 局部变量表的数量
verification_type_info locals[number_of_locals]; // 局部变量表的数据类型
u2 number_of_stack_items; // 操作数栈的数量
verification_type_info stack[number_of_stack_items]; // 操作数栈的类型
}
*/
full_frame; // 完整记录了局部变量表和操作数栈
}
复制代码
除了Code属性 ,每个方法可以有一个 Exceptions 属性,用于保存该方法可能抛出的异常。它结构如下:
Exceptions_attribute {
u2 attribute_name_index; // 固定为Exceptions
u4 attribute_length; // 属性长度
u2 number_of_exceptions; // 表项数量,可能抛出的异常数
u2 exception_index_table[number_of_exceptions]; // 存储了所有异常,每一项为执行常量池的一个索引
}
复制代码
注意:方法的Exceptions表示一个方法可能抛出的异常,通常是由 throws 关键字指定的,而 Code 内的异常表是异常处理机制,由 try-catch 语句生成的。
对我生成的Class文件方法部分做一个简单的分析:
对于 main 方法,可以看到它的访问标识为 00 09 (ACC_STATIC | ACC__PUBLIC),name_index对应常量池的 00 15 ,描述符对应常量池的 00 16 ,属性个数为 00 01 ,然后查看它的属性表:
可以根据前面的内容进行一一对照,最大操作栈数量为4,最大局部变量数量为54,方法内长度为54, code 里面存放的是让虚拟机执行的指令,下面我们主要对它的 code 内容进行分析,在此之前,要先了解虚拟机中的一些指令。
JVM的指令集有很多,大体可以分为:
-1,0,1,2,3,4,5 推送到栈顶,对于int型,其他的数值使用push系列命令。 对于const系列命令和push系列命令操作范围之外的数值类型常量,以及所有不是通过new创建的String,都放在常量池中。 i,f,l,d,y 可能是 i,f,l,d,c,s,b 。 具体的指令集命令可以查看该博客: CSDN JVM指令集整理 。
了解了字节码相关指令后,来对生成的Class文件进行一次实战吧:
实际的代码为:
private static int num = 0;
public static void main(String[] args) {
String[] strs = {"bigkai1", "bigkai2"};
for (int i = 0; i < 10; i++) {
num++;
if(i == 5) continue;
System.out.println("HelloWorld!");
}
}
复制代码
首先用 iconst_2 存入一个 int 类型的2到栈顶,接着 anewarray 创建一个数组的引用并推送到栈顶(栈顶元素出栈作为数组长度),使用 dup 复制栈顶数值并将复制值压入栈顶,接着 iconst_0 将 int 类型的0入栈,使用 ldc 将 String 型常量值从常量池中推送至栈顶,此处指向的是常量池中的 05 —— bigkai1 ,调用 aastore 将栈顶引用型数值存入指定数组的指定索引位置,它是根据栈顶的引用数值、数组下标、数组引用出栈,将数值存入对应的数组元素。此时就将第一个元素 bigkai1 存入字符串数组 strs 。
接着调用 dup 将栈顶元素复制并再次压入栈顶,然后 iconst_1 压入 int 类型的1, ldc 从常量池中取出 bigkai2 ,接着调用 aastore 弹出栈的两个值,给数组的第二个元素赋值为 bigkai2 ,此时完成了给字符串数组的所有赋值。
接着进入 for 方法, iconst_0 压入 int 类型的0,然后 istore_2 将栈顶 int 型数值存入第2个本地变量, iload_2 将第2个 int 型本地变量推送至栈顶, bipush 将单字节的常量值( -128~127 )推送至栈顶, if_icmpge 比较栈顶两int型数值大小,当结果大于等于0时跳转到第53个指令(第53个执行是 return ,即结束函数,返回返回值),接着调用 getstatic 获取指定类的静态域(从常量池中获取 num )并将其值压入栈顶, iconst_1 压入 int 类型的1。 iadd 将栈顶两int型数值相加并将结果压入栈顶,然后 putstatic 为 num 赋值,此时是将 num++ 执行完毕。
iload_2 将第2个 int 型本地变量推送至栈顶(将 i=0 推送),然后 iconst_5 压入5,使用 if_icmpne 比较栈顶两int型数值大小,当结果不等于0时跳转到第39条指令,否则调用 goto 跳转到第47条指令。第47条指令是 iinc ,是将指定的 int 型变量增加指定值,需要两个变量,分别表示 index , const , index 指第 index 个 int 型本地变量, const 表示增加的值,然后又 goto 到第17条指令。此处是实现 if(i == 5) continue 。
如果第33条指令 if_icmpne 不等于0,就跳转到第39条指令,第39条指令是 getstatic ,它获取的是常量池中的 java/lang/System.out ,然后执行 ldc ,将 HelloWorld! 压入栈顶,调用 invokevirtual 指令,它的作用是调用实例方法,根据对象的实际类型进行派发,支持多态。此处是实现 System.out.println("HelloWorld!") 。
Class文件也自带一些属性,由属性长度和属性内容组成,主要的属性有:
SourceFile 属性用于描述当前这个Class文件是由哪个源代码文件编译出来的。
SourceFile_attribute {
u2 attribute_name_index; // 固定SourceFile
u4 attribute_length; // 属性长度,固定为2
u2 sourcefile_index; // 源代码文件名,指向常量池索引
}
复制代码
BootstrapMethods 属性用于支持 invokeDynamic 指令,它是描述和保存引导方法。
invokeDynamic是JDK1.7支持动态类型语言开发的指令,所谓动态类型语言就是它的类型检查的主体过程是在运行期而不是编译期,典型的代表语言就是 Python 。
引导方法可以简单地理解为一个查找方法的方法。
BootstrapMethods_attribute {
u2 attribute_name_index; // 固定BootstrapMethods
u4 attribute_length; // 属性总长度(不包含前6个字节)
u2 num_bootstrap_methods; // 这个类中抱哈的引导方法的个数
{ u2 bootstrap_method_ref; // 指明函数
u2 num_bootstrap_arguments; // 指明引导方法的参数个数
u2 bootstrap_arguments[num_bootstrap_arguments]; // 引导方法的参数
} bootstrap_methods[num_bootstrap_methods];
}
复制代码
它用来描述外部类和内部类之间的关系:
InnerClasses_attribute {
u2 attribute_name_index; // 固定InnerClasses
u4 attribute_length; // 属性长度
u2 number_of_classes; // 内部类格式
{ u2 inner_class_info_index; // 内部类类型
u2 outer_class_info_index; // 外部类类型
u2 inner_name_index; // 内部类名称
u2 inner_class_access_flags; // 内部类访问标识符
} classes[number_of_classes]; // 内部类内容
}
复制代码
内部类的访问标识符支持以下:
Deprecated 可用于类、方法、字段等结构中,表示该类、方法、字段将在未来版本中被弃用。它的结构如下:
Deprecated_attribute {
u2 attribute_name_index; // 固定Deprecated
u4 attribute_length; // 固定为0
}
复制代码
当一个类、方法、字段被标记为 Deprecated 时,就会产生这个属性。
只要遵循Class文件的规范,通过Class文件,各种语言都可以由源代码被编译成Class文件,并最终得以在虚拟机上执行。