注:书中第五章-调优案例分析与实战,包含几个案例分析和eclipse调优,但是其中的例子很简单,就先不整理到笔记里了
目录
一、无关性
实现语言无关性的基础是虚拟机和字节码存储格式,Java虚拟机不和包括Java在内的任何语言绑定,它只与class文件这种特定的二进制文件格式所关联,Java虚拟机不关心class的来源是何种语言。比如Groovy、Scala等语言都能产出符合规范的class文件:Java虚拟机规范要求在class文件中使用许多强制性的语法和结构化约束。
二、Class类文件结构
class文件是一组以8位bit(1字节)为基础单位的二进制流,各个数据项目严格按照顺序紧凑的排列在class文件之中,中间没有任何分隔符。当遇到需要占用1字节以上空间的数据项时,则会按照高位在前的方式分割成若干个1字节进行存储。
其中包含两种数据类型:无符号数和表。
-
无符号数:基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
-
表:由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以"_info"结尾。表用于描述有层次关系的复合结构的数据,整个class文件本质上就是一张表。
class文件主要由以下数据项构成(按顺序排列)
类型 | 名称 | 含义 | 数量 |
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 |
注:表格中将属性表排列在最后,但实际上,类、字段表、方法表都可能包含对应的属性表,属性表并不是一个单独的部分。
2.1 魔数与class文件版本
每个class文件的头4个字节称为魔数(Magic Number),它唯一的作用是确定这个文件是否为一个能被虚拟机接受的class文件,固定为0xCAFEBABE。
跟着魔数的4个字节存储class文件的版本号,第5、6字节为次版本号,第7、8字节为主版本号。Java的版本号从45开始,高版本的JDK能向下兼容低版本的class文件,但不能运行更高版本的class文件,即使文件格式并没有发生任何变化,虚拟机也必须拒绝执行超过其版本号的class文件。
2.2 常量池
紧跟着次主版本号的是常量池入口,常量池可以理解为class文件中的资源仓库。每个class的常量池大小都可能不一样,所以在常量池入口需要放置一项u2类型的数据,代表常量池容量计数器。注意,这个容量计数器是从1开始的,而不是0。也就是说,如果常量池容量为0x0016,也就是十进制的22,就代表常量池中有21项常量,索引值范围为1~21。将第0项空出来的目的在于满足后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义。class文件结构中只有常量池的容量计数器是从1开始的,其它都从0开始。
常量池中主要存放两大类常量:
- 字面量:比较接近于Java语言层面的常量概念,如文本字符串、final修饰的常量值等。
- 符号引用:属于编译原理方面的概念,包括了类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。
Java代码在编译的时候,不像c、c++那样有“连接”的步骤,而是在JVM加载class文件的时候动态进行连接。在class文件中不会保存方法、字段的最终内存布局信息,当JVM运行时,需要从常量池获得对应的符号引用,再在类创建或运行时解析、翻译到具体的内存中。
常量池中的每一项常量都是一个表,在JDK1.7之前有11种结构不同的表结构,在JDK1.7中又新增了3种。这14种表都有一个共同点:表开始的第一位是一个u1类型的标志位,代表当前这个常量属于哪种常量类型。这14中常量类型所代表的具体含义见下表:
常量 | 项目(结构) | 类型 | 描述 |
CONSTANT_Utf8_info | tag | u1 | 值为1 |
length | u2 | UTF-8编码字符串占用的字节数 | |
bytes | u1 | 长度为length的UTF-8编码字符串 | |
CONSTANT_Integer_info | tag | u1 | 值为3 |
bytes | u4 | 按照高位在前存储的int值 | |
CONSTANT_Float_info | u1 | 值为4 | |
tag | |||
bytes | u4 | 按照高位在前存储的float值 | |
CONSTANT_Long_info | tag | u1 | 值为5 |
bytes | u8 | 按照高位在前存储的long值 | |
CONSTANT_Double_info | tag | u1 | 值为6 |
bytes | u8 | 按照高位在前存储的double值 | |
CONSTANT_Class_info | tag | u1 | 值为7 |
index | u2 | 指向全限定名常量项的索引 | |
CONSTANT_String_info | tag | u1 | 值为8 |
index | u2 | 指向字符串字面量的索引 | |
CONSTANT_Fieldref_info | tag | u1 | 值为9 |
index | u2 | 指向声明字段的类或接口描述符CONSTANT_Class_info的索引项 | |
index | u2 | 指向字段描述符CONSTANT_NameAndType的索引项 | |
CONSTANT_Methodref_info | tag | u1 | 值为10 |
index | u2 | 指向声明方法的类描述符CONSTANT_Class_info的索引项 | |
index | u2 | 指向名称及类型描述符CONSTANT_NameAndType的索引项 | |
CONSTANT_InterfaceMethodref_info | tag | u1 | 值为11 |
index | u2 | 指向声明方法的接口描述符CONSTANT_Class_info的索引项 | |
index | u2 | 指向名称及类型描述符CONSTANT_NameAndType的索引项 | |
CONSTANT_NameAndType_info | tag | u1 | 值为12 |
index | u2 | 指向该字段或方法名称常量项的索引 | |
index | u2 | 指向该字段或方法描述符常量项的索引 | |
CONSTANT_MethodHandle_info | tag | u1 | 值为15 |
reference_kind | u2 | 范围[1,9],它决定了方法句柄的类型。方法句柄类型的值表示方法句柄的字节码行为 | |
reference_index | u2 | 值必须是对常量池的有效索引 | |
CONSTANT_MethodType_info | tag | u1 | 值为16 |
descriptor_index | u2 | 值必须是对常量池的有效索引,常量池在该索引处的项必须是CONSTANT_Utf8_info结构,表示方法描述符 | |
CONSTANT_InvokeDynamic_info | tag | u1 | 值为18 |
bootstrap_method_attr_index | u2 | 值必须是对当前class文件中引导方发表的bootstrap_methods[]数组的有效索引 | |
name_and_type_index | u2 | 值必须是对当前常量池的有效索引,常量池在该索引处的项必须是CONSTANT_NameAndType_info结构,表示方法名和方法描述符 |
有了上表的项目类型定义,我们就可以分析字节码了,以下面的字节码文件为例:
我们跳过前8个字节的魔数+次主版本号,获取2个字节的常量池大小:0x002c,即十进制的44,这代表常量池中有43项常量。然后看第一项常量的tag,值为0x0a,即十进制的10,在上表中找到tag为10的常量池项目,发现为:CONSTANT_Methodref_info,其包含两个u2类型的index:0x0006和0x001e,即十进制的6和30,代表的也是常量池中的位置,这里中间隔了几个常量项,就不继续分析了。
javap
JDK专门提供了用于分析class文件的工具:javap,使用javap分析该字节码(只列出了常量池部分),可以看到,和我们分析的第一项常量是一样的:
2.3 访问标记
常量池结束后,紧接着的两个字节代表访问标记(access_flags),这个标记用于标识一些类或者接口层次的访问信息,包括:
标志名称 | 标志值 | 含义 |
ACC_PUBLIC | 0x0001 | 是否为public |
ACC_FINAL | 0x0010 | 是否被声明为final |
ACC_SUPER | 0x0020 | 是否允许使用invokespecial字节码指令的新语意。该指令的语意在JDK1.2发生过改变,为了区别这条指令使用哪种语意,JDK1.0.2之后编译的字节码这个标志都必须为真 |
ACC_INTERFACE | 0x0200 | 标识是一个接口 |
ACC_ABSTRACT | 0x0400 | 是否为abstract类型,对于接口或抽象类来说,此标志为真,其它都为假 |
ACC_SYSTHETIC | 0x1000 | 标识这个类并非由用户代码产生 |
ACC_ANNOTATION | 0x2000 | 标识是一个注解 |
ACC_ENUM | 0x4000 | 标识是一个枚举 |
access_flags占两个字节,一共16位,所以有16个标志位可以使用,当前只定义了其中8个,没有使用到的标志位要求一律为0。如果多个标志同时存在,那么使用按位或(|)运算组合,判断标志位时,使用按位与(&),判断结果是否大于0即可。
2.4 类索引、父类索引与接口索引集合
类索引和父类索引都是一个u2类型的数据,而接口索引是一组u2类型的数据的集合,class文件中由这三项数据来确定类的继承关系。
- 类索引:用于确定这个类的全限定名,指向一个类型为CONSTANT_Class_info的类描述符常量,通过该常量中的索引值可以找到定义在CONSTANT_Utf8_info类型的常量中的全限定名字符串。
- 父类索引:用于确定这个类的父类的全限定名,由于Java不允许多继承,所以父类索引只有一个,指向一个类型为CONSTANT_Class_info的类描述符常量,同类索引。
- 接口索引集合:描述这个类实现了哪些接口,这些被实现的接口将按照implements语句(如果类本身就是一个接口,那应当是extends语句)后的接口顺序从左到右排列在接口索引集合中。由于接口可能存在多个,所以接口索引集合的入口是一个u2类型的接口计数器,表示接口数量,如果该类没有实现任何接口,那么该计数器为0,如果计数器为0,那么后面接口的索引表不占用任何字节。
2.5 字段表集合
字段表用于描述接口或者类中声明的变量。字段包括类级变量和实例级变量,但不包括在方法内部声明的局部变量。一个字段可以包含的信息有:
-
字段的作用域:public private等
-
实例变量还是类变量:static修饰符
-
可变性:final修饰符
-
并发可见性:volatile修饰符
-
可否被序列化:transient修饰符
-
字段数据类型:基本类型、对象、数组
-
字段名称
上述信息中,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合用标志位来表示,参考前面的访问标记。而字段名称、字段数据类型都是无法固定的,只能引用常量池中的常量来描述。字段表结构如下:
名称 | 类型 | 数量 |
access_flags | u2 | 1 |
name_index | u2 | 1 |
descriptor_index | u2 | 1 |
attribute_count | u2 | 1 |
attributes | attribute_info | attributes_count |
其中的access_flags和前文中的类访问标记类似,这里就不提了。跟着access_flags的name_index和descriptor_index分别代表字段的简单名称以及字段和方法的描述符。
注:
简单名称:指没有类型和参数修饰的方法或字段名称,比如void say()方法和int i字段的简单名称分别为"say"和"i"。
描述符:描述符用来描述字段的数据类型、方法的参数列表和返回值。根据描述符规则,基本数据类型以及void都使用对应名称的首字母大写表示,而对象类型使用字符”L“加对象的全限定名表示,数组的每一维度都是用一个前置的”[“字符来描述。如一个定义为"java.lang.String[][]"类型的二维数组,将被记录为:"[[Ljava/lang/String","int[]"被记录为:"[I"。全限定名:类似com/loren/test/MainClass,就是把类全名中的“.”替换成了”/“,为了使连续多个全限定名之间不产生混淆,使用时最后会加入一个”;“表示全限定名结束。
字段表包含的固定数据项目到descriptor_index就结束了,不过在descriptor_index之后跟随着一个属性表集合用于存储一些额外的信息。比如对于声明:”final static int m = 123;“那就可能会存在一项名为ConstantValue的属性,其值指向常量123。
2.6 方法表集合
方法表的结构和前面字段表一样,依次包含了访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)几项。
相对于字段来说,方法表的访问标志中没有了ACC_VOLATILE标志和ACC_TRANSIENT标志。与之相对的,新增了ACC_SYNCHRONIZED、ACC_NATIVE、ACC_STRICTFP和ACC_ABSTRACT标志。
这里要注意,方法里的Java代码,经过编译器编译成字节码指令后,存放在方法属性表集合中一个名为"Code"的属性里面。
与字段表集合相对应的,如果父类方法在子类中没有被重写,那么方法表集合中就不会出现来自父类的方法信息,同样的,有可能会出现编译器自动添加的方法,最典型的就是类构造器"<clinit>"和实例构造器"<init>"方法。
注:在Java语言层面,方法的重载除了要与原方法有相同的简单名称外,还要求必须拥有一个与原方法不同的特征签名:方法中各个参数在常量池中的字段符号引用的集合,也就是因为返回值不包含在特征签名中,因此Java语言无法仅仅依靠返回值不同来对一个已有方法进行重载。但是在class字节码层面,只要描述符不完全一致,两个方法就能共存,也就是只要返回值不同的两个方法都能共存于一个class文件中。
2.7 属性表集合
前文提到过几次,属性表不是单独的一部分,而是由class文件、字段表、方法表等携带,以描述某些场景专有的信息。属性表集合没有那么严格的限制,只要不与已有属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java虚拟机运行时会忽略掉它不认识的属性。在<<Java虚拟机规范(Java SE 7)>>版中,预定义了21项属性,这里简单举几个例子(属性表中的属性太多,处于篇幅考虑,这里就以Code属性详细说明,其他属性就进行简单总结):
2.7.1 Code
Code属性出现在方法表的属性集合中,用于存储方法体中的代码(字节码指令),但是像接口、抽象类中的方法就不存在Code属性。Code属性表的结构如下:
类型 | 名称 | 数量 |
u2 | attribute_name_index | 1 |
u4 | attribute_length | 1 |
u2 | max_stack | 1 |
u2 | max_locals | 1 |
u4 | code_length | 1 |
u1 | code | code_length |
u2 | exception_table_length | 1 |
exception_info | exception_table | exception_table_length |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
其中,attribute_name_index是一项指向CONSTANT_Utf8_info型常量的索引,固定值为“Code”,代表了该属性的名称。attribute_length指示了属性值的长度,由于属性名称索引和属性长度一共占6个字节,所以属性值的长度固定为这个属性的属性表长度减去6个字节。由于属性的结构可以完全自定义,所以通过attribute_length说明属性值占的长度即可,根据长度将属性值读取出来,再根据attribute_name_index确定到底该如何解析。也就是如果要跳过一个属性,那么就是跳过2+4+attribute_length个字节。
max_stack表示操作数栈的最大深度,在方法执行的任意时刻,操作数栈都不会超过这个深度。虚拟机运行时需要根据这个值来分配栈帧中的操作数栈深度。
max_locals代表了局部变量表所需要的存储空间,单位是Slot,Slot是虚拟机为局部变量分配内存所使用的的最小单位,对于不超过32位的数据类型,占用1个Slot,对于long和double这两种64位的数据类型需要2个Slot。另外,局部变量表中的Slot可以重用,当代码执行超出一个局部变量的作用域时,这个变量所占的Slot可以被其他局部变量所用。
code_length和code用来存储方法编译后生成的字节码指令。code_length代表字节码长度,code用于存储代表字节码指令的一系列u1类型的字节流。当虚拟机读取到code中的一个字节码时,就可以对应找出这个字节码代表的是什么指令,并且可以知道该指令后面是否需要跟随参数,以及参数应当如何理解。u1类型可以表达256条指令。关于code_length,虽然是u4类型,但是实际只是用了2个字节,也就是限制了一个方法不允许超过65535条字节码指令,如果超过这个限制,虚拟机会拒绝编译。
注:对于实例方法,编译器在编译的时候,会把this关键字的访问转变为一个普通方法参数的访问,然后在虚拟机调用实例方法时自动传入此参数。因此在实例方法的局部变量表中至少会存在一个指向当前对象实例的局部变量,局部变量表中也会预留出第一个Slot位来存放对象实例的引用,也就是通过javap的结果可以看到,实例方法的Args_size>=1,Locals>=1。静态方法则不会出现这个情况。
在字节码指令之后的是这个方法的显示异常处理表,异常表包含4个字段,描述的含义是:如果字节码在start_pc行到第end_pc行(不包含)之间出现了类型为catch_type或其子类的异常,则转到第handler_pc继续处理,可参考通过字节码理解try-catch-finally。
2.7.2 Exceptions
不同于Code中的异常表,这里的Exceptions属性和Code属性平级,作用是列举出方法中可能抛出的受查异常,也就是方法描述时再throws关键字后面列举的异常。
2.7.3 LineNumberTable
用于描述Java源码行号与字节码行号(字节码的偏移量)之间的对应关系。它不是运行时必须的属性,但默认会生成到class文件之中。可以在javac中分别使用-g:none或-g:lines选项来取消或要求生成这项信息。如果没有该属性,对程序产生的主要影响就是当抛出异常时,堆栈中就不会显示出错的行号,并且在调试程序的时候也无法按照源码行来设置断点。
2.7.4 LocalVariable Table
用于描述栈帧中局部变量表中的变量与Java源码中定义的变量之间的关系。它不是运行时必须的属性,但默认会生成到class文件之中。可以在javac中分别使用-g:none或-g:vars选项来取消或要求生成这项信息。如果没有该属性,也对程序运行没有什么影响,但是当其他人引用这个方法时,所有的参数名称都将丢失,IDE将会使用诸如arg0、arg1之类的占位符来代替原有的参数名。
2.7.5 InnerClass属性
用与记录内部类和宿主类之间的关联。如果一个类中定义了内部类,那编译器将会为它以及它所包含的内部类生成InnerClasses属性。
2.7.6 Deprecated及Synthetic
两个都属于标志类型的布尔属性,只存在有和没有的区别,没有属性值的概念。Deprecated属性用于标识某个类、字段或方法,已经被程序推荐不再使用。Synthetic代表此字段或者方法并不是由Java源码直接产生的,而是由编译器自行添加的。
2.7.7 StackMapTable
包含0至多个栈映射帧,每个栈映射帧都显式或隐式地代表了一个字节码偏移量,用于表示该执行到该字节码时,局部变量表和操作数栈的验证类型。类型检查验证器会通过检查目标方法的局部变量和操作数栈所需要的类型来确定一段字节码指令是否符合逻辑约束。
2.7.8 Signature
由于JAVA语言的泛型采用的擦除法实现的伪泛型。由于泛型擦除,原有的泛型类型会被替换为泛型上限(如果没有指定上限,则为Object),所以在运行期无法通过反射获得真实的泛型信息。Signature属性就是为了弥补这个缺陷而增设的,现在Java的反射API能够获取泛型类型,最终的数据来源也就是这个属性。它可以出现于类、字段表、方法表结构的属性表中。
2.7.9 BootstrapMethods
位于类文件的属性表中,用于保存invokedynamic指令引用的引导方法限定符。
三、字节码指令
Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的0至多个所需参数(称为操作数,Operands)构成。由于Java虚拟机采用面向操作数栈的架构,而不是寄存器,所以多大数的指令都不包含操作数,只有一个操作码(追求小数量、高传输效率),对操作数栈进行出栈、入栈操作。由于操作码的长度为一个字节,所以指令集的操作码总数不能超过256条。
3.1 字节码与数据类型
大多数的指令都包含了其操作所对应的数据类型信息。比如,iload指定用于从局部变量表加载int型数据到操作数栈,而fload指令加载的则是float类型的数据。但是前面提到操作码只有一个字节,如果每一种与数据类型相关的指令都支持Java虚拟机所有运行时数据类型的话,那指令的数量绝对会超出一个字节所能表示的数字范围。
因此Java虚拟机的指令集对于特定的操作只提供了有限的类型相关指令去支持。例如load指令有操作int类型的iload,但是没有操作byte类型的同类指令。大部分的指令都没有支持整数类型byte、char和short,甚至没有任何指令支持boolean类型。编译器会在编译期或运行期将byte和short类型的数据带符号扩展为相应的int类型数据,将boolean和char类型的数据零位扩展为相应的int类型数据。与之类似,在处理boolean、byte、short和char类型的数组时,也会转换为使用对应的int类型的字节码指令处理。因此大多数对于boolean、byte、short、char类型数据的操作,实际上都是使用相应的int类型作为运算类型。
3.2 字节码用途分类
-
加载和存储指令:用于将数据在栈帧中的局部变量表和操作数栈之间来回传输。比如iload、istore、bipush等。
-
运算指令:用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作数栈顶。比如加法指令:iadd,减法指令:isub等等。
-
类型转换指令:将两种不同的数值类型进行相互转换,这些转换操作一般用于实现用户代码中的显示类型转换操作,或者处理前面提到的指令集中数据类型相关指令无法与数据类型一一对应的问题(byte、short等扩展为int)。
-
对象创建与访问指令:要注意Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令。创建类实例:new,创建数组:nwarray、anewarray等。
-
操作数栈管理指令:类似于操作普通数据结构中的栈,Java虚拟机提供了一些用于直接操作操作数栈的指令。比如pop、dup、swap等。
-
控制转移指令:可以让Java虚拟机有条件或无条件的修改程序计数器的值。包括条件分支(比如ifeq)、复合条件分支(比如tableswitch)、无条件分支(比如goto)等等。
-
方法调用和返回指令:方法调用指令包括,像invokevirtual指令:用于调用对象的实例方法,invokespecial指令:调用一些需要特殊处理的方法,包括实例初始化方法、私有方法和父类方法;方法调用指令与数据类型无关,但方法返回指令是根据返回值类型区分的,包括ireturn(返回boolean、byte、char、short、int),lreturn、freturn、dreturn和areturn,另外还有一条return指令供声明为void的方法、实例初始化方法以及类和接口类初始化方法使用。
-
异常处理指令:Java程序中显示抛出异常的操作(throw)都是用athrow指令来实现。除此之外,Java虚拟机规范还规定了许多运行时异常会在其他Java虚拟机指令检测到异常状况时自动抛出。比如在整数运算中,当除数为0时,虚拟机会在idiv或ldiv指令中抛出ArithmeticException异常。现在在Java虚拟机中处理异常是采用异常表完成的,以前则使用的是jsr和ret指令实现。
-
同步指令:synchronized语句块对应的指令就是monitorenter和monitorexit。编译器必须确保无论方法通过何种方式完成,方法中调用过的每条monitorenter指令都必须执行其对应的monitorexit指令。所以为了保证在方法异常完成时,monitorenter和monitorexit指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可以处理所有的异常。关于synchronized的深入理解,可参考Java并发编程(九):深入JVM 核心全面理解 Synchronized。
四、总结
理解字节码文件结构和分析字节码指令是每一个Java程序员都应该掌握的基础技能。本章主要介绍了class文件的结构组成、class数据的存储和访问、字节码指令的一些描述。ava项目安全发布--Jar包(class)加解密实践中应用式的操作了字节码,感兴趣的可以瞅瞅~~