深入Java虚拟机(四)连接模型

程序猿的角度来看,理解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
[Ljava.lang.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,L d >表示类或者接口,N表示类或接口的名字,L d 表示类或接口的定义类加载器
  • N L i 表示类或者接口,N表示类或接口的名字,L i 表示类或接口的初始类加载器。

原则

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

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

编译时常量解析

对于静态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 没有实例变量,上面代码产生的对象映像应该如下:

深入Java虚拟机(四)连接模型


CockerSpaniel 的对象映像来说,其超类
Dogs 的实例变量出现在
CockerSpaniel 的实例变量之前。
CockerSpaniel 的实例变量按照它们声明的顺序出现:先是
woofCount ,然后是
wimperCount

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

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

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

按照上面的假设,我们看下 Dogs 类的方法表:

深入Java虚拟机(四)连接模型

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

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

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

再看 CockerSpaniel 的方法表:

深入Java虚拟机(四)连接模型

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

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

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

深入Java虚拟机(四)连接模型

我们对比下 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
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() 可以推断出来)

原文 

https://juejin.im/post/5efb59056fb9a07e9c59c7bb

本站部分文章源于互联网,本着传播知识、有益学习和研究的目的进行的转载,为网友免费提供。如有著作权人或出版方提出异议,本站将立即删除。如果您对文章转载有任何疑问请告之我们,以便我们及时纠正。

PS:推荐一个微信公众号: askHarries 或者qq群:474807195,里面会分享一些资深架构师录制的视频录像:有Spring,MyBatis,Netty源码分析,高并发、高性能、分布式、微服务架构的原理,JVM性能优化这些成为架构师必备的知识体系。还能领取免费的学习资源,目前受益良多

转载请注明原文出处:Harries Blog™ » 深入Java虚拟机(四)连接模型

赞 (0)
分享到:更多 ()

评论 0

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址