Java编译原理--类加载过程

 

Java语言在刚刚诞生的时候提出过一句著名的口号“一次编写,到处运行”,这句话充分的表达了开发人员对于冲破平台界限的渴望,也解释了Java语言跟平台无关的设定。

一、 概述

上一篇文章介绍了class文件的存储细节,class文件包括了类的各种描述信息,但是Java程序的运行需要在内存中实现,
那么虚拟机是如何加载这些class文件的?class文件中的静态结构是如何转换成实际的存储结构的?内存分配是如何完成的?这些都是本篇文章要讨论的内容。

虚拟机将类的描述文件class文件加载到内存,并且进行安全校验、数据类型解析、内存分配以及初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程就是虚拟机的类加载机制。与解释执行语言不通,Java语言是编译型语言,类型的连接(即加载、连接、初始化过程)是在程序运行期进行的,这样就可以在程序运行期间动态的加载一些内容,这种形式虽然会增加系统的运行开销,但是可以让程序设计更加的灵活。 

 二、类加载的时机

一个类从加载到内存开始,一直到被卸载结束,它的整个生命周期包括加载、连接(验证、准备、解析)、初始化、使用、卸载阶段,其中连接阶段包括验证、准备和解析过程,这几个过程的发生顺序如下图所示:


          什么时候触发类的加载动作呢?Java虚拟机规范并没有强制规定类加载时机,这个情况需要具体的虚拟机进行自由实现,例如Tomcat再启动时,会启动引导类加载器、拓展类加载器、通用类加载器和应用类加载器,引导类加载器、拓展类加载器和通用类加载器首先加载和初始化一些类(jvm所需类、Tomcat所需类、及一些通用类),其余的类是收到请求时才进行类的加载操作。
    虽然虚拟机没有明确说明类加载的时机,但是对于初始化阶段,虚拟机规范给了严格规定,有且只有以下几种情况必须立即对类进行初始化:
    1、遇到new、putstatic、getstatic及invokestatic这4条字节码指令时,如果类没有初始化,则立即进行初始化,这4个命令分别代表实例化一个类、设置&读取一个静态字段(没有被final修饰)、调用类的静态方法;
    2、使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有初始化;
    3、当初始化一个类的时候,发现其父类没有初始化;
    4、当虚拟机启动时,需用将执行启动的主类(有main()方法的那个类)进行初始化;
   5、当使用动态语言时,如果一个java.lang.invoke.MethodHandle实例最终的解析结果是REF_getStatic、REF_putStatic、REF_invokeStatic句柄时,并且这个句柄对应的类没有初始化。
    对于这几种需要初始化的场景,虚拟机规范给了很严格的规定词汇"有且只有",这几种场景称为对类的主动引用,除此之外,其他对类的引用称为被动引用,下面举几个被动引用的例子(来源:深入理解Java虚拟机):

父类代码: 

package com.jvm.classloader;

public class SuperClass {
	static {
		System.out.println("SuperClass init");
	}
	public static String name = "123";
}

子类代码: 

package com.jvm.classloader;

public class SubClass extends SuperClass {
	static {
		System.out.println("SubClass init");
	}
}

测试类:

package com.jvm.classloader;

public class Client {

	public static void main(String[] args) {
		System.out.println(SubClass.name);
	}

}

输出结果:


    输出结果为"SuperClass init"和"123",因为静态变量没有使用final修饰,所以这个字段在初始化之前没有被赋值,需要在初始化阶段赋值,初始化阶段会调用类的类构造器函数<clinit>()方法,这时候静态代码块会执行。

修改后的父类代码:

package com.jvm.classloader;

public class SuperClass {
	static {
		System.out.println("SuperClass init");
	}
	public final static String name = "123";
}

输出结果:


    输出结果只有"123",跟例一不同,使用final修饰后,静态变量在经过编译后,class文件会字段表的attribute_info表中添加ConstantValue属性,这个字段标识这个字段可以在类加载的准备阶段赋值,不需要在类加载的初始化阶段的<clinit>()方法中赋值,所以不会执行静态代码块。
测试代码:

package com.jvm.classloader;

public class Client {

	public static void main(String[] args) {
		SubClass[] subClasses = new SubClass[10];
		System.out.println(subClasses);
	}

}

输出结果:


    输出结果为"[Lcom.jvm.classloader.SubClass;@15db9742",没有任何静态代码块的输出结果,这个数组并不是由用户代码产生,是由虚拟机自动生成的,这个类代表了元素类型为 "Lcom.jvm.classloader.SubClass"的一维数组,数组中的属性和方法都在这个类中,这个类继承自java.lang.Object类。

三、类加载的过程

接下来讨论类加载的详细过程,包括加载、验证、准备、解析和初始化阶段。

 3.1 加载

    "加载"是"类加载"这个过程的一个阶段,是 “类加载”过程中最先开始进行的操作,加载阶段,虚拟机需要完成三件事:
    1、 根据类的全限定名获取定义此类的二进制字节流;
    2、 将这个字节流代表的静态存储结构转换为方法区的运行时数据结构;
    3、 在方法区中为这个类生成一个java.lang.Class对象,作为方法区这个类的访问入口。


    Java的虚拟机规范并没有规定从哪里获取、怎样获取二进制字节流,这个阶段也是用户参与度最高的阶段,用户可以根据二进制文件的不同形式在自定义类加载器控制字节流的获取方式,比如成熟的二进制获取方式和类加载器有:
    1、 从Zip包中读取二进制文件,比如常见的jar、war、ear包;
    2、 运行时动态生成,比如动态代理技术,在java.lang.reflect.Proxy中,使用 ProxyGenerator.generateProxyClass为各种就接口生成形如"*$Proxy"的代理类的二进制字节流;
    3、 从网络中获取,这种场景比较常见的是Applet应用;
    4、 其他文件生产,比如jsp文件生成的二进制class文件;

    ……


    数组的加载跟普通类型加载有所不同,因为数组本身不是通过类加载器加载产生的,数组类是虚拟机自动生成的,但是数组的类型是通过类加载器完成加载的,数组类的创建过程需要遵循以下规则:
    1、 如果数组的类型是引用类型,则引用类型需要使用递归来进行加载,并且数组需要被加载该数组类型的类加载器的命名空间上进行标识;
    2、 如果数组的类型不是引用类型,是基本数据类型,Java虚拟机将会把数组标记为与引导类加载器关联;
    3、 数组的可见性与数组类型的可见性保持一致,如果数组类型是基本类型,则默认可见性为public。

 3.2 验证

验证是类加载的第二个阶段,这个阶段也是持续时间最长(从阶段连续性来说),这个阶段从加载开始进行,一直进行到解析阶段结束。验证是为了保证class文件中的内容是符合虚拟机规范的二进制字节流,防止通过执行一些不安全的二进制字节流而导致虚拟机奔溃。 

Java语言本身是安全的语言,它做了很多的安全校验,比如类型转换、非正常的分支语句跳转、不合法的名称定义等等。但是我们知道,Java虚拟机并不只是执行Java语言编译后的class文件,它可以执行所有的二进制字节流文件(只要符合文件规范),所以我们不能保证其他的文件是合法的,所以需要进行一些安全校验,以保证虚拟机执行的代码是不会危害虚拟机本身安全的。从整体来看,类加载过程的验证阶段可以分为四个部分:文件格式验证、元数据验证、字节码验证和符号引用验证。

1、文件格式验证

这一阶段主要验证字节流是否符合class文件格式规范,并且能被虚拟机处理,保证输入的字节流能被正确的解析并且存储在方法区,这个阶段的验证是基于二进制字节流的验证,它的发生时间是在开始加载后,但是还没有在方法区存储之前,所以这个阶段再加载开始后进行,但是和加载阶段是混合进行的,这一阶段的校验包括以下这些验证点:
    1、 是否已魔数CAFEBABE开头;
    2、 主次版本号是否在当前虚拟机处理范围之内;
    3、 常量池中的常量是否有不被支持的类型(使用tag标识校验);
    4、 指向常量的索引是否有不存在或者支持的类型;
    5、 字符类型是否符合规范;
    6、 class文件是否被修改过;
    ...... 

2、元数据验证

第二阶段主要是进行语法分析,以保证class文件符合Java语法规范,这个阶段的验证点包括:
    1、 这个类是否有父类(除java.lang.Object类之外都有父类);
    2、 这个类的父类是否继承了不允许被继承的类(final修饰);
    3、 如果这个类不是抽象类,是否实现了其父类或者接口中要求实现的所有方法;
    4、 类中的字段、方法是否跟父类中的字段、方法冲突;
    ......

3、字节码验证

这个阶段是语义分析,是验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流,确定语义是合法并且符合逻辑的,这个阶段主要是针对方法体进行分析,以保证方法在运行过程中不会出现危害虚拟机的操作,验证点包括:
    1、 变量要在使用之前进行初始化;
    2、 方法调用与对象引用类型要匹配;
    3、 数据和方法的访问要符合权限设置规则;
    4、 对本地变量的访问都落在运行时本地方法栈内;
    5、 运行时堆栈没有溢出;
    ......

下面我们通过一个示例查看修改字节码之后虚拟机是如何处理的。

示例代码:

public class BytecodeVerifyTest {

	public static void main(String[] args) {
		System.out.println("1 + 2 == " + fun());
	}

	/**
	 * A function that computes 1 + 2.
	 * 
	 * @return 3, if the code has not been corrupted
	 */
	public static int fun() {
		int m;
		int n;
		m = 1;
		n = 2;
		// use hex editor to change to "m = 2" in class file
		int r = m + n;
		return r;
	}

}

步骤1、编译这个类

执行这个类,可以看到执行结果:

 

 步骤2、使用Javap命令查看编辑器如何编译fun方法:

 解释下code部分,java虚拟机是基于栈的指令集实现的,所以所有的命令都是在操作数栈以及局部变量表之间的数据传输及存储计算:

序列号: 操作符及操作数 含义
0 iconst_1 将常量1加载到操作数栈
1 istore_0 将操作数栈顶的数据出栈并存放在局部变量表位置为0的slot中(给m赋值)
2 iconst_2 将常量2加载到操作数栈
3 istore_1 将操作数栈位置1的数据出栈并且存放在局部变量表位置为1的slot中(给n赋值)
4 iload_0 将局部变量表位置为0的slot中的数据复制到操作数栈顶
5 iload_1 将局部变量表位置为1的slot中的数据复制到操作数栈顶
6 iadd 将操作数栈中头两个数据出栈相加,并且将结果重新入栈
7 istore_2 将操作数栈顶的数据出栈并且放在局部变量表位置为2的slot中(给r赋值)
8 iload_2 将局部变量表位置为2的slot中的数据复制到操作数栈顶
9 ireturn 结束方法执行,并且将结果返回给方法调用者

 我们使用16进制编辑器打开class文件,找到并修改指令3的istore_1,修改为istore_0,也就是说,将操作数栈顶的数据(分别为1和2)赋值给局部变量表中位置为0的slot中,也就是变量m(两次赋值均为m),没有为n赋值,n没有被初始化。

继续测试,出现错误

4、符号引用验证

最后阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个过程将会在解析阶段进行,符号引用验证可以看作是对类自身以外的信息进行匹配校验,确保解析动作可以正常执行,这一阶段通常需要校验以下内容:
    1、 通过全限定名能否找到对应的类;
    2、 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段;
    3、 符号引用中的类、方法、字段的访问性(private、protected、public)是否可以被当前类访问到;
    ......

对于虚拟机来说,验证阶段是一个非常重要但不是必须进行的阶段,因为对虚拟机运行期没有影响,如果运行的所有代码可以保证能正常执行,不会危害虚拟机的运行,那么可以考虑关闭虚拟机的大部分验证过程,以缩短类加载时间。

3.3准备

 准备阶段是证实为类变量分配内存并且设置初始化值的阶段,这些变量所使用的内存都在方法区分配。这个阶段进行初始化的数据只有静态字段,并且是赋值初始化值(final修饰的字段除外),不是代码中定义的值。
    假设我们定义一个变量:

    public static int value = 123;

在准备阶段,value在方法区分配内存,并且设置初始值0,如果value被final修饰,形如:

    public static final int value = 123;

则该变量在准备阶段将会被赋值123,并且不会引起类的初始化过程,示例及说明见第二部分(类加载的时机)的示例。

Java各数据类型初始值:

        

序号

数据类型

大小/位

封装类

默认值

可表示数据范围

1

byte(位)

8

Byte

0

-128~127

2

short(短整数)

16

Short

0

-32768~32767

3

int(整数)

32

Integer

0

-2147483648~2147483647

4

long(长整数)

64

Long

0

-9223372036854775808~9223372036854775807

5

float(单精度)

32

Float

0.0

1.4E-45~3.4028235E38

6

double(双精度)

64

Double

0.0

4.9E-324~1.7976931348623157E308

7

char(字符)

16

Character

0~65535

8

boolean

8

Boolean

flase

true或false

3.4解析

解析阶段是虚拟机将符号引用转化为直接引用的过程,符号引用在之前已经介绍过了,在class文件中以形如"CONSTANT_Class_info"、"CONSTANT_Fieldref_info"、"CONSTANT_Methodref_info"格式存在,接下来我们从更深层次的角度讨论这两个引用的区别。
    转自知乎
    作者:RednaxelaFX
    链接:https://www.zhihu.com/question/30300585/answer/51335493
    来源:知乎
    著作权归作者所有,转载请联系作者获得授权
    先看Class文件里的“符号引用”。

    考虑这样一个Java类:

public class X {
  public void foo() {
    bar();
  }

  public void bar() { }
}

它编译出来的Class文件的文本表现形式如下: 
 

Classfile /private/tmp/X.class
  Last modified Jun 13, 2015; size 372 bytes
  MD5 checksum 8abb9cbb66266e8bc3f5eeb35c3cc4dd
  Compiled from "X.java"
public class X
  SourceFile: "X.java"
  minor version: 0
  major version: 51
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #4.#16         //  java/lang/Object."<init>":()V
   #2 = Methodref          #3.#17         //  X.bar:()V
   #3 = Class              #18            //  X
   #4 = Class              #19            //  java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Utf8               Code
   #8 = Utf8               LineNumberTable
   #9 = Utf8               LocalVariableTable
  #10 = Utf8               this
  #11 = Utf8               LX;
  #12 = Utf8               foo
  #13 = Utf8               bar
  #14 = Utf8               SourceFile
  #15 = Utf8               X.java
  #16 = NameAndType        #5:#6          //  "<init>":()V
  #17 = NameAndType        #13:#6         //  bar:()V
  #18 = Utf8               X
  #19 = Utf8               java/lang/Object
{
  public X();
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0       
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return        
      LineNumberTable:
        line 1: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
               0       5     0  this   LX;

  public void foo();
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0       
         1: invokevirtual #2                  // Method bar:()V
         4: return        
      LineNumberTable:
        line 3: 0
        line 4: 4
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
               0       5     0  this   LX;

  public void bar();
    flags: ACC_PUBLIC
    Code:
      stack=0, locals=1, args_size=1
         0: return        
      LineNumberTable:
        line 6: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
               0       1     0  this   LX;
}

可以看到Class文件里有一段叫做“常量池”,里面存储的该Class文件里的大部分常量的内容。

来考察foo()方法里的一条字节码指令:

1: invokevirtual #2  // Method bar:()V

这在Class文件中的实际编码为:

[B6] [00 02]

其中0xB6是invokevirtual指令的操作码(opcode),后面的0x0002是该指令的操作数(operand),用于指定要调用的目标方法。
这个参数是Class文件里的常量池的下标。那么去找下标为2的常量池项,是:

#2 = Methodref          #3.#17         //  X.bar:()V

这在Class文件中的实际编码为(以十六进制表示,Class文件里使用高位在前字节序(big-endian)):

[0A] [00 03] [00 11]

其中0x0A是CONSTANT_Methodref_info的tag,后面的0x0003和0x0011是该常量池项的两个部分:class_index和name_and_type_index。这两部分分别都是常量池下标,引用着另外两个常量池项。
顺着这条线索把能传递引用到的常量池项都找出来,会看到(按深度优先顺序排列):

   #2 = Methodref          #3.#17         //  X.bar:()V
   #3 = Class              #18            //  X
  #18 = Utf8               X
  #17 = NameAndType        #13:#6         //  bar:()V
  #13 = Utf8               bar
   #6 = Utf8               ()V

把引用关系画成一棵树的话:

     #2 Methodref X.bar:()V
     /                     \
#3 Class X       #17 NameAndType bar:()V
    |                /             \
#18 Utf8 X    #13 Utf8 bar     #6 Utf8 ()V

标记为Utf8的常量池项在Class文件中实际为CONSTANT_Utf8_info,是以略微修改过的UTF-8编码的字符串文本。

这样就清楚了对不对?
由此可以看出,Class文件中的invokevirtual指令的操作数经过几层间接之后,最后都是由字符串来表示的。这就是Class文件里的“符号引用”的实态:带有类型(tag) / 结构(符号间引用层次)的字符串。

==================================================

然后再看JVM里的“直接引用”的样子。

这里就不拿HotSpot VM来举例了,因为它的实现略复杂。让我们看个更简单的实现,Sun的元祖JVM——Sun JDK 1.0.2的32位x86上的做法。
请先参考另一个回答里讲到Sun Classic VM的部分:为什么bs虚函数表的地址(int*)(&bs)与虚函数地址(int*)*(int*)(&bs) 不是同一个? - RednaxelaFX 的回答

Sun Classic VM:(以32位Sun JDK 1.0.2在x86上为例)
 
         HObject             ClassObject
                       -4 [ hdr            ]
--> +0 [ obj     ] --> +0 [ ... fields ... ]
    +4 [ methods ] \
                    \         methodtable            ClassClass
                     > +0  [ classdescriptor ] --> +0 [ ... ]
                       +4  [ vtable[0]       ]      methodblock
                       +8  [ vtable[1]       ] --> +0 [ ... ]
                       ... [ vtable...       ]

(请留心阅读上面链接里关于虚方法表与JVM的部分。Sun的元祖JVM也是用虚方法表的喔。)

元祖JVM在做类加载的时候会把Class文件的各个部分分别解析(parse)为JVM的内部数据结构。例如说类的元数据记录在ClassClass结构体里,每个方法的元数据记录在各自的methodblock结构体里,等等。
在刚加载好一个类的时候,Class文件里的常量池和每个方法的字节码(Code属性)会被基本原样的拷贝到内存里先放着,也就是说仍然处于使用“符号引用”的状态;直到真的要被使用到的时候才会被解析(resolve)为直接引用。

假定我们要第一次执行到foo()方法里调用bar()方法的那条invokevirtual指令了。
此时JVM会发现该指令尚未被解析(resolve),所以会先去解析一下。
通过其操作数所记录的常量池下标0x0002,找到常量池项#2,发现该常量池项也尚未被解析(resolve),于是进一步去解析一下。
通过Methodref所记录的class_index找到类名,进一步找到被调用方法的类的ClassClass结构体;然后通过name_and_type_index找到方法名和方法描述符,到ClassClass结构体上记录的方法列表里找到匹配的那个methodblock;最终把找到的methodblock的指针写回到常量池项#2里。

也就是说,原本常量池项#2在类加载后的运行时常量池里的内容跟Class文件里的一致,是:

[00 03] [00 11]

(tag被放到了别的地方;小细节:刚加载进来的时候数据仍然是按高位在前字节序存储的)
而在解析后,假设找到的methodblock*是0x45762300,那么常量池项#2的内容会变为:

[00 23 76 45]

(解析后字节序使用x86原生使用的低位在前字节序(little-endian),为了后续使用方便)
这样,以后再查询到常量池项#2时,里面就不再是一个符号引用,而是一个能直接找到Java方法元数据的methodblock*了。这里的methodblock*就是一个“直接引用”

解析好常量池项#2之后回到invokevirtual指令的解析。
回顾一下,在解析前那条指令的内容是:

[B6] [00 02]

而在解析后,这块代码被改写为:

[D6] [06] [01]

其中opcode部分从invokevirtual改写为invokevirtual_quick,以表示该指令已经解析完毕。
原本存储操作数的2字节空间现在分别存了2个1字节信息,第一个是虚方法表的下标(vtable index),第二个是方法的参数个数。这两项信息都由前面解析常量池项#2得到的methodblock*读取而来。
也就是:

invokevirtual_quick vtable_index=6, args_size=1


这里例子里,类X对应在JVM里的虚方法表会是这个样子的:

[0]: java.lang.Object.hashCode:()I
[1]: java.lang.Object.equals:(Ljava/lang/Object;)Z
[2]: java.lang.Object.clone:()Ljava/lang/Object;
[3]: java.lang.Object.toString:()Ljava/lang/String;
[4]: java.lang.Object.finalize:()V
[5]: X.foo:()V
[6]: X.bar:()V

所以JVM在执行invokevirtual_quick要调用X.bar()时,只要顺着对象引用查找到虚方法表,然后从中取出第6项的methodblock*,就可以找到实际应该调用的目标然后调用过去了。

假如类X还有子类Y,并且Y覆写了bar()方法,那么类Y的虚方法表就会像这样:

[0]: java.lang.Object.hashCode:()I
[1]: java.lang.Object.equals:(Ljava/lang/Object;)Z
[2]: java.lang.Object.clone:()Ljava/lang/Object;
[3]: java.lang.Object.toString:()Ljava/lang/String;
[4]: java.lang.Object.finalize:()V
[5]: X.foo:()V
[6]: Y.bar:()V

于是通过vtable_index=6就可以找到类Y所实现的bar()方法。

所以说在解析/改写后的invokevirtual_quick指令里,虚方法表下标(vtable index)也是一个“直接引用”的表现。

关于这种“_quick”指令的设计,可以参考远古的JVM规范第1版的第9章。这里有一份拷贝:http://www.cs.miami.edu/~burt/reference/java/language_vm_specification.pdf

在现在的HotSpot VM里,围绕常量池、invokevirtual的解析(再次强调是resolve)的具体实现方式跟元祖JVM不一样,但是大体的思路还是相通的。

HotSpot VM的运行时常量池有ConstantPool和ConstantPoolCache两部分,有些类型的常量池项会直接在ConstantPool里解析,另一些会把解析的结果放到ConstantPoolCache里。以前发过一帖有简易的图解例子,可以参考:请问,jvm实现读取class文件常量池信息是怎样呢?

==================================================

由此可见,符号引用通常是设计成字符串--用文本形式来表示引用关系,而直接引用则是jvm所能直接使用的形式,它既可以表现为直接指针,也可能是其他形式,关键点不在于形式是否为"直接指针",而是在于jvm能否"直接使用"这种形式的数据。

虚拟机规范没有给出具体的解析发生的时间,只要求了执行anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield、putstatic这16个用于操作符号引用的字节码指令之前,需要对他们所使用的的符号引用进行解析。

针对静态符号指令(非invokedynamic指令),同一个符号引用只需要解析一次,虚拟机对第一次解析的指令进行缓存(在运行时常量池中记录直接引用,并且吧常量标识为已解析状态),无论进行多少次解析动作,这个符号引用都只进行一次解析,如果解析失败,之后的解析请求也都是返回相同的异常;对于invokedynamic指令,每一次针对此指令的解析动作都需要重新进行,因为这个命令本身就是用于动态语言支持的,我们可以通过在合格指令实现动态加载功能(比如动态代理),它所对应的的引用称为"动态调用点限定符",这里"动态"的含义就是必须等到程序运行时解析动作才执行。

解析动作主要针对类或接口、类方法、接口方法、方法类型、方法句柄、字段以及调用点限定符等符号进行,分别对应常量池中的CONSTANT_Class_info、CONSTANT_Methodref_info、CONSTANT_interfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_Fieldref_info、CONSTANT_InvokeDynamic_info,下面介绍几种解析过程:

1、类或接口的解析

假设当前所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或者接口C的直接引用,虚拟机在整个解析过程中需要以下3个步骤:
    1、 如果C不是一个数组类型,那么虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类,在加载过程中,由于元数据验证、字节码验证的需要,可能触发其他类的加载动作,如果加载过程出现任何异常,则整个解析过程失败;
    2、 如果C是一个数组类型,并且数组的类型是一个对象,也就是N的描述符会是类似"[Ljava/lang/Integer"的形式,那么会按照第一点来加载数组元素类型,然后由虚拟机生成一个代表此数组维度和元素的数组对象;
    3、 如果上面的步骤没有出现任何异常,那么C在虚拟机中已经成为一个有效的类或者接口了,但在解析完成之前还需要进行引用符号验证,以确认D具有对C的访问权限,如果权限不够,则将抛出java.lang.IllegalAccessError异常。

2、字段解析

要解析一个字段符号引用,首先要解析其所述的类引用符号CONSTANT_Class_info,然后再解析类中的字段,解析步骤如下:
        1、 如果类本身包含了简单名称和字段描述符都与目标匹配的字段,则返回这个字段的直接引用,查找结束;
        2、 如果第一步没找到,如果此类实现了接口,将会按照从下往上的顺序递归搜索各个接口和它的父接口,如果接口和父接口中包含了简单名称和字段描述符匹配的字段,则返回字段的直接引用,查找结束;
        3、 如果第二步依然没有找到直接引用,如果此类不是java.lang.Object类的话,则按照继承关系从下往上查询父类,如果在父类中存在简单名称和字段描述符匹配的字段,则返回字段的直接引用,查找结束;
        4、 如果依然没有找到直接引用,则查找失败,抛出java.lang.NoSuchFieldError异常,查找结束。
        注意:如果在此类中没有找到直接引用,但是如果有同名字段同时出现在类的接口和父类中或者同事在父类的多个接口中出现,则编译器会编译失败。示例如下:

3、类方法解析

类方法解析首先也需要解析其所属类,也就是方法表中class_index所属的类或者接口的符号引用,类方法解析步骤如下:
        1、 类方法和接口方法符号引用的常量定义是分开的,如果类方法表中发现class_index中索引的类是个接口,则直接抛出java.lang.IncompatibleClassChangeError异常;
          2、 在方法表中查找简单名称和方法描述符相匹配的方法,如果找到则返回直接引用,查找结束;
          3、 否则,在类的父类中查找简单名称和方法描述符匹配的方法,如果找到则返回直接饮用,查找结束;
      4、 否则,在类的接口和父接口中查找简单名称和方法描述符匹配的接口,如果找到,说明这个类是抽象类,抛出java.lang.AbstractMethodError异常;
         5、 否则,方法查找失败,抛出java.lang.NoSuchMethodError异常,查找结束。


4、接口方法解析

接口方法解析也需要首先解析其所属类,也就是方法表中的class_index项中索引的方法所属的类或者接口的符号索引,解析步骤如下:
        1、 如果在接口的方法表中发现class_index中的索引是个类,则抛出java.lang.IncompatibleClassChangeError异常;
        2、 否则,在接口中查找简单名称和方法描述符匹配的方法,如果找到则返回方法的直接引用,查找结束;
        3、 否则,在接口的父接口中递归查找,知道找到java.lang.Object类(查找范围包括Object类),如果找到则返回方法的直接引用,查找结束;
        4、 否则,查找失败,抛出java.lang.NoSuchMethodError异常,查找结束。

3.5初始化 

初始化阶段是类加载过程的最后一步,这个阶段才开始真正的执行用户定义的Java程序。在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则需要为类变量(非final修饰的类变量)和其他变量赋值,其实就是执行类的<clinit>()方法。

在Java语言体系中,<clinit>()不是一个合法变量,这个方法也不是由用户显示定义的,是由编译器生成的,编译器在编译阶段会自动手机类中的所有类变量的赋值动作和静态语句块(static{})中的语句合并而成的,编译器手机的顺序是由语句的顺序决定的,静态语句块只能访问到定义在静态语句块之前的变量,定义在静态语句块之后的变量,可以赋值,但是不能访问。示例如下:

package com.jvm.classloader;

public class StaticFieldTest {
	static {
		i = 0;
		System.out.println(i);
	}
	static int i = 1;

}

报错结果如下:

<clinit>()方法与类的构造方法不同,它不需要用户显示的调用,虚拟机会保证父类的<clinit>()方法先于子类的<clinit>()执行,java.lang.Object的<clinit>()方法是最先执行的。

接口总不能使用用静态语句块,所以接口的<clinit>()只包含类变量,所以接口的<clinit>()方法执行时,不要求限制性父接口的<clinit>()方法。

<clinit>()方法对于类和接口来说不是必须的,如果类或接口中没有定义类变量,也没有静态语句块,那么编译器将不为这个类或者接口生成<clinit>()方法,如果类或者接口中生成了<clinit>()方法,那么这个方法在执行过程中,虚拟机会保证在多线程环境下的线程安全问题。
    

猜你喜欢

转载自blog.csdn.net/u010942465/article/details/81709246
今日推荐