【Java杂货铺】JVM#Class类结构

718 阅读11分钟

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

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

无关性

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

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

Class类文件的结构

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

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

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

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

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 通知虚拟机自动为静态变量赋值,在初始化之前就进行赋值。
  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。