C/C++也作为很多初学初学的语言,它们都是直接编译为机器码,所以执行效率会更高,并且都不需要执行环境用户的使用成本会更低,不像很多语言还需要安装所需的环境。

也因为这些原因,它们的一次编码或编译只适用于一种平台,对于不同操作系统而言,有时需要修改编码再编译,有时直接重新编译即可。

 

Java是直接编译成 字节码(.class),这种编译产物是 介于原始编码和机器码的一种中间码。这样的话,Java程序 需要特定的执行环境(JVM)了,执行效率相比会低一些,还可能有虚拟化损失。但是这样也有一个好处就是可以 编译一次,多处执行(跨平台)。

 

各种不同平台的Java虚拟机,以及所有平台都统一支持的程序存储格式——字节码(Byte Code) 是构成 平台无关性 的基石。

时至今日,商业企业和开源机构已经在Java语言之外发展出一大批运行在Java虚拟机之上的语言, 如Kotlin、Clojure、Groovy、JRuby、JPython、Scala等。

实现语言无关性的基础仍然是虚拟机和字节码存储格式。Java虚拟机 不与包括Java语言在内的任何程序语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联,Class文件中包含了Java虚拟机指令集、符号表以及若干其他辅助信息。

 

 

  Class 类文件的结构

Class文件是一组以8个字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在文件之中,中间没有添加任何分隔符。当遇到需要占用8个字节以上空间的数据项时,则会按照高位在前的方式分割成若干个8个字节进行存储。

根据《Java虚拟机规范》的规定,Class文件格式采用一种类似于C语言结构体伪结构来存储数据,这种伪结构中只有两种数据类型:“无符号数”“表”

  • 无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个 字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值
  • 表是由多个 无符号数或者其他表 作为数据项构成的复合数据类型,为了便于区分,所有表的命名 都习惯性地以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上也可以视作是一张表,这张表由表6-1所示的数据项按严格顺序排列构成。

无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器若干个连续的数据项的形式,这时候称这一系列连续的某一类型的数据为某一类型的“集合”

1、魔数与 Class 文件的版本

每个Class文件的 头4个字节 被称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件

不仅是Class文件,很多文件格式标准中都有使用魔数来进行身份识别的习惯,譬如图片格式,如GIF或者JPEG等在文件头中都存有魔数。使用魔数而不是扩展名来进行识别主要是基于安全考虑,因为文件扩展名可以随意改动。

Class文件的魔数值为 0xCAFEBABE(咖啡宝贝?)。这个魔数似乎也预示着日后“Java”这个商标名称的出现(一杯咖啡)。

紧接着魔数的4个字节 存储的是Class文件的版本号:第5和第6个字节是次版本号(Minor Version),第7和第8个字节是主版本号(Major Version)。Java的版本号是从45开始的,JDK 1.1之后 的每个JDK大版本发布主版本号向上加1(JDK 1.0~1.1使用了45.0~45.3的版本号),高版本的JDK能 向下兼容以前版本的Class文件,但不能运行以后版本的Class文件,因为《Java虚拟机规范》在Class文 件校验部分明确要求了即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的Class 文件。

    代表次版本号的第5个和第6个字节值为0x0000,而主版本号的值 为0x0032,也即是十进制的50,该版本号说明这个是可以被JDK 6或以上版本虚拟机执行的Class文 件。

 

2、常量池

紧接着主、次版本号之后的是常量池入口,通常是占用Class文件空间最大的数据项目之一,另外,它还是在Class文件中第一个出现的表类型数据项目。

由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表 常量池容量计数值(constant_pool_count)。与Java中语言习惯不同,这个容量计数是从1而不是0开始的,如上一节的图所示,常量池容量(偏移地址:0x00000008)为十六进制数0x0016,即十进制的22,这就代表常量池中有21项常量,索引值范围为1~21。

在Class文件格式规范制定之时,设计者将第0项常量 空出来是有特殊考虑的,这样做的目的在于,如果后面某些指向常量池的索引值的数据在特定情况下 需要表达“不引用任何一个常量池项目”的含义,可以把索引值设置为0来表示。Class文件结构中只有 常量池的容量计数是从1开始,对于其他集合类型,包括接口索引集合、字段表集合、方法表集合等的容量计数都与一般习惯相同,是从0开始。

常量池中主要存放两大类常量字面量(Literal)和符号引用(Symbolic References)。

    • 字面量 比较接近于Java语言层面的常量概念,如文本字符串、被声明为final的常量值等。
    • 符号引用 则属于编译原理方面的概念,主要包括下面几类常量:
      • 被模块导出或者开放的包(Package)
      • 类和接口的全限定名(Fully Qualified Name)
      • 字段的名称和描述符(Descriptor)
      • 方法的名称和描述符
      • 方法句柄和方法类型(Method Handle、Method Type、Invoke Dynamic)
      • 动态调用点和动态常量(Dynamically-Computed Call Site、Dynamically-Computed Constant)

常量池中每一项常量都是一个表,最初常量表中共有11种结构各不相同的表结构数据,后来为了 更好地支持动态语言调用,额外增加了4种动态语言相关的常量,为了支持Java模块化系统 (Jigsaw),又加入了CONSTANT_Module_info和CONSTANT_Package_info两个常量,所以截至JDK 13,常量表中分别有17种不同类型的常量。 这17类表都有一个共同的特点,表结构起始的 第一位是个u1类型的标志位(tag,取值见表6-3中标 志列),代表着当前常量属于哪种常量类型

这17种常量类型各自有着完全独立的数据结构,两两之间并没有什么共性和联系,因此只能逐项进行讲解。

在JDK的bin目录中,Oracle公司已经为我们 准备好一个专门用于分析Class文件字节码的工具:javap。javap -verbose TestClass

 

3、访问标志

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

 

4、类索引、父类索引与接口索引集合

Class文件中由这三项数据来确定该类型的继承关系。

    • 类索引:u2类型的数据。用于确定这个类全限定名,
    • 父类索引:u2类型的数据。用于确定这个类的父类的全限定名。由于Java语言不允许多重继承,所以父类索引只有一个,除了 java.lang.Object外,所有Java类的父类索引都不为0。
    • 接口索引集合:一组u2类型的数据的集合就用来描述这个类实现了哪些接口,这些被实现的接口将按implements关键字后的接口顺序从左到右排列在接口索引集合中。

类索引和父类索引用两个u2类型的索引值表示,它们各自 指向一个类型为 CONSTANT_Class_info 的类描述符常量,通过 CONSTANT_Class_info类型的常量中的索引值可以找到定义在 CONSTANT_Utf8_info 类型的常量中的全限定名字符串。

 

5、字段表集合

Java语言中的“字段”(Field)包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。

字段可以包括的修饰符有字段的作用域(public、private、protected修饰 符)、是实例变量还是类变量(static修饰符)、可变性(final)、并发可见性(volatile修饰符,是否 强制从主内存读写)、可否被序列化(transient修饰符)、字段数据类型(基本类型、对象、数组)、 字段名称。

 字段修饰符放在 access_flags 项目中,它与类中的access_flags项目是非常类似的,都是一个u2的数 据类型,其中可以设置的标志位和含义下表所示。

 access_flags 后面的 name_index 和 descriptor_index。它们都是对常量池项的引用。

  • name_index 字段的简单名称

“org/fenixsoft/clazz/TestClass”是这 个类的全限定名,仅仅是把类全名中的“.”替换成了“/”而已,为了使连续的多个全限定名之间不产生混 淆,在使用时最后一般会加入一个“;”号表示全限定名结束。简单名称则就是指没有类型和参数修饰的方法或者字段名称。

  • descriptor_index  字段和方法的描述符

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

    • 根据描述符规则,基本数据类型(byte、char、double、float、int、long、short、boolean)以及代表无返回值的void类型都用一个大 写字符来表示。
    • 对象类型则用字符L加对象的全限定名来表示。
    • 对于数组类型,每一维度将使用一个前置的“[”字符来描述,如一个定义为“java.lang.String[][]”类型 的二维数组将被记录成“[[Ljava/lang/String;”,一个整型数组“int[]”将被记录成“[I”。
    • 用描述符来描述方法时,按照先参数列表、后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号“()”之内。如方法 int indexOf(char[] source,int sourceOffset,int sourceCount,char[] target, int targetOffset,int targetCount,int fromIndex)的描述符为“([CII[CIII)I”。

 

 

 

5、方法表集合

Class文件存储 格式中对方法的描述对字段的描述采用了几乎完全一致的方式,方法表的结构如同字段表一样,依 次包括访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表 集合(attributes)几项。这些数据项目的含义也与字段表中的非常类似,仅在访问标志和属性表集合的可选项中有所区别。

因为volatile关键字和transient关键字不能修饰方法(与字段相比),所以方法表的访问标志中没有了 ACC_VOLATILE标志和ACC_TRANSIENT标志。与之相对,synchronized、native、strictfp和abstract 关键字可以修饰方法(与字段相比),方法表的访问标志中也相应地增加了ACC_SYNCHRONIZED、 ACC_NATIVE、ACC_STRICTFP和ACC_ABSTRACT标志。对于方法表,所有标志位及其取值可参见 表6-12。

 

 方法的定义可以通过访问标志、名称索引、描述符索引来 表达清楚,但方法里面的代码去哪里了?方法里的Java代码,经过Javac编译器编译成字节码指令之 后,存放在方法属性表集合中一个名为“Code”的属性里面,属性表作为Class文件格式中最具扩展性的 一种数据项目,将在下一节中详细讲解。

 

5、属性表集合

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

《Java虚拟机规范》最初只预定义了9项所有Java虚拟机实现都应 当能识别的属性,而在最新的《Java虚拟机规范》的Java SE 12版本中,预定义属性已经增加到29项, 这些属性具体见表6-13。后文中将对这些属性中的关键的、常用的部分进行讲解。

(1)Code 属性

Java程序方法体里面的代码经过Javac编译器处理之后,最终变为字节码指令存储在Code属性内。

Code属性出现在方法表的属性集合之中,但并非所有的方法表都必须存在这个属性,譬如接口或者抽象类中的方法就不存在Code属性。

    • attribute_name_index是一项指向CONSTANT_Utf8_info型常量的索引,此常量值固定为“Code”。
    • max_stack代表了操作数栈(Operand Stack)深度的最大值。在方法执行的任意时刻,操作数栈都 不会超过这个深度。
    • max_locals代表了局部变量表所需的存储空间。在这里,max_locals的单位是变量槽(Slot),变量槽是虚拟机为局部变量分配内存所使用的最小单位。对于byte、char、float、int、short、boolean和 returnAddress等长度不超过32位的数据类型,每个局部变量占用一个变量槽,而double和long这两种64 位的数据类型则需要两个变量槽来存放。Java 会将局部变量表中的变量槽进行重用,当代码执行超出一个局部变量的作用域时,这个局部变量所占的变量 槽可以被其他局部变量所使用,Javac编译器会根据变量的作用域来分配变量槽给各个变量使用,根据 同时生存的最大局部变量数量和类型计算出max_locals的大小。
    • code_length和code用来存储Java源程序编译后生成的字节码指令。code_length代表字节码长度, code是用于存储字节码指令的一系列字节流。既然叫字节码指令,那顾名思义每个指令就是一个u1类 型的单字节。

Code属性是Class文件中最重要的一个属性,如果把一个Java程序中的信息分为代码(Code,方法 体里面的Java代码)元数据(Metadata,包括类、字段、方法定义及其他信息)两部分,那么在整 个Class文件里,Code属性用于描述代码,所有的其他数据项目都用于描述元数据。

// 原始Java代码
public class TestClass {
   private int m;
   public int inc() {
     return m + 1;
   }
}
C:\>javap -verbose TestClass
// 常量表部分的输出见代码清单6-1,因版面原因这里省略掉
{
public org.fenixsoft.clazz.TestClass();
 Code:
   Stack=1, Locals=1, Args_size=1
   0: aload_0
   1: invokespecial #10; //Method java/lang/Object."<init>":()V
   4: return
 LineNumberTable:
   line 3: 0
 LocalVariableTable:
   Start Length Slot Name Signature
   0 5 0 this Lorg/fenixsoft/clazz/TestClass;
public int inc();
 Code:
   Stack=2, Locals=1, Args_size=1
   0: aload_0
   1: getfield #18; //Field m:I
   4: iconst_1
   5: iadd
   6: ireturn
 LineNumberTable:
   line 8: 0
 LocalVariableTable:
   Start Length Slot Name Signature
   0 7 0 this Lorg/fenixsoft/clazz/TestClass;
}

这个类有两个方法——实例构造器()inc(),这两个方法很明显都是没有参数的,为什么Args_size(参数个数)会为1?而且无论是在参数列表里还是方法体内,都没有定义任何局部变量,那Locals又为什么会等于1?

如果有这样疑问的读者, 大概是忽略了一条Java语言里面的潜规则:在任何实例方法里面,都可以通过“this”关键字访问到此方法所属的对象。而它的实现非常简单,仅仅是通过在Javac编译器编译的时候把对 this关键字的访问 转变为对一个普通方法参数的访问,然后在虚拟机调用实例方法时自动传入此参数而已。因此在实例方法的局部变量表中至少会存在一个指向当前对象实例的局部变量,局部变量表中也会预留出第一个变量槽位来存放对象实例的引用,所以实例方法参数值从1开始计算。这个处理只对实例方法有效,如果方法被声明为static,那Args_size就不会等 于1而是等于0了

 

《Java 虚拟机规范》中明确要求Java语言的编译器应当选择使用异常表而不是通过跳转指令来实现Java异常及 finally处理机制。如果存在异常表,那它的格式应如表6-16所示,包含四个字段,这些字段的含义为:

    • 如果当字节码从start_pc 行到第 end_pc 行之间(不含第end_pc行)
    • 出现了类型为 catch_type 或者其子类的异常 (catch_type为指向一个CONSTANT_Class_info型常量的索引),则转到第 handler_pc继续处理。
    • catch_type 的值为 0 时,代表任意异常情况都需要转到 handler_pc 处进行处理。
// Java源码
public int inc() {
 int x;
 try {
   x = 1;
   return x;
 } catch (Exception e) {
   x = 2;
   return x;
 } finally {
   x = 3;
 }
}
// 编译后的ByteCode字节码及异常表
public int inc();
 Code:
   Stack=1, Locals=5, Args_size=1
   0: iconst_1 // try块中的x=1
   1: istore_1
   2: iload_1 // 保存x到returnValue中,此时x=1
   3: istore 4
   5: iconst_3 // finaly块中的x=3
   6: istore_1
   7: iload 4 // 将returnValue中的值放到栈顶,准备给ireturn返回
   9: ireturn
   10: astore_2 // 给catch中定义的Exception e赋值,存储在变量槽 2中
   11: iconst_2 // catch块中的x=2
   12: istore_1
   13: iload_1 // 保存x到returnValue中,此时x=2
   14: istore 4
   16: iconst_3 // finaly块中的x=3
   17: istore_1
   18: iload 4 // 将returnValue中的值放到栈顶,准备给ireturn返回
   20: ireturn
   21: astore_3 // 如果出现了不属于java.lang.Exception及其子类的异常才会走到这里
   22: iconst_3 // finaly块中的x=3
   23: istore_1
   24: aload_3 // 将异常放置到栈顶,并抛出
   25: athrow
   Exception table:
     from to target type
     0 5 10 Class java/lang/Exception
     0 5 21 any
     10 16 21 any

从Java代码的 语义上讲,这三条执行路径分别为:

    • 如果try语句块中出现属于Exception或其子类的异常,转到catch语句块处理;
    • 如果try语句块中出现不属于Exception或其子类的异常,转到finally语句块处理;
    • 如果catch语句块中出现任何异常,转到finally语句块处理。

即,如果没有出现异常,返回值是1;如果出现了Exception异常,返回值是2;如果出现了 Exception以外的异常,方法非正常退出,没有返回值。

(2)Exceptions 属性

这里的Exceptions属性是在方法表中与Code属性平级的一项属性,读者不要与前面刚刚讲解完的异常表产生混淆。Exceptions属性的作用是列举出方法中可能抛出的受查异常(Checked Excepitons),也就是方法描述时在throws关键字后面列举的异常

(3)LineNumberTable 属性

用于描述Java源码行号与字节码行号(字节码的偏移量)之间的对应关系。 它并不是运行时必需的属性,但默认会生成到Class文件之中,可以在Javac中使用-g:none或-g:lines 选项来取消或要求生成这项信息。如果选择不生成LineNumberTable属性,对程序运行产生的最主要影响就是当抛出异常时,堆栈中将不会显示出错的行号,并且在调试程序的时候,也无法按照源码行来 设置断点。

(3)LocalVariableTable 及 LocalVariableTableType 属性 

LocalVariableTable属性用于描述栈帧中局部变量表的变量Java源码中定义的变量间的关系,它也不是运行时必需的属性,但默认会生成到Class文件之中,可以在Javac中使用-g:none或-g:vars选项 来取消或要求生成这项信息。如果没有生成这项属性,最大的影响就是当其他人引用这个方法时,所有的参数名称都将会丢失,譬如IDE将会使用诸如arg0、arg1之类的占位符代替原有的参数名,这对程序运行没有影响,但是会对代码编写带来较大不便,而且在调试期间无法根据参数名称从上下文中获得参数值。