第8章 连接模型
动态连接和解析:
java class文件把它所有的引用符号保存在常量池中。每一个class文件有一个常量池,每一个被java虚拟机装载的类或者接口都有一份内部版本的独立常量池,被称为运行时常量池。运行时常量池是一个特定于虚拟机实现的数据结构,数据结构映射到class文件中的常量池。因此,当一个类被首次装载时,所有来自于类型的符号引用都装载到了类型的运行时常量池。
在程序运行时,如果某个特定的符号引用将要被使用,它首先要被解析。解析过程就是根据符号引用查找到实体,在把符号引用替换成一个直接引用的过程。因为所有的符号引用都保存在常量池中,所以这个过程也称作常量池解析。
java虚拟机为每一个装载的类和接口保存一份独立的常量池。每一个常量池入口可能被多个指令引用,但它只被解析一次,当符号引用被一条指令解析过后,来自其他指令后续访问尝试会认为解析工作已经完成,都使用第一次解析的直接引用结果。
虽然虚拟机的实现有选择何时解析符号引用的自由,但不管怎样,都应该给外界一个迟解析的印象。不管何时解析,都应该在程序第一次实际访问一个符号引用的时候抛出错误。
在java虚拟机解析符号引用时,它可以选择类装载器,当解析常量池中的入口需要装载类型的时候,它使用装载引用类型的同一个类装载器来装载所需的类型。比如,使用启动类装载器装载的类型,当它的符号引用被解析时,虚拟机也使用启动类的装载器来装载被引用的类型。使用用户自定义装载器装载的类型,当它的符号引用被解析时,虚拟机也使用同一个用户自定义的类装载器来装载被引用的类型。
类装载器和双亲委派模型:
java1.2版本中,每一个用户自定义的类装载器在创建时被分配一个双亲类装载器,如果没有显式传递一个双亲类装载器给用户自定义的类装载器的构造方法,系统类装载器被默认指定为双亲。如果向构造方法传递了null,启动类装载器成为双亲。
当符合双亲委派模型的类装载器装载一个类型的时候,它首先委派给它的双亲(请求它的双亲试着装载这个类型),这个委派的过程一直进行到委派链的末端,一般来说时启动类装载器。
在java术语中,要求某个类装载器装载一个类型,但是却返回了其他类装载器装载的类型,这种装载器被称为是那个类型的初始类装载器。而实际装载和定义那个类型的类装载器被称为那个类型的定义类装载器。任何被要求装载类型,并且能够返回Class实例的引用的类装载器都是这个类型的初始类装载器。
常量池解析:如果解析过程中抛出了错误,错误被看成是由指向执行解析的常量池入口的引用者抛出的。
解析CONSTANT_Class_info入口:用来表示指向类(包括数组类)和接口的符号引用。有的指令直接使用CONSTANT_Class_info入口,如new和anewarray;有的指令从其他类型的入口间接指向CONSTANT_Class_info入口,如putfield和invokevirtrual。
根据类型是否是数组,或者引用的类型是由启动类装载器还是由用户自定义的类装载器装载的,解析CONSTANT_Class_info入口的细节会有所不同。
(1) 数组类:如果一个CONSTANT_Class_info入口的name_index项指向的CONSTANT_Utf8_info字符串是由一个左方括号开始的("["),那么它指向的是一个数组类。指向数组类的符号引用的最终解析结果是一个Class实例,表示该数组类。如果当前的类装载器已经被记录为被解析的数组类的初始装载器,就使用同样的类。否则虚拟机执行以下步骤:
a. 如果数组的元素类型是基本类型,那么虚拟机立即就会创建相应元素类型的新数组类,维度也在此
时确定,然后创建一个Class的实例来代表这个数组类型。数组类会被标记为是由启动类装载器装载并
定义的。
b. 如果数组的元素类型是一个引用类型,虚拟机先使用当前类装载器解析元素类型。然后创建相应元素
类型的数组,并创建一个Class的实例来代表这个数组类型。数组类会被标记为由装载并定义数组元素
类型的类装载器定义的。
(2) 非数组类和接口:如果name_index项指向的CONSTANT_Utf8_info字符串不是由一个左方括号开始的。那么这是一个指向非数组类或接口的符号引用。解析这种类型的符号引用分为多步:
a. 装载类型或者任何超类型:解析非数组类或接口的基本要求是确认类型被装载到了当前命名空间。为了
做出决定,虚拟机必须查明当前类装载器是否被标记为该类型的初始装载器。对于每一个类装载器,
java虚拟机维护一张列表,其中记录了以当前类装载器为初始类装载器的所有已加载类型,每一张这样
的列表组成了java虚拟机内部的命名空间。在解析过程中,虚拟机使用这个列表来决定是否一个类已经
被当前的类装载器装载过了。如果发现希望装载的全限定名已经在当前命名空间中列出,它将只使用已
经被装载的类型(该类型存储在方法区中,并由堆中的相关Class实例表示)。虚拟机包装每个类装载
器都只装载一次给定名字的类型。
如果希望装载的类型还没有被装载进当前命名空间,虚拟机把类型的全限定名传递给当前的类装载
器。java虚拟机总是要求当前类装载器(就是发起引用的类型的装载定义类装载器),来试图装载被引
用的类型。
一旦被引用的类型被装载了,虚拟机仔细检查它的二进制数据。如果类型是一个类并且不是Object
类,虚拟机根据类的数据得到它的直接超类的全限定名;接着查看直接超类是否已经被装载进当前命名
空间,如果没有先装载超类;当虚拟机装载超类的时候,它实际上只是解析另外一个符号引用。一直重
复到超类为Object为止。在从Object返回的路上,它会检查每个类型的数据,看是否实现了任何接口,
并确保那么接口也被装载了。对于每一个装载的接口,也会检查它们的类型数据,以确认他们的超接口
也被装载了。
一旦一个类型被装载进入当前命名空间,而且通过递归解析,所有的超类和超接口也都被成功装载。
虚拟机就会创建新的Class实例来代表这个类型。在此过程中可能抛出NoClassDefFoundError、
ClassNotFoundError、LinkageError、UnsupportedClassVersionError等异常。
b. 检查访问权限:随着装载结束,虚拟机检查访问权限。如果发起引用的类型没有访问被引用类型的权
限,虚拟机抛出IllegalAccessError异常。如果在权限检查之前一起正常,这个类总体来说还是可以使用
的,只不过不能 被发起引用的类型使用。如果错误在检查权限之前抛出,类型是不可用的,必须被标记
为不可用或者被取消。
c. 连接并初始化类型和任何超类型:此时,被解析的CONSTANT_Class_info入口引用的类型和超类型、
超接口已经被装载,但还没有进行必要的连接和初始化(某些超类型可能已经被别的类型引用,而被初
始化了)。如果虚拟机因为主动使用一个类型而正在解析该类,它必须确认所有的超类都被初始化了
(超接口不必初始化)。
校验类型:校验过程可能要求虚拟机装载新的类型来确认字节码复合java语言的语义,在校验阶段
如果遇到麻烦,虚拟机会抛出VerifyError异常。
准备类型:虚拟机为类变量以及相应虚拟机实现的数据结构分配内存(如方法表)。
解析类型(可选):虚拟机的实现可以可选地在这个时候解析类型。
初始化类型:经过类型装载、校验、准备以及可选的解析步骤,类型就准备好进行初始化了。如果类
型拥有任何超类,初始化类型的超类是按照自顶(Object)向下的顺序进行的。如果类型拥有一个类初始化
方法(<clinit>),那就在此时执行。如果初始化方法抛出的异常是Error的子类,虚拟机直接抛出那个异
常,如果抛出某个非Error子类的异常,虚拟机把该异常作为构造方法的参数,抛出
ExeptionInInitializerError异常。
class Salutation { public static void main(String[] args) { System.out.println("hello world"); } }
解析CONSTANT_Fieldref_info入口:要解析类型的CONSTANT_Fieldref_info,虚拟机必须先解析class_index项中指明的CONSTANT_Class_info。因此,解析CONSTANT_Fieldref_info时可能抛出任何因解析CONSTANT_Class_info而抛出的错误。如果CONSTANT_Class_info解析成功,虚拟机在此类型和它的超类型上搜索所需要的字段。如果找到需要的字段,虚拟机还要检查当前类是否拥有访问这个字段的权限。虚拟机按以下步骤执行字段搜索:
(1) 虚拟机在被引用的类型中查找具有指定的名字和类型的字段。
(2) 虚拟机检查类型直接实现或者扩张的接口,以及递归地检查它们的超接口。
(3) 如果类型拥有一个直接的超类,虚拟机检查类型的直接超类,并且递归地检查类型的所有超类。
(4) 如果没找到,字段搜索失败,虚拟机抛出NoSuchFieldError异常;如果搜索成功,但当前的类型没有权
限访问该字段,虚拟机抛出IllegalAccessError异常;否则虚拟机把这个入口标记为已解析,并在常量池
入口的数据中放上指向这个字段的直接引用。
class Salutation { public static void main(String[] args) { System.out.println("hello world"); } }
解析CONSTANT_Methodref_info入口:要解析CONSTANT_Methodref_info,虚拟机必须先解析class_index项中指定的CONSTANT_Class_info。因此,解析CONSTANT_Methodref_info时可能抛出任何因解析CONSTANT_Class_info而抛出的错误。如果解析成功,虚拟机在类型和它的超类型中搜索指定的方法。如果找到了方法,虚拟机检查当前类是否有权限访问该方法。虚拟机按如下步骤执行方法解析:
(1) 如果被解析的类型是接口,而不是类,虚拟机抛出IncompatibleClassChangeError异常。
(2) 虚拟机检查被引用的类是否有一个方法符合指定的名字及描述符。
(3) 如果类有一个直接超类,虚拟机检查类的直接超类,并且递归地检查类的所有超类。
(4) 虚拟机检查这个类直接实现的任何接口,并且递归地检查由类型直接实现的接口的超接口。
(5) 如果没找到,方法搜索失败。如果虚拟机没有在被引用的类和它的任何超类型中找到名字、返回类型、
参数数量和类型都符合的方法,虚拟机抛出NoSuchMethodError异常;如果方法存在,但是一个抽象
方法,虚拟机抛出AbstractMethodError异常;如果当前类没有权限访问该方法,虚拟机抛出
IlligalAccessError异常。否则,虚拟机把这个入口标记为已解析并在常量池入口的数据中放入指向该
方法的直接引用。
解析CONSTANT_InterfaceMethodref_info入口:虚拟机按如下步骤执行接口方法解析:
(1) 如果被解析的类型是一个类,而不是接口,虚拟机抛出IncompatibleClassChangeError异常。
(2) 虚拟机检查被引用的接口是否有一个方法符合指定的名字及描述符。
(3) 虚拟机检查被引用的接口的直接超接口,并且递归地检查接口的所有超接口及Object类。
(4) 如果没找到,接口方法搜索失败。如果虚拟机没有在被引用的接口和它的任何超类型中找到名字、返
回类型、参数数量和类型都符合的方法,虚拟机抛出NoSuchMethodError异常。否则,虚拟机把这个
入口标记为已解析并在常量池入口的数据中放入指向该方法的直接引用。
解析CONSTANT_String_info入口:要解析CONSTANT_String_info,虚拟机必须把一个指向内部字符串对象的引用放入要被解析的常量池入口的数据中。该字符串对象(String类的实例)必须按照string_index项中指明的CONSTANT_Utf8_info入口所指定的字符顺序组织。每一个java虚拟机必须维护一张内部列表,列出所有在程序运行过程中已被“拘留”(intern)的字符串对象的引用。维护这个表的关键是任何特定的字符序列在这个列表上只出现一次。在解析时,虚拟机检查内部列表上是否已经拘留这个字符序列,如果已经在编,虚拟机使用指向以前拘留的字符串对象的引用;否则,按照这个字符序列创建一个新的字符串对象,并把这个对象的引用编入列表。解析完成时,把被拘留字符串对象的引用放入被解析的常量池入口数据中。
在java程序中,可以调用String.intern()方法来拘留一个字符串。如果相同序列的Unicode字符串已经被拘留过,intern()返回一个指向已经被拘留的字符串对象的引用;否则,这个对象本身被拘留。
public class InternExample { public static void main(String[] args) { String argsZero = args[0]; String literalString = "helloworld"; if (argsZero == literalString) System.out.println("they are the same string object"); else System.out.println("they are different string object"); argsZero = argsZero.intern(); System.out.println("after intern args[0]"); if (argsZero == literalString) System.out.println("they are the same string object"); else System.out.println("they are different string object"); } } 命令行:java InternExample helloworld 输出为: they are different string object after intern args[0] they are the same string object
解析其他类型的入口:CONSTANT_Integer_info、CONSTANT_Long_info、CONSTANT_Float_info、CONSTANT_Double_info入口本身包含它们所表示的常量值,可以直接被解析。解析这类入口,虚拟机的实现什么都不需要做,直接使用那么值就行了。CONSTANT_Utf8_info、CONSTANT_NameAndType_info类型的入口永远不会被指令直接引用,它们只有通过其他入口类型的才能被引用,并且在那些引用入口被解析时才被解析。
编译时常量解析:对于所有的基本类型和java.lang.String,被初始化为编译时常量的static final变量的引用,在编译时被解析为常量值的一个本地拷贝。隐藏在常量的特殊处理背后的一个动机是条件编译。通过if语句,java支持条件编译。
直接引用:常量池解析的最终目标是把符号引用替换为直接引用。指向类型、类变量、类方法的直接引用可能是指向方法区的本地指针。类型的直接引用可能简单地指向保存类型数据的方法区中的与虚拟机实现相关的数据结构;类变量的直接引用可以指向方法区中保存的类变量的值;类方法的直接引用可以指向方法区中一段数据结构(方法区中包含调用方法的必要数据)。指向实例变量和实例方法的直接引用都是偏移量。实例变量的直接引用可能是从对象的映像开始算起到这个实例变量位置的偏移量;实例方法的直接引用可能是到方法表的偏移量。使用偏移量来表示实例变量和实例方法的直接引用,取决于类的对象映像中字段的顺序和类方法表中方法的顺序的预先决定。在任何实现中,对象中的字段的顺序和方法表中方法的顺序都是被定义好的,也是可以预测的。
interface Friendly { void sayHello(); void sayGoodbye(); } class Dog { void sayHello() { System.out.println("Wag"); } @Override public String toString() { return "Woof"; } } class CockerSpaniel extends Dog implements Friendly { public void sayHello() { super.sayHello(); System.out.println("Woof"); } public void sayGoodbye() { System.out.println("Wimper"); } } class Cat implements Friendly { public void eat() {} public void sayHello() {} public void sayGoodbye() {} protected void finalize() { System.out.println("Meow"); } }
麻烦之处在于,实现了相同接口的类并不能保证都是从同一个超类继承的。这样,接口中声明的方法并不能保证处于类型的方法表的同一个位置。因此,不管何时,java虚拟机从接口引用调用一个方法,它必须搜索对象的类的方法表来找到一个合适的方法;这种调用接口引用的实例方法的途径会比在类引用上调用实例方法慢很多。不管怎样,给定接口引用时调用方法总是比给定类引用时调用方法慢得多。