Java虚拟机加载类的全过程包括:加载、验证、准备、解析、初始化。验证、准备、解析叫连接过程。今天我们讲解析。

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用在class文件中以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现,那解析阶段中,所说的直接引用和符号引用有什么关联呢?

  • 符号引用:符合引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。符号引用与虚拟机实现的内存布局无关,因为引用的目标不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是他们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在java虚拟机规范的class文件格式中。
  • 直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。

(个人理解:解析就是将符号引用变为直接引用。为什么要这么做呢?就是要将对象加载到内存中。“直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄” 这句话意味着对象已经被加载到内存中了。因为句柄的定义就是:当前对象的唯一一个标识。这里说解析阶段的目的是什么,那么接下来应该说解析发生的时间,以及解析是怎么做的了)

虚拟机规范中未规定解析阶段发生的具体时间,只要求在执行anewarray,checkcast,getfield,getstatic,instanceof,invokedynamic,invokeinterface,invokespecial,invokestatic,invokevirtual,ldc,ldc_w,multianewarray,new,putfield和putstatic 这16个用于操作符号引用的字节码指令之前,先对他们所使用的符号进行解析。所以虚拟机实现可以根据需要来判断到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析他。

上述指令对应的操作分别为:
anewarray、multianewarray:创建数组
checkcast、instanceof:检查类实例类型
getfield、getstatic、putfield、putstatic:访问类字段(static字段,或者称为类变量)和实例字段(非static字段,或者称为实例变量)
invokeinterface:调用接口方法,他会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用
invokespecial:调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法
invokestatic:调用类方法(static方法)
invokevirtual:调用对象实例方法,根据对象的实际类型进行分派(虚方法分派),这也是java语言中最常见的分派方式
invokedynamic:用于在运行时动态解析出调用点限定符所引用的方法,并执行改方法
ldc:将一个常量加载到操作数栈的指令
ldc_w:将一个常量加载到操作数栈的指令
new:创建类实例

(个人理解:执行这16个操作的时候,要先对他们所使用的符号进行解析,“他们所使用的符号”意思就是类或者接口。比如 new Person时,Person 就是所使用的符号,这时候应该对Person进行解析)

对同一个符号引用进行多次解析请求是很常见的事情,出invokedynamic指令之外,虚拟机实现可以对第一次解析的结果进行缓存(在运行时常量池中记录直接引用,并把常量标识为已解析状态)从而避免解析动作重复进行。无论是否真正执行了多次解析动作,虚拟机需要保证的是:在同一个实体中,如果一个符号引用之前被成功解析过,那么后续的引用解析请求就应当一直成功;同样的,如果第一次解析失败了,那么其他指令对这个符号的解析请求也应该收到相同的异常。

对于invokedynamic,上面的规则不成立。当碰到某个前面已经由invokedynamic指令触发过解析的符号引用时,并不意味着这个解析结果对于其他invokedynamic指令也同样生效。因为invokedynamic指定的目的本来就是用于动态语言支持,他所对应的引用称为“动态调用点限定符”,这里“动态”的含义就是必须等到程序实际运行到这条指令的时候,解析工作才能进行。相对的,其余可触发解析的指令都是“静态”的,可以在刚刚完成加载阶段,还没有开始执行代码的时候进行解析。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行,他们对应常量池的7种常量类型。本文讲前面四种。

一、类或接口的解析
假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析的过程需要以下三个步骤:

  1. 如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载过程种,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程宣告失败。
  2. 如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似“Ljava/lang/Inter”的形式,那将会按照第一点的规则加载数组元素类型。如果N的描述符如前面所假设的形式,需要加载的元素类型就是“java.lang.inter”,接着由虚拟机生成一个代表此数组维度和元素的数组对象。
  3. 如果上面的步骤没有出现任何异常,那么C在虚拟机中实际上已成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认D是否具备对C的访问权限。如果发现不具备访问权限,将抛出“lava.lang.IlldgalAccessError”异常

个人理解如下:
当我们在main中定义personaa 的时候,main的类加载器会去加载Person类,可能还会加载Person的父类(如果有的话)。如果没有报错或异常,那么Person实际上在虚拟机里面就有一个有效的类了,但是在解析完成前还要进行符号引用验证,确认D是否具备对C的访问权限。如果多次new Person 的话,解析不会重复进行,因为在运行时常量池中将这个符号的直接引用记录下来,并把常量标识为已解析状态。

public class Person {
	public String clientName;
	public String clientId;

	public String getClientName() {
		return clientName;
	}

	public void setClientName(String clientName) {
		this.clientName = clientName;
	}

	public String getClientId() {
		return clientId;
	}

	public void setClientId(String clientId) {
		this.clientId = clientId;
	}
}
public class Main {
	public static void main(String args[]){
		Person personaa = new Person();
	}
}

二、字段解析
要解析一个未被解析过的字段符号引用,首先将会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用。如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。如果解析成功完成,那将这个字段所属的类或接口用C表示,虚拟机规范要求按照如下步骤对C进行后续字段的搜索。

  1. 如果C本身包含了简单名称和字段描述符都与目标匹配的字段,则返回这个字段的直接引用,查找结束;
  2. 否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和他的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束;
  3. 否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束;
  4. 否则,查找失败,抛出java.lang.NoSuchFieldError异常。

如果查找过程成功返回了引用,将会对这个字段进行权限验证,如果发现不具备对字段的访问权限,将抛出java.lang.IllegalAccessError异常。
在实际应用中,虚拟机的编译器实现可能会比上述规范要求的更加严格一些,如果有一个同名字段同时出现在C的接口和父类中,或者同时在自己或父类的多个接口中出现,那编译器将可能拒绝编译。在下面的代码中,如果注释了Sub类中的“public static int A=4”,接口与父类同时存在字段A,那编译器将提示"The field Sub.A is ambiguous",方法歧义,是编译器无法确定,代码中使用哪一个方法,并拒绝编译。

public class FieldResolution {
	
	interface Interface0{
		int A = 0;
	}
	
	interface Interface1 extends Interface0{
		int A = 1;
	}
	
	interface Interface2{
		int A = 2;
	}
	
	static class Parent implements Interface1{
		public static int A = 3;
	}
	
	static class Sub extends Parent implements Interface2{
		public static int A = 4;
	}
	
	public static void main(String[] arg){
		System.out.println(Sub.A);
	}
}

个人理解:
字段解析首先要解析这个字段所在的类。比如上述代码调用Sub.A时需要先解析Sub类,保证Sub类在虚拟机有直接引用(实际上就是对Sub类进行类的解析,第一点说的)。如果没问题的话,可以认为这个类已经在内存中了(假定为C)。然后虚拟机会在类C里面找,看看有没有这个字段,(按照简单名称和字段描述符找)。如果有的话直接返回。如果C中实现了接口,就需要在所有父接口里面找。如果C有父类,还要在父类里面找。都没找到的话抛异常。如果成功找到了,也要进行权限验证。
如果将上述代码的 Sub类中的 “public static int A=4”注释掉,你可以把main中的执行代码写成 System.out.println(Parent.A);或者System.out.println(Interface1.A);或者System.out.println(Interface2.A); 这证明了在字段解析的过程中,其父类和实现的接口也被解析了。

三、类方法解析
类方法解析的第一个步骤与字段解析一样,也需要先解析出类防范表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,我们依然用C表示这个类,接下来虚拟机将会按照如下步骤进行后续的类方法搜索。

  1. 类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现class_index中索引的C是个接口,那就直接抛出java.lang.IncompatibleClassChangeError异常;
  2. 如果通过了第1步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的引用,查找结束;
  3. 否则,在类C实现的接口列表及他们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的引用,查找结束;
  4. 否则,在类C实现的接口列表及他们的父接口中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,证明C是一个抽象类,这时查找结束,抛出java.lang.AbstractMethodError异常
  5. 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError.

最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此方法的访问权限,将抛出java.lang,IllegalAccessError.

个人理解:类方法解析和字段解析一样,先要解析这个方法所在的类(用C表示这个类)。C只能是类,不能是接口,如果是的话报错。其他的跟字段解析一样。

四、接口方法解析
接口方法也需要先解析出接口方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,依然用C表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索。

  1. 与类方法解析不同,如果在接口方法表中发现class_index项中的索引C是个类而不是接口,那就直接抛出java.lang.IncompatibleClassChangeError;
  2. 否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束;
  3. 否则,在接口C的父接口中递归查找,直到java.lang.Object类(查找范围会包括Object类)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束;
  4. 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError.

由于接口中的所有方法默认都是public的,所以不存在访问权限的问题,因此接口方法的符号解析不会抛出java.lang,IllegalAccessError.

个人理解:C只能是接口,不能是类,类方法和接口方法的调用不同。