大家好,今天为大家讲解类加载机制的解析部分和初始化部分,如果前面的部分还没看,可以先看一下JAVA虚拟机入门(2)——类加载机制(上)
(四)解析
首先明确一点:解析阶段的目的是什么?
解析阶段是将常量池中类的符号引用转化为直接引用的过程。
还记得在验证阶段中的符号引用验证吗?就是为了使得解析能更顺利地进行。如果不记得的话,可以去看一下JAVA虚拟机入门(2)——类加载机制(上)
符号引用的标识主要以COSNTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等出现。
那么解析阶段具体什么时候开始呢?java虚拟机还是没有规定具体什么时候开始,只是要在执行13个操作符号引用的字节码指令之前对符号引用进行解析就行。那这13个操作符号是什么呢?
anewarray、checkcast、getfield、getstatic、instanceof、invokeinterface、invokespecial、invokestatic、invokevirtual、multianewarray、new、putfield和putstatic
这就给虚拟机提供了灵活性,虚拟机会根据需要来判断到底是在类被加载器加载的时候就对常量池中的符号引用进行解析还是等到符号引用要被使用的时候才进行解析。
同时,由于会出现对同一个符号引用进行多次解析的情况,虚拟机也有权利决定是否要对解析进行缓存,避免解析动作重复执行。
不过,不管是否进行了缓存,不同的虚拟机都要保证解析结果的一致性,意思就是如果第一次解析成功,后面的解析都要保证成功,如果第一次解析失败,那么后面不管怎么解析都是失败的。
说了那么多关于解析的时机和目的,接下来让我们透过栗子来看看对待不同情况解析分别是怎么进行的。
1、CONSTANCT_Class_info(类或接口的解析)
类或接口的解析分为两种情况,非数组类型和数组类型,接下来看看两种情况下怎么处理符号引用。
(1)非数组类型
通俗点说就是单纯的一个类或者接口,应该怎么解析它的符号引用(也就是全限定名)。
栗子上!
class A{
............
}
public class B extends A{
}
特别粗暴的一个例子,但是很好解释,B类继承A类。
首先来看看A类的全限定名在哪。回想我们在Java虚拟机入门(1)—–类文件结构(下) 中关于类索引的部分是怎么解释的。首先类索引是在访问标志后面的,然后类索引的16进制数指引我们去常量池中哪个常量找,找什么呢?是全限定名吗?不是的,实际上是全限定名的索引,也就是CONSTANT_Class_info。CONSTANT_Class_info的结构如下:
(u1表示1个字节,u2同理可得)
tag u1 值为7
index u2 指向全限定名常量项的索引
再根据这个全限定名的索引最终在常量池中找到继承类的全限定名。将这种思想引用到这里来,B类中的类索引指引到常量池中的一个常量,这个常量再指引我们到常量池中的另一个地方,这里就储存着A的全限定名了。
得知A的全限定名后,虚拟机将A的全限定名(比如com/example/test/A)传递给B的类加载器。B的类加载器对A类以及A的父类、接口(如果有的话)进行加载,如果加载过程中出现异常,则说明解析失败。
ps:大家可能注意到,这里提到了类加载器。类加载器是在加载阶段出现的,这更验证了类加载机制各个阶段之间的相互渗透:验证阶段的符号引用验证要等到解析阶段才进行,而解析阶段的解析结果要等到加载阶段才能得知。
(2)数组类型
对于非数组类型,就是寻找全限定名–>根据全限定名加载类—>判断加载成功与否。
而对于数组类型,也就是类或接口的数组,实际上也是按照这个过程进行的。
举个栗子!
class A{
............
}
public class B{
public A[] aClassInB;
}
这个例子中B中有个A[]类型的字段 aClssInB,再考大家一下,A[]在Class文件中怎么表示呢?不懂的话去看看Java虚拟机入门(1)—–类文件结构(下) 中关于descriptor_index的解释。A[]表示为[Lcom.example.test.A。B仍会加载com.example.test.A,然后虚拟机生成一个表示维度和元素的数组对象。
如果上面两种类型都能正常加载的话,那么A在虚拟机中就是一个有效的类了。这样子就结束了吗?不,就算A加载成功,最多说明真的存在A这个类,那B能否访问A还没验证,如果A在别的包里,并且声明为protected,那B就访问不到了,不是吗?所以要进行符号引用验证,来确定B对A具有访问的权利。如果这个也通过的话,这才说明解析成功。
2、CONSTANT_Fieldref_info(字段解析)
我们先来看CONSTANT_Fieldref_info的结构:
tag u1 值为9
index u2 指向声明字段的接口或者类CONSTANT_Class_info的索引项
index u2 指向字段描述符CONSTANT_NameAndType的索引项
要想解析字段,肯定解析它所在的类或者接口,因此就回到CONSTANT_Class_info的解析了。如果类或接口解析失败,就没必要进行下去了,如果解析类或接口成功,那么将这个字段所属的类或接口用C表示,然后分4种情况进行分析:
(1)如果在C本身就存在简单名称和字段描述符都和目标相匹配的字段,则直接返回这个字段的直接引用,查找结束。
(简单名称:比如public int a = 1;简单名称就是a、字段描述符:int表示为 I)
(2)否则,如果C实现了接口,则从下往上搜索,如果找到一个字段的简单名称和字段描述符和目标相同,查找结束。
(3)否则,如果C实现了类,则则从下往上搜索,如果找到一个字段的简单名称和字段描述符和目标相同,查找结束。
(4)否则,查找失败。
什么意思呢?举个栗子。
(1)如果在C本身就存在简单名称和字段描述符都和目标相匹配的字段,则直接返回这个字段的直接引用,查找结束:
package javaLearning;
class A{
int a = 1;
}
class B extends A{
int a = 2;
}
class C extends B{
int a = 3;
}
public class MainClass {
public static void main(String[]args){
C c = new C();
System.out.println(c.a);
}
}
上面C类继承B,B类继承A,同时A,B,C都有a这个字段,并且都是int字段,这种情况下,按照由下往上搜索原则,最终输出 3。
注意:可能会有人想要用static块来看到底A,B类是否会被调用,像这样:
package javaLearning;
class A{
static int a = 1;
static {
System.out.println("A is called");
}
}
class B extends A{
static int a = 2;
static {
System.out.println("B is called");
}
}
class C extends B{
static int a = 3;
static {
System.out.println("C is called");
}
}
public class MainClass {
public static void main(String[]args){
System.out.println(C.a);
}
}
这样实际上是不可行的,无法用于验证它到底有没有搜索父类的变量,为什么呢?我们知道,如果一个变量声明为static,当使用这个变量时(getstatic),一定会对它所在的类进行初始化,这样的话,如果这个类的父类没有初始化,则也一定会被先初始化,因此上面的代码肯定三个类A,B,C都会打印出来。
那如果将a变量去掉static会怎么样呢?同样的,如果去掉static,那你要使用这个变量,总要对这个变量所在的类new你才能引用这个变量吧,那你一new,不就更直接地初始化了全部父类了吗?
(2)否则,如果C实现了接口,则从下往上搜索,如果找到一个字段的简单名称和字段描述符和目标相同,查找结束。
对应代码如下:
package javaLearning;
interface A{
int a = 1;
}
interface B extends A{
int a = 2;
}
class C implements B{
}
public class MainClass {
public static void main(String[]args){
C c = new C();
System.out.println(c.a);
}
}
C中没有a字段,同时C实现了B接口,B实现了A接口,这样的话C搜索到B,B中刚好有a字段而且还是int,因此搜索结束,输出为2。
(3)否则,如果C实现了类,则则从下往上搜索,如果找到一个字段的简单名称和字段描述符和目标相同,查找结束。
代码如下:
package javaLearning;
class A{
int a = 1;
}
class B extends A{
int a = 2;
}
class C extends B{
}
public class MainClass {
public static void main(String[]args){
C c = new C();
System.out.println(c.a);
}
}
这里只是将(1)中的代码去掉C中的a字段而已。分析和(2)一模一样,输出当然就是2了。
如果经过字段查找成功后,将会对这个字段进行权限验证,如果发生引用这个字段的类并不具备对它的访问权利,那么将抛出异常。
除此之外,我们还要考虑另一种情况,看下面的例子:
interface A{
int a = 2;
}
class B{
int a = 4;
}
class C extends B implements A{
int a = 5;
}
public class MainClass {
public static void main(String[]args){
C c = new C();
System.out.println(c.a);
}
}
这个情况下到底打印出来的是2还是4呢?
实际上,这种情况下压根就不会让你有运行的机会,会直接报错,因为interface和class在同一个等级,所以c.a可能是指interface的a,也有可能指class的a。那我如果让C类也具有一个a变量呢?
interface A{
int a = 2;
}
class B{
int a = 4;
}
class C extends B implements A{
}
public class MainClass {
public static void main(String[]args){
C c = new C();
System.out.println(c.a);
}
}
这种情况下会不会报错呢?答案是不会的,记得吗?搜索一个字段是从下往上搜索的,如果C类有int a字段,那么它将直接返回这个a;而如果没有这个a,那么它将懵逼于到底是去interface还是去class父类找a,因为两者都是有a的。
3、CONSTANT_Methodref_info(类方法解析)
我们详细讲解字段解析是有原因的,因为理解了字段解析,方法解析也是差不多的。
首先来看CONSTANT_Methodref_info的结构:
tag u1 值为10
index u2 指向声明方法的类描述符CONSTANT_Class_info的索引项
index u2 指向名称和类型描述符CONSTANT_NameAndType的索引项
接下来让我们来逐步揭开类方法解析的秘密。
回想一下字段解析的第一步是什么?没错,就是先找到这个方法在哪个类里面,也就是解析 CONSTANT_Class_info 对应的类或者接口,得到这个类或者接口的全限定名,然后根据这个全限定名去加载这个类,如果解析成功,我们依旧用C表示这个类。接下来虚拟机将根据下面的步骤来解析类方法:
a、如果解析出来的C不是一个类而是一个接口(因为CONSTANT_Class_info可以表示类,也可以表示接口),则直接抛出异常。
b、如果通过a,则在C中查找是否有简单名称和描述符和目标相匹配的方法,如果有的话,直接返回这个方法的直接引用。
c、如果没有通过b、则沿着类继承从下往上搜索每个父类,一旦搜索到对应的方法,则立刻返回这个方法的直接引用。
d、如果没有通过c,则尝试沿着接口继承从下往上搜索每个父接口,一旦搜索到,就说明C类是个抽象类,抛出java.lang.AbstractMethodError异常。为什么就说明C类是抽象类呢?想一想,一个方法,在C类中找不到,但是在接口中找到了,这意味着什么?意味着C类继承了接口但是没有实现这个方法,因此C类只可能是抽象类。
e、如果连d都通不过,就说明压根没这个方法,抛出异常。
和字段解析一样,如果成功返回了方法的直接引用,则验证当前类是否对这个方法具有访问权限,如果没有的话,抛出异常,否则,解析成功。
4、CONSTANT_InterfaceMethodref_info(接口方法解析)
接口方法解析和类方法解析相似。请注意他们解析过程中的细微区别。
a、与类方法解析相反,如果在加载成功C后发现C不是一个接口而是一个类,则抛出异常,解析失败。
b、否则,在C中查找有没有简单名称和描述符与目标相匹配的方法,如果有,则返回方法的直接引用,解析成功。
c、否则,沿着C继承的接口不断地往上搜索,如果搜索到简单名称和描述符与目标相匹配的方法,则返回方法的直接引用,解析成功。
d、否则,解析失败。
注意:
(1)接口方法解析并不需要搜索类,只需要搜索接口,为啥呢?显而易见,接口是无法继承类的。
(2)在返回直接引用后不需要检查访问权限,因为接口的方法都是public,不存在访问权限的问题。
这样,我们就把解析过程给剖析完了。想一下几个问题:
1、解析的目的是什么?
解析是为了将符号引用转化为直接引用。
2、解析有几种?
类解析、字段解析,类方法解析、接口方法解析。
3、每种解析的CONSTANT info结构是什么?
请前往这里的(三)常量池 寻找答案
整理完解析的知识点后,休息一下,来看初始化阶段。
(五)初始化
初始化阶段是执行类构造器<clinit>()
方法的过程,在这个过程中真正执行java程序代码(或者说是字节码)。
首先来想一下,<clinit>()
和我们的构造方法是一样的吗?不是的,俩看看它到底是什么?
1、<clinit>()
方法是由编译器自动收集类中的类变量以及static方法块中的语句构成并产生的,收集的顺序就是类变量和static方法块在源文件中出现的顺序。
栗子上:
class A{
static{
a = 1;
}
static int a = 2;
static{
a = 3;
}
}
public class MainClass {
public static void main(String[]args){
System.out.println(A.a);
}
}
请问这个输出的是什么?输出的是3,因为在<clinit>()
中静态变量和staic语句块是按照源代码中出现的先后顺序构成的,因此输出就是3。
2、<clinit>()
不需要显式调用父类的构造函数,因为虚拟机会保证在子类的<clinit>()
调用之前,父类的<clinit>()
已经执行完毕。因此第一个执行<clinit>()
方法的肯定就是java.lang.object了。
3、由于父类的<clinit>()
先执行,也就意味着父类定义的静态语句块会先于子类的变量赋值操作。
class A{
static{
a = 1;
System.out.println("static 1 in A");
}
static int a = 2;
static{
a = 3;
System.out.println("static 2 in A");
}
}
class B extends A{
static{
a = 4;
System.out.println("static 1 in B");
}
}
public class MainClass {
public static void main(String[]args){
System.out.println(B.a);
}
}
请问这个代码输出的是什么?B类的static块有没有执行呢?
答案是没有的,输出如下:
static 1 in A
static 2 in A
3
4、<clinit>()
并不是每个接口或者类需要的,如果一个类没有静态语句块,也没有对变量的赋值操作,那么编译器可以不生成一个<clinit>()
。
5、接口中没有静态语句块,但是因为可以有赋值语句,因此也是有<clinit>()
不同的是,接口并不是严格按照父接口调用<clinit>()
后,子接口才能调用<clinit>()
,只有在真正需要父接口的时候,才会使父接口初始化。
interface K{
static int a = 3;
}
6、类implements的接口,类在初始化的时候也不会调用接口的<clinit>()
。
7、虚拟机会保证<clinit>()
在多线程环境下正确地加锁和同步。这也就意味着如果一个类的<clinit>()
耗时过长,则可能造成多个线程阻塞。
举例如下:
class A{
static{
if(true){
System.out.println(Thread.currentThread() + " is inited");
while(true){
}
}
}
}
public class MainClass {
public static void main(String[]args){
Runnable example = new Runnable(){
@Override
public void run(){
System.out.println("class is begun");
A a = new A();
System.out.println("class is over");
}
};
Thread thread1 = new Thread(example);
Thread thread2 = new Thread(example);
thread1.start();
thread2.start();
}
}
这里开启了两个线程去初始化A类,因为<clinit>
能够正确的加锁,因此在第一个线程进入<clinit>
的时候就加锁,第二个线程因为获取不到锁,就一直在等待第一个线程完成。然而,因为第一个线程进入static方法块的时候来到了while(true)无限循环,所以就造成线程堵塞了。输出结果如下:
class is begun
class is begun
Thread[Thread-1,5,main] is inited
看到没有,第二个线程始终在等待第一个线程结束,然而第一个线程结束不了,因此第二个线程就无法进入<clinit>
了。