乐趣区

Java杂货铺JVMClass类结构

代码编译的结果从本地机器码转为字节码,是储存格式发展的一小步,却是编程语言的一大步。——《深入理解 Java 虚拟机》

计算机只认识 0 和 1. 所以我们写的编程语言只有转义成二进制本地机器码才能让机器认识。然而随着虚拟机的发展,包括 Java 在内的很多语言,都选择了一种和操作系统、机器指令集无关的中立储存格式来储存编译后的数据。

无关性

我们都知道 Java 经典标语,“一次编译,到处运行”。实现这一目标,每个平台上定制的虚拟机,需要读取统一的数据。这种数据不依赖于任何一种平台,甚至不关心是由哪种语言编译来的,只要统一了格式,虚拟机就能正确的使用它。这种统一的格式就是——字节码(Class 文件)。

Class 文件中储存了 Java 虚拟机指令集和符号表以及若干其他辅助和结构化约束。处于安全考虑,Class 文件中使用了许多强制性的语法和结构化约束。

Class 类文件的结构

下面来看下本文的硬菜,Class 文件的结构。虽说大佬书中是以 JDK1.4 为版本讲述的,但是它所包含的指令、属性是 Class 文件中最重要最基础的。后续不同的版本都是对它的增强。

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

Class 文件是以一组以 8 位字节为基础单位的二进制流,这个数据项目严格按照顺序紧凑地排列在 Class 文件之中,中间没有添加任何分隔符,这使得整个 Class 文件中储存的内容几乎是程序运行的必要数据,没有空格存在。

Class 有两种数据类型(虽然用十六进制编辑器打开,看上去都是十六进制字符):无符号数和表。无符号数可以用来描述数字、索引引用、数量值或者按照 UTF- 8 编码构成字符串值。表是由多个无符号数或者其他表作为数据项构造成的符合数据类型,所有表都习惯性地以“info_”结尾。表用于描述层次关系的复合结构数据,整个 Class 文件实质上就是一张表。

其中类似于紧挨着的 constant_pool_count、constant_pool 这样的数据可视为一个整体(一个表),前面记录后者数据的数量。

魔数与 Class 文件的版本

看 class 文件结构那张表,第一个就是 u4 magic。这是一个占了 4 个字节的魔数,它的唯一作用就是确定这个文件是否为一个 Class 文件。它就是一个标志,告诉虚拟机自己是 Class 文件,这样做更加安全,四个字节储存的值是固定的,十六进制下为“0xCAFEBABE”,咖啡宝贝。

接下来分别是两个字节的 minor(次版本)和两个字节的 major(主版本)。分别储存着此 Class 文件时何种版本的编译器 编译 的,例如 50.3,50 就是主版本 3 就是次版本。在 运行 时可以向下兼容,比如 51 版本虚拟机可以运行 50.3 版本的 class 文件,但是反过来就不行了。

常量池

紧接着 constant_pool_count、constant_pool 就是常量池部分。常量池可以理解为 Class 文件的资源仓库,它是 Class 文件结构中与其他项目关联最多的数据类型,也是占用 Class 文件最大的数据项目之一。

首先两字节的 constant_pool_count 是统计后面 constant_pool 的常量数量的。注意后面的数量是从 1 开始,例如 constant_pool_count 储存的数字是 22,那么 constant_pool 中就储存了 21 个数据项。这么设计是为了让“第 0 个位置”储存写特殊的数据。Class 文件只有这一部分计数是从 1 开始的,其他部分还是从 0 开始。

常量池中主要储存两大类常量:字面量和符号引用。字面量好理解就是注入字符串、final 修饰的常量值等等。符号引用主要包含一下三个常量:

  1. 类和接口的全限定名
  2. 字段的名称和描述符
  3. 方法的名称和描述符

Class 文件中不会储存各个方法、字段的最终内存分布,只有在执行到特定的代码时才会知道真正的内存入口(某信息的地址)。在 JDK1.4 中,常量池可包含的常量项如下(以后的版本会对内容进行扩充):

最麻烦的这些类型分别有自己的结构,不过共同的特点是第一个字节都储存着 tag,即告诉虚拟机自己那种常量项。从这部分内容可以看出很多东西,比如说一个变量名称最大时两个字节,即 64KB 英文字符大小,当然按常理来说不会出现这样变态的变量名吧。

访问标志

在常量池结束之后,紧接着两个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息。

访问标志使用或来计算,比如一个类被 ACC_PIBLIC(0x0001)、ACC_SUPER(0x0020)所修饰,那么计算为 0x0001|0x0020 = 0x0021,该值就是被访问标志储存的值。Java 中有专门计算关键字的包。

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

类索引(this_class)和父类索引(super_class)都是一个 u2 类型的数据,而接口索引是一组 u2 类型的数据的集合。Class 文件中有这三项来确定继承关系。除了 Object 类以外,所有的夫索引都不是 0。如果结构计数器的大小是 0,那么后面那部分就没有数据。

字段表集合

字段表用于描述接口或者类中声明的变量。字段包括类级变量和实例级(对象级)变量,但不包括方法内部的局部变量。以下是字段表结构和字段表的第一个属性访问标志。

access_flags 的计算方式和前面类或者接口的访问表示相同。后面紧跟着两个属性是 name_index
和 descriptor_index, 分别代表着简易名称和方法描述符。

字段表集合中不会列出从超类或者父接口中继承下来的字段,但是可以列出本来 Java 代码中不存在的字段,譬如在内部类中为了保持对外部类的访问性自动添加的字段。另外在 Java 中,同一个类不能出现简易名称相同的字段名,例如 int name,后面紧跟着 String name。但是在字节码层面,简易名称可以相同,后面的描述不同就好了。

方法表集合

方法表的结构和字段表的机构基本类似。

与字段表集合相对应,如果父类方法在子类中没有被重写,方法表集合中就不会出现父类的方法信息。在 Java 语言中,要重载一个方法,除了要与原方法具有相同的简单名称之外,还要求拥有一个与原方法不同的特征签名。特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合,也就是因为返回值不会包含在特征签名中,所以仅仅是返回值不同,不是重载。

属性表集合

在 Class 文件、字段表、方法表都携带自己的属性表集合。属性表的数据项目相对于其他部分比较宽松一点,但是内容也有很多。下面来看一下比较重要的。

Code 属性

Java 类的程序方法体中的代码经过编译后储存在 Code 属性中,但是接口和抽象类中的方法就不存在 Code 属性中。

max_locals 代表了局部变量表所需要的储存空间,其中最小单位是 Slot。其中 Slot 可以复用,当代码执行超出一个局部变量的作用域时,这个局部变量所占的 Slot 可以被其他局部变量所使用,极大节省了空间。

code_length 和 code 值储存的时 Java 源代码编译后生成的字节码指令。由于每个 code 只占了一个字节,所以能表示的指令数只有 256 个。code_length 的长度虽然时四个字节,但是由于虚拟机的规定只能使用两个字节,所以最大只能编译 65535 条指令,一般来说也是够用了,但是在编译复杂的 JSP 的时候要注意,某些编译器会把 JSP 内容和页面输出的信息归并于一个方法中,就可能导致编译失败。

值得一提的是,Javac 在编译方法的时候,参数即使你没有填,agrs_size 也可能是 1,这是由于隐式传进去了 this,当然 static 修饰的方法参数就是 0(不填写的情况下)。

曾经使用 try-catch 的时候,注意到 finlly 不会改变局部变量的值,以为是 try 已经 return 了,return 之后才去执行的 finlly 中的数据,其实不然。例如下面这段代码。

public int inc(){
    int x;try{
        x=1;return x;}catch(Exception e){
        x=2;return x;}finally{x=3;}
}

这段代码永远不会输出 x =3,执行顺序是这样的(以不会抛出异常为例):首先执行 x =1,此时局部变量等于 1. 然后读到 return 指令,然后将 x 的值赋给一个空间,这个空间是 return 时返回的值,我们暂且将这块空间起个名字,叫做 returnX,然后代码进入 finally,注意此时,还在这个 inc()方法的作用域中。然后将 x 赋值等于 3,最后执行 return 指令,返回刚才那块 returnX 空间的值给调用者。离开 inc()作用域,此时 x 那块 Slot 可以被复用。

其他

  1. Exceptions 储存方法 throws 后面的异常。
  2. LineNumberTable 不是必填项,但是默认填上,如果不填,抛异常栈的时候就无法定位到哪一行了。
  3. LocalVariableTable 不是必填项,用于描述栈帧中局部变量表中的变量于 Java 源码中定义的变量之间的关系。
  4. SourceFile 记录生成这个 Class 文件源码的名称
  5. ConstantValue 通知虚拟机自动为静态变量赋值,在 <init> 初始化之前就进行赋值。
  6. InnerClasser 记录内部类和宿主类之间的关联。
  7. Signature 这个写 AOP 的时候经常见,此属性会为泛型记录信息,因为 Java 在编译的时候会进行泛型擦除,所以需要记录一下,让 Java 在运行的时候可以拿到泛型的原始信息。
  8. BootstrapMethods 这个属性保存 invokedynamic 指令引用的引导方法限定符,和 Invoke 包有很大关系。

字节码指令

字节码指令不会超过 256 个,一般来说一个指令后面会跟着参数,这很自然,就像我们写方法时需要加入参数(没有参数也是种参数)。但是由于 Java 虚拟机采用面向操作数栈而不是寄存器(编译语言)的架构,所以大多数情况下只包含一个操作码。

由于字节码数量有限,所以很多指令会被强制统一。比如处理 boolean、byte、short 和 char 类型的数组时,也会转化为对应的 int 类型的字节码指令来处理。

字节码操作的时候可能会导致溢出,例如两个很大的正整数相加,结果可能会称为一个负数。当一个操作产生溢出时,将会使用有符号的无穷大来表示,如果某个操作结果没有明确的数字定义的话,将会使用 NaN 值来表示。所有使用 NaN 值作为操作数的算术操作,结果会返回 NaN。

Java 虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都时使用管程(Monitor)来支持的。可以看作 Synchronized 此时拿的锁就是 Monitor。

退出移动版