浅析Class文件

来源:互联网 发布:网络信息监控 编辑:程序博客网 时间:2024/06/08 08:48

前言

Java虚拟机的作用就是将编译器编译后的字节码转变为机器能识别的指令,并将程序运行起来。Java虚拟机并不是只能运行Java语言编写的程序,也可以运行其他的语言,例如Clojure、Groovy、JRuby、Jython、Scala等。与平台无关性一样,语言无关性也是Java虚拟机的特点,能让Java虚拟机运行多种语言编译的程序,是因为虚拟机只关心“Class文件”这类储存字节码的二进制文件。因为虚拟机能运行多种语言是依靠Class文件,所以虚拟机对Class文件的语法和结构有强制性的约束,来保证安全性,避免对虚拟机造成破坏。

Class文件结构

Class文件是一组以8位(1字节)为基础单位的二进制流,没有任何分隔符。当需要用到一个字节以上的空间时,会按照高位在前(Big-Endian)的方式进行储存。

Class文件格式中只有两种数据类型:无符号数。无符号数以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成的字符串值。表是由多个无符号数或者其他表作为数据项构成的复合数据类型,表类型都会以“_info”结尾,整个Class文件本质上就是一张表。

下表是Class文件的数据项构成:

类型 名称 数量 u4 magic(魔数) 1 u2 minor_version(次版本号) 1 u2 major_version(主版本号) 1 u2 constant_pool_count(常量池计数值) 1 cp_info constant_pool(常量池) constant_pool_count-1 u2 access_falgs(访问标志) 1 u2 this_class(类索引) 1 u2 super_class(父类索引) 1 u2 interfaces_count(接口索引数) 1 u2 interfaces(接口索引) interfaces_count u2 fields_count(字段表长度) 1 field_info fields(字段表) fields_count u2 method_count(方法表长度) 1 method_info method(方法表) method_count u2 attributes_count(属性表长度) 1 attribute_info attributes(属性表) attributes_count

如上表中所示,Class的结构对顺序、数量还有字节序这样的细节规定得非常严格,不允许改变。

魔数与版本号

Class文件的头4个字节用来表示魔数,唯一的作用就是标识这个文件为虚拟机可接受的文件,例如GIF或者JPEG等文件都使用了魔数。Class文件的魔数值为:0xCAFEBABE(咖啡宝贝)。紧接着魔数的后4个字节是Class文件的版本号,第5和第6个字节是次版本号,第7和第8个字节是主版本号。Java的版本号是从45(十进制)开始的,JDK 1.1之后的每个JDK大版本发布主版本号向上加1(JDK 1.0 ~ 1.1使用的是 45.0 ~ 45.3的版本号)。高版本的JDK能向下兼容低版本的Class文件,但是不能向上兼容,即使文件格式是正确的虚拟机也会拒绝执行。

常量池

在主版本号之后是常量池的入口,因为常量池长度不是固定的,所以入口是一个u2类型的计数值,来表示常量池里常量的个数,是Class文件中唯一一个从1开始的计数值,因为之后的某些指向常量池的索引值为0来表示不引用任何一个常量。常量池是Class文件的资源区域,很多结构中其他的部分都会指向常量池,因此常量池是Class文件中占用空间最大的项目之一。

常量池中主要存放两大类常量:字面量符号引用。字面量就例如本文字符串和声明为final的常量值,而符号引用属于编译方面的概念,包含以下三类:

  • 类和接口的全限定名:全限定名是把包名中的“.”换成“/”,并在最后加一个“;”为的是连续的多个全限定名之间做分隔。
  • 字段的名称和描述符
  • 方法的名称和描述符

常量池目前总有14种类型,每个类型都有自己的结构,但有一个共同的特点是,第一位是一个u1类型的标志位,用来标志是哪种类型的常量。就不贴具体的标记类型了,需要可以自己搜索。我们可以利用JDK的bin目录下的javap工具来计算出Class文件中的整个常量池。

这里要特别注意一点,UTF-8的常量结构有一个类型是u2的长度字段,因此属性名和方法名的长度都不能超过65535,否则会导致无法编译。

访问标志

访问标志用于识别一些类或者接口层次的访问信息,包括这个Class是类还是接口,是否是public类型,是否是abstract类型等。下表是具体的标志位以及标志含义:

标志名称 标志值 含义 ACC_PUBLIC 0x0001 是否为public类型 ACC_FINAL 0x0010 是否被声明为final,只有类可以设置 ACC_SUPER 0x0020 是否允许使用invokespecial字节码指令的新语意,JDK 1.0.1之后这个标志必须为真 ACC_INTERFACE 0x0200 标识这是一个接口 ACC_ABSTRACT 0x0400 是否是abstract类型 ACC_SYNTHETIC 0x1000 标识这个类并非由用户代码产生的 ACC_ANNOTATION 0x2000 标识这是一个注解 ACC_ENUM 0x4000 标识这是一个枚举

访问标志共有16个标志位可以用,当前只定义了其中8个,没有使用到的一律为0。提示:两个字节一共有16位,每位的1或0代表着这个标志是真或假,将每个标志值进行‘或’运算即得出最后的访问标志值。

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

Class文件中的这三项用来确定这个类的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。因为Java语言不允许多种继承,因此父类索引只有一个,java.lang.Object的父类索引为0。

接口索引集合的入口是u2类型的集合长度,如果没有实现任何接口计数值则为0,后面的索引表不再占用任何字节。

字段表集合

字段表用于描述接口或者类中声明的变量。字段包含类级变量以及实例级变量,但不包含在方法体内声明的局部变量。集合中不会列出从超类或者父类继承而来的字段,但是有可能列出原本Java代码中不存在的字段,例如内部类为了保持对外部类的访问,会自动添加指向外部类的实例字段。

字段表的结构如下表:

类型 名称 数量 u2 access_flag 1 u2 name_index 1 u2 descriptor_index 1 u2 attributes_count 1 attribute_info access_flag attributes_count

access_flag是字段修饰符,与类的访问标志大同小异,在此不再介绍。name_index是字段的简单名称,例如“private int a”,“a”就是简单名称。descriptor_index是字段的描述符,描述符的作用是用来描述字段的数据类型。下表是10中描述符表示的字符含义:

标识字符 含义 标识字符 含义 -B 基本类型byte J 基本类型long C 基本类型char S 基本类型short D 基本类型double Z 基本类型boolean F 基本类型float V 特殊类型void I 基本类型int L 对象类型,如Ljava/lang/Objet

如果字段是数组类型,一个维度在前面加一个“[”,例如“int[]”这样的整型数组的描述符就是“[I”。所以上文中a字段的描述符为I。

注意:在descriptor_index之后还跟着一个属性表用于储存一些额外的信息,字段表里的每一项数据都包含一个属性表,这个属性表就是下小节介绍的属性表,意思就是说,属性表是穿插在某些特定位置的一个表,并不是单独提取总结出来的一个表。

方法表集合

方法表其实和字段表相差无几,它的结构和字段表一样,仅在访问标志和属性表集合的可选项中有所区别。还有一点需要补充的是descriptor_index方法的描述符,按照参数列表在前,返回值在后的顺序。参数列表放在一组小括号“()”中,例如方法int sum(int a, int b)的描述符为“(II)I”。

与字段表一样,如果父类方法在子类中没有被重写,那么方法表集合中就不会出现来自父类的方法信息。在Java类中重载一个方法,除了要求方法的简单名称相同之外,还必须有不同的特征签名,这个特征签名包含了方法名称、参数顺序及参数类型,因此只是返回值不同,不能算方法重载。

属性表集合

之前讲述了某些位置(例如类文件、字段表、方法表、Code属性等)的每一项之后都会跟着一个属性表,属性表不是单独提取总结成的一个表。虚拟机对属性表的规定没有那么严格,编译时任何人可以在不重名的情况下写入自定义的属性,但是虚拟机在运行时会忽略掉它不认识的属性。为了能正确解析Class文件,规范中还是预定义了21项属性,属性表的结构如下表:

类型 名称 数量 u2 attribute_name_index 1 u4 attribute_length 1 u1 info attribute_length

attribute_name_index是指向UTF-8常量的索引,每个类型的属性的attribute_name_index是固定的。attribute_length代表着属性的长度,这个长度是表的总长减去名字和长度占用的6个字节。info是属性的具体内容,不同的属性有不同的结构。

下面介绍几个重要的属性:

Code 属性

Java程序方法体中的代码经过编译器处理后最终变为字节码指令储存在Code属性内,因此Code属性是Class文件中最重要的一个属性。它的结构如下:

类型 名称 数量 u2 attribute_name_index 1 u4 attribute_length 1 u2 max_stack 1 u2 max_locals 1 u4 code_length 1 u1 code code_length u2 exception_table_length 1 exception_info exception_table exception_table_length u2 attributes_count 1 attribute_info attributes attributes_count

max_stack代表了操作数栈深度的最大值,方法在执行时操作数栈都不会超过这个深度,虚拟机在运行时需要根据这个值来分配栈帧中操作栈的深度。

max_locals代表了局部变量表所需的储存空间,单位是Slot,每个不超过32位的数据类型占用1个Slot,double和long占用2个Slot。方法参数、显式异常处理器的参数、方法体中定义的参数都需要局部变量表来存放,但并不是方法体中用到了多少参数就把所占用的Slot数作为max_locals的值,原因是当代码执行超出一个局部变量表的作用域时,这个局部变量所占用的Slot可以被其他局部变量所使用,javac编译器会根据变量的作用域来分配Slot给各个变量使用,然后再计算出max_locals的大小。需要注意一点的是,有些方法中没有参数,但是生成的方法表中却显示有一个参数,这是因为在实例方法里,都可以通过“this”关键字访问到此方法所属的对象,虚拟机会自动传入此参数放在局部变量表的第一个Slot位中。

code_length和code用来储存Java源程序编译后生成的字节码指令。code_length代表字节码长度,code用于储存字节码指令的一系列字节流。每个字节码指令占用一个字节,也就是可以代表256条指令,目前虚拟机规范已经定义好了其中200条编码值对应的指令含义。code_length是u4类型的,但是规范中明确限制了一个方法中不允许超过65535条指令。

在字节码指令之后是这个方法的显式异常处理表,异常表不是必须存在的,它是Java代码中的一部分,编译器使用异常表来实现Java异常及finally的处理机制。

Code属性也可以有自己的属性表,表里可以包含例如下文会提到的LineNumberTable 属性或LocalVariableTable 属性。

Exceptions 属性

Exceptions 属性是属于方法的,并不是上文提到的异常表。Exceptions 属性的作用是列出方法中可能抛出的受查异常,也就是throws关键字后列举出的异常。

LineNumberTable 属性

LineNumberTable 属性用于描述Java源码行号与字节码行号(偏移量)之间的对应关系,如果选择不生成这个属性,在抛出异常时则不会显示出错的行号,调试时也无法按照源码行来设置断点。

LocalVariableTable 属性

LocalVariableTable 属性用于描述栈帧中局部变量表中的变量与Java源码中定义的变量之间的关系,如果选择不生成这个属性,IDE将会使用例如arg0、arg1之类的占位符来代替原有的参数名。

ConstantValue 属性

ConstantValue 属性用于通知虚拟机自动为静态变量赋值,只有被static关键字修饰的类变量才可以使用这个属性。如果使用了static关键字的类变量也使用了final关键字,就会生成这个属性来初始化,如果没有则在类构造器方法中初始化这个类变量。

InnerClasses 属性

InnerClasses 属性用于记录内部类与宿主类之间的关联,如果一个类中定义了内部类,编译器则会为它及它所包含的内部类生成InnerClasses 属性。

认识ClassLoader

ClassLoader就是类加载器,用于将Class文件的二进制字节流传递给虚拟机,设计出类加载器是为了让应用程序自己决定如何去获取所需要的类。对于任意一个类,都需要通过它的类加载器和类本身来确立它在虚拟机中的唯一性。判断类是否相等除了来源于同一个Class文件外,还需要被同一个类加载器加载才算相等。

双亲委派模型

从Java虚拟机来说,只存在两种类加载器,一种是启动类加载器,这个加载器由C++语言实现,是虚拟机自身的一部分;另一种是其他的类加载器,这些加载器由Java语言实现,并全都继承自抽象类java.lang.ClassLoader。

JDK默认提供了3种类加载器:

  1. 启动类加载器(Bootstrap ClassLoader):这个类加载器负责将存放在<JAVA_HOME>\lib目录中的或是被所属参数指定的路径中的,并且能被虚拟机识别的类库。启动类加载器无法被Java程序直接引用,当需要引用时,直接用null代替即可。
  2. 扩展类加载器(Extension ClassLoader):这个类加载器负责将存放在<JAVA_HOME>\lib\ext目录中的或是被所属参数指定的路径中的所有类库,扩展类加载器是用JAVA语言编写,由sun.misc.Launcher$ExtClassLoader实现。
  3. 应用程序类加载器(Application ClassLoader):这个类加载器由sun.misc.Launcher$AppClassLoader实现,它负责加载用户路径上所指定的类库。这个类加载器是ClassLoader中getSystemClassLoader()方法的返回值,所以是系统默认的类加载器,可以供开发者使用。

如果有必要,开发者可以加入自己定义的类加载器。下图是虚拟机中这些类加载器的层次关系:

img

这种关系称为双亲委派模型,意思就是当我们需要加载一个类时,先委托自己的父加载器去尝试加载,所以最开始也就是最顶端的启动类加载器会去尝试加载,如果没找到目标Class就会再一层一层往下转递尝试加载,如果到了自己还没加载成功,则会抛出异常。

这样的好处是使Java类随着加载器具备了一种带有优先级的层级关系。例如Object类,它是Java语言中最基本的类,当需要加载时最终都优先交给启动类去加载,保证了这个类在Java程序中的唯一性。如果不利用这样的模型去加载,可能会导致程序中出现多种Object类,使Java体系一片混乱。

双亲委派模型并不是一个强制性的约束模型,而是设计者推荐给开发者的类加载器的实现方式。现实中也存在很多违反了双亲委派模型的例子,比如OSGi模块化技术。

类加载

类加载时机

一个类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括7步:

  • 加载
  • 验证
  • 准备
  • 解析
  • 初始化
  • 使用
  • 卸载

其中加载、验证、准备、初始化和卸载这5个阶段是顺序是确定的,他们会按照这个顺序开始,但不是必须按照这个顺序进行,因为他们可能会互相交叉地混合式进行。解析阶段可能在初始化阶段之后才开始。

什么时候进行加载并没有强制约束,可以由虚拟机自由把握。但是初始化阶段在以下5种情况下必须立即开始(加载、验证、准备自然需要在此之前完成):

  1. 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行初始化过需先触发其初始化。这4条指令的使用场景:new关键字实例化对象、读取或设置一个类的静态字段(被final修饰的除外)、调用一个静态方法。
  2. 使用反射。
  3. 当初始化一个类时其父类还没有初始化,则需先触发其父类的初始化。
  4. 当虚拟机启动用户需要指定一个入口(包含main()方法的那个类)。
  5. 当使用动态语言支持时。我不理解这个概念所以先不解释了,以后再说

上述5种情况称为主动引用,引用不会触发初始化的称为被动引用,下面举3个被动引用的例子:

  • 通过子类引用父类的静态字段,子类不会初始化。
  • 创建数组引用的类不会初始化,但是会触发一个叫做“数组类”的初始化。
  • 引用被final修饰的static字段在编译时会存入调用类的常量池中,因此不会触发类的初始化。

接口的初始化与类初始化不同的一点是在接口初始化时,并不要求其父接口也必须全部完成初始化,只有在使用到父接口时才会初始化。

类加载过程

加载

加载就是利用上文提到的类加载器将一个Class文件加载进虚拟机内存中,虚拟机需要完成3件事:
1. 通过一个类的全限定名来获取定义此类的二进制流;
2. 将二进制流所代表的静态储存结构转化为方法区的运行时数据结构;
3. 在内存中生成一个代表这个类的Class对象,作为方法区这个类的各种数据的访问入口。

在HotSpot虚拟机中,Class对象比较特别,是存放在方法区中的。在加载还未完成的时候,连接(验证、准备、解析)阶段可能已经开始了。还是那句话,类加载并不保证按顺序完成,但是一定是按顺序开始执行的

验证

验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机的自身安全。如果开发者尝试将一个对象强转成它并未实现的类型等虚拟机不支持的行为,编译器将会拒绝编译。但是Class文件不一定是Java编译编译而来,甚至可以自己用十六进制编写,所以如果虚拟机不验证Class文件,上述的执行指令将会对虚拟机产生危害甚至崩溃。

验证阶段主要完成4个检验动作:

  1. 文件格式验证:只有通过文件格式的验证,字节流才会进入内存的方法区中储存。
  2. 元数据验证:对字节码描述的信息进行语义分析,例如是否有父类、是否继承了不允许被继承的类等。
  3. 字节码验证:对类的方法体进行校验分析,保证类的方法运行时不会做出危害虚拟机安全的事件。
  4. 符号引用验证:符号引用验证将在解析阶段发生,对类常量池中的各种符号引用进行匹配性校验,目的是为了确保解析动作能正常执行。

准备

准备阶段正式为类变量分配内存并设置类变量初始值,这些类变量所使用的内存都将在方法区中分配。这个初始值赋值是指数据类型的零值,并不是赋予的真实值,真正赋值是在初始化阶段的类构造器方法中。

通常情况下是这样的,但是有一些特殊情况,比如字段属性表中存在ConstantValue属性(被final修饰),那么类变量在准备阶段就会被赋予真实值。

解析

解析是虚拟机将常量池内的符号引用替换为直接引用的过程。虚拟机规范中并没有规定解析阶段的具体发生时间,只要求了在特定16中字节码指令之前完成即可,因此虚拟机实现可以根据需要来判断是在类加载器加载是就对常量池里的符号引用进行解析还是等到一个符号引用将要被使用前才去解析。

类或接口的解析

如果在Java程序代码的某处需要使用到一个类,会在当前代码所在类的类加载器去加载这个使用到的类,由于元数据和字节码验证,又会触发其他类的加载动作。如果这个需要使用的类是数组类型,虚拟机会按之前的逻辑加载数组元素类,并生成一个数组对象

字段的解析

在解析字段符号引用时,会先去解析这个字段所属的类,如果这个类本身就包含了与这个字段简单名称匹配的字段则返回直接引用,如果没有,则会按照继承关系从下往上递归搜索父类。如果返回了引用,会对这个字段进行权限验证,如果没有访问这个字段的权限则抛出异常。在实际应用中,编译器会很严格,如果出现了不符合规范的用法则会拒绝编译。

类方法的解析

与字段的解析一样,在解析方法之前需要先解析所属的类或接口的符号引用。在所属类中查找,如果找不到再遍历父类,如果还没有查找结束,则在类的实现接口列表及它们的父接口中查找,如果找到了说明该类是抽象类(因为自身和父类中都没有找到,但是又在实现接口中找到了,所以是抽象类,因为抽象类可以不用实现接口中的方法),不能调用没有实现过的方法,抛出异常。最后如果返回了方法的引用还要验证是否有权限访问,否则抛出异常。

接口方法的解析

与类方法类似,具体不表。

初始化

初始化阶段是执行类构造器()方法的过程。虚拟机会保证在子类的()方法执行之前,父类的()方法已经执行完毕。如果类中没有静态语句块也没有为类变量赋值,那么编译器可以不为这个类生成()方法。与类不同,执行接口的()方法不需要父类已经执行了()方法,只有当使用到父类的类变量时父类才会初始化。虚拟机会保证一个类的()方法能在多线程中正确地同步执行,有可能会出现其他线程阻塞很长时间的情况,但是实际上很难发现这种阻塞。

总结

Class文件是虚拟机启动后的入口,所以想要了解虚拟机如何运行Java程序,对Class文件的结构和虚拟机如何加载Class文件的学习很有必要。

原创粉丝点击