高亮的杂货铺

JVM从0到1-class文件结构初探

2020-03-14 · 9 min read
JVM Java


class文件是一组以8个字节为基础单位的二进制流,各个数据项紧凑排列,没有任何分隔符,当遇到需要占用8个字节以上空间的数据项时,会采用大端原则进行排列,即高位在前。

class文件采用了一种类似C语言结构体的伪结构来存储数据,这个伪结构中,只有两种基本的数据类型,"无符号数"和"表"。

无符号数属于基本数据类型,用u1,u2,u4,u8分别来表示1、2、4、8个字节的无符号数,这些无符号数可以用来描述数字、索引引用、数值量或者按照UTF-8编码构成的字符串。

表是由多个无符号数或其他表作为数据结构的复合数据结构,为了便于区分,这里给命名加上了后缀"_info"。整个class文件也可以看作是一张表。

无论是无符号数还是标配,当需要描述同一个类型的但是数量不定的数据时,经常会前置一个计数器。

魔数与Class文件版本

class文件的魔数是 0xCAFEBABE(咖啡宝贝??) ,有兴趣可以查一下这个数字的来历。 魔数占据了4个字节。

紧接着的是class文件版本号,其中第5、6个字节是次版本号,第7、8个字节是主版本号。 JDK8的版本号是0x00000034,即52.00。 大版本号随着JDK大版本的发布加1。 Java虚拟机规范要求,高版本的JDK能兼容之前版本的class文件,但强制要求不能运行以后任何版本的class文件,即使其中的内容旧版本的jdk也完全可以解析。

常量池

紧接着的两个字节,是常量池计数器(constant_pool_count),需要注意的是,这个计数器的值和java中的习惯不同,容量计数是从1开始而不是0开始的,所以其值的大小等于常量池中的成员数+1。 class文件中,只有常量池计数器的是从1开始的。

常量池中主要存放两大类常量,字面量(Literal)和符号引用(Symbolic References),字面量比较接近Java语言中常量的概念,例如文本字符串,被声明为final的常量值等。 符号引用属于编译原理方面的概念,主要包括下面几种常量:

  • 被模块导出或者开放的包(Package)
  • 类和接口的全限定名(Full Qualified Name)
  • 字段的名称和描述(Descriptor)
  • 方法的名称和描述
  • 方法句柄和方法类型(Method Handle,Method Type, Invoke Dynamic)
  • 动态调用点和动态常量(Dynamically-Computed Call Site、Dynamically-computed Constant)

Java在进行编译时,并没有C和C++的连接这个步骤,连接是在class文件加载的时候动态进行的,所以class文件不会保存各个方法和字段最终在内存中的布局,这些个字段、方法的符号引用如果不经过虚拟机的转换是无法获得真正的内存入口的。 Java虚拟机在加载类时,会从常量池中获得对应的符号引用,再在类创建时运或者运行时解析、翻译到具体的内存地址中。

常量池的每一项都是一个表,而且目前共有17中不同类型的常量,所以常量池是最繁琐的数据,这部分内容会在下一篇中单独介绍。

访问标志(access_flags)

紧接着常量池的是访问标志,占用两个字节,这个标志用于识别一些类或者接口的访问信息,例如,这个class是类还是接口,是否是public,是否为abstract,是否被声明为final,是否是注解,是否是枚举,是否是非用户产生的class,是否是模块,等等。

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

类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,接口索引集合(interfaces)则是以组u2类型的数据的集合,class文件中由着三项数据来确定类的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个父类的全限定名,接口索引集合就是用来描述这个类实现了哪些接口。

对于接口索引集合,入口的第一个u2类型的数据为接口计数器,表示索引表的容量。

通过索引的值,到常量池中查找对应类的全限定名。

字段表集合(field_info)

字段表用于描述接口或类中声明的变量(不包括方法内部声明的局部变量)。对一个字段的描述包括,作用域(public,private,protected),是类级还是实例级变量(static),是否可变(final),并发可见性(volatile),是否可被序列化(transient修饰符),字段数据类型(基本类型、对象、数组),字段名称。 其中,各种修饰符是否存在都可以用布尔值来表示,刚好可以采用标志位的方式来存储。而字段名,字段被定义为什么类型是无法固定的,所以只能引用常量池中的常量来描述。字段表的结构如下

其中字段修饰符(access_flags)是一个u2类型的数据结构,通过标志位的方式来实现对字段修饰符是否存在的表示。

access_flags后是name_index和descriptor_index,都是对常量池项的引用,分别代表字段的简单名称以及字段和方法的描述符。

全限定名把类全名中的.替换为/, 简单名称是指的没有类型和参数修饰符的方法或者字段名称,例如inc()方法和m字段的简单名称就是"inc"和"m"。

相较于简单名,方法和字段的描述符则较为复杂,描述符的作用是来描述字段的数据类型、方法的参数列表(包括数量,类型和顺序)以及返回值。 根据描述符的规定,基本数据类型(byte、char、double、float、int、long、short、boolean)以及代表无返回值的void类型都是用一个大写字符来表示,而对象类型则用字符L加对象的全限定名来表示。

对于数组类型,每个维度前使用一个前置的[来描述。

用描述符描述方法时,按照先参数列表,后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号中,然后是返回值,例如:方法int indexOf(char[]source,int sourceOffset,int sourceCount,char[]target,int targetOffset,int targetCount,int fromIndex)的描述符为“([CII[CIII)I”。

字段表结束后,还有一个属性表集合,用于存储一些额外的信息,例如果将字段m的声明改为“final static int m=123;”,那就可能会存在一项名称为ConstantValue的属性,其值指向常量123。

字段表不会列出从父类或者父接口中继承而来的字段,但是可能会出现原本Java代码中不存在的字段,例如在内部类中为了保持对外部类的访问性,会自动添加对指向外部类实例的字段。

方法表集合

方法表集合和字段表集合的内容几乎一致,依次包括访问标志(access_flags)、名称索引(name_index)、描述符索引(desciptor_index)、属性表集合(attributes)几项。

由于方法和字段修饰符的不同,所以访问标记的含义也略有不同

至此,方法的定义就可以通过访问标志、名称索引、描述符索引来表达清楚了,但是方法里面的代码去哪了呢? 方法里的Java代码,在经过javac编译器编译为字节码指令之后,存放在方法属性表集合中的一个Code的属性中,属性表经常作为class文件中最具扩展性的一种数据项目。

与字段表集合相对应的,如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息,但同样的,也有可能会出现由编译器自动添加的方法,最常见的边是类构造器<clinit>()和实例构造器<init>()方法。

Java方法中,重载(Overload)一个方法,要求其简单名相同之外,害要求必须拥有一个与原方法不同的特征签名,特征签名是指一个方法中各个参数在常量池中的字符引用的集合,也正是因此,返回值不包含在特征签名中。

属性表集合

属性表(attribute_info)在前面的讲解中已经出现过多次,class文件、字段表、方法表都可以由自己写带的属性表集合,以描述某些场景专有的信息。虚拟机规范对属性表的限制稍微宽松一些,不要求各个属性具有严格顺序,而且只要不与已有属性崇明,任何人实现的编译其都可以向属性表中写入自己定义的属性信息,Java虚拟机运行时,会忽略它不认识的属性。

对于每一个属性,他的名称都要从常量池中引用一个CONSTANT_Utf8_info类型的常量来表示,而属性值的结构是完全自定义的,只需要通过一个u4长度属性来表明属性值所占用的位数即可。

对于属性表详细的解释,会在下一篇中进行介绍。