[关闭]
@HUST-SuWB 2015-12-21T03:05:24.000000Z 字数 11576 阅读 321

类的结构与加载

读书笔记


类的结构

Class类文件的结构

Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在。当遇到需要占用8位字节以上空间的数据项时,则会按照高位在前的方式分隔成若干个8位字节进行存储。
根据Java虚拟机规范的规定,Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表,后面的解析都要以这两种数据类型为基础,所以这里要先介绍这两个概念。
无符号数术语基本的数据类型,以u1、u2、u4、u8来分别代表一个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性得以"_info"结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张表。

1. 魔数
每个Class文件的头4个字节称为魔数(Magic Number),它的唯一作用是用于确定这个文件是否为一个能被虚拟机接受的Class文件。很多文件存储标准中都使用魔数来进行身份识别,譬如图片格式,如gif或jpeg等在文件头中都存有魔数。Class文件魔数的值为0xCAFEBABE。如果一个文件不是以0xCAFEBABE开头,那它就肯定不是Java class文件。
2. 版本号
紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6是次版本号(Minior Version),第7个和第8个字节是主版本号(Major Version)。Java的版本号是人45开始的,JDK1.1之后的每个JDK大版本发布主版本号向上加1,高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件,即使文件格式并未发生变化。JDK1.1能支持版本号为45.0~45.65535的Class文件,JDK1.2则能支持45.0~46.65535的Class文件。JDK1.7可生成的Class文件主版本号的最大值为51.0。
3. 常量池
紧接着魔数与版本号之后的是常量池入口,常量池是Class文件结构中与其它项目关联最多的数据类型,也是占用Class文件空间最大的数据项目之一,同时它还是在文件中第一个出现的表类型数据项目。由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值(constant_pool_count)。从1开始计数。第0项腾出来满足后面某些指向常量池的索引值的数据在特定情况下需要表达"不引用任何一个常量池项目"的意思,这种情况就可以把索引值置为0来表示。但尽管constant_pool列表中没有索引值为0的入口,缺失的这一入口也被constant_pool_count计数在内。例如,当constant_pool中有14项,constant_poo_count的值为15。Class文件结构中只有常量池的容量计数是从1开始的,对于其他集合类型,包括接口索引集合、字段表集合、方法表集合等的容量计数都是从0开始的。
常量池之中主要存放两大类常量:字面量和符号引用。字面量比较接近于Java语言层面的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则属于编译原理方面的概念,包括了下面三类常量:

Java代码在进行Java编译的时候,并不像C和C++那样有"连接"这一步骤,而是在虚拟机加载Class文件的时候进行动态连接。也就是说,在Class文件中不会保存各个方法和字段的最终内存布局信息,因此这些字段和方法的符号引用不经过转换的话是无法被虚拟机使用的。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析并翻译到具体的内存地址之中。
常量池中的每一项常量都是一个表,共有14种结构各不相同的表结构数据,这14种表都有一个共同的特点,就是表开始的第一位是一个u1类型的标志位,代表当前对象属于哪种常量类型,下图列出了JDK1.7之前的11种常量表结构。

4. 访问标志
紧接常量池后的两个字节称为access_flags,它展示了文件中定义的类或接口的几段信息。例如,访问标志指明文件中定义的是类还是接口;访问标志还定义的在类或接口的声明中,使用了哪种修饰符oder和接口是抽象的,还是公共的;类的类型可以为final,而final类不可能是抽象的;接口不能为final类型的。
5. 类索引
访问标志后面接下来的两个字节是类索引(this_class),它是一个对常量池的索引。在this_class位置的常量池入口必须为CONSTANT_Class_info表。该表由两个部分组成——tag和name_index。tag部分是代表其的标志位,name_index位置的常量池入口为一个包含了类或接口全限定名的CONSTANT_Utf8_info表。
6. 父类索引
在class文件中,紧接在this_class之后是super_class项,它是一个两个字节的常量池索引。在super_class位置的常量池入口是一个指向该类超类全限定名的CONSTANT_Class_info入口。因为Java程序中所有对象的基类都是java.lang.Object类,除了Object类以外,常量池索引super_class对于所有的类均有效。对于Object类,super_class的值为0。对于接口,在常量池入口super_class位置的项为java.lang.Object
7. interfaces_count和interfaces
紧接着super_class的是interfaces_count,此项的含义为:在文件中出该类直接实现或者由接口所扩展的父接口的数量。在这个计数的后面,是名为interfaces的数组,它包含了对每个由该类或者接口直接实现的父接口的常量池索引。每个父接口都使用一个常量池中的CONSTANT_Class_info入口来描述,该CONSTANT_Class_info入口指向接口的全限定名。这个数组只容纳那些直接出现在类声明的implements子句或者接口声明的extends子句中的父接口。超类按照在implements子句和extends子句中出现的顺序在这个数组中显现。
8. fields_count和fields
在class文件中,紧接在interfaces后面的是对在该类或者接口中所声明的字段的描述。首先是名为fields_count的计数,它是类变量和实例变量的字段的数量总和。在这个计数后面的是不同长度的field_info表的序列(fields_count指出了序列中有多少个field_info表)。只有在文件中由类或者接口声明了的字段才能在fields列表中列出。在fields列表中,不列出从超类或者父接口继承而来的字段。另一方面,fields列表可能会包含在对应的Java源文件中没有叙述的字段,这是因为Java编译器可以会在编译时向类或者接口添加字段。
9. method_count和methods
紧接着field后面的是对在该类或者接口中所声明的方法的描述。其结构与fields一样,不一样的是访问标志。
10. attributes_count和attributes
class文件中最后的部分是属性,它给出了在该文件类或者接口所定义的属性的基本信息。属性部分由attributes_count开始,attributes_count是指出现在后续attributes列表的attribute_info表的数量总和。每个attribute_info的第一项是指向常量池中CONSTANT_Utf8_info表的引引,该表给出了属性的名称。
属性有许多种。Java虚拟机规范定义了几种属性,但任何人都可以创建他们自己的属性种类,并且把它们置于class文件中,Java虚拟机实现必须忽略任何不能识别的属性。

字节码的指令简介

Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的操作码(Opcode)以及跟随其后的零至多个代表此操作所需参数的操作数(Operands)所构成。
对于大部分为与数据类型相关的字节码指令,他们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务:i代表对int类型的数据操作,l代表long,s代表short,b代表byte,c代表char,f代表float,d代表double,a代表reference。
a、加载和存储指令
将一个局部变量加载到操作栈的指令包括有:iload、iload_、lload、lload_、fload、fload_、dload、dload_、aload、aload_
将一个数值从操作数栈存储到局部变量表的指令包括有:istore、istore_、lstore、lstore_、fstore、fstore_、dstore、dstore_、astore、astore_
将一个常量加载到操作数栈的指令包括有:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1等
扩充局部变量表的访问索引的指令:wide
b、运算指令
加法指令:iadd、ladd、fadd、dadd
减法指令:isub、lsub、fsub、dsub
乘法指令:imul、lmul、fmul、dmul
除法指令:idiv、ldiv、fdiv、ddiv
求余指令:irem、lrem、frem、drem
取反指令:ineg、lneg、fneg、dneg
位移指令:ishl、ishr、iushr、lshl、lshr、lushr
按位或指令:ior、lor
按位与指令:iand、land
按位异或指令:ixor、lxor
局部变量自增指令:iinc
比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp
c、类型转换指令
Java虚拟机对于宽化类型转换直接支持,并不需要指令执行,包括:
int类型到long、float或者double类型
long类型到float、double类型
float类型到double类型
窄化类型转换指令包括有:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f。但是窄化类型转换很可能会造成精度丢失。
d、对象创建和访问指令
创建类实例的指令:new
创建数组的指令:newarray,anewarray,multianewarray
访问类字段(static字段,或者称为类变量)和实例字段(非static字段,或者成为实例变量)的指令:getfield、putfield、getstatic、putstatic
把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload
将一个操作数栈的值储存到数组元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore
取数组长度的指令:arraylength
检查类实例类型的指令:instanceof、checkcast
e、操作数栈管理指令
Java虚拟机提供了一些用于直接操作操作数栈的指令,包括:pop、pop2、dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2和swap
f、控制转移指令
条件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt, if_icmpgt、if_icmple、if_icmpge、if_acmpeq和if_acmpne。
复合条件分支:tableswitch、lookupswitch
无条件分支:goto、goto_w、jsr、jsr_w、ret
g、方法调用和返回指令
invokevirtual指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是Java语言中最常见的方法分派方式。
invokeinterface指令用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。
invokespecial指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法(§2.9)、私有方法和父类方法。
invokestatic指令用于调用类方法(static方法)。
而方法返回指令则是根据返回值的类型区分的,包括有ireturn(当返回值是boolean、byte、char、short和int类型时使用)、lreturn、freturn、dreturn和areturn,另外还有一条return指令供声明为void的方法、实例初始化方法、类和接口的类初始化方法使用。
h、异常处理指令
athrow
i、同步指令
monitorenter&monitorexit两条指令支持synchronized关键字的语义。

类的加载

类加载的过程

  1. 加载
    “加载”(Loading)阶段是“类加载”(Class Loading)过程的一个阶段。在加载阶段,虚拟机需要完成以下三件事情:
    a.通过一个类的全限制名来获取定义此类的二进制字节流。
    b.将这个字节流所代表的静态存储结构转化为方法区的运行进数据结构。
    c.在Java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口。
    虚拟机规范的这三点要求实际上并不具体,因此虚拟机实现与具体应用的灵活度相当大。例如“通过一个类的全限制名来获取定义此类的二进制字节流”,并没有指明二进制字节流要从一个Class文件中获取,准确地说是根本没有指明要从哪里获取及怎样获取。虚拟机设计团队加载阶段搭建了一个相当开放的,广阔的舞台,Java发展历程中,许多举足轻重的Java技术都建立在这一基础上,例如:
    a.从ZIP包中读取,这很常见,最终成为日后JAR,EAR,WAR格式的基础
    b.从网络中获取,这种场景最典型的应用就是Applet
    c.运行时计算生成,这种场景使用得最多的就是动态代理技术,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generatProxyClass来为特定接口生成 *$Proxy的代理类的二进制字节流。
    d.由其它文件生成,典型场景:JSP应用。
    e.从数据库中读取,这种场景相对少见些,有些中间件服务器(如SAP Netweaver)可以选择把程序安装到数据库中来完成程序代码在集群间的分发。
    f. .......
    加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属性连接阶段的内容,这两阶段的开始时间仍然保持着固的先后顺序。
  2. 验证
    验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
    尽管验证阶段是非常重要的,并且验证阶段的工作量在虚拟机的类加载子系统中占了很大一部分。如果验证到输入的字节流不符合Class文件的存储格式,就抛出一个java.lang.VerifyError错误或者其子错误。具体应当检查哪些方面,如何检查,何时检查,虚拟机规范都没有明确说明,所以不同的虚拟机对验证的实现可能会有所不同,但大致上都会完成四个阶段的验证过程:文件格式验证,元数据验证,字节码验证和符号引用验证。
  3. 准备
    准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。需要强调的是:首先是这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配是Java堆中。其次是这里所说的初始值“通常情况“下是数据类型的零值,假设一个类变量定义为:
    public static int value = 123;
    那么变量value在准备阶段过后的初始值是0而不是123,因为这时候尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是在程序被编译后,存放于类构造器”“方法中的,所以把value赋值为123的动作将在初始化阶段才会被执行。
    上面提到的”通常情况“下初始值为零值,但是,如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量value就会初始初始化为ConstantValue属性所指定的值,假设上面类变量value被定义为:
    public static final int value = 123;
    编译时Javac将会为value生成ConstantValue属性表,在准备阶段虚拟会就会根据ConstantValue的设置将value赋值为123。
  4. 解析
    解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,在Class文件中它以CONSTANT_Class_info,CONSTANT_Fieldref_info,CONSTANT_Methodref_info等类型的常量出现。直接引用与符号引用的关联是:
    a.符号引用(Symbolic References)以一组符号来描述所引用的目录,符号可以任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到了内存中。
    b.直接引用可以是直接指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。直接引用与虚拟机实现的内存布局相关,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标就必须已经在内存中存在。
    虚拟机规范中并未规定解析阶段发生的具体时间,只要求在执行了newarray,heckcast,getfield,etstatic,instanceof,invokeinterface,invokespecial,nvokestatic,invokevritual,multianewarray,new,putfield和putstatic这13个用于操作符号引用的字节码指令之前,先对它们所使用的符号引用进行解析。所以虚拟机实现会根据需要来判断,到底是在类加载器加载时就是常量池中的符号引用进行解析,还是等到一个符号引用将要被使用时才去解析它。
    同一个符号引用可能会进行多次解析请求,虚拟机实现可能会对第一次解析的结果进行缓存从而避免解析动作重复进行。无论是否真正执行了多次解析操作,虚拟机需要保证的都是在同一个实体中,如果一个符号引用被成功解析过,那么后续的解析请求就应当一直成功;同样的,如果第一次解析失败了,其它指令对这个符号引用的解析请求也应该收到相同的异常。
    解析动作主要针对的是类或接口,字段,方法,接口方法四类符号引用进行的,分别对应于常量池的CONSTANT_Class_info,CONSTANT_Fieldref_info,CONSTANT_Methodref_info及CONSTANT_InterfaceMetodref_info四种常量类型。下面是这四种引用的解析过程。

    • 类或接口的解析过程
      假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析过程需要包括以下3个步骤:
      a.如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D类的加载器去加载这个类C。在加载过程中,由于无数据验证,字节码验证的需要,又将可能触发其它相关类的加载动作,例如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程就将宣告失败。
      b.如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似”[Ljava.lang.Integer"的形式,那么会按照第a点的规则加载数组元素类型。如果N的描述符如前面所假设的形式,需要加载的元素类型就是“java.lang.Integer”,接着由虚拟机生成一个代表此数组和元素的数组对象。
      c.如果上面的步骤没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认C是否具备对D的访问权限,如果发现不具体访问权限,将抛出java.lang.IllegalAccessError错误。
    • 字段解析
      要解析一个未被解析过的字段符号引用,首先将会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属性的类或接口的符号引用。如果在解析这个类或接口符号引用过程中出现了任何异常,都会导致字段符号引用解析失败。如果解析成功完成,那么这个字段所属性的类或接口用C表示,虚拟机规范要求如下步骤对C进行后续字段的搜索:
      a.如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
      b.否则,如果C中实现了接口,将会按照继承关系从上往下递归搜索各个接口和它的父接口,如果接口中包含了简单名称答字段描述符都与目标相匹配的字段,则返回该字段的直接引用,查找结束。
      c.否则,如果C不是java.lang.Object的放,将会按照继承关系从上往下递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标匹配的字段,则返回这个字段的直接引用,查找结束。
      d.否则,查找失败,招抛出java.lang.NoSuchFieldError错误。
    • 类方法解析
      类方法解析的第一个步骤与字段解析一样,也是需要先解析出方法表的class_index项中索引的方法所属性类或接口的符号引用,如果解析成功,依然用C表示这个类,接下来虚拟机将会按照如下步骤进行后续的类方法搜索:
      a.类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现了class_index中索引的C是个接口,那么直接就抛出java.lang.IncompatibleClassChangeError错误。
      b.如果通了第a步,在类C中查找是否有简单名称和描述符与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
      c.否则,在类C的父类中递归查找是否有简单名称和字段描述符都与目标匹配的方法,则返回这个方法的直接引用,查找结束。
      d.否则,在类C实现的接口列表及它们的父接口中递归查找否有简单名称和字段描述符都与目标匹配的方法,说明类C是一个抽象类,这时候查找结束,抛出java.lang.AbstractMethodError错误。
      e.否则,宣告查找失败,抛出java.lang.NoSuchMethodError错误。
      最后,如果查找过程成功返回了直接引用,将会对晕个方法进行权限验证;如果发现不具务对此方法的访问权限,将抛出java.lang.IllegalAccessError错误。
    • 接口方法解析
      接口方法也需要先解析出接口方法表中的class_index项中索引的方法所属性的类或接口的符号引用,如果解析成功,依然用C表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口访求搜索:
      a.与类方法解析相反,如果在接口方法表中发现了class_index中索引的C是个类而不是接口,那么直接就抛出java.lang.IncompatibleClassChangeError错误。
      b.否则,在接口C中查找是否有简单名称的描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
      c.否中,在接口C的父接口中递归查找,直到java.lang.Object类为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
      d.否则,宣告查找失败,抛出java.lang.NoSuchMethodError错误。
      由于接口中的所有方法都默认是public的,所以不存在访问权限问题,因此接口方法的符号解析应该不会抛出java.lang.IllegalAccessError错误。
  5. 初始化
    类的初始化是类加载过程的最后一步,前面的类加载动作,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正执行类中定义的Java程序代码(或者说是字节码)。
    在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序制定的主观计划去初始化类变量和其它资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器()方法的过程。()方法执行过程可能会影响程序运行行为的一些特点与细节,如下:
    1.()方法是由编译器自动收集类中所有类变量的赋值动作和静态语句块(static{})中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后变量,在前面的静态语句块中可以赋值,但是不能访问。
    2.()方法与类的构造器()不同,它不需要显示地调用父类类构造器,虚拟机会保证在子类的()方法执行之前,父类的()方法已经执行完毕。因此在虚拟机中第一个被执行()方法的类肯定是java.lang.Object。
    3.由于父类的()方法先执行,所就意味着父类中定义的静态语句块要优先于子类的类变量赋值操作。
    4.()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这类生成()方法。
    5.接口中不能使用静态语句块,但仍然可以有变量初始化的同仁操作,因此接口与类一样都会生成()方法,但接口与类不同的是,执行接口的()方法不需要先执行父接口的()方法。只有当父接口中定义的变量被使用时,父接口才会初始化。另外,接口的实现类在初始化时也不会执行接口的()方法。
    6.虚拟机会保证一个类的()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的()方法,其它线程都需要阻塞等待,直到活动线程执行()方法完毕。如果在一个类的()方法中有很耗时的操作,那就可能造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的。

类加载器

JVM的类加载是通过ClassLoader及其子类来完成的,类的层次关系和加载顺序可以由下图来描述:

1)Bootstrap ClassLoader
负责加载JAVA_HOME中jre/lib/rt.jar里所有的class,由C++实现,不是ClassLoader子类
2)Extension ClassLoader
负责加载java平台中扩展功能的一些jar包,包括JAVA_HOME中jre/lib/*.jar或-Djava.ext.dirs指定目录下的jar包
3)App ClassLoader
负责记载classpath中指定的jar包及目录中class
4)Custom ClassLoader
属于应用程序根据自身需要自定义的ClassLoader,如tomcat、jboss都会根据j2ee规范自行实现ClassLoader。
加载过程中会先检查类是否被已加载,检查顺序是自底向上,从Custom ClassLoader到BootStrap ClassLoader逐层检查,只要某个classloader已加载就视为已加载此类,保证此类只所有ClassLoader加载一次。而加载的顺序是自顶向下,也就是由上层来逐层尝试加载此类。
PS:

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注