类文件结构

Java语言的无关性

 从事Java语言开发工作的人都知道,Java语言有一个非常著名的宣传口号就是——一次编写,到处运行。那到底是怎么做到“一次编写,到处运行”的呢?

 其实,我们知道计算机在底层只认识0和1这种二进制格式,如C,C++这些平台相关的高级语言都是由编译器将源代码翻译成0和1构成的二进制代码才能由计算机执行,它们的具体实现需要依赖底层的操作系统和不同的CPU指令集。但是近年来,大量建立在虚拟机上的语言蓬勃发展,将源程序编译成二进制本地机器码已经不再是唯一的选择,越来越多的程序语言选择了与操作系统和机器指令集无关的,平台中立的格式作为程序编译后的存储格式——字节码存储格式。Java语言就是运行于Java虚拟机之上,使用字节码存储格式的语言,Java语言之所以宣称其平台无关性就是由于字节码存储格式。

字节码存储格式是实现平台无关性和语言无关性的基础:

  • 平台无关性:不管底层是什么操作系统(windows,linux),虚拟机都可以载入与平台无关的字节码。在虚拟机这一层做了屏蔽,不同操作系统对应着不同的虚拟机实现。

  • 语言无关性:Java虚拟机并非面向Java语言或者其他建立在其上的其他语言——Clojure,Groovy,JRuby,Jython,Scala等,而是面向字节码,即我们所熟知的“Class文件”。因此,Java虚拟机并不关心什么语言运行其上,它只关心字节码文件。字节码文件中包含了Java虚拟机指令集和符号表以及若干其他辅助信息。

image

Class类文件结构

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

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

 Class文件采用一种类似于c语言结构体的伪结构体来存储数据,这种伪结构体有两种数据类型:无符号数

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

  • 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表习惯性以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张表。

下表表示Class文件的格式

类型名称解释数量
u4magic魔数:0xCAFEBABE1
u2minor_version次版本号1
u2major_version主版本号1
u2constant_pool_count常量池容量计数值1
cp_infoconstant_pool常量池入口constant_pool_count-1
u2access_flags访问标志1
u2this_class类索引1
u2super_class父类索引1
u2interfaces_count接口容量计数值1
u2interfaces接口索引集合interfaces_count
u2fields_count字段容量计数值1
field_infofields字段表fields_count
u2methods_count方法容量计数值1
method_infomethods方法表methods_count
u2attributes_count属性表容量计数值1
attribute_infoattributes属性表attributes_count

为了便于分析Class文件中的各个属性,现在提供一段示例代码,并使用十六进制编译器WinHex打开这个Class文件的结果进行分析

示例代码如下:

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

WinHex打开这个Class文件后结果如下:

image

魔数与Class文件版本

 每个Class文件的头4个字节称为魔数:0xCAFEBABE(从上面的16进制结果中可以看到),它的唯一作用是确定这个文件是否是一个能被虚拟机接受的Class文件。

 紧接着魔数的4个字节存储的是Class文件的版本号:第5,6字节存储的是次版本号,第7,8字节存储的主版本号。Java的版本号是45开始的,高版本的JDK能向下兼容低版本的Class文件,但不能运行更高版本的Class文件。通过上图可知:次版本号为 0x0000,十进制数为 0;主版本号为 0x0034,十进制数为 52,版本号为:52.0,说明该文件是 JDK1.8 及以上的版本虚拟机执行的Class文件。

常量池

为了便于分析,使用 javap -verbose 工具得到类的文件字节码内容,给出常量池部分的字节码内容。

常量池字节码内容:

Compiled from "TestClass.java"
public class TestClass
minor version: 0 //次版本号
major version: 52 //主版本号
flags: ACC_PUBLIC, ACC_SUPER //标志位
Constant pool: //常量池
#1 = Methodref          #4.#15         // java/lang/Object."<init>":()V
#2 = Fieldref           #3.#16         // TestClass.m:I
#3 = Class              #17            // TestClass 类索引
#4 = Class              #18            // java/lang/Object 父类索引
#5 = Utf8               m
#6 = Utf8               I
#7 = Utf8               <init>
#8 = Utf8               ()V
#9 = Utf8               Code
#10 = Utf8               LineNumberTable
#11 = Utf8               inc
#12 = Utf8               ()I
#13 = Utf8               SourceFile
#14 = Utf8               TestClass.java
#15 = NameAndType        #7:#8          // "<init>":()V
#16 = NameAndType        #5:#6          // m:I
#17 = Utf8               TestClass
#18 = Utf8               java/lang/Object

 主次版本号之后是常量池入口(constant_pool),常量池可以理解为Class文件之中的资源仓库:

  • 常量池是Class文件结构中与其他项目关联最多的数据类型

  • 常量池是占用Class文件空间最大的数据项目之一

  • 常量池是Class文件中出现的第一个表类型数据项目

 常量池常量的数量是不固定的,所以设置了一个u2类型的数据项用来表示常量的数量——常量池容量计数值(constant_pool_count)。这里需要注意的是:容量计数是从1开始而不是从0开始的。通过WinHex的结果可知,主版本号之后的两位代表常量的个数:0x0013,十进制数为 19,代表有 18 个常量,索引范围为:1 —— 18,通过观察常量池的字节码内容,我们也可以得到有 18 个常量。

 常量池中主要存放两大类常量:字面量和符号引用。字面量主要包括文本字符串,声明为final的常量值等。符号引用有如下三类常量:

  • 类和接口的全限定名

  • 字段的名称和描述符

  • 方法的名称和描述符

常量池中存储的项目如下:

类型描述标志
CONSTANT_UTF-8_infoUTF-8编码的字符串1
CONSTANT_Integer_info整型字面量3
CONSTANT_Float_info浮点型字面量4
CONSTANT_Long_info长整型字面量5
CONSTANT_Double_info双精度浮点型字面量6
CONSTANT_Class_info类或接口的符号引用7
CONSTANT_String_info字符串类型字面量8
CONSTANT_Fieldref_info字段的符号引用9
CONSTANT_Methodref_info类中方法的符号引用10
CONSTANT_InterfaceMethodref_info接口中方法的符号引用11
CONSTANT_NameAndType_info字段或方法的部分符号引用12
CONSTANT_MethodHandle_info表示方法句柄15
CONSTANT_MethodType_info标识方法类型16
CONSTANT_InvokeDynamic_info表示一个动态方法调用点18

 通过上表可知,常量池中存储着14个项目表,代表着不同类型数据的符号引用和字面量值。它们有一个共同点就是表开始的第一位都是一个u1的标志位,代表当前这个常量属于哪种常量类型。

访问标志

 常量池之后的两个字节代表访问标志,这个标志用于识别类或接口层次的访问信息:

标志名称标志值含义
ACC_PUBLIC0x0001是否为public类型
ACC_FINAL0x0010是否被声明为final,类可以设置,接口不可以
ACC_SUPER0x0020是否使用invokespecial的新语义
ACC_INTERFACE0x0200标识这是一个接口
ACC_ABSTRACT0x0400是否为abstract类型,对于接口或者抽象类来说,此标志值为真,其他类值为假
ACC_SYNTHETIC0x1000标识这个类并非由用户代码产生
ACC_ANNOTATIONox2000标识这是一个注解
ACC_ENUM0x4000标识这是一个枚举

 从上面示例代码的常量池信息中可以看到,其标志位为:ACC_PUBLIC, ACC_SUPER。ACC_SUPER 为真,因为在Java中,除 java.lang.Object类没有父类之外,其他每个没有显示继承的类都是 java.lang.Obejct 类的子类。

三大索引——类索引,父类索引,接口索引集合

 接着访问标志之后是类索引(this_class),父类索引(super_class)和接口索引集合(interfaces),类索引和父类索引都是一个u2类型的数据,而接口索引集合是一组u2类型的数据集合,Class文件中由这三项数据来确定这个类型的继承关系。

  • 类索引用于确定这个类的全限定名

  • 父类索引用于确定这个类的父类的全限定名,由于Java语言是单继承的,所以父类索引只有一个。除了java.lang.Object类之外,所有的Java类都有父类,因此除了java.lang.Object之外,所有Java类的父类索引都不为0

  • 接口索引集合用来描述一个类实现了哪些接口,这些被实现的接口将按照implements(接口和接口是继承extends)后的接口顺序从左到右排列在接口索引集合中

 类索引,父类索引的两个u2类型的索引值表示各自指向一个类型为CONSTANT_Class_info的类型描述符常量,通过CONSTANT_Class_info类型的常量中的索引值可以找到定义在CONSTANT_Utf8_info类型常量中的全限定名字符串。接口索引集合入口的第一项是一个u2类型的接口计数器,表示索引表的容量。如果该类没有实现任何接口,则该计数值为0,后面接口的索引表(u2)不占用任何字节。

字段表集合

 字段表(field_info)用于描述接口或者类中声明的变量。字段包括类变量(static)和实例变量,但不包括在方法内部声明的局部变量。Java中可以使用如下限定符描述字段:

  • 字段作用域:public,private,protected,默认

  • 实例变量(new)还是类变量(static修饰)

  • 可变性(final)

  • 并发可见性(volatile)

  • 可否被序列化(transient)

  • 字段数据类型(基本类型,对象,数组)

  • 字段名称

 在上面这些描述信息中,各个修饰符都是布尔值,要么有某个或几个修饰符,要么没有,适合使用标志位来表示。而字段的名字,字段的类型是无法固定的,只能用常量池中的常量来描述。

如下是字段的访问标志表:

标志名称标志值含义
ACC_PUBLIC0x0001字段是否是public
ACC_PRIVATE0x0002字段是否为private
ACC_PROTECTED0x0004字段是否为protected
ACC_STATIC0x0008字段是否为static
ACC_FINAL0x0010字段是否为final
ACC_VOLATILE0x0040字段是否为volatile
ACC_TRANSIENT0x0080字段是否为transient
ACC_SYNTHETIC0x1000字段是否由编译器自动产生
ACC_ENUM0x4000字段是否为enum

三个概念解析:全限定名,简单名称,描述符

  • 全限定名:将类名的“.”替换成“/”。例如:类名 com.yuangh.myclass.myjava,它的全限定名为 com/yuangh/myclass/myjava

  • 简单名称:没有类型或者参数修饰的方法或者字段名称,例如上面代码示例中inc()方法和m字段的简单名称分别是“inc”和“m”

  • 方法和字段的描述符要相对复杂一些,描述符的作用是用来描述字段的数据类型,方法的参数列表(包括数量,类型以及顺序)和返回值。下表示描述符的标识字符含义:

    标识字符含义标识字符含义
    B基本类型byteJ基本类型long
    C基本类型charS基本类型short
    D基本类型doubleZ基本类型boolean
    F基本类型floatV特殊类型void
    I基本类型intL对象类型L,如Ljava/lang/Object

    对象使用字符L加对象的全限定名来表示,如果是数组类型,每一个维度使用一个前置的“[”字符来描述

 字段表集合中不会列出从超类或者父接口中继承而来的字段,但有可能列出原本Java代码中不存在的字段(例如:内部类为了保持对外部类的访问性,会自动添加指向外部类实例的字段)。在Java中字段时不可以重载的,但是在字节码层面,只要两个字段的描述符不一致,那么字段重名就是合法的。

方法表集合

 接着字段表之后是方法表,其实方法表和字段表有很多相似的地方。这里就不多赘述,直接给出方法的访问标志表如下:

方法的访问标志表:

标志名称标志值含义
ACC_PUBLIC0x0001方法是否是public
ACC_PRIVATE0x0002方法是否为private
ACC_PROTECTED0x0004方法是否为protected
ACC_STATIC0x0008方法是否为static
ACC_FINAL0x0010方法是否为final
ACC_SYNCHRONIZED0x0020方法是否为synchronized
ACC_BRIDEG0x0040方法是否为编译器产生的桥接方法
ACC_VARARGES0x0080方法是否接收不定长参数
ACC_NATIVE0x0100方法是否为native
ACC_ABSTRACT0x0400方法是否为public
ACC_STRICTFP0x0800方法是否为strictfp
ACC_SYNTHETIC0x1000方法是否由编译器自动产生

 上表中列出的是可以修饰方法的所有修饰符。方法的定义可以通过访问标志,名称索引,描述符表达清楚。方法里面的Java代码经过编译器编译成字节码指令后,存放在方法属性表集合中一个名为“Code”的属性里面,关于属性表下文详细说明。与字段表类似,如果父类方法在子类中没有重写,子类方法表集合中就不会出现来自父类的方法信息。 但是会出现编译器自己添加的方法,例如:类构造器方法 () 和实例构造器方法 ()。

属性表集合

Class文件,字段表,方法表都可以携带自己的属性表集合。属性表集合中有很多非常重要的属性,下面挑几个进行说明。

1. Code属性

 Java程序方法体中的代码经过javac编译器处理后,最终变成字节码指令存储在Code属性内。Code属性出现在方法表的属性集合中,但并非所有的方法都必须存在这个属性,例如接口或者抽象类中的方法就不存在Code属性。

如下是示例代码的字节码文件(非常量池内容)

非常量池字节码内容:

{
  public TestClass();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:   //Code属性
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 1: 0

  public int inc();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: getfield      #2                  // Field m:I
         4: iconst_1
         5: iadd
         6: ireturn
      LineNumberTable:
        line 6: 0
}
SourceFile: "TestClass.java"

注意:Java虚拟机执行字节码时基于栈的体系结构。

 通过上面的字节码来解释Code属性表中的几个重要参数:max_stack,max_locals,code_length和code

  • max_stack:代表操作数栈深度的最大值,就是字节码文件中的 stack。在方法执行的任意时刻,操作数栈都不会超过这个深度。虚拟机运行的时候需要根据这个值来分配栈帧中的操作栈深度。

  • max_locals:代表了局部变量表所需要的存储空间,就是字节码文件中 locals。max_locals的单位是Slot,Slot是虚拟机为局部变量分配内存所使用的最小单位。对于byte,char,float,short,boolean,returnAddress等长度不超过32位的数据类型,每个局部变量占用一个Slot,而double和long这两种64位的数据类型则需要两个Slot来存放。并不是在方法中用到了几个局部变量,max_locals的值就是这些局部变量之和,因为局部变量表中的Slot是可以重用的,javac编译器会根据变量的作用域来分配Slot给各个变量使用,然后计算出max_locals的大小。

  • ode_length和code:存储Java程序编译后生成的字节码指令,code_length代表字节码长度,code是用于存储字节码指令的一系列字节流。注意:code_length虽然是一个u4类型的长度值,理论上最大值可以是 2^32 - 1,但是虚拟机规范明确规定了一个方法不允许超过65535条字节码指令,实际只是用了u2的长度,如果超过这个限制,javac编译器会拒绝编译。

 继续讨论上面的字节码,解释了stack和locals,还有一个参数是 args_size,代表方法中传递的参数个数。但是看了这两个方法会有点疑问:该类的两个方法——实例构造器()和一般方法inc(),我们并没有看到方法中的参数,为什么 args_size=1 呢?而且还有一点,在方法体中也没有定义任何局部变量,为什么 locals=1 呢?其实这就是我们需要重点注意的:在任何实例方法里都可以通过“this”关键字访问到此方法所属的对象。this 这个参数是编译器自动添加的,javac编译器编译的时候会把this关键字的访问转变为对一个普通参数的访问,然后在虚拟机调用实例方法时自动传入此参数。因此在实例方法的局部变量表中至少会存在一个指向当前对象实例的局部变量,局部变量表中也会预留出第一个Slot位来存放对象实例的引用,方法参数值从1开始计算。这只对实例方法有效,如果是用static修饰的方法,则不会有 this 这个引用。

2. ConstantValue

 ConstantValue 属性的作用是通知虚拟机自动为静态变量赋值,只有被static关键字修饰的变量(类变量)才可以使用该属性。

  • 对于非static类型的变量(实例变量)赋值是在实例构造器方法中进行

  • 对于类变量,则由两种方式可以选择:

    • 在类构造器方法中使用进行初始化
    • 使用ConstantValue

    目前Sun Javac的选择是:如果同时使用final和static来修饰一个变量(常量),并且这个变量的数据类型是基本数据类型或者java.lang.String的话,就生成ConstantValue属性来进行初始化;如果这个变量没有被final修饰,或者并非基本数据类型和字符串,则会选择在方法中进行初始化。

参考

《深入理解Java虚拟机》

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值