从程序猿的角度来看,理解Java体系结构最重要的方面之一就是连接模型。前面曾说过,Java的连接模型允许用户自行设计类装载器,通过自定义的类装载器,程序可以装载在编译时并不知道或许尚未存在的类或者接口,并动态连接它们。

上一篇文章只是简单描述了类生命周期的各个阶段,但是没有深究装载解析的细节。现在,我们用整个篇幅来讲一下装载和解析的细节,并展示解析过程如何和动态扩展相关联。

解析

当编译一个Java程序时,每个类或者接口都会编译成独立的class文件。虽然class文件之间看上去毫无关联,实际上它们之间通过符号引用互相联系,或者与JavaAPI的class文件相联系。当程序运行时,Java虚拟机装载类和接口,并且在动态连接的过程中把他们相互关联起来。

解析相关的一些说明

不同Java虚拟机的实现在类型解析的时间上可以有不同的选择。

  • 一种是预先解析所有的符号引用,从启动类开始,到后续的各个类,直到所有的符号引用都被解析。这种情况程序在它的 main()方法尚未被调用时就已经完全连接了。这种称为早解析
  • 另一种方式是在程序访问符号引用的最后一刻才去解析它。这种称为迟解析

不管虚拟机在何时进行解析,都应该在程序第一次试图访问一个符号引用时才抛出错误。意思就是如果虚拟机按照第一种方式预先解析,过程中发现某个 class 文件无法找到,它不应该抛出对应的错误,而是直到程序实际访问这个类时才抛出。如果程序不使用这个类,错误永远不会被抛出。

常量池回顾

为了给常量池解析做铺垫,我们先来自己摸索下解析规则

基础概念

class文件把所有的引用符号保存在常量池。并且每一个class文件都有一个常量池,而每一个被Java虚拟机装载的类或者接口都有一份内部版本的常量池,称作运行时常量池运行时常量池不同的虚拟机对其数据结构的实现方式不同,只要能够与class文件中的常量池一一对应即可。总之,当一个类型被首次装载时,该类型中的所有符号引用都装载到了该类型的运行时常量池

当程序运行到某些时刻,如果某个特定的符号引用将要被使用,它首先要被解析。解析过程就是根据符号引用查找到实体,再把符号引用替换成一个直接引用的过程。又因为所有的符号引用都保存在常量池中,所以这个过程常被称作常量池解析

解析模拟

对于类StaticTest

class StaticTest{
    static int len = 9;
}

编译成class文件后,我们可以通过javap -v StaticTest.class来查看格式化后的常量池信息。
信息如下:

class hua.lee.jvm.StaticTest
  minor version: 0
  major version: 52
  flags: ACC_SUPER
Constant pool:
   #1 = Methodref          #4.#17         // java/lang/Object."<init>":()V
   #2 = Fieldref           #3.#18         // hua/lee/jvm/StaticTest.len:I
   #3 = Class              #19            // hua/lee/jvm/StaticTest
   #4 = Class              #20            // java/lang/Object
   #5 = Utf8               len
   #6 = Utf8               I
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lhua/lee/jvm/StaticTest;
  #14 = Utf8               <clinit>
  #15 = Utf8               SourceFile
  #16 = Utf8               Angry.java
  #17 = NameAndType        #7:#8          // "<init>":()V
  #18 = NameAndType        #5:#6          // len:I
  #19 = Utf8               hua/lee/jvm/StaticTest
  #20 = Utf8               java/lang/Object
{
  static int len;
    descriptor: I
    flags: ACC_STATIC

  hua.lee.jvm.StaticTest();
    descriptor: ()V
    flags:
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 64: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lhua/lee/jvm/StaticTest;

  static {};
    descriptor: ()V
    flags: ACC_STATIC
    Code:
      stack=1, locals=0, args_size=0
         0: bipush        9
         2: putstatic     #2                  // Field len:I
         5: return
      LineNumberTable:
        line 65: 0
}

我们已经知道常量池中的每一项拥有一个唯一的索引(就是#1#20吧)。
以上面类中的putstatic #2操作码(给静态变量赋值)为例,关于操作数#2的查找过程,可能是这样的:

  • 1: putstatic操作码在字节流后面会跟随一个常量池索引#2
  • 2: 常量池中#2的属性是Fieldref(字段引用),数值是两个新的常量池索引#3.#18
    • 常量池中#3的属性是Class类信息,数值是一个新的常量池索引#19
      • 常量池中#19描述的是一个类的全限定名hua/lee/jvm/StaticTest
    • 常量池中#18的属性是NameAndType(属性名称:属性类型),数值是两个新的常量池索引#5:#6
      • 常量池中#5的属性是Utf8的字符串,内容是len(类中声明的变量名)
      • 常量池中#6的属性是Utf8的字符串,内容是I(int类型的助记符)
  • 3: 经过步骤2后,#2找到了:
    • 一个#3代表的类全限定名hua/lee/jvm/StaticTest
    • 一个#18代表的类变量len,并且是 int 类型
  • 4:到这里,虚拟机大概就明白了putstatic #2就是给名为hua/lee/jvm/StaticTest.len的静态变量赋值,类型为 I

请记住,Java虚拟机为每一个装载的类或接口保存一份独立的常量池(Android中的Dex字节码把多个类的常量池整合到一起了,此处有伏笔!!!)。所以当一条指令使用到常量池元素时(比如#5),它指向的是当前类(正在执行方法的类)的常量池。

常量池解析

本节会描述每一种常量池入口类型的解析细节,包括可能在过程中抛出的错误。

解析CONSTATNT_Class_info入口

CONSTATNT_Class_info用来表示指向类(包括数组)和接口的符号引用。有几个指令,比如newanewarray,直接使用CONSTATNT_Class_info入口。其他指令,比如putfield或者invokevirtual,从其他类型的入口间接指向CONSTATNT_Class_info

数组类

如果CONSTATNT_Class_info入口的name_index指向的是CONSTATNT_Utf8_info字符串是由一个[开始,那么它指向的是一个数组类,每一维增加一个[,后面跟的是元素类型。如果元素类型由一个L开头,那么数组是一个关于引用的数组。否则元素类型是一个基本类型,比如I表示int,D表示double。
请注意如下区分:

  • [I,那么它指向的是一个int数组类。
  • [Ljava.lang.Integer,那么这个是Integer数组,元素类型是Integer类。

指向数组类的符号引用的最终解析结果是一个Class实例,表示该数组类。如果当前的类装载器已经被记录为被解析数组类的初始装载器,就是同样的类。否则,虚拟机执行以下步骤:

  • 如果数组的元素类型是一个引用类型(数组是一个关于引用的数组),虚拟机用当前类装载器解析元素类型。
    • 举例来说,[[java.lang.Integer的数组类,虚拟机会确认java.lang.Integer被装载到 当前类装载器的命名空间中
  • 如果数组是关于基本类型的数组,虚拟机会立即创建关于那个元素类型的新数组类,维数也在此时确定,然后创建一个Class的实例来代表这个类型。

如果是关于引用的数组,数组会被标记为由加载它元素类型的类装载器定义的。
如果是关于基本类型的数组,数组类会被标记为是由启动类装载器定义的。

非数组类和接口

如果CONSTATNT_Class_info入口的name_index指向一个非[开始的CONSTATNT_Utf8_info字符串,那么这是一个指向非数组类或者接口的符号引用。解析这种类型的符号引用分为多步:

步骤1a:装载类型或者任何超类

解析非数组类或者接口的基本要求是确认类型被装载到了当前命名空间。为了做出确认,虚拟机必须查明是否当前类装载器被标记为该类型的初始装载器

双亲委派模型中,如果委托链中的某个类装载器第一次成功地装载了类型,那么这个类装载器就被称为定义类装载器。而委托链中的所有排在定义类装载器前面的类装载器都会标记为初始化类装载器(因为定义类装载器的双亲、祖父、曾祖父等都没有成功装载这个类型)

对于每一个类装载器,Java虚拟机都维护一张列表,其中记录了类装载器是初始类装载器的类型名称。每一张这样的表就组成了Java虚拟机内部的命名空间。在解析过程中,虚拟机使用这些列表来判断一个类型是否已经被一个特定的类装载器装载过了。

如果虚拟机发现希望装载的类型已经在当前命名空间中了,它将只使用已经被装载的类型,该类型由方法区的类型数据块所定义,并由堆中相关的Class实例所表示。

如果希望被装载的类型还没有被装载进当前的命名空间,虚拟机把类型的全限定名传递给当前的类装载器,Java虚拟机总是要求当前类装载器来装载被引用的类型。

在装载类型时,不管是请求启动类装载器还是用户自定义的类装载器,类装载器都有两个选择:

  • 自行装载类型
  • 委派其他装载器装载

考虑到前面提到的双亲委派模型,当委派过程一直进行到委派的末端,有一个类装载器不再委派而是决定装载这个类型的时候,这个类装载器大多数情况下就是启动类装载器。此时如果类装载器试图装载这个类型但是失败了,控制权会重新回到子装载器。子装载器在所有双亲都无法装载此类型时,它会试图自行装载。

如果用户自定义类装载器的loadClass()方法能够找到或者产生一个字节数组,loadClass()必须调用defineClass方法。

protected final Class<?> defineClass(String name, byte[] b, int off, int len);

调用defineClass方法会使得虚拟机试图解析二进制数据b,将其转化为方法区中的内部数据结构。虚拟机用传递进来的name(全限定名)来校验,需要装载的类型名字与name是否一致。

一旦引用的类型被装载了,虚拟机仔细检查它的二进制数据。如果类型是一个类,并且不是java.lang.Object,虚拟机根据类的数据得到它的直接超类的全限定名。接着虚拟机查看超类是否已经被装载到当前命名空间了。如果没有,先装载超类。装载超类的逻辑和子类的一样,继续检查它的超类,直到遇到Object为止。

Object返回的路上,虚拟机再次检查每个类型的数据,看看他们是否直接实现了任何接口。如果是这样,它会先确保对应的接口也被装载进来。对于每个虚拟机要装载的接口,虚拟机检查它们的类型数据,看他们是否直接扩展了其它接口。如果是这样,虚拟机会确认那些超接口也被装载了。

当虚拟机装载超接口是,它再次解析更多的CONSTANT_Class_info入口。正在被装载的类型包含的接口相关的信息保存在class文件的interfaces元素中。

当虚拟机递归地解析超类和接口时,它使用的类装载器是子类型的定义类装载器。一旦一个类型被装载进入了当前命名空间,所有该类型的超类和接口也都被成功装载了,虚拟机同时会创建对应的Class实例。

通过步骤1a,Java虚拟机确认某个类型是否被装载了,包括其超类和所有的接口。在这个步骤中,这些类型没有连接或者初始化,仅仅是装载

步骤1b:检查访问权限

随着装载结束,虚拟机检查访问权限。如果发起引用的类型没有访问被引用类型的权限,虚拟机会抛出IllegalAccessError异常。逻辑上说,步骤1b是校验的一部分,但是并非在正式校验阶段完成。但检查访问权限总是在步骤1a之后,以确保符号引用指向的类型被装载进正确的命名空间,这是解析符号引用的一部分。一旦检查结束,步骤1b以及整个解析CONSTANT_Class_info入口的过程就结束了。

如果步骤1a或者步骤1b发生了错误,符号引用解析就失败了。但是如果在步骤1b权限检查之前一切正常的话,这个类还是可以使用的,只不过不能被发起引用的类型使用。如果步骤1a出现异常,类型是不可使用的,必须标记为不可使用或者被取消。

步骤2:连接并初始化类型和任何超类

经过步骤1b步骤1a类型已经被装载了,但是还没有进行必要的连接和初始化。类型所有的超类和超接口也被装载了,但是也没有进行必要的连接和初始化。

虚拟机因为主动使用一个类而正在解析该类(不是接口)的引用,它必须确认它的所有超类都被初始化了,从Object开始沿着继承结构向下处理,知道被引用的类(和步骤1a正好相反)。如果一个类型还没有被连接,在初始化之前必须被连接(只有超类必须被初始化)。

步骤2a:校验类型

步骤2是从正式连接校验阶段开始,而校验过程可能要求虚拟机装载新的类型来确认字节码是否符合Java语言的语义。比如,一个指向类B的实例引用被赋值给了一个以类A为类型声明的变量(A a = new B()),虚拟机可能需要装载这两种类型,已确认BA的子类。

步骤2b:准备类型

随着正式校验阶段的结束,类型必须被准备好。准备阶段虚拟机为类变量分配内存,并且不同虚拟机实现为其内部数据结构(比如方法表)进行的内存分配也有差别

步骤2c:解析类型(可选步骤)

步骤1a步骤2a步骤2b已经解析了发起引用的类型的CONSTANT_Class_info入口。步骤2c是关于被引用类型(不是发起引用的类型)中所包含的符号引用的解析。

举个例子,虚拟机正在解析一个从Cat类指向Mouse类的符号引用,虚拟机为Mouse类执行了步骤1a步骤2a步骤2b,在从Cat类的常量池中解析指向Mouse的符号引用时,虚拟机可能可选择地(作为步骤2c)解析Mouse类常量池中的所有符号引用。假设Mouse类的常量池中包含一个指向Cheese类的符号引用,虚拟机这个时候可能装载并可选地连接Cheese类。虚拟机不能在这里试图初始化Cheese,因为Cheese没有被主动使用

前面讲过,如果一个虚拟机在解析过程中的这个时刻执行步骤2c,这属于提前解析,虚拟机必须在这个符号引用被首次实际使用之前不报告任何错误。比如,在解析Mouse的常量池过程中,虚拟机无法找到Cheese类,那么它也不能立即抛出NoClassDefFound错误,除非Cheese被程序实际使用。

步骤2d:初始化类型

到这里,常量池中符号引用指向的类型已经被装载校验准备好了,也可能可选的被解析了。也就可以开始初始化了。

初始化包括两个步骤:

  • 如果类型拥有任何超类,超类也需要进行初始化,按照自顶向下的顺序进行初始化。
  • 如果类型中有一个类初始化方法<clinit>,也在此时执行。

解析CONSTANT_Fieldref_info入口

要解析的类型是CONSTANT_Fieldref_info入口,虚拟机必须首先解析class_index中指明的CONSTANT_Class_info入口。

CONSTANT_Class_info解析成功后,虚拟机在此类型和它的超类上搜索指定的字段。找到了需要的字段,虚拟机还要检查当前类是否有访问这个字段的权限。

虚拟机会按照如下步骤执行字段搜索过程:

  • 虚拟机在被引用的类型中查找具有指定的名字和类型的字段。如果虚拟机找到了这样一个字段,搜索完成。
  • 否则,虚拟机检查被引用类型直接实现的接口,以及递归地检查接口对应的超接口。如果找到了名字和类型都符合的字段,搜索完成。
  • 否则,查找被引用类型的直接超类,并且递归地检查类型的所有超类。如果找到了名字和类型都符合的字段,搜索完成。
  • 否则,搜索失败。
解析失败

如果虚拟机在被引用的类或者任何它的超类中都没有找到名字和类型都符合的字段(搜索失败),虚拟机就会抛出NoSuchFieldError错误。另外,如果字段搜索成功,但是当前类没有访问该字段的权限,虚拟机就会抛出IllegalAccessError异常。

解析成功

虚拟机把这个入口标记为已解析,并在这个常量池入口的数据中放上指向这个字段的直接引用。

解析CONSTANT_Methodref_info入口

要解析的类型是CONSTANT_Methodref_info入口,虚拟机必须首先解析class_index中指明的CONSTANT_Class_info入口。

CONSTANT_Class_info解析成功后,虚拟机在此类型和它的超类上搜索指定的方法。找到了需要的方法,虚拟机还要检查当前类是否有访问这个方法的权限。

虚拟机使用如下步骤执行方法分析:

  • 如果解析的类型是一个接口,而非类,虚拟机抛出IncompatibleClassChangeError
  • 否则,被解析的类型是一个类。虚拟机会检查被引用的类是否有一个方法符合指定的名字以及描述符。如果虚拟机找到了这样一个方法,搜索完成。
  • 否则,如果类有一个直接超类,虚拟机会检查类的直接超类,并且递归检查类的所有超类,查看是否有方法符合指定的名字和描述符。如果虚拟机找到了这样一个方法,搜索完成。
  • 否则,虚拟机检查是否这个类实现了任何接口,并且递归地检查接口的超接口,查看是否有方法符合指定的名字和操作符。如果虚拟机找到了这样一个方法,搜索完成。
  • 否则,搜索失败。
解析失败

如果虚拟机没有在被引用的类和它的任何超类型中找到名字、返回类型、参数数量和类型都符合的方法(搜索失败),虚拟机会抛出NoSuchMethodError错误。否则,如果方法存在,但是方法是一个抽象方法,虚拟机会抛出AbstractMethodError异常。否则,如果方法存在,但是当前类没有访问权限,虚拟机就会抛出IllegalAccessError异常。

解析成功

虚拟机把这个入口标记为已解析,并在这个常量池入口的数据中放上指向这个方法的直接引用。

解析CONSTANT_InterfaceMethodref_info入口

要解析的类型是CONSTANT_InterfaceMethodref_info入口,虚拟机必须首先解析class_index中指明的CONSTANT_Class_info入口。

CONSTANT_Class_info解析成功后,虚拟机在此接口和它的超接口上搜索指定的方法。(虚拟机并不需要确认权限相关的问题,因为接口中定义的所有方法都是隐含公开的)

虚拟机按照如下步骤执行接口方法解析:

  • 如果被解析的类型是一个类,而非接口,虚拟机抛出IncompatibleClassChangeError异常
  • 否则,被解析的类型是一个接口。虚拟机检查被引用的接口是否有方法符合指定的名字和描述符。如果发现了这样一个方法,搜索完成。
  • 否则,虚拟机检查接口的直接超接口,并且递归地检查接口的所有超接口以及Object类来查找符合指定名字和操作符的方法。如果发现了这样一个方法,搜索完成。
  • 如果虚拟机没有在被引用的接口和它的任何超类型中找到名字、返回类型、参数数量和类型都符合的方法,虚拟机抛出NoSuchMethodError错误。

否则,虚拟机把这个入口标记为已解析,并将符号引用替换为直接引用。

解析CONSTANT_String_info入口

要解析类型是CONSTANT_String_info 的入口,Java虚拟机必须把一个指向内部字符串对象的引用放置到要被解析的常量池入口数据中去。该字符串对象(java.lang.String类的实例)必须按照string_index项在CONSTANT_String_info中指明的CONSTANT_Utf8_info入口所制定的字符顺序组织。

是不是对上面加粗的描述有点晕,看下面的例子消化一下。。。。。
代码示例:

class ExampleE{
    public static void main(String[] args) {
        String a = "ABC";
    }
}

javap -v ExampleE.class查看常量池结构:

class hua.lee.jvm.ExampleE
  minor version: 0
  major version: 52
  flags: ACC_SUPER
Constant pool:
   #1 = Methodref          #4.#20         // java/lang/Object."<init>":()V
   #2 = String             #21            // ABC
   #3 = Class              #22            // hua/lee/jvm/ExampleE
   #4 = Class              #23            // java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Utf8               Code
   #8 = Utf8               LineNumberTable
   #9 = Utf8               LocalVariableTable
  #10 = Utf8               this
  #11 = Utf8               Lhua/lee/jvm/ExampleE;
  #12 = Utf8               main
  #13 = Utf8               ([Ljava/lang/String;)V
  #14 = Utf8               args
  #15 = Utf8               [Ljava/lang/String;
  #16 = Utf8               a
  #17 = Utf8               Ljava/lang/String;
  #18 = Utf8               SourceFile
  #19 = Utf8               InitExample.java
  #20 = NameAndType        #5:#6          // "<init>":()V
  #21 = Utf8               ABC
  #22 = Utf8               hua/lee/jvm/ExampleE
  #23 = Utf8               java/lang/Object

请注意常量池中的#2项,是一个CONSTANT_String类型。它的数值保存的是一个常量池索引#21。所以虚拟机对#21的要求是索引为#21所在位置的数据必须是CONSTANT_Utf8类型,这样才能被正常的解析为字符串。(是不是不晕了。。。。)

每个Java虚拟机必须维护一张内部列表,它列出了所有在运行程序的过程中已经被拘留(intern)的字符串对象引用。基本上,如果一个字符串在虚拟机的拘留列表上出现,就说明它是被拘留的。

要拘留CONSTANT_String_info入口所代表的字符序列,虚拟机要检查内部拘留名单上这个字符序列是否已经在编了。如果已经在编,虚拟机使用指向以前拘留的字符串对象的引用。否则,虚拟机按照这个字符序列创建一个新的字符串对象,并把这个对象的引用编入列表。要完成CONSTANT_String_info入口的解析过程,虚拟机应把被拘留字符串对象的引用放置到被解析的常量表入口中去

在Java程序中,可以调用String类的intern()方法来拘留一个字符串。另外,所有字面上表达的字符串都在解析CONSTANT_String_info入口的过程中被拘留了。如果具有相同序列的Unicode字符串已经被拘留过,intern()方法返回一个指向相同的已经被拘留的字符串对象的引用。如果intern()对象被调用(没有被拘留过),那么这个对象本身就会被拘留。

看下面这个代码实例:

class ExampleF{
    public static void main(String[] args) {
        String argsZero = args[0];
        String literalString = "hello";
        String internZero = argsZero.intern();
        if (argsZero==literalString){
            System.out.println("args[0] 和 literalString 是同一个对象");
        }else {
            System.out.println("args[0] 和 literalString 不是同一个对象");
        }
        if (internZero==literalString){
            System.out.println("internZero 和 literalString 是同一个对象");
        }else {
            System.out.println("internZero 和 literalString 不是同一个对象");
        }
    }
}

控制台输出:

args[0] 和 literalString 不是同一个对象
internZero 和 literalString 是同一个对象

很明了不是么?

解析其他类型的入口

CONSTANT_Integer_infoCONSTANT_Long_infoCONSTANT_Float_infoCONSTANT_Double_info入口本身包含它们所表示的常量值,它们可以直接被解析。要解析这类入口,很多虚拟机都不需要做额外的操作,直接使用那些值就可以了。

CONSTANT_Utf8_infoCONSTANT_NameAndType_info类型的入口永远不会被指令直接引用。它们只有通过其他入口类型才能被引用,并且在那些引用入口被解析时后才被解析。

装载约束

Java类型可以符号化地引用常量池中的其他类型,解析时需要特别注意,尤其当存在多个类装载器的时候,要保证类型安全。

  • 当一个类型包含指向另一个类型中的字段的符号引用时,符号引用包含一个描述符,它指明了该字段的类型。
  • 当一个类型包含指向另外一个类型的方法的符号引用是,符号引用也包含一个描述符,它指明了返回值的类型和参数。

如果引用的类型和被引用的类型并非由同一个初始类装载器装载,虚拟机必须确保在字段或者方法描述符中提及的类型在不同的命名空间中保持一致。

为了确保Java虚拟机能够保证类型在不同命名空间保持一致性,Java虚拟机规范定义了几种装载约束。本篇只是简单介绍一下,类型安全不是一个小事

先了解几个表示方法:

  • <N,Ld>表示类或者接口,N表示类或接口的名字,Ld表示类或接口的定义类加载器。
  • NLi表示类或者接口,N表示类或接口的名字,Li表示类或接口的初始类加载器。
原则

当类或者接口C=<N1,L1>含有指向另一个类或者接口D=<N2,L2>的字段或者方法符号引用时,这个符号会包含表示字段类型,或方法参数和返回类型的描述符。重要的是:字段或方法描述符里提到的任意类型名称 N,无论是由L1加载还是由L2加载,其解析结果都应该表示同一个类或者接口。

为了确保这个原则,虚拟机会在连接(准备解析)阶段强制实施NL1=NL2形式的加载约束。

编译时常量解析

对于静态final变量(常量啦),在编译时会被解析为常量值的本地拷贝,对于所有的基本类型和java.lang.String都是适用的。

这种对于常量的特殊处理使Java 语言具有了两个特性:

  • 常量池的本地拷贝是的静态 final 变量可以用于 switch 语句中的 case 表达式。在字节码中实现 switch 语句的两条虚拟机指令是 tableswitch 和 lookupswitch,需要 case 值嵌套在字节码流中,这些指令不支持运行时解析 case 值。
  • 通过 if 语句(其表达式解析成编译时常量),Java 支持条件编译。

请看如下代码:

class ExampleE{
    private static final boolean debug = true;
    public static void main(String[] args) {
        if (debug){
            System.out.println("hello");
        }
    }
}

字节码内容如下:

  public static main([Ljava/lang/String;)V
   L0
    LINENUMBER 41 L0
    GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
    LDC "hello"
    INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V
   L1
    LINENUMBER 43 L1
    RETURN
   L2
    LOCALVARIABLE args [Ljava/lang/String; L0 L2 0
    MAXSTACK = 2
    MAXLOCALS = 1

当我们把debug设置成false,再次编译结果如下:

  public static main([Ljava/lang/String;)V
   L0
    LINENUMBER 43 L0
    RETURN
   L1
    LOCALVARIABLE args [Ljava/lang/String; L0 L1 0
    MAXSTACK = 0
    MAXLOCALS = 1

我们可以看到debug设置成false后,Java 编译器把整个 if 语句都从 main 方法中去除了,甚至 println 方法都没有编译进去。

直接引用

常量池解析的最终目标是把符号引用替换为直接引用

直接引用应该是什么格式呢?

虽然直接引用的格式也是由不同的 Java 虚拟机实现的。然而,在大多数实现中,总会有一些通用的特征

指向类型类变量类方法的直接引用可能是指向方法区的本地指针。

  • 类型的直接引用可能简单地指向保存类型数据的方法区中与实现相关的数据结构。
  • 类变量的直接引用可以指向方法区中保存的类变量的值。
  • 类方法的直接引用可以指向方法区中的一段数据结构(方法区中包含调用方法的必要数据)。比如类方法的数据结构可能包含方法是否为本地方法的标志信息:
    • 如果方法是本地的,数据结构可能包含一个指向本地方法实现的函数指针。
    • 如果方法不是本地的,数据结构可能包含方法的字节码、max_stackmax_local 等信息

指向实例变量实例方法的直接引用都是偏移量。

  • 实例变量的直接引用可能是从对象的映像开始算起到这个实例变量位置的偏移量。
  • 实例方法的直接引用可能是到方法表的偏移量。

使用偏移量来表示实例变量和实例方法的直接引用,取决于类的对象映像中字段的顺序和类方法表中方法的顺序。虽然虚拟机的实现方式各不相同,但几乎可以肯定的是,它们对所有的类型都使用同样的方式。

一个简单的示例

请看下面三个类和一个接口:

interface Friendly {
    void sayHello();
    void sayGoodbye();
}

class Dogs{
    private int wagCount = (int) (Math.random() * 5 + 1);

    void sayHello() {
        System.out.print("wag");
        for (int i = 0; i < wagCount; i++) {
            System.out.print(", wag");
        }
        System.out.println(".");
    }

    @Override
    public String toString() {
        return "woof!";
    }
}

class CockerSpaniel extends Dogs implements Friendly {

    private final int woofCount = (int) (Math.random() * 4 + 1);
    private final int wimperCount = (int) (Math.random() * 3 + 1);
    @Override
    public void sayHello() {
        super.sayHello();
        System.out.print("woof");
        for (int i = 0; i < woofCount; i++) {
            System.out.print(", woof");
        }
        System.out.println(".");
    }

    @Override
    public void sayGoodbye() {
        System.out.print("wimper");
        for (int i = 0; i < wimperCount; i++) {
            System.out.print(", wimper");
        }
        System.out.println(".");
    }
}

class Cat implements Friendly{
    public void eat(){
        System.out.println("Chomp, chomp, chomp.");
    }

    @Override
    public void sayHello() {
        System.out.println("Rub, rub ,rub.");
    }

    @Override
    public void sayGoodbye() {
        System.out.println("Scamper.");
    }
    @Override
    protected void finalize() throws Throwable {
        System.out.println("Meow!");
    }
}

假设装载这些类型的Java虚拟机组织对象采用的的方式是:

  • 实例变量在子类中声明之前,就把在超类中声明的该实例变量放到了对象映像中;
  • 并且每一个类的实例变量出现的顺序和它们在class文件中出现的顺序是一致的;

假设Object没有实例变量,上面代码产生的对象映像应该如下:
image
CockerSpaniel的对象映像来说,其超类Dogs的实例变量出现在CockerSpaniel的实例变量之前。CockerSpaniel的实例变量按照它们声明的顺序出现:先是woofCount,然后是wimperCount

请注意实例变量wagCountDogsCockerSpaniel中都被作为偏移量1出现。在这个Java虚拟机实现中,指向类DogswagCount字段的符号引用会被解析为一个偏移量为1的直接引用。不管实际的对象是DogsCockerSpaniel或者任何Dogs的子类,实例变量wagCount总是在对象映像中作为偏移量1出现。


在方法表中也呈现同样的情形,方法表中的一个入口以某种方式关联到方法区中的一段数据(方法区包含让虚拟机调用一个方法的足够信息)。假设在当前的虚拟机实现中,方法表是关于指向方法区的指针的数组,并且方法表入口指向的数据结构和我们前面提到的类方法的数据结构类似。我们再假设虚拟机装载方法表的方法是:

  • 来自超类的方法出现在来自子类的方法之前;
  • 每个类排列指针的顺序和方法在class文件中出现的顺序相同,这种排序的例外情况是,被子类覆盖的方法会出现在超类中该方法第一次出现的位置。

按照上面的假设,我们看下Dogs类的方法表:
image
请注意方法表中只有非私有的实例方法才会出现。
invokestatic指令调用的类方法也不会在这里出现,因为它们是静态绑定的,不需要通过方法表间接指向。私有的方法和实例的初始化方法不需要在这里出现,因为它们是被invokespecial调用的,所以也是静态绑定的。只有被invokevirtual或者invokeinterface调用的方法才会出现在这个方法表中

源码中,Dogs覆盖了Object类中的toString()方法,在Dogs的方法表中toString()只出现了一次,而且是在Object的方法表中同样的位置出现(黄色标注,偏移量7)。在Dogs的方法表中,这个指针位于偏移量7,并且指向DogstoString()实现的数据。

而在Dogs中第一次声明的方法sayHello(),位于偏移量11.所有Dogs的子类都会继承或者覆盖这个sayHello()方法的实现,并且子类的sayHello()会一直出现在偏移量11上。


再看CockerSpaniel的方法表:
image

请注意sayHello()依然位于偏移量11,和在Dogs中的一致。当虚拟机解析指向Dogs或者任何子类的sayHello()方法的符号引用时,直接引用时方法表偏移量11。当虚拟机解析指向CockerSpaniel或者任何子类的sayGoodbye()方法的符号引用时,直接引用就是方法表偏移量12。

一旦一个指向实例方法的符号引用被解析为一个方法表的偏移量后,虚拟机就可以调用此方法。


当虚拟机有一个指向类类型的引用(CONSTANT_Methodref_info入口)的时候,它总是可以依靠方法表偏移量。如果Dogs类中的sayHello()方法出现在偏移量7,那么在它的子类中该方法总是会出现在偏移量7上。
但是当符号引用指向接口类型(CONSTANT_InterfaceMethodref_info入口)的时候,这个规律就不成立了。我们看下Cat类的方法表:
image

我们对比下CockerSpanielCat的方法表,都是实现了Friendly接口,但是sayHello()sayGoodbye()在方法表中的位置却不相同。这主要是因为实现Friendly接口的类并不能保证都是从一个超类继承的

因此,不管何时Java虚拟机从接口引用调用一个方法,它必须搜索对象的类的方法表来找到一个合适的方法。这种调用接口引用的实例方法的途径会比在类引用上调用实例方法慢很多。当然,在如何搜索方法表上,虚拟机实现可以灵活一些。

一个程序的执行示例

先来个简单的代码实例:

public class Salutation {
    private static final String hello = "Hello World!";
    private static final String greeting = "Greeting Planet!";
    private static final String salutation = "Salutation orbs!";
    
    private static int choice = (int) (Math.random()*5*2.99);

    public static void main(String[] args) {
        String s = hello;
        if (choice==1){
            s = greeting;
        }else if (choice==2){
            s=salutation;
        }
        System.out.println(s);
    }
}

假设想让Java 虚拟机运行Salutation。当虚拟机启动时,它试图调用Salutationmain 方法。但虚拟机很快意识到,不管用什么方法都无法调用main方法。调用类中声明的方法是对类的一次主动使用,在类被初始化之前,这是不允许的。所以,在虚拟机可以调用main方法前,它必须初始化Salutation

装载

所以虚拟机把Salutation的全限定名交给启动类加载器,后者取得类的二进制形式,将二进制数据解析成内部数据结构,并创建一个java.lang.Class的实例,这部分其实就是类型的装载过程。解析后的常量池信息如下:

Constant pool:
   #1 = Methodref          #14.#39        // java/lang/Object."<init>":()V
   #2 = Class              #40            // hua/lee/jvm/Salutation
   #3 = String             #41            // Hello World!
   #4 = Fieldref           #2.#42         // hua/lee/jvm/Salutation.choice:I
   #5 = String             #43            // Greeting Planet!
   #6 = String             #44            // Salutation orbs!
   #7 = Fieldref           #45.#46        // java/lang/System.out:Ljava/io/PrintStream;
   #8 = Methodref          #47.#48        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #9 = Methodref          #49.#50        // java/lang/Math.random:()D
  #10 = Double             5.0d
  #12 = Double             2.99d
  #14 = Class              #51            // java/lang/Object
  #15 = Utf8               hello
  #16 = Utf8               Ljava/lang/String;
  #17 = Utf8               ConstantValue
  #18 = Utf8               greeting
  #19 = Utf8               salutation
  #20 = Utf8               choice
  #21 = Utf8               I
  #22 = Utf8               <init>
  #23 = Utf8               ()V
  #24 = Utf8               Code
  #25 = Utf8               LineNumberTable
  #26 = Utf8               LocalVariableTable
  #27 = Utf8               this
  #28 = Utf8               Lhua/lee/jvm/Salutation;
  #29 = Utf8               main
  #30 = Utf8               ([Ljava/lang/String;)V
  #31 = Utf8               args
  #32 = Utf8               [Ljava/lang/String;
  #33 = Utf8               s
  #34 = Utf8               StackMapTable
  #35 = Class              #52            // java/lang/String
  #36 = Utf8               <clinit>
  #37 = Utf8               SourceFile
  #38 = Utf8               Salutation.java
  #39 = NameAndType        #22:#23        // "<init>":()V
  #40 = Utf8               hua/lee/jvm/Salutation
  #41 = Utf8               Hello World!
  #42 = NameAndType        #20:#21        // choice:I
  #43 = Utf8               Greeting Planet!
  #44 = Utf8               Salutation orbs!
  #45 = Class              #53            // java/lang/System
  #46 = NameAndType        #54:#55        // out:Ljava/io/PrintStream;
  #47 = Class              #56            // java/io/PrintStream
  #48 = NameAndType        #57:#58        // println:(Ljava/lang/String;)V
  #49 = Class              #59            // java/lang/Math
  #50 = NameAndType        #60:#61        // random:()D
  #51 = Utf8               java/lang/Object
  #52 = Utf8               java/lang/String
  #53 = Utf8               java/lang/System
  #54 = Utf8               out
  #55 = Utf8               Ljava/io/PrintStream;
  #56 = Utf8               java/io/PrintStream
  #57 = Utf8               println
  #58 = Utf8               (Ljava/lang/String;)V
  #59 = Utf8               java/lang/Math
  #60 = Utf8               random
  #61 = Utf8               ()D

Salutation装载过程中,Java 虚拟机首先要确认所有Salutation的超类都被装载了。虚拟机先查看super_class项所指定的Salutation的类型数据,它的值是#14。虚拟机查询常量池中的#14位置,是个CONSTANT_Class_info入口#51,它指向的内容是java.lang.Object的符号引用。虚拟机解析这个符号引用,这导致类Object的装载。因为ObjectSalutation继承树的顶端,已经不存在其他超类了,所以虚拟机就开始连接初始化它。

连接

Java虚拟机在装载完了Salutation,并且也已经装载连接、并初始化了它的所有超类,现在虚拟机准备来连接Salutation了。

连接过程的第一步,就是校验类Salutation的二进制完整性,大体包括三种:

  • Salutation在二进制数据结构上是正确的
  • Salutation正确地实现了Java语言的语义
  • Salutation的字节码不会导致虚拟机崩溃

准备

当Java虚拟机校验完Salutation后,它必须为Salutation准备需要的内存空间。在这个阶段,虚拟机为Salutation的类变量choice分配内存,并且给它一个默认初始值。因为choice类变量是一个int型数据,所以他的默认初始值为0。

三个文本字符串(hellogreetingsalutation)是常量,而非类变量。它们不在方法区中作为类变量占据内存空间,它们也不需要接受默认初始值。它们在Salutation的常量池中作为CONSTANT_String_info入口出现。

解析

校验准备过程成功结束后,类已经准备好被解析了。解析阶段我们在生命周期那一篇中说过,分为早解析迟解析。我们假设当前虚拟机使用迟解析的方案,当符号引用第一次使用时才会去解析,并在解析成功后将符号引用转换为直接引用

初始化

一旦这个Java虚拟机装载校验准备Salutation,就可以初始化了(解析我们刚才说了用迟解析方案)。前面说过,虚拟机在初始化一个类之前必须初始化它所有的超类。在Salutation中,虚拟机需要先初始化Object类。

当超类都已经初始化完成后,虚拟机准备执行Salutation<clinit>方法。因为Salutation包含一个类变量choice(非常量表达式形式),所以编译器就在Salutation的class文件中放了一个<clinit>方法。
内容如下:

  static <clinit>()V
   L0
    LINENUMBER 7 L0
    INVOKESTATIC java/lang/Math.random ()D
    LDC 5.0
    DMUL
    LDC 2.99
    DMUL
    D2I
    PUTSTATIC hua/lee/jvm/Salutation.choice : I
    RETURN
    MAXSTACK = 4
    MAXLOCALS = 0

虚拟机执行Salutation<clinit>方法,把choice的属性设置为正确的初始值,在执行<clinit>之前,choice的默认初始值为0;执行<clinit>后,choice的值被伪随机地置为:0、1或者2。

到这里,类Salutation已经被初始化了,虚拟机终于可以使用它了,Java虚拟机调用main()方法,程序开始执行,字节码信息如下:

  public static main([Ljava/lang/String;)V
   L0
    LINENUMBER 10 L0
    LDC "Hello World!"
    ASTORE 1
   L1
    LINENUMBER 11 L1
    GETSTATIC hua/lee/jvm/Salutation.choice : I
    ICONST_1
    IF_ICMPNE L2
   L3
    LINENUMBER 12 L3
    LDC "Greeting Planet!"
    ASTORE 1
    GOTO L4
   L2
    LINENUMBER 13 L2
   FRAME APPEND [java/lang/String]
    GETSTATIC hua/lee/jvm/Salutation.choice : I
    ICONST_2
    IF_ICMPNE L4
   L5
    LINENUMBER 14 L5
    LDC "Salutation orbs!"
    ASTORE 1
   L4
    LINENUMBER 16 L4
   FRAME SAME
    GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
    ALOAD 1
    INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V
   L6
    LINENUMBER 17 L6
    RETURN
   L7
    LOCALVARIABLE args [Ljava/lang/String; L0 L7 0
    LOCALVARIABLE s Ljava/lang/String; L1 L7 1
    MAXSTACK = 2
    MAXLOCALS = 2

动态连接

除了简单的在运行时连接类型之外,Java 程序也可以在运行时决定连接哪一个类型(动态扩展功能)。

动态扩展 Java 程序可以通过以下两种方式:

  • java.lang.ClassforName方法
  • 自定义java.lang.ClassLoaderloadCalss方法

java.lang.Class.forName

动态扩展最直接的就是java.lang.ClassforName方法,它有两种重载的形式:

    public static Class<?> forName(String name);
    public static Class<?> forName(String name, boolean initialize,ClassLoader loader);

name传入的是要装载类型的全限定名。
initializetrue的话,类型会在forName方法返回之前完成连接并初始化。
initializefalse的话,类型会 被装载,可能会被连接,但是不会被forName方法明确的初始化。
loader传入一个ClassLoader用来请求类型。当传入为null 时,使用启动类装载器来请求类型。

java.lang.ClassLoader.loadCalss

动态扩展的另外一种方式就是使用自定义类装载器的loadCalss方法。

public abstract class ClassLoader {
    public Class<?> loadClass(String name);
    protected Class<?> loadClass(String name, boolean resolve);
}

name传入的是要装载类型的全限定名。
resolve表示是否在装载时执行该类型的连接。

前面讲过,连接过程分为三步:

  • 校验被装载的类型
  • 准备(包括为类型分配内存)
  • 解析类型中的符号引用(可选)

如果resolvetrueloadCalss方法会确保在方法返回某个类型的 Class 实例之前已经装载并连接了该类型;如果为falseloadCalss方法仅仅去试图装载请求的类型,而不关心类型是否被连接了。

使用forName还是loadClass取决于用户的需要。如果需要让请求的类型在装载时就要初始化的话,forName则是唯一的选择。

类装载器和双亲委派模型

双亲个人理解翻译的不恰当,每个ClassLoader只定义了一个parent用的不好。

Java在1.2版本引入了类装载器的形式化双亲委派模型。核心代码如下:

    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        //保证类加载过程的线程安全
        synchronized (getClassLoadingLock(name)) {
            // 首先查找类是否已被加载
            Class<?> c = findLoadedClass(name);
            //如果没有找到 c==null
            if (c == null) {
                //查找是否存在父加载器
                //如果有的话执行父类的loadClass查找加载类
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    //当不存在父加载器时
                    //使用启动类加载器去查找加载类
                    c = findBootstrapClassOrNull(name);
                }
                //上面委托父类加载器和启动类加载器都没找到的话
                //使用当前自定义的findClass方法
                //ClassLoader的findClass是一个空方法
                if (c == null) {
                    c = findClass(name);
                }
            }
            //resolve=true时,执行连接过程
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

对外的构造方法有两个

    protected ClassLoader(ClassLoader parent) {
        this(checkCreateClassLoader(), parent);
    }
    protected ClassLoader() {
        this(checkCreateClassLoader(), getSystemClassLoader());
    }
    //私有构造方法,在此处给parent赋值
    private ClassLoader(Void unused, ClassLoader parent) {
        this.parent = parent;
        //省略
    } 

所以在自定义类装载器创建时会被分配一个父类类装载器:

  • 对于无参的构造方法protected ClassLoader(),系统类装载器就被默认指定为父类
  • 对于显式传入父类的情况,当参数为null时,启动类装载器就是父类。(从loadClass()findBootstrapClassOrNull()可以推断出来)
Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐