JVM的Class文件结构

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

前端需要考虑不同浏览器的兼容性,后端也需要考虑不同硬件体系结构和不同操作系统的兼容性。然而为什么你在写JVM平台语言(注意这里刻意没有说Java)的时候感受不到这点呢,因为JVM作为操作系统到应用程序之间抽象出来的一道中间层,已经帮你做好了这种兼容性,class文件则是这种兼容性的基石。

首先class文件具有兼容性(兼容不同语言),其次JVM在解释执行class文件时也具有兼容性(兼容不同平台)。当然JVM这道抽象层可不仅仅只有兼容作用,还有运行时的功能辅助和灵活扩展等诸多玩法,让JVM技术体系大放异彩。

要了解JVM,那就从了解JVM的基石 —— class文件说起。

注:class的数据结构和字节码指令涉及诸多底层繁琐细节的表示,本文只作提纲挈领,拎主干脉络,具体细节可参考《深入理解Java虚拟机》和《Java虚拟机规范》

一、类文件的结构

JVM虽名Java Virtual Machine,但是可以说它和Java语言没有直接关系,JVM只认识class文件,而不关心class的来源是何种语言。很多语言,包括具有动态类型特性的语言都能编译成class,因此class所能提供的语义描述能力肯定会比Java语言本身更加强大。所以在了解class时,你可以拿它的语义与你熟悉的Java语言进行对比以增进理解,但也要时刻清楚,Class is more than Java,Class is also a platform.

Java程序中的信息可以分为两大部分:
(1)元数据 —— Metadata,包括类、字段、方法定义和其他信心。
(2)代码 —— Code,方法体里的Java代码。
这从内容上划分的两部分组成了Java程序的信息,也构成了class文件信息的主要内容。

Class文件格式 —— 《深入理解Java虚拟机》
这里写图片描述

Class文件中一组以8位字节为基础单位且高位在前的二进制流。Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,且只有两种数据类型:无符号数和表。

如上图所示,以u1表示1个字节的无符号数,以u2表示2个字节的无符号数…… 无符号数可以表示数量值、按照UTF-8编码构成的字符串值、数字、索引引用。表是由多个无符号数或者其他表作为数据项构成的符合数据类型,在上图中以 _info 结尾表示。

我们再把class文件格式聚合成几块以把握class的整体框架:
(1)以magic、minor-version、major-version存储class文件的身份验证和版本信息
(2)以constant-pool-count、constant-pool存储各种字面量和符号引用
(3)以access-flags存储类本身的访问标志
(4)以this-class、super-class、interfaces-count、interfaces存储类本身的索引、其父类的索引和实现的接口的索引
(5)以fields-count、fields存储类的字段,包括类级变量和实例级变量,但不包括从超类或父接口中继承而来的字段
(6)以methods-count、methods存储类的方法,但不包括来自父类且没有被重写的方法
(7)attributes-count、attributes存储可补充描述上述信息的一些额外属性,比如最常见的方法体中的字节码指令流就存储在Code属性中

1.1、常量池(constant-pool)

常量池可以理解为Class文件中的资源仓库,它是Class文件结构中与其他项目关联最多的数据类型,也是占用Class文件空间最大的数据项目之一。

常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。符号引用属于编译原理方面的概念,它包括下面三类常量:

  • 类和接口的全限定名(Fully Qualified Name)
  • 字段的名称和描述符(Descriptor)
  • 方法的名称和描述符

说到这里,肯定还是会对常量池不明所以。先抛弃上面的概念,理清一个事实:Java代码在Javac编译时,并不像C/C++有“连接”这一步骤,而是在虚拟机加载Class文件时进行动态连接。也就是说,在Class文件中不会保存各个方法、字段的最终内存布局信息,也就意味着,编译后的Class跟任何物理机都没有直接关联。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建或运行时时解析、翻译到具体的内存地址中,这时才与物理机产生关联。

package org.fenixsoft.clazz

public class TestClass {
    private int m;
    public int inc() {
        return m + 1;
    }
}

所以所谓“符号引用”,通俗解释就是它指向了一种具有虚拟机能理解的特殊意义的字符串。形如“org/fenixsoft/clazz/TestClass”表示类的全限定名,形如“m”、“inc”表示字段或方法的名称,形如“I”、“()V”表示字段或方法的描述符。

这些常量池中的符号引用会被其他字段表(field-info)、方法表(method-info)等所引用,以充分描述对应的字段、方法等。看上去就是:字段表、方法表等 → (去引用)→ 常量池中的符号引用 → (去引用)→ 常量池中的字符串字面量

这些符号引用在语义层面做到对程序语言的等价描述,然后虚拟机在运行时,再将符号引用转换为实际物理机内存的等价描述。

再看一下常量池中的项目类型 —— 《深入理解Java虚拟机》
这里写图片描述

讲个小插曲:Java中方法、字段名是有长度限制的,最长不可超过65535位,即64KB。为啥呢?因为因为方法表、字段表会引用常量池中的CONSTANT-Fieldref-info、CONSTANT-Methodref-info这两个符号引用,这两个符号引用又会去引用各自的CONSTANT-Utf8-info,而CONSTANT-Utf8-info的length由u2表示,u2最大可表示的数字为65536。

1.2、类索引、父类索引和接口索引(this-class/super-class/interfaces)

理解了前面的符号引用概念,再理解类索引就易如反掌了。在Class文件中类索引表示的信息少得可怜,类索引指向的符号引用也就存了个类的全限定名。虽然信息少,但在信息表述上时完善不缺的,虚拟机在运行时通过类索引拿到类的全限定名后会根据需要进一步通过其他的Class文件获取足够的类信息,以此来表示类的继承关系,实现类继承的各种语义和功能。

1.3、字段表(field-info)

描述一个类或接口的字段可以包括的信息有:

  1. 字段的作用(public、private、protected)
  2. 是实例变量还是类变量(static)
  3. 可变性(final)
  4. 并发可见性(volatile)
  5. 可否被序列化(transient)
  6. 字段数据类型
  7. 字段名称

其中前5种信息都可以用“有无”来表示,适合使用标志位;而第6、7种的信息都是无限的(因为Java中的类型可以无限、取名可以无限),只能引用常量池中的常量来描述。

字段表结构 —— 《深入理解Java虚拟机》
这里写图片描述

如上图,name-index引用了常量池中的某个CONSTANT-Utf8-info;descriptor-index引用了常量池中的某个CONSTANT-NameAndType-info,也就是字段或方法的描述符。

描述符的作用:

  • 针对字段描述符,就是描述字段的数据类型
  • 针对方法描述符,就是描述方法的参数列表(包括数量、类型和顺序)以及返回值。

描述符标识字符含义 —— 《深入理解Java虚拟机》
这里写图片描述

比如若字段为int型,描述符为 I,若为double[]型,描述符为 [D,若为 java.lang.String[][]型,描述符为 [[Ljava/lang/String ; 针对方法,void inc() 的描述符为 ()V, short eg(char[] source, double offset) 的描述符为 ([CD)S 。

Class中对字段的约束比Java中的约束要小。在Java中两个字段的数据类型不管是否相同都必须使用不一样的名称,在Class中只要两个字段的描述符不一致,两个字段就可以重名。

1.4、方法表(method-info)

方法表结构与字段表结构一致,只不过volatile、transient不能修饰方法,而synchronized、native、strcitfp、abstract可以修饰方法。除此之外,方法表中还有一项重要的属性Code,存放着方法体中的字节码指令流。

Class中对方法的约束要求也比Java中的约束要小。在Java中如果两个方法的名称和参数列表相同,就算返回值类型不同也不可以共存,在Class中只要两个方法的描述符(方法描述符包含方法的入参和返回值)不一致,两个方法就可以共存。

1.5、属性表(attribute-info)

属性表在一开始的设计上就考虑到未来的可扩展性,比如JDK1.1引入的内部类、JDK1.5引入的泛型和动态注解,JDK1.6引入的新类型检查验证器,都是仅对属性表进行扩充,而对其他的表改动甚少。下面选几个常见属性:

(1)Code属性

Code属性出现在方法表的属性集合之中,存储方法体的字节码指令流。

Code属性表的结构 —— 《深入理解Java虚拟机》
这里写图片描述

attribute-name-index指向的常量的值固定为“Code”; max-stack和max-locals在编译时就可以确定大小了,max-stack表示操作数栈的最大深度;max-locals表示局部变量表的最大空间。Slot是虚拟机为局部变量分配内存使用的最小单位,double、long型变量占有2个Slot,除double、long以外的每个局部变量占用1个Slot。局部变量所占用的Slot在超出其作用域之外后可以被复用,所以一般来说局部变量表的大小都会比局部变量个数要小。

(2)Exceptions属性

用于列举出方法中可能抛出的受检查异常(Checked Exceptions)

(3)LineNumberTable属性

用于描述Java源码行号与字节码行号(字节码的偏移量)之间的对应关系,它能使抛出异常时,堆栈中显示出错的行号、调试程序时可按照源码行来设置断点。

(3)LineNumberTable属性

用于描述栈帧中局部变量表中的变量与Java源码中定义的变量名称的对应关系。若取消生成这项信息,则IDE会使用诸如arg0、arg1之类的占位符代替原有的参数名,并且在调试期间无法根据参数名称从上下文中获取参数值。

(4)SourceFile属性

用于记录生成这个Class文件的源码文件的名称。若取消生成这项信息,那么当抛出异常时,堆栈中将不会显示出错代码所属的文件名。

(5)ConstantValue属性

虚拟机对于非static变量的赋值时在实例构造器<init>方法中进行的;对于类变量可以在类构造器<clinit>方法中进行或使用ConstantValue属性。Sun Javac限制若同时使用final和static来修饰一个变量的话,就生成ConstantValue属性来进行初始化,否则就在<clinit>方法中进行,并且ConstantValue的属性值只能限于基本类型和String。

(6)InnerClasses属性

用于记录内部类与宿主类之间的关联。

(7)Signature属性

用于记录泛型签名信息。现在Java的反射API能够获取泛型类型,最终的数据来源就是这个属性。

二、字节码指令

顾名思义,Java虚拟机的指令全都一个字节长度的代表着某种特定操作含义的数字(称为操作码)。由于Java虚拟机采用面向操作数栈而不是面向寄存器的架构,所以大多数的指令都不包含操作数。

又由于Class文件格式放弃了编译后代码的操作数长度对齐,所以虚拟机处理超过一个字节数据的时候,就需要在运行时从字节中重建出具体数据的结构,这能带来一些性能上的损失,但也意味着可以获得短小精干的编译代码。

字节码执行模型 —— 《深入理解Java虚拟机》
这里写图片描述

(1)字节码与数据类型

字节码指令大多数都包含了其操作所对应的数据类型信息,然而有些指令都没有支持byte、char和short类型,甚至没有任何指令支持boolean类型。对于这些类型,会在编译期或运行期将其带扩展为相应的int类型数据,使用对应的int类型的字节码指令来处理。

(2)加载和存储指令

加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输。面向操作数栈的指令集架构,其运算都是在操作数栈上进行的,局部变量表只为存储中间数据用。

(3)运算指令

包括加减乘除、求余、取反、位移、按位或/与/异或、局部变量自增、比较。

(4)类型转换指令

在宽化类型转换时无需显式的转换指令,在窄化类型转换时必须显式地使用转换指令。

(5)对象创建与访问指令

虽然类实例和数组都是对象,但虚拟机对它们俩的创建与操作使用了不用的字节码指令。

(6)操作数栈指令

用于直接操作操作数栈,包括栈顶元素出栈、复制栈顶元素并重新压入栈顶、栈顶两元素互换。

(7)控制转移指令

可以认为控制转移指令就是在有条件或无条件地修改PC寄存器的值。

(8)方法调用和返回指令

决定方法的分派和调用

(9)异常处理指令

用于显式抛出异常的操作,注意这与异常的处理不同,异常的处理是通过Code属性中的异常表来实现的。

(10)同步指令

方法级的同步时隐式的,无须通过字节码指令来控制,它实现在方法调用和返回操作之中,而Java中的synchronized语句块就需要monitorenter和monitorexit两条指令来支持。

正确实现synchronized关键字需要Javac编译器和Java虚拟机两者共同协作支持。编译器必须确保无论方法通过何种方式完成,方法中调用过的每条monitorenter指令都必须执行其对应的monitorexit指令,并且为了保证在方法异常完成时monitorenter和monitorexit依然可以正确配对执行,编译器会自动产生一个异常处理器,其声明为可处理所有的异常,目的就是用来执行monitorexit指令。

三、总结

整个虚拟机执行子系统有三大过程:(1)类文件的生成(2)类加载(3)字节码执行。本文只讲了第一个过程,并且也只讲了类文件生成后的样子,而没有讲类文件的生成过程(Javac的处理过程)。

本文大量参考周志明的《深入理解Java虚拟机》,很多是我挑其重点而录的原话,有些是我阅读后的个人理解。在这里再次对这本书表以崇高的敬意。

四、参考书籍

《深入理解Java虚拟机》—— 周志明
第6章 类文件结构

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值