【读书笔记】深入理解JVM C6-9 虚拟机执行子系统

第三部分 虚拟机执行子系统

第6章 类文件结构

6.2 无关性的基石

各种不同平台的Java虚拟机,以及所有平台都统一支持的程序存储格式------字节码(Byte Code) 是构成平台无关性的基石,但本节标题中笔者刻意省略了"平台"二字,那是因为笔者注意到虚拟机的 另外一种中立特性------语言无关性正在越来越被开发者所重视。直到今天,或许还有相当一部分程序 员认为Java虚拟机执行Java程序是一件理所当然和天经地义的事情。 但在Java技术发展之初,设计者们 就曾经考虑过并实现了让其他语言运行在Java虚拟机之上的可能性,他们在发布规范文档的时候,也 刻意把Java的规范拆分成了《Java语言规范》(The Java Language Specification)及《Java虚拟机规范》 (The Java Virtual Machine Specification)两部分。

实现语言无关性的基础仍然是虚拟机和字节码存储格式。Java虚拟机不与包括Java语言在内的任何 程序语言绑定,它只与"Class文件"这种特定的二进制文件格式所关联,Class文件中包含了Java虚拟机 指令集、符号表以及若干其他辅助信息。基于安全方面的考虑,《Java虚拟机规范》中要求在Class文 件必须应用许多强制性的语法和结构化约束,但图灵完备的字节码格式,保证了任意一门功能性语言 都可以表示为一个能被Java虚拟机所接受的有效的Class文件。作为一个通用的、与机器无关的执行平 台,任何其他语言的实现者都可以将Java虚拟机作为他们语言的运行基础,以Class文件作为他们产品 的交付媒介。例如,使用Java编译器可以把Java代码编译为存储字节码的Class文件,使用JRuby等其他 语言的编译器一样可以把它们的源程序代码编译成Class文件。虚拟机丝毫不关心Class的来源是什么语 言,它与程序语言之间的关系如图6-1所示。

6.3 Class类文件的结构

Java技术能够一直保持着非常良好的向后兼容性,Class文件结构的稳定功不可没,任何一门程序 语言能够获得商业上的成功,都不可能去做升级版本后,旧版本编译的产品就不再能够运行这种事 情。

Class文件是一组以8个字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在文 件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数 据,没有空隙存在。当遇到需要占用8个字节以上空间的数据项时,则会按照高位在前[2]的方式分割 成若干个8个字节进行存储

根据《Java虚拟机规范》的规定,Class文件格式采用一种类似于C语言结构体的伪结构来存储数 据,这种伪结构中只有两种数据类型:"无符号数"和"表"。后面的解析都要以这两种数据类型为基 础,所以这里笔者必须先解释清楚这两个概念。

  • ·无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个 字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串 值
  • 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,为了便于区分,所有表的命名 都习惯性地以"_info"结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上也可以视 作是一张表,这张表由表6-1所示的数据项按严格顺序排列构成。

本节结束之前,笔者需要再强调一次,Class的结构不像XML等描述语言,由于它没有任何分隔符 号,所以在表6-1中的数据项,无论是顺序还是数量,甚至于数据存储的字节序(Byte Ordering,Class 文件中字节序为Big-Endian)这样的细节,都是被严格限定的,哪个字节代表什么含义,长度是多少, 先后顺序如何,全部都不允许改变。接下来,我们将一起看看这个表中各个数据项的具体含义。

6.3.1 魔数与Class文件的版本

每个Class文件的头4个字节被称为魔数(Magic Number),它的唯一作用是确定这个文件是否为 一个能被虚拟机接受的Class文件。不仅是Class文件,很多文件格式标准中都有使用魔数来进行身份识 别的习惯,譬如图片格式,如GIF或者JPEG等在文件头中都存有魔数。使用魔数而不是扩展名来进行 识别主要是基于安全考虑,因为文件扩展名可以随意改动。

紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6个字节是次版本号(Minor Version),第7和第8个字节是主版本号(Major Version)。

为了讲解方便,笔者准备了一段最简单的Java代码(如代码清单6-1所示),本章后面的内容都将 以这段程序使用JDK 6编译输出的Class文件为基础来进行讲解

6.3.2 常量池

紧接着主、次版本号之后的是常量池入口,常量池可以比喻为Class文件里的资源仓库,它是Class 文件结构中与其他项目关联最多的数据,通常也是占用Class文件空间最大的数据项目之一,另外,它 还是在Class文件中第一个出现的表类型数据项目。

由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常 量池容量计数值(constant_pool_count)。与Java中语言习惯不同,这个容量计数是从1而不是0开始 的,如图6-3所示,常量池容量(偏移地址:0x00000008)为十六进制数0x0016,即十进制的22,这就 代表常量池中有21项常量,索引值范围为1~21。在Class文件格式规范制定之时,设计者将第0项常量 空出来是有特殊考虑的,这样做的目的在于,如果后面某些指向常量池的索引值的数据在特定情况下 需要表达"不引用任何一个常量池项目"的含义,可以把索引值设置为0来表示。Class文件结构中只有 常量池的容量计数是从1开始,对于其他集合类型,包括接口索引集合、字段表集合、方法表集合等的 容量计数都与一般习惯相同,是从0开始。

常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。字面量比 较接近于Java语言层面的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则属于编译 原理方面的概念,主要包括下面几类常量:

  • 被模块导出或者开放的包(Package)
  • 类和接口的全限定名(Fully Qualified Name)
  • 字段的名称和描述符(Descriptor)
  • 方法的名称和描述符
  • 方法句柄和方法类型(Method Handle、Method Type、Invoke Dynamic)
  • 动态调用点和动态常量(Dynamically-Computed Call Site、Dynamically-Computed Constant)

Java代码在进行Javac编译的时候,并不像C和C++那样有"连接"这一步骤 ,而是在虚拟机加载Class 文件的时候进行动态连接(具体见第7章) 。也就是说,在Class文件中不会保存各个方法、字段最终 在内存中的布局信息 ,这些字段、方法的符号引用不经过虚拟机在运行期转换的话是无法得到真正的内存入口地址,也就无法直接被虚拟机使用的。当虚拟机做类加载时,将会从常量池获得对应的符号 引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。关于类的创建和动态连接的内容, 在下一章介绍虚拟机类加载过程时再详细讲解。 (TODO:看下《程序员的自我修养------链接、装载与库》)

常量池中每一项常量都是一个表,最初常量表中共有11种结构各不相同的表结构数据,后来为了 更好地支持动态语言调用,额外增加了4种动态语言相关的常量[1],为了支持Java模块化系统 (Jigsaw),又加入了CONSTANT_Module_info和CONSTANT_Package_info两个常量,所以截至JDK 13,常量表中分别有17种不同类型的常量。

之所以说常量池是最烦琐的数据,是因为这17种常量类型各自有着完全独立的数据结构,两两之 间并没有什么共性和联系,因此只能逐项进行讲解。

顺便提一下,由于Class文件中方法、字段等都需要引用CONSTANT_Utf8_info型常量来描述名 称,所以CONSTANT_Utf8_info型常量的最大长度也就是Java中方法、字段名的最大长度。而这里的 最大长度就是length的最大值,既u2类型能表达的最大值65535。所以Java程序中如果定义了超过64KB 英文字符的变量或方法名,即使规则和全部字符都是合法的,也会无法编译。

本例中这个字符串的length值(偏移地址:0x0000000E)为0x001D,也就是长29个字节,往后29 个字节正好都在1~127的ASCII码范围以内,内容为"org/fenixsoft/clazz/TestClass",有兴趣的读者可以 自己逐个字节换算一下,换算结果如图6-4中选中的部分所示。

6.3.3 访问标志

在常量池结束之后,紧接着的2个字节代表访问标志(access_flags),这个标志用于识别一些类或 者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract 类型;如果是类的话,是否被声明为final;等等。

6.3.4 类索引、父类索引与接口索引集合

类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引集合 (interfaces)是一组u2类型的数据的集合,Class文件中由这三项数据来确定该类型的继承关系。类索 引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。由于Java语言不允许多 重继承,所以父类索引只有一个,除了java.lang.Object之外,所有的Java类都有父类,因此除了 java.lang.Object外,所有Java类的父类索引都不为0。接口索引集合就用来描述这个类实现了哪些接 口,这些被实现的接口将按implements关键字(如果这个Class文件表示的是一个接口,则应当是 extends关键字)后的接口顺序从左到右排列在接口索引集合中。

类索引、父类索引和接口索引集合都按顺序排列在访问标志之后,类索引和父类索引用两个u2类 型的索引值表示,它们各自指向一个类型为CONSTANT_Class_info的类描述符常量,通过 CONSTANT_Class_info类型的常量中的索引值可以找到定义在CONSTANT_Utf8_info类型的常量中的 全限定名字符串。图6-6演示了代码清单6-1中代码的类索引查找过程。

6.3.5 字段表集合

字段表(field_info)用于描述接口或者类中声明的变量。Java语言中的"字段"(Field)包括类级变 量以及实例级变量,但不包括在方法内部声明的局部变量。读者可以回忆一下在Java语言中描述一个 字段可以包含哪些信息。字段可以包括的修饰符有字段的作用域(public、private、protected修饰 符)、是实例变量还是类变量(static修饰符)、可变性(final)、并发可见性(volatile修饰符,是否 强制从主内存读写)、可否被序列化(transient修饰符)、字段数据类型(基本类型、对象、数组)、 字段名称。上述这些信息中,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标 志位来表示。而字段叫做什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常 量池中的常量来描述。

字段修饰符放在access_flags项目中,它与类中的access_flags项目是非常类似的,都是一个u2的数 据类型,其中可以设置的标志位和含义如表6-9所示

很明显,由于语法规则的约束,ACC_PUBLIC、ACC_PRIVATE、ACC_PROTECTED三个标志最 多只能选择其一,ACC_FINAL、ACC_VOLATILE不能同时选择。接口之中的字段必须有 ACC_PUBLIC、ACC_STATIC、ACC_FINAL标志,这些都是由Java本身的语言规则所导致的。

用描述符来描述方法时,按照先参数列表、后返回值的顺序描述,参数列表按照参数的严格顺序 放在一组小括号"()"之内。如方法void inc()的描述符为"()V",方法java.lang.String toString()的描述符 为"()Ljava/lang/String;",方法int indexOf(char[]source,int sourceOffset,int sourceCount,char[]target, int targetOffset,int targetCount,int fromIndex)的描述符为"([CII[CIII)I"。

字段表所包含的固定数据项目到descriptor_index为止就全部结束了,不过在descrip-tor_index之后 跟随着一个属性表集合,用于存储一些额外的信息,字段表可以在属性表中附加描述零至多项的额外 信息。对于本例中的字段m,它的属性表计数器为0,也就是没有需要额外描述的信息,但是,如果将 字段m的声明改为"final static int m=123;",那就可能会存在一项名称为ConstantValue的属性,其值指 向常量123。关于attribute_info的其他内容,将在6.3.7节介绍属性表的数据项目时再做进一步讲解。

字段表集合中不会列出从父类或者父接口中继承而来的字段,但有可能出现原本Java代码之中不 存在的字段,譬如在内部类中为了保持对外部类的访问性,编译器就会自动添加指向外部类实例的字 段。另外,在Java语言中字段是无法重载的,两个字段的数据类型、修饰符不管是否相同,都必须使 用不一样的名称,但是对于Class文件格式来讲,只要两个字段的描述符不是完全相同,那字段重名就 是合法的。

6.3.6 方法表集合

如果理解了上一节关于字段表的内容,那本节关于方法表的内容将会变得很简单。Class文件存储 格式中对方法的描述与对字段的描述采用了几乎完全一致的方式,方法表的结构如同字段表一样,依 次包括访问标志(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表 集合(attributes)几项

行文至此,也许有的读者会产生疑问,方法的定义可以通过访问标志、名称索引、描述符索引来 表达清楚,但方法里面的代码去哪里了?方法里的Java代码,经过Javac编译器编译成字节码指令之 后,存放在方法属性表集合中一个名为"Code"的属性里面,属性表作为Class文件格式中最具扩展性的 一种数据项目,将在下一节中详细讲解。

与字段表集合相对应地,如果父类方法在子类中没有被重写(Override),方法表集合中就不会出 现来自父类的方法信息。但同样地,有可能会出现由编译器自动添加的方法,最常见的便是类构造 器"()"方法和实例构造器"()"方法[1]。

在Java语言中,要重载(Overload)一个方法,除了要与原方法具有相同的简单名称之外,还要求 必须拥有一个与原方法不同的特征签名[2]。特征签名是指一个方法中各个参数在常量池中的字段符号 引用的集合,也正是因为返回值不会包含在特征签名之中,所以Java语言里面是无法仅仅依靠返回值 的不同来对一个已有方法进行重载的。但是在Class文件格式之中,特征签名的范围明显要更大一些 , 只要描述符不是完全一致的两个方法就可以共存。也就是说,如果两个方法有相同的名称和特征签 名,但返回值不同,那么也是可以合法共存于同一个Class文件中的

6.3.7 属性表集合

属性表(attribute_info)在前面的讲解之中已经出现过数次,Class文件、字段表、方法表都可以 携带自己的属性表集合,以描述某些场景专有的信息。

与Class文件中其他的数据项目要求严格的顺序、长度和内容不同,属性表集合的限制稍微宽松一 些,不再要求各个属性表具有严格顺序,并且《Java虚拟机规范》允许只要不与已有属性名重复,任 何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java虚拟机运行时会忽略掉它不认识 的属性。

1.Code属性

java 复制代码
// 原始Java代码
public class TestClass {
    private int m;
    public int inc() {
        return m + 1;
    }
}
C:\>javap -verbose TestClass
// 常量表部分的输出见代码清单6-1,因版面原因这里省略掉
{
    public org.fenixsoft.clazz.TestClass();
    Code:
    Stack=1, Locals=1, Args_size=1
    0:   aload_0
    1:   invokespecial   #10; //Method java/lang/Object."<init>":()V
    4:   return
    LineNumberTable:
    line 3: 0
    LocalVariableTable:
    Start  Length  Slot  Name    Signature
    0      5       0     this    Lorg/fenixsoft/clazz/TestClass;
    public int inc();
    Code:
    Stack=2, Locals=1, Args_size=1
    0:   aload_0
    1:   getfield        #18; //Field m:I
    4:   iconst_1
    5:   iadd
    6:   ireturn
    LineNumberTable:
    line 8: 0
    LocalVariableTable:
    Start  Length  Slot  Name    Signature
    0      7       0     this    Lorg/fenixsoft/clazz/TestClass;
}

如果大家注意到javap中输出的"Args_size"的值,可能还会有疑问:这个类有两个方法------实例构 造器()和inc(),这两个方法很明显都是没有参数的,为什么Args_size会为1?而且无论是在参数列 表里还是方法体内,都没有定义任何局部变量,那Locals又为什么会等于1?如果有这样疑问的读者, 大概是忽略了一条Java语言里面的潜规则:在任何实例方法里面,都可以通过"this"关键字访问到此方 法所属的对象。这个访问机制对Java程序的编写很重要,而它的实现非常简单,仅仅是通过在Javac编 译器编译的时候把对this关键字的访问转变为对一个普通方法参数的访问,然后在虚拟机调用实例方法 时自动传入此参数而已。因此在实例方法的局部变量表中至少会存在一个指向当前对象实例的局部变 量,局部变量表中也会预留出第一个变量槽位来存放对象实例的引用,所以实例方法参数值从1开始计 算。这个处理只对实例方法有效,如果代码清单6-1中的inc()方法被声明为static,那Args_size就不会等 于1而是等于0了。

如果存在异常表,那它的格式应如表6-16所示,包含四个字段,这些字段的含义为:如果当字节 码从第start_pc行[1]到第end_pc行之间(不含第end_pc行)出现了类型为catch_type或者其子类的异常 (catch_type为指向一个CONSTANT_Class_info型常量的索引),则转到第handler_pc行继续处理。当 catch_type的值为0时,代表任意异常情况都需要转到handler_pc处进行处理。

异常表实际上是Java代码的一部分,尽管字节码中有最初为处理异常而设计的跳转指令,但《Java 虚拟机规范》中明确要求Java语言的编译器应当选择使用异常表而不是通过跳转指令来实现Java异常及 finally处理机制[2]。

DeepSeek:

  • 所有计算操作都在栈上完成
java 复制代码
// Java源码
public int inc() {
    int x;
    try {
        x = 1;
        return x;
    } catch (Exception e) {
        x = 2;
        return x;
    } finally {
        x = 3;
    }
 }
 // 编译后的ByteCode字节码及异常表
public int inc();
    Code:
        Stack=1, Locals=5, Args_size=1
        0:   iconst_1           // try块中的x=1
        1:   istore_1
        2:   iload_1    // 保存x到returnValue中,此时x=1
        3:   istore  4
        5:   iconst_3   // finaly块中的x=3
        6:   istore_1
        7:   iload   4  // 将returnValue中的值放到栈顶,准备给ireturn返回
        9:   ireturn
        10:  astore_2   // 给catch中定义的Exception e赋值,存储在变量槽 2中
        11:  iconst_2   // catch块中的x=2
        12:  istore_1
        13:  iload_1    // 保存x到returnValue中,此时x=2
        14:  istore  4
        16:  iconst_3   // finaly块中的x=3
        17:  istore_1
        18:  iload 4    // 将returnValue中的值放到栈顶,准备给ireturn返回
        20:  ireturn
        21:  astore_3   // 如果出现了不属于java.lang.Exception及其子类的异常才会走到这里
        22:  iconst_3   // finaly块中的x=3
        23:  istore_1
        24:  aload_3    // 将异常放置到栈顶,并抛出
        25:  athrow
    Exception table:
    from   to  target type
        0     5    10   Class java/lang/Exception
        0     5    21   any
        10    16   21   any

编译器为这段Java源码生成了三条异常表记录,对应三条可能出现的代码执行路径。从Java代码的 语义上讲,这三条执行路径分别为: ·

  • 如果try语句块中出现属于Exception或其子类的异常,转到catch语句块处理;
  • 如果try语句块中出现不属于Exception或其子类的异常,转到finally语句块处理;
  • 如果catch语句块中出现任何异常,转到finally语句块处理。

字节码中第0~4行所做的操作就是将整数1赋值给变量x,并且将此时x的值复制一份副本到最后一 个本地变量表的变量槽中(这个变量槽里面的值在ireturn指令执行前将会被重新读到操作栈顶,作为 方法返回值使用。为了讲解方便,笔者给这个变量槽起个名字:returnValue)。如果这时候没有出现异 常,则会继续走到第5~9行,将变量x赋值为3,然后将之前保存在returnValue中的整数1读入到操作栈 顶,最后ireturn指令会以int形式返回操作栈顶中的值,方法结束。如果出现了异常,PC寄存器指针转 到第10行,第10~20行所做的事情是将2赋值给变量x,然后将变量x此时的值赋给returnValue,最后再 将变量x的值改为3。方法返回前同样将returnValue中保留的整数2读到了操作栈顶。从第21行开始的代 码,作用是将变量x的值赋为3,并将栈顶的异常抛出,方法结束。

2.Exceptions属性

这里的Exceptions属性是在方法表中与Code属性平级的一项属性,读者不要与前面刚刚讲解完的异 常表产生混淆。Exceptions属性的作用是列举出方法中可能抛出的受查异常(Checked Excepitons),也 就是方法描述时在throws关键字后面列举的异常。

3.LineNumberTable属性

LineNumberTable属性用于描述Java源码行号与字节码行号(字节码的偏移量)之间的对应关系。 它并不是运行时必需的属性,但默认会生成到Class文件之中,可以在Javac中使用-g:none或-g:lines 选项来取消或要求生成这项信息。如果选择不生成LineNumberTable属性,对程序运行产生的最主要影 响就是当抛出异常时,堆栈中将不会显示出错的行号,并且在调试程序的时候,也无法按照源码行来 设置断点。

4.LocalVariableTable及LocalVariableTypeTable属性

LocalVariableTable属性用于描述栈帧中局部变量表的变量与Java源码中定义的变量之间的关系,它 也不是运行时必需的属性,但默认会生成到Class文件之中,可以在Javac中使用-g:none或-g:vars选项 来取消或要求生成这项信息。如果没有生成这项属性,最大的影响就是当其他人引用这个方法时,所 有的参数名称都将会丢失,譬如IDE将会使用诸如arg0、arg1之类的占位符代替原有的参数名,这对程 序运行没有影响,但是会对代码编写带来较大不便,而且在调试期间无法根据参数名称从上下文中获 得参数值。

5.SourceFile及SourceDebugExtension属性

SourceFile属性用于记录生成这个Class文件的源码文件名称。这个属性也是可选的,可以使用Javac 的-g:none或-g:source选项来关闭或要求生成这项信息。

6.ConstantValue属性

ConstantValue属性的作用是通知虚拟机自动为静态变量赋值。只有被static关键字修饰的变量(类 变量)才可以使用这项属性。类似"int x=123"和"static int x=123"这样的变量定义在Java程序里面是非 常常见的事情,但虚拟机对这两种变量赋值的方式和时刻都有所不同。对非static类型的变量(也就是 实例变量)的赋值是在实例构造器()方法中进行的;而对于类变量,则有两种方式可以选择:在 类构造器()方法中或者使用ConstantValue属性。目前Oracle公司实现的Javac编译器的选择是,如 果同时使用final和static来修饰一个变量(按照习惯,这里称"常量"更贴切),并且这个变量的数据类 型是基本类型或者java.lang.String的话,就将会生成ConstantValue属性来进行初始化;如果这个变量没 有被final修饰,或者并非基本类型及字符串,则将会选择在()方法中进行初始化。

7.InnerClasses属性

InnerClasses属性用于记录内部类与宿主类之间的关联。如果一个类中定义了内部类,那编译器将 会为它以及它所包含的内部类生成InnerClasses属性。

8.Deprecated及Synthetic属性

Deprecated和Synthetic两个属性都属于标志类型的布尔属性,只存在有和没有的区别,没有属性值 的概念。

9.StackMapTable属性

StackMapTable属性在JDK 6增加到Class文件规范之中,它是一个相当复杂的变长属性,位于Code 属性的属性表中。这个属性会在虚拟机类加载的字节码验证阶段被新类型检查验证器(Type Checker)使用(详见第7章字节码验证部分),目的在于代替以前比较消耗性能的基于数据流分析的 类型推导验证器。

10.Signature属性

Signature属性在JDK 5增加到Class文件规范之中,它是一个可选的定长属性,可以出现于类、字段 表和方法表结构的属性表中。在JDK 5里面大幅增强了Java语言的语法,在此之后,任何类、接口、初 始化方法或成员的泛型签名如果包含了类型变量(Type Variable)或参数化类型(Parameterized Type),则Signature属性会为它记录泛型签名信息。之所以要专门使用这样一个属性去记录泛型类 型,是因为Java语言的泛型采用的是擦除法实现的伪泛型,字节码(Code属性)中所有的泛型信息编 译(类型变量、参数化类型)在编译之后都通通被擦除掉。使用擦除法的好处是实现简单(主要修改 Javac编译器,虚拟机内部只做了很少的改动)、非常容易实现Backport,运行期也能够节省一些类型 所占的内存空间。但坏处是运行期就无法像C#等有真泛型支持的语言那样,将泛型类型与用户定义的 普通类型同等对待,例如运行期做反射时无法获得泛型信息。Signature属性就是为了弥补这个缺陷而 增设的,现在Java的反射API能够获取的泛型类型,最终的数据来源也是这个属性。关于Java泛型、 Signature属性和类型擦除,在第10章讲编译器优化的时候我们会通过一个更具体的例子来讲解。 Signature属性的结构如表6-29所示。

11.BootstrapMethods属性

BootstrapMethods属性在JDK 7时增加到Class文件规范之中,它是一个复杂的变长属性,位于类 文件的属性表中。这个属性用于保存invokedynamic指令引用的引导方法限定符。

12.MethodParameters属性

MethodParameters是在JDK 8时新加入到Class文件格式中的,它是一个用在方法表中的变长属性。 MethodParameters的作用是记录方法的各个形参名称和信息。

13.模块化相关属性

JDK 9的一个重量级功能是Java的模块化功能,因为模块描述文件(module-info.java)最终是要编 译成一个独立的Class文件来存储的,所以,Class文件格式也扩展了Module、ModulePackages和 ModuleMainClass三个属性用于支持Java模块化相关功能。

Module属性是一个非常复杂的变长属性,除了表示该模块的名称、版本、标志信息以外,还存储 了这个模块requires、exports、opens、uses和provides定义的全部内容

14.运行时注解相关属性

6.4 字节码指令简介

由于Java虚拟机采用 面向操作数栈而不是面向寄存器的架构(这两种架构的执行过程、区别和影响将在第8章中探讨),所 以大多数指令都不包含操作数,只有一个操作码,指令参数都存放在操作数栈中。

字节码指令集可算是一种具有鲜明特点、优势和劣势均很突出的指令集架构,由于限制了Java虚 拟机操作码的长度为一个字节(即0~255),这意味着指令集的操作码总数不能够超过256条;又由于 Class文件格式放弃了编译后代码的操作数长度对齐,这就意味着虚拟机在处理那些超过一个字节的数 据时,不得不在运行时从字节中重建出具体数据的结构,譬如要将一个16位长度的无符号整数使用两 个无符号字节存储起来(假设将它们命名为byte1和byte2),那它们的值应该是这样的 (byte1 << 8) | byte2

这种操作在某种程度上会导致解释执行字节码时将损失一些性能,但这样做的优势也同样明显: 放弃了操作数长度对齐[1],就意味着可以省略掉大量的填充和间隔符号;用一个字节来代表操作码, 也是为了尽可能获得短小精干的编译代码。这种追求尽可能小数据量、高传输效率的设计是由Java语 言设计之初主要面向网络、智能家电的技术背景所决定的,并一直沿用至今。

如果不考虑异常处理的话,那Java虚拟机的解释器可以使用下面这段伪代码作为最基本的执行模 型来理解,这个执行模型虽然很简单,但依然可以有效正确地工作:

java 复制代码
do {
    自动计算PC寄存器的值加1;
    根据PC寄存器指示的位置,从字节码流中取出操作码;
     if (字节码存在操作数) 从字节码流中取出操作数;
    执行操作码所定义的操作;
} while (字节码流长度 > 0);
6.4.1 字节码与数据类型

在Java虚拟机的指令集中,大多数指令都包含其操作所对应的数据类型信息。举个例子,iload指 令用于从局部变量表中加载int型的数据到操作数栈中,而fload指令加载的则是float类型的数据。这两 条指令的操作在虚拟机内部可能会是由同一段代码来实现的,但在Class文件中它们必须拥有各自独立 的操作码。

对于大部分与数据类型相关的字节码指令,它们的操作码助记符中都有特殊的字符来表明专门为 哪种数据类型服务:i代表对int类型的数据操作,l代表long,s代表short,b代表byte,c代表char,f代表 float,d代表double,a代表reference。也有一些指令的助记符中没有明确指明操作类型的字母,例如 arraylength指令,它没有代表数据类型的特殊字符,但操作数永远只能是一个数组类型的对象。还有另 外一些指令,例如无条件跳转指令goto则是与数据类型无关的指令。

因为Java虚拟机的操作码长度只有一字节,所以包含了数据类型的操作码就为指令集的设计带来 了很大的压力:如果每一种与数据类型相关的指令都支持Java虚拟机所有运行时数据类型的话,那么 指令的数量恐怕就会超出一字节所能表示的数量范围了。因此,Java虚拟机的指令集对于特定的操作 只提供了有限的类型相关指令去支持它,换句话说,指令集将会被故意设计成非完全独立的。 (《Java虚拟机规范》中把这种特性称为"Not Orthogonal",即并非每种数据类型和每一种操作都有对 应的指令。)有一些单独的指令可以在必要的时候用来将一些不支持的类型转换为可被支持的类型。

请注意,从表6-40中看来,大部分指令都没有支持整数类型byte、char和short,甚至没有任何指令 支持boolean类型。编译器会在编译期或运行期将byte和short类型的数据带符号扩展(Sign-Extend)为 相应的int类型数据,将boolean和char类型数据零位扩展(Zero-Extend)为相应的int类型数据。与之类 似,在处理boolean、byte、short和char类型的数组时,也会转换为使用对应的int类型的字节码指令来 处理。因此,大多数对于boolean、byte、short和char类型数据的操作,实际上都是使用相应的对int类 型作为运算类型(Computational Type)来进行的

6.4.2 加载和存储指令

加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈(见第2章关于内存区域的介绍)之 间来回传输,这类指令包括:

  • 将一个局部变量加载到操作栈:iload、iload_、lload、lload_、fload、fload_、dload、 dload_、aload、aload_
  • 将一个数值从操作数栈存储到局部变量表:istore、istore_、lstore、lstore_、fstore、 fstore_、dstore、dstore_、astore、astore_
  • 将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、 iconst__、lconst_、fconst_、dconst__

__

6.4.3 运算指令

《Java虚拟机规范》要求虚拟机实现在处理浮点数时,必须严格遵循IEEE 754规范中所规定行为 和限制,也就是说Java虚拟机必须完全支持IEEE 754中定义的"非正规浮点数值"(Denormalized Floating-Point Number)和"逐级下溢"(Gradual Underflow)的运算规则。这些规则将会使某些数值算 法处理起来变得明确,不会出现模棱两可的困境。譬如以上规则要求Java虚拟机在进行浮点数运算 时,所有的运算结果都必须舍入到适当的精度,非精确的结果必须舍入为可被表示的最接近的精确 值;如果有两种可表示的形式与该值一样接近,那将优先选择最低有效位为零的。这种舍入模式也是 IEEE 754规范中的默认舍入模式,称为向最接近数舍入模式。而在把浮点数转换为整数时,Java虚拟 机使用IEEE 754标准中的向零舍入模式,这种模式的舍入结果会导致数字被截断,所有小数部分的有 效字节都会被丢弃掉。向零舍入模式将在目标数值类型中选择一个最接近,但是不大于原值的数字来 作为最精确的舍入结果。

另外,Java虚拟机在处理浮点数运算时,不会抛出任何运行时异常(这里所讲的是Java语言中的异 常,请读者勿与IEEE 754规范中的浮点异常互相混淆,IEEE 754的浮点异常是一种运算信号),当一 个操作产生溢出时,将会使用有符号的无穷大来表示;如果某个操作结果没有明确的数学定义的话, 将会使用NaN(Not a Number)值来表示。所有使用NaN值作为操作数的算术操作,结果都会返回 NaN。

6.4.4 类型转换指令

Java虚拟机直接支持(即转换时无须显式的转换指令)以下数值类型的宽化类型转换(Widening Numeric Conversion,即小范围类型向大范围类型的安全转换):

  • int类型到long、float或者double类型
  • long类型到float、double类型
  • float类型到double类型

与之相对的,处理窄化类型转换(Narrowing Numeric Conversion)时,就必须显式地使用转换指 令来完成,这些转换指令包括i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f。窄化类型转换可能会导致 转换结果产生不同的正负号、不同的数量级的情况,转换过程很可能会导致数值的精度丢失。

在将int或long类型窄化转换为整数类型T的时候,转换过程仅仅是简单丢弃除最低位N字节以外的 内容,N是类型T的数据类型长度,这将可能导致转换结果与输入值有不同的正负号。对于了解计算机 数值存储和表示的程序员来说这点很容易理解,因为原来符号位处于数值的最高位,高位被丢弃之 后,转换结果的符号就取决于低N字节的首位了。

Java虚拟机将一个浮点值窄化转换为整数类型T(T限于int或long类型之一)的时候,必须遵循以 下转换规则:

  • 如果浮点值是NaN,那转换结果就是int或long类型的0。
  • 如果浮点值不是无穷大的话,浮点值使用IEEE 754的向零舍入模式取整,获得整数值v。如果v在 目标类型T(int或long)的表示范围之类,那转换结果就是v;否则,将根据v的符号,转换为T所能表 示的最大或者最小正数。

从double类型到float类型做窄化转换的过程与IEEE 754中定义的一致,通过IEEE 754向最接近数舍 入模式舍入得到一个可以使用float类型表示的数字。如果转换结果的绝对值太小、无法使用float来表 示的话,将返回float类型的正负零;如果转换结果的绝对值太大、无法使用float来表示的话,将返回 float类型的正负无穷大。对于double类型的NaN值将按规定转换为float类型的NaN值。

尽管数据类型窄化转换可能会发生上限溢出、下限溢出和精度丢失等情况,但是《Java虚拟机规 范》中明确规定数值类型的窄化转换指令永远不可能导致虚拟机抛出运行时异常。

6.4.5 对象创建与访问指令

虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指 令(在下一章会讲到数组和普通类的类型创建过程是不同的)。

6.4.6 操作数栈管理指令

如同操作一个普通数据结构中的堆栈那样,Java虚拟机提供了一些用于直接操作操作数栈的指 令,包括:

  • 将操作数栈的栈顶一个或两个元素出栈:pop、pop2
  • 复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、 dup2_x1、dup_x2、dup2_x2
  • 将栈最顶端的两个数值互换:swap
6.4.7 控制转移指令

控制转移指令可以让Java虚拟机有条件或无条件地从指定位置指令(而不是控制转移指令)的下 一条指令继续执行程序,从概念模型上理解,可以认为控制指令就是在有条件或无条件地修改PC寄存 器的值。

6.4.8 方法调用和返回指令

方法调用(分派、执行过程)将在第8章具体讲解,这里仅列举以下五条指令用于方法调用:

  • invokevirtual指令:用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派), 这也是Java语言中最常见的方法分派方式。
  • invokeinterface指令:用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找 出适合的方法进行调用。
  • invokespecial指令:用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和 父类方法。
  • invokestatic指令:用于调用类静态方法(static方法)。
  • invokedynamic指令:用于在运行时动态解析出调用点限定符所引用的方法。并执行该方法。前面 四条调用指令的分派逻辑都固化在Java虚拟机内部,用户无法改变,而invokedynamic指令的分派逻辑 是由用户所设定的引导方法决定的。
6.4.9 异常处理指令

在Java程序中显式抛出异常的操作(throw语句)都由athrow指令来实现,除了用throw语句显式抛 出异常的情况之外,《Java虚拟机规范》还规定了许多运行时异常会在其他Java虚拟机指令检测到异常 状况时自动抛出。例如前面介绍整数运算中,当除数为零时,虚拟机会在idiv或ldiv指令中抛出 ArithmeticException异常。

而在Java虚拟机中,处理异常(catch语句)不是由字节码指令来实现的(很久之前曾经使用jsr和 ret指令来实现,现在已经不用了),而是采用异常表来完成。

6.4.10 同步指令

Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管 程(Monitor,更常见的是直接将它称为"锁")来实现的

方法级的同步是隐式的,无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟 机可以从方法常量池中的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否被声明为 同步方法。当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如 果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成 还是非正常完成)时释放管程。在方法执行期间,执行线程持有了管程,其他任何线程都无法再获取 到同一个管程。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同 步方法所持有的管程将在异常抛到同步方法边界之外时自动释放。

同步一段指令集序列通常是由Java语言中的synchronized语句块来表示的,Java虚拟机的指令集中 有monitorenter和monitorexit两条指令来支持synchronized关键字的语义,正确实现synchronized关键字 需要Javac编译器与Java虚拟机两者共同协作支持,譬如有代码清单6-6所示的代码。

java 复制代码
void onlyMe(Foo f) {
    synchronized(f) {
        doSomething();
    }
 }

编译后,这段代码生成的字节码序列如下:

java 复制代码
Method void onlyMe(Foo)
0 aload_1                         // 将对象f入栈
1 dup                           // 复制栈顶元素(即f的引用)
2 astore_2                        // 将栈顶元素存储到局部变量表变量槽 2中
3 monitorenter                    // 以栈定元素(即f)作为锁,开始同步
4 aload_0                         // 将局部变量槽 0(即this指针)的元素入栈
5 invokevirtual #5                // 调用doSomething()方法
8 aload_2                         // 将局部变量Slow 2的元素(即f)入栈
9 monitorexit                     // 退出同步
10 goto 18                        // 方法正常结束,跳转到18返回
13 astore_3                       // 从这步开始是异常路径,见下面异常表的Taget 13
 14 aload_2                        // 将局部变量Slow 2的元素(即f)入栈
15 monitorexit                    // 退出同步
16 aload_3                        // 将局部变量Slow 3的元素(即异常对象)入栈
17 athrow                         // 把异常对象重新抛出给onlyMe()方法的调用者
18 return                         // 方法正常返回
Exception table:
 FromTo Target Type
   4    10     13 any
  13    16     13 any

编译器必须确保无论方法通过何种方式完成,方法中调用过的每条monitorenter指令都必须有其对 应的monitorexit指令,而无论这个方法是正常结束还是异常结束。

从代码清单6-6的字节码序列中可以看到,为了保证在方法异常完成时monitorenter和monitorexit指 令依然可以正确配对执行,编译器会自动产生一个异常处理程序,这个异常处理程序声明可处理所有 的异常,它的目的就是用来执行monitorexit指令。

6.5 公有设计,私有实现

虚拟机实现者可以使用这种伸缩性来让Java虚拟机获得更高的性能、更低的内存消耗或者更好的 可移植性,选择哪种特性取决于Java虚拟机实现的目标和关注点是什么,虚拟机实现的方式主要有以 下两种:

  • 将输入的Java虚拟机代码在加载时或执行时翻译成另一种虚拟机的指令集;
  • 将输入的Java虚拟机代码在加载时或执行时翻译成宿主机处理程序的本地指令集(即即时编译器 代码生成技术)。

精确定义的虚拟机行为和目标文件格式,不应当对虚拟机实现者的创造性产生太多的限制,Java 虚拟机是被设计成可以允许有众多不同的实现,并且各种实现可以在保持兼容性的同时提供不同的新 的、有趣的解决方案。

第7章 虚拟机类加载机制

7.1 概述

上一章我们学习了Class文件存储格式的具体细节,在Class文件中描述的各类信息,最终都需要加 载到虚拟机中之后才能被运行和使用。而虚拟机如何加载这些Class文件,Class文件中的信息进入到虚 拟机后会发生什么变化,这些都是本章将要讲解的内容。

Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最 终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。与那些在编译时需 要进行连接的语言不同,在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成 的,这种策略让Java语言进行提前编译会面临额外的困难,也会让类加载时稍微增加一些性能开销, 但是却为Java应用提供了极高的扩展性和灵活性,Java天生可以动态扩展的语言特性就是依赖运行期动 态加载和动态连接这个特点实现的。例如,编写一个面向接口的应用程序,可以等到运行时再指定其 实际的实现类,用户可以通过Java预置的或自定义类加载器,让某个本地的应用程序在运行时从网络 或其他地方上加载一个二进制流作为其程序代码的一部分。这种动态组装应用的方式目前已广泛应用 于Java程序之中,从最基础的Applet、JSP到相对复杂的OSGi技术,都依赖着Java语言运行期类加载才 得以诞生。

7.2 类加载的时机

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载 (Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化 (Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称 为连接(Linking)。这七个阶段的发生顺序如图7-1所示。

图7-1中,加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按 照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始, 这是为了支持Java语言的运行时绑定特性(也称为动态绑定或晚期绑定)。请注意,这里笔者写的是 按部就班地"开始",而不是按部就班地"进行"或按部就班地"完成",强调这点是因为这些阶段通常都 是互相交叉地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。

关于在什么情况下需要开始类加载过程的第一个阶段"加载",《Java虚拟机规范》中并没有进行 强制约束,这点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,《Java虚拟机规范》 则是严格规定了有且只有六种情况必须立即对类进行"初始化"(而加载、验证、准备自然需要在此之 前开始):

  • 1)遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始 化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:
    • 使用new关键字实例化对象的时候。
    • 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外) 的时候。
    • 调用一个类型的静态方法的时候。
  • 2)使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需 要先触发其初始化。
  • 3)当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  • 4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先 初始化这个主类。
  • 5)当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解 析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句 柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
  • 6)当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有 这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

对于这六种会触发类型进行初始化的场景,《Java虚拟机规范》中使用了一个非常强烈的限定语 ------"有且只有",这六种场景中的行为称为对一个类型进行主动引用。除此之外,所有引用类型的方 式都不会触发初始化,称为被动引用。下面举三个例子来说明何为被动引用,分别见代码清单7-1、代 码清单7-2和代码清单7-3。

java 复制代码
package org.fenixsoft.classloading;
 /**
 * 被动使用类字段演示二:
 * 通过数组定义来引用类,不会触发此类的初始化
 **/
 public class NotInitialization {
    public static void main(String[] args) {
        SuperClass[] sca = new SuperClass[10];
    }
 }

为了节省版面,这段代码复用了代码清单7-1中的SuperClass,运行之后发现没有输出"SuperClass init!",说明并没有触发类org.fenixsoft.classloading.SuperClass的初始化阶段。但是这段代码里面触发了 另一个名为"[Lorg.fenixsoft.classloading.SuperClass"的类的初始化阶段,对于用户代码来说,这并不是 一个合法的类型名称,它是一个由虚拟机自动生成的、直接继承于java.lang.Object的子类,创建动作由 字节码指令newarray触发。

这个类代表了一个元素类型为org.fenixsoft.classloading.SuperClass的一维数组,数组中应有的属性 和方法(用户可直接使用的只有被修饰为public的length属性和clone()方法)都实现在这个类里。Java语 言中对数组的访问要比C/C++相对安全,很大程度上就是因为这个类包装了数组元素的访问[1],而 C/C++中则是直接翻译为对数组指针的移动。在Java语言里,当检查到发生数组越界时会抛出 java.lang.ArrayIndexOutOfBoundsException异常,避免了直接造成非法内存访问。

7.3 类加载的过程

7.3.1 加载

"加载"(Loading)阶段是整个"类加载"(Class Loading)过程中的一个阶段,希望读者没有混淆 这两个看起来很相似的名词。在加载阶段,Java虚拟机需要完成以下三件事情:

  • 1)通过一个类的全限定名来获取定义此类的二进制字节流。
  • 2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入 口。

相对于类加载过程的其他阶段,非数组类型的加载阶段(准确地说,是加载阶段中获取类的二进 制字节流的动作)是开发人员可控性最强的阶段。加载阶段既可以使用Java虚拟机里内置的引导类加 载器来完成,也可以由用户自定义的类加载器去完成,开发人员通过定义自己的类加载器去控制字节 流的获取方式(重写一个类加载器的findClass()或loadClass()方法),实现根据自己的想法来赋予应用 程序获取运行代码的动态性。

对于数组类而言,情况就有所不同,数组类本身不通过类加载器创建,它是由Java虚拟机直接在 内存中动态构造出来的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型(Element Type,指的是数组去掉所有维度的类型)最终还是要靠类加载器来完成加载,一个数组类(下面简称 为C)创建过程遵循以下规则:

加载阶段结束后,Java虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中 了,方法区中的数据存储格式完全由虚拟机实现自行定义,《Java虚拟机规范》未规定此区域的具体 数据结构。类型数据妥善安置在方法区之后,会在Java堆内存中实例化一个java.lang.Class类的对象, 这个对象将作为程序访问方法区中的类型数据的外部接口。

加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段 尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部 分,这两个阶段的开始时间仍然保持着固定的先后顺序。

7.3.2 验证

从整体上看,验证阶段大致上会完成下面四个阶段的检验动作:文件格式验证、元数据验证、字节 码验证和符号引用验证

7.3.3 准备

准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初 始值的阶段,从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区 本身是一个逻辑上的区域,在JDK 7及之前,HotSpot使用永久代来实现方法区时,实现是完全符合这 种逻辑概念的;而在JDK 8及之后,类变量则会随着Class对象一起存放在Java堆中,这时候"类变量在 方法区"就完全是一种对逻辑概念的表述了,关于这部分内容,笔者已在4.3.1节介绍并且验证过。

7.3.4 解析

解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用在第6章讲解Class 文件格式的时候已经出现过多次,在Class文件中它以CONSTANT_Class_info、 CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现,那解析阶段中所说的直接 引用与符号引用又有什么关联呢?

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

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这7 类符号引用进行,分别对应于常量池的CONSTANT_Class_info、CON-STANT_Fieldref_info、 CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、 CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_Dyna-mic_info和 CONSTANT_InvokeDynamic_info 8种常量类型[2]。下面笔者将讲解前4种引用的解析过程,对于后4 种,它们都和动态语言支持密切相关,由于Java语言本身是一门静态类型语言,在没有讲解清楚 invokedynamic指令的语意之前,我们很难将它们直观地和现在的Java语言语法对应上,因此笔者将延 后到第8章介绍动态语言调用时一起分析讲解。

1.类或接口的解析

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

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

2.字段解析

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

1)如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引 用,查找结束。

2)否则,如果在C中实现了接口 ,将会按照继承关系从下往上递归搜索各个接口和它的父接口, 如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找 结束。

3)否则,如果C不是java.lang.Object的话将会按照继承关系从下往上递归搜索其父类,如果在父 类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。

4)否则,查找失败,抛出java.lang.NoSuchFieldError异常。

3.方法解析

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

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

4.接口方法解析

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

  • 1)与类的方法解析相反,如果在接口方法表中发现class_index中的索引C是个类而不是接口,那 么就直接抛出java.lang.IncompatibleClassChangeError异常。
  • 2)否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方 法的直接引用,查找结束。
  • 3)否则,在接口C的父接口中递归查找,直到java.lang.Object类(接口方法的查找范围也会包括 Object类中的方法)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方 法的直接引用,查找结束。
  • 4)对于规则3,由于Java的接口允许多重继承,如果C的不同父接口中存有多个简单名称和描述符 都与目标相匹配的方法,那将会从这多个方法中返回其中一个并结束查找,《Java虚拟机规范》中并 没有进一步规则约束应该返回哪一个接口方法。但与之前字段查找类似地,不同发行商实现的Javac编 译器有可能会按照更严格的约束拒绝编译这种代码来避免不确定性
  • 5)否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。
7.3.5 初始化

类的初始化阶段是类加载过程的最后一个步骤,之前介绍的几个类加载的动作里,除了在加载阶 段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由Java虚拟机来主导控 制。直到初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程 序

进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通 过程序编码制定的主观计划去初始化类变量和其他资源。我们也可以从另外一种更直接的形式来表 达:初始化阶段就是执行类构造器()方法的过程。()并不是程序员在Java代码中直接编写 的方法,它是Javac编译器的自动生成物

  • ()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的 语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问 到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访 问,如代码清单7-5所示。
java 复制代码
public class Test {
    static {
        i = 0;  //  给变量复制可以正常编译通过
        System.out.print(i);  // 这句编译器会提示"非法向前引用"
    }
    static int i = 1;
 }
  • ()方法与类的构造函数(即在虚拟机视角中的实例构造器()方法)不同,它不需要显 式地调用父类构造器,Java虚拟机会保证在子类的()方法执行前,父类的()方法已经执行 完毕。因此在Java虚拟机中第一个被执行的()方法的类型肯定是java.lang.Object
  • 由于父类的()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值 操作
  • ()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的 赋值操作,那么编译器可以不为这个类生成()方法。
  • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成 ()方法。但接口与类不同的是,执行接口的()方法不需要先执行父接口的()方法, 因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也 一样不会执行接口的()方法。
  • Java虚拟机必须保证一个类的()方法在多线程环境中被正确地加锁同步,如果多个线程同 时去初始化一个类,那么只会有其中一个线程去执行这个类的()方法,其他线程都需要阻塞等 待,直到活动线程执行完毕()方法。如果在一个类的()方法中有耗时很长的操作,那就 可能造成多个进程阻塞[2],在实际应用中这种阻塞往往是很隐蔽的。代码清单7-7演示了这种场景。

7.4 类加载器

Java虚拟机设计团队有意把类加载阶段中的"通过一个类的全限定名来获取描述该类的二进制字节 流"这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类。实现这个动 作的代码被称为"类加载器"(Class Loader)

7.4.1 类与类加载器

类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远超类加载阶段。对于 任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每 一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否"相 等",只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

这里所指的"相等",包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance() 方法的返回结果,也包括了使用instanceof关键字做对象所属关系判定等各种情况。如果没有注意到类 加载器的影响,在某些情况下可能会产生具有迷惑性的结果,代码清单7-8中演示了不同的类加载器对 instanceof关键字运算的结果的影响。

代码清单7-8中构造了一个简单的类加载器,尽管它极为简陋,但是对于这个演示来说已经足够。 它可以加载与自己在同一路径下的Class文件,我们使用这个类加载器去加载了一个名 为"org.fenixsoft.classloading.ClassLoaderTest"的类,并实例化了这个类的对象。

两行输出结果中,从第一行可以看到这个对象确实是类org.fenixsoft.classloading.ClassLoaderTest实 例化出来的,但在第二行的输出中却发现这个对象与类org.fenixsoft.classloading.ClassLoaderTest做所属 类型检查的时候返回了false。这是因为Java虚拟机中同时存在了两个ClassLoaderTest类,一个是由虚拟 机的应用程序类加载器所加载的,另外一个是由我们自定义的类加载器加载的,虽然它们都来自同一 个Class文件,但在Java虚拟机中仍然是两个互相独立的类,做对象所属类型检查时的结果自然为 false

7.4.2 双亲委派模型

站在Java虚拟机的角度来看,只存在两种不同的类加载器:

  • 一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现[1],是虚拟机自身的一部分;
  • 另外一种就是其他所有 的类加载器,这些类加载器都由Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。

1\] 这里只限于HotSpot,像MRP、Maxine这些虚拟机,整个虚拟机本身都是由Java编写的,自然Boot strap ClassLoader也是由Java语言而不是C++实现的。退一步说,除了HotSpot外的其他两个高性能虚拟 机JRockit和J9都有一个代表Bootstrap ClassLoader的Java类存在,但是关键方法的实现仍然是使用JNI回 调到C(而不是C++)的实现上,这个Bootstrap ClassLoader的实例也无法被用户获取到。在JDK 9以 后,HotSpot虚拟机也采用了类似的虚拟机与Java类互相配合来实现Bootstrap ClassLoader的方式,所以 在JDK 9后HotSpot也有一个无法获取实例的代表Bootstrap ClassLoader的Java类存在了。

站在Java开发人员的角度来看,类加载器就应当划分得更细致一些。自JDK 1.2以来,Java一直保 持着三层类加载器、双亲委派的类加载架构,尽管这套架构在Java模块化系统出现后有了一些调整变 动,但依然未改变其主体结构,我们将在7.5节中专门讨论模块化系统下的类加载器。

本节内容将针对JDK 8及之前版本的Java来介绍什么是三层类加载器,以及什么是双亲委派模型。 对于这个时期的Java应用,绝大多数Java程序都会使用到以下3个系统提供的类加载器来进行加载。

  • 启动类加载器(Bootstrap Class Loader):前面已经介绍过,这个类加载器负责加载存放在 <JAVA_HOME>\lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够 识别的(按照文件名识别,如rt.jar、tools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类 库加载到虚拟机的内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时, 如果需要把加载请求委派给引导类加载器去处理,那直接使用null代替即可,代码清单7-9展示的就是 java.lang.ClassLoader.getClassLoader()方法的代码片段,其中的注释和代码实现都明确地说明了以null值 来代表引导类加载器的约定规则。
  • 扩展类加载器(Extension Class Loader):这个类加载器是在类sun.misc.Launcher$ExtClassLoader 中以Java代码的形式实现的。它负责加载<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs系统变量所 指定的路径中所有的类库。根据"扩展类加载器"这个名称,就可以推断出这是一种Java系统类库的扩 展机制,JDK的开发团队允许用户将具有通用性的类库放置在ext目录里以扩展Java SE的功能,在JDK 9之后,这种扩展机制被模块化带来的天然的扩展能力所取代。由于扩展类加载器是由Java代码实现 的,开发者可以直接在程序中使用扩展类加载器来加载Class文件。
  • 应用程序类加载器(Application Class Loader):这个类加载器由 sun.misc.Launcher$AppClassLoader来实现。由于应用程序类加载器是ClassLoader类中的getSystem ClassLoader()方法的返回值,所以有些场合中也称它为"系统类加载器"。它负责加载用户类路径 (ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有 自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器

图7-2中展示的各种类加载器之间的层次关系被称为类加载器的"双亲委派模型(Parents Delegation Model)"。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载 器。不过这里类加载器之间的父子关系一般不是以继承(Inheritance)的关系来实现的,而是通常使用 组合(Composition)关系来复用父加载器的代码

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加 载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的 加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请 求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。

使用双亲委派模型来组织类加载器之间的关系,一个显而易见的好处就是Java中的类随着它的类 加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一 个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类 在程序的各种类加载器环境中都能够保证是同一个类。反之,如果没有使用双亲委派模型,都由各个 类加载器自行去加载的话,如果用户自己也编写了一个名为java.lang.Object的类,并放在程序的 ClassPath中,那系统中就会出现多个不同的Object类,Java类型体系中最基础的行为也就无从保证,应 用程序将会变得一片混乱。如果读者有兴趣的话,可以尝试去写一个与rt.jar类库中已有类重名的Java 类,将会发现它可以正常编译,但永远无法被加载运行[2]。

双亲委派模型对于保证Java程序的稳定运作极为重要,但它的实现却异常简单,用以实现双亲委 派的代码只有短短十余行,全部集中在java.lang.ClassLoader的loadClass()方法之中,如代码清单7-10所示 。

java 复制代码
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException
 {
    // 首先,检查请求的类是否已经被加载过了
    Class c = findLoadedClass(name);
    if (c == null) {
        try {
        if (parent != null) {
            c = parent.loadClass(name, false);
        } else {
            c = findBootstrapClassOrNull(name);
        }
        } catch (ClassNotFoundException e) {
            // 如果父类加载器抛出ClassNotFoundException
            // 说明父类加载器无法完成加载请求
        }
        if (c == null) {
            // 在父类加载器无法加载时
            // 再调用本身的findClass方法来进行类加载
            c = findClass(name);
        }
    }
    if (resolve) {
        resolveClass(c);
    }
    return c;
}

这段代码的逻辑清晰易懂:先检查请求加载的类型是否已经被加载过,若没有则调用父加载器的 loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。假如父类加载器加载失败, 抛出ClassNotFoundException异常的话,才调用自己的findClass()方法尝试进行加载。

7.4.3 破坏双亲委派模型

上文提到过双亲委派模型并不是一个具有强制性约束的模型,而是Java设计者推荐给开发者们的 类加载器实现方式。在Java的世界中大部分的类加载器都遵循这个模型,但也有例外的情况,直到Java 模块化出现为止,双亲委派模型主要出现过3次较大规模"被破坏"的情况。

双亲委派模型的第一次"被破坏"其实发生在双亲委派模型出现之前------即JDK 1.2面世以前的"远 古"时代。由于双亲委派模型在JDK 1.2之后才被引入,但是类加载器的概念和抽象类 java.lang.ClassLoader则在Java的第一个版本中就已经存在,面对已经存在的用户自定义类加载器的代 码,Java设计者们引入双亲委派模型时不得不做出一些妥协,为了兼容这些已有代码,无法再以技术 手段避免loadClass()被子类覆盖的可能性,只能在JDK 1.2之后的java.lang.ClassLoader中添加一个新的 protected方法findClass(),并引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在 loadClass()中编写代码。上节我们已经分析过loadClass()方法,双亲委派的具体逻辑就实现在这里面, 按照loadClass()方法的逻辑,如果父类加载失败,会自动调用自己的findClass()方法来完成加载,这样 既不影响用户按照自己的意愿去加载类,又可以保证新写出来的类加载器是符合双亲委派规则的。

双亲委派模型的第二次"被破坏"是由这个模型自身的缺陷导致的,双亲委派很好地解决了各个类 加载器协作时基础类型的一致性问题(越基础的类由越上层的加载器进行加载),基础类型之所以被 称为"基础",是因为它们总是作为被用户代码继承、调用的API存在,但程序设计往往没有绝对不变 的完美规则,如果有基础类型又要调用回用户的代码,那该怎么办呢?

这并非是不可能出现的事情,一个典型的例子便是JNDI服务,JNDI现在已经是Java的标准服务, 它的代码由启动类加载器来完成加载(在JDK 1.3时加入到rt.jar的),肯定属于Java中很基础的类型 了。但JNDI存在的目的就是对资源进行查找和集中管理,它需要调用由其他厂商实现并部署在应用程 序的ClassPath下的JNDI服务提供者接口(Service Provider Interface,SPI)的代码,现在问题来了,启 动类加载器是绝不可能认识、加载这些代码的,那该怎么办?

为了解决这个困境,Java的设计团队只好引入了一个不太优雅的设计:线程上下文类加载器 (Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContext-ClassLoader()方 法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内 都没有设置过的话,那这个类加载器默认就是应用程序类加载器。

有了线程上下文类加载器,程序就可以做一些"舞弊"的事情了。JNDI服务使用这个线程上下文类 加载器去加载所需的SPI服务代码,这是一种父类加载器去请求子类加载器完成类加载的行为,这种行 为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性 原则,但也是无可奈何的事情。Java中涉及SPI的加载基本上都采用这种方式来完成,例如JNDI、 JDBC、JCE、JAXB和JBI等。不过,当SPI的服务提供者多于一个的时候,代码就只能根据具体提供 者的类型来硬编码判断,为了消除这种极不优雅的实现方式,在JDK 6时,JDK提供了 java.util.ServiceLoader类,以META-INF/services中的配置信息,辅以责任链模式,这才算是给SPI的加 载提供了一种相对合理的解决方案。

双亲委派模型的第三次"被破坏"是由于用户对程序动态性的追求而导致的,这里所说的"动态 性"指的是一些非常"热"门的名词:代码热替换(Hot Swap)、模块热部署(Hot Deployment)等。说 白了就是希望Java应用程序能像我们的电脑外设那样,接上鼠标、U盘,不用重启机器就能立即使用, 鼠标有问题或要升级就换个鼠标,不用关机也不用重启。对于个人电脑来说,重启一次其实没有什么 大不了的,但对于一些生产系统来说,关机重启一次可能就要被列为生产事故,这种情况下热部署就 对软件开发者,尤其是大型系统或企业级软件开发者具有很大的吸引力。

7.5 Java模块化系统

在JDK 9中引入的Java模块化系统(Java Platform Module System,JPMS)是对Java技术的一次重 要升级,为了能够实现模块化的关键目标------可配置的封装隔离机制,Java虚拟机对类加载架构也做 出了相应的变动调整,才使模块化系统得以顺利地运作。JDK 9的模块不仅仅像之前的JAR包那样只是 简单地充当代码的容器,除了代码外,Java的模块定义还包含以下内容:

  • 依赖其他模块的列表。
  • 导出的包列表,即其他模块可以使用的列表。
  • 开放的包列表,即其他模块可反射访问模块的列表。
  • 使用的服务列表。
  • 提供服务的实现列表。
7.5.1 模块的兼容性

为了使可配置的封装隔离机制能够兼容传统的类路径查找机制,JDK 9提出了与"类路 径"(ClassPath)相对应的"模块路径"(ModulePath)的概念。简单来说,就是某个类库到底是模块还 是传统的JAR包,只取决于它存放在哪种路径上。只要是放在类路径上的JAR文件,无论其中是否包 含模块化信息(是否包含了module-info.class文件),它都会被当作传统的JAR包来对待;相应地,只 要放在模块路径上的JAR文件,即使没有使用JMOD后缀,甚至说其中并不包含module-info.class文 件,它也仍然会被当作一个模块来对待。

模块化系统将按照以下规则来保证使用传统类路径依赖的Java程序可以不经修改地直接运行在 JDK 9及以后的Java版本上,即使这些版本的JDK已经使用模块来封装了Java SE的标准类库,模块化系 统的这套规则也仍然保证了传统程序可以访问到所有标准类库模块中导出的包

  • JAR文件在类路径的访问规则:所有类路径下的JAR文件及其他资源文件,都被视为自动打包在 一个匿名模块(Unnamed Module)里,这个匿名模块几乎是没有任何隔离的,它可以看到和使用类路 径上所有的包、JDK系统模块中所有的导出包,以及模块路径上所有模块中导出的包
  • 模块在模块路径的访问规则:模块路径下的具名模块(Named Module)只能访问到它依赖定义 中列明依赖的模块和包,匿名模块里所有的内容对具名模块来说都是不可见的,即具名模块看不见传 统JAR包的内容。
  • JAR文件在模块路径的访问规则:如果把一个传统的、不包含模块定义的JAR文件放置到模块路 径中,它就会变成一个自动模块(Automatic Module)。尽管不包含module-info.class,但自动模块将 默认依赖于整个模块路径中的所有模块,因此可以访问到所有模块导出的包,自动模块也默认导出自 己所有的包。

以上3条规则保证了即使Java应用依然使用传统的类路径,升级到JDK 9对应用来说几乎(类加载 器上的变动还是可能会导致少许可见的影响,将在下节介绍)不会有任何感觉,项目也不需要专门为 了升级JDK版本而去把传统JAR包升级成模块。

7.5.2 模块化下的类加载器

** **首先,是扩展类加载器(Extension Class Loader)被平台类加载器(Platform Class Loader)取代。 这其实是一个很顺理成章的变动,既然整个JDK都基于模块化进行构建(原来的rt.jar和tools.jar被拆分 成数十个JMOD文件),其中的Java类库就已天然地满足了可扩展的需求,那自然无须再保留 <JAVA_HOME>\lib\ext目录,此前使用这个目录或者java.ext.dirs系统变量来扩展JDK功能的机制已经没 有继续存在的价值了,用来加载这部分类库的扩展类加载器也完成了它的历史使命。类似地,在新版 的JDK中也取消了<JAVA_HOME>\jre目录,因为随时可以组合构建出程序运行所需的JRE来,譬如假 设我们只使用java.base模块中的类型,那么随时可以通过以下命令打包出一个"JRE":

plain 复制代码
jlink -p $JAVA_HOME/jmods --add-modules java.base --output jre

其次,平台类加载器和应用程序类加载器都不再派生自java.net.URLClassLoader,如果有程序直接 依赖了这种继承关系,或者依赖了URLClassLoader类的特定方法,那代码很可能会在JDK 9及更高版 本的JDK中崩溃。现在启动类加载器、平台类加载器、应用程序类加载器全都继承于 jdk.internal.loader.BuiltinClassLoader,在BuiltinClassLoader中实现了新的模块化架构下类如何从模块中 加载的逻辑,以及模块中资源可访问性的处理。两者的前后变化如图7-5和7-6所示。

另外,读者可能已经注意到图7-6中有"BootClassLoader"存在,启动类加载器现在是在Java虚拟机 内部和Java类库共同协作实现的类加载器,尽管有了BootClassLoader这样的Java类,但为了与之前的代 码保持兼容,所有在获取启动类加载器的场景(譬如Object.class.getClassLoader())中仍然会返回null来 代替,而不会得到BootClassLoader的实例。

最后,JDK 9中虽然仍然维持着三层类加载器和双亲委派的架构,但类加载的委派关系也发生了 变动。当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能 够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器 完成加载,也许这可以算是对双亲委派的第四次破坏。在JDK 9以后的三层类加载器的架构如图7-7所 示,请读者对照图7-2进行比较。

第8章 虚拟机字节码执行引擎

执行引擎是Java虚拟机核心的组成部分之一。"虚拟机"是一个相对于"物理机"的概念,这两种机 器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层 面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执 行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。

8.2 运行时栈帧结构

Java虚拟机以方法作为最基本的执行单元,"栈帧"(Stack Frame)则是用于支持虚拟机进行方法 调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)[1]的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息,如 果读者认真阅读过第6章,应该能从Class文件格式的方法表中找到以上大多数概念的静态对照物。每 一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息在编译Java程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算 出来,并且写入到方法表的Code属性之中[2]。换言之,一个栈帧需要分配多少内存,并不会受到程序 运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。

一个线程中的方法调用链可能会很长,以Java程序的角度来看,同一时刻、同一条线程里面,在 调用堆栈的所有方法都同时处于执行状态。而对于执行引擎来讲,在活动线程中,只有位于栈顶的方 法才是在运行的,只有位于栈顶的栈帧才是生效的,其被称为"当前栈帧"(Current Stack Frame),与 这个栈帧所关联的方法被称为"当前方法"(Current Method)。执行引擎所运行的所有字节码指令都只 针对当前栈帧进行操作,在概念模型上,典型的栈帧结构如图8-1所示。

图8-1所示的就是虚拟机栈和栈帧的总体结构,接下来,我们将会详细了解栈帧中的局部变量表、 操作数栈、动态连接、方法返回地址等各个部分的作用和数据结构。

8.2.1 局部变量表

局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义 的局部变量。在Java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方 法所需分配的局部变量表的最大容量

局部变量表的容量以变量槽(Variable Slot)为最小单位,《Java虚拟机规范》中并没有明确指出 一个变量槽应占用的内存空间大小,只是很有导向性地说到每个变量槽都应该能存放一个boolean、 byte、char、short、int、float、reference或returnAddress类型的数据,这8种数据类型,都可以使用32位 或更小的物理内存来存储,但这种描述与明确指出"每个变量槽应占用32位长度的内存空间"是有本质 差别的,它允许变量槽的长度可以随着处理器、操作系统或虚拟机实现的不同而发生变化,保证了即 使在64位虚拟机中使用了64位的物理内存空间去实现一个变量槽,虚拟机仍要使用对齐和补白的手段 让变量槽在外观上看起来与32位虚拟机中的一致。

既然前面提到了Java虚拟机的数据类型,在此对它们再简单介绍一下。一个变量槽可以存放一个 32位以内的数据类型,Java中占用不超过32位存储空间的数据类型有boolean、byte、char、short、int、 float、reference[1]和returnAddress这8种类型。

Java虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的变 量槽数量。如果访问的是32位数据类型的变量,索引N就代表了使用第N个变量槽,如果访问的是64位 数据类型的变量,则说明会同时使用第N和N+1两个变量槽。对于两个相邻的共同存放一个64位数据 的两个变量槽,虚拟机不允许采用任何方式单独访问其中的某一个,《Java虚拟机规范》中明确要求 了如果遇到进行这种操作的字节码序列,虚拟机就应该在类加载的校验阶段中抛出异常。

当一个方法被调用时,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程, 即实参到形参的传递。如果执行的是实例方法(没有被static修饰的方法),那局部变量表中第0位索 引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字"this"来访问到这个隐 含的参数。其余参数则按照参数表顺序排列,占用从1开始的局部变量槽,参数表分配完毕后,再根据 方法体内部定义的变量顺序和作用域分配其余的变量槽。

为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变 量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用 域,那这个变量对应的变量槽就可以交给其他变量来重用。不过,这样的设计除了节省栈帧空间以 外,还会伴随有少量额外的副作用,例如在某些情况下变量槽的复用会直接影响到系统的垃圾收集行 为,请看代码清单8-1、代码清单8-2和代码清单8-3的3个演示。

java 复制代码
public static void main(String[] args)() {
    byte[] placeholder = new byte[64 * 1024 * 1024];
    System.gc();
}

在虚拟机运行参数中加上"-verbose:gc"来看看垃圾收集的过程 . 发现在System.gc()运行后并没有回收 掉这64MB的内存

java 复制代码
public static void main(String[] args)() {
    {
        byte[] placeholder = new byte[64 * 1024 * 1024];
    }
    System.gc();
 }

加入了花括号之后,placeholder的作用域被限制在花括号以内,从代码逻辑上讲,在执行 System.gc()的时候,placeholder已经不可能再被访问了,但执行这段程序,会发现运行结果如下,还是 有64MB的内存没有被回收掉

java 复制代码
public static void main(String[] args)() {
    {
        byte[] placeholder = new byte[64 * 1024 * 1024];
    }
    int a = 0;
    System.gc();
 }

但运行一下程序,却发现这次内存真的被正确回收了

代码清单8-1至8-3中,placeholder能否被回收的根本原因就是:局部变量表中的变量槽是否还存有 关于placeholder数组对象的引用。第一次修改中,代码虽然已经离开了placeholder的作用域,但在此之 后,再没有发生过任何对局部变量表的读写操作,placeholder原本所占用的变量槽还没有被其他变量 所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联。这种关联没有被及时打断, 绝大部分情况下影响都很轻微。但如果遇到一个方法,其后面的代码有一些耗时很长的操作,而前面 又定义了占用了大量内存但实际上已经不会再使用的变量,手动将其设置为null值(用来代替那句int a=0,把变量对应的局部变量槽清空)便不见得是一个绝对无意义的操作,这种操作可以作为一种在极 特殊情形(对象占用内存大、此方法的栈帧长时间不能被回收、方法调用次数达不到即时编译器的编 译条件)下的"奇技"来使用。Java语言的一本非常著名的书籍《Practical Java》中将把"不使用的对象 应手动赋值为null"作为一条推荐的编码规则(笔者并不认同这条规则),但是并没有解释具体原因, 很长时间里都有读者对这条规则感到疑惑。

8.2.2 操作数栈

操作数栈(Operand Stack)也常被称为操作栈,它是一个后入先出(Last In First Out,LIFO) 栈。同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项 之中。操作数栈的每一个元素都可以是包括long和double在内的任意Java数据类型。32位数据类型所占 的栈容量为1,64位数据类型所占的栈容量为2。Javac编译器的数据流分析工作保证了在方法执行的任 何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。

另外在概念模型中,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但是在 大多虚拟机的实现里都会进行一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数 栈与上面栈帧的部分局部变量表重叠在一起,这样做不仅节约了一些空间,更重要的是在进行方法调 用时就可以直接共用一部分数据,无须进行额外的参数复制传递了,重叠的过程如图8-2所示。

Java虚拟机的解释执行引擎被称为"基于栈的执行引擎",里面的"栈"就是操作数栈。后文会对基 于栈的代码执行过程进行更详细的讲解,介绍它与更常见的基于寄存器的执行引擎有哪些差别。

8.2.3 动态连接

每个栈帧都包含一个指向运行时常量池[1]中该栈帧所属方法的引用,持有这个引用是为了支持方 法调用过程中的动态连接(Dynamic Linking)。通过第6章的讲解,我们知道Class文件的常量池中存 有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。

  • 这些符号 引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析
  • 另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。
8.2.4 方法返回地址

方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的 局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值 以指向方法调用指令后面的一条指令等。笔者这里写的"可能"是由于这是基于概念模型的讨论,只有 具体到某一款Java虚拟机实现,会执行哪些操作才能确定下来。

8.3 方法调用

方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本 (即调用哪一个方法),暂时还未涉及方法内部的具体运行过程。在程序运行时,进行方法调用是最 普遍、最频繁的操作之一,但第7章中已经讲过,Class文件的编译过程中不包含传统程序语言编译的 连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局 中的入口地址(也就是之前说的直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使 得Java方法调用过程变得相对复杂,某些调用需要在类加载期间,甚至到运行期间才能确定目标方法 的直接引用。

8.3.1 解析

承接前面关于方法调用的话题,所有方法调用的目标方法在Class文件里面都是一个常量池中的符 号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能够成立的前 提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不 可改变的。换句话说,调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来。这类方法 的调用被称为解析(Resolution)

在Java语言中符合"编译期可知,运行期不可变"这个要求的方法,主要有静态方法和私有方法两 大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通 过继承或别的方式重写出其他版本,因此它们都适合在类加载阶段进行解析。

调用不同类型的方法,字节码指令集里设计了不同的指令。在Java虚拟机支持以下5条方法调用字 节码指令,分别是:

  • invokestatic。用于调用静态方法。
  • invokespecial。用于调用实例构造器()方法、私有方法和父类中的方法。
  • invokevirtual。用于调用所有的虚方法。
  • invokeinterface。用于调用接口方法,会在运行时再确定一个实现该接口的对象。
  • invokedynamic。先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面4 条调用指令,分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户设定的引 导方法来决定的。

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本, Java语言里符合这个条件的方法共有静态方法、私有方法、实例构造器、父类方法4种,再加上被final 修饰的方法 (尽管它使用invokevirtual指令调用),这5种方法调用会在类加载的时候就可以把符号引 用解析为该方法的直接引用。这些方法统称为"非虚方法"(Non-Virtual Method),与之相反,其他方 法就被称为"虚方法"(Virtual Method)。

解析调用一定是个静态的过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号 引用全部转变为明确的直接引用,不必延迟到运行期再去完成。而另一种主要的方法调用形式:分派 (Dispatch)调用则要复杂许多,它可能是静态的也可能是动态的,按照分派依据的宗量数可分为单 分派和多分派[1]。这两类分派方式两两组合就构成了静态单分派、静态多分派、动态单分派、动态多 分派4种分派组合情况,下面我们来看看虚拟机中的方法分派是如何进行的。

8.3.2 分派

众所周知,Java是一门面向对象的程序语言,因为Java具备面向对象的3个基本特征:继承、封装 和多态。**本节讲解的分派调用过程将会揭示多态性特征的一些最基本的体现,如"重载"和"重写"在 Java虚拟机之中是如何实现的 **

1.静态分派

在开始讲解静态分派[1]前,笔者先声明一点,"分派"(Dispatch)这个词本身就具有动态性,一 般不应用在静态语境之中,这部分原本在英文原版的《Java虚拟机规范》和《Java语言规范》里的说法 都是"Method Overload Resolution",即应该归入8.2节的"解析"里去讲解,但部分其他外文资料和国内 翻译的许多中文资料都将这种行为称为"静态分派",所以笔者在此特别说明一下,以免读者阅读英文 资料时遇到这两种说法产生疑惑。

为了解释静态分派和重载(Overload),笔者准备了一段经常出现在面试题中的程序代码,读者 不妨先看一遍,想一下程序的输出结果是什么。后面的话题将围绕这个类的方法来编写重载代码,以 分析虚拟机和编译器确定方法版本的过程。程序如代码清单8-6所示。

java 复制代码
package org.fenixsoft.polymorphic;
 /**
 * 方法静态分派演示
 * @author zzm
 */
 public class StaticDispatch {
    static abstract class Human {
    }
    static class Man extends Human {
    }
    static class Woman extends Human {
    }
    public void sayHello(Human guy) {
        System.out.println("hello,guy!");
    }
    public void sayHello(Man guy) {
        System.out.println("hello,gentleman!");
    }
    public void sayHello(Woman guy) {
        System.out.println("hello,lady!");
    }
    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        StaticDispatch sr = new StaticDispatch();
        sr.sayHello(man);
        sr.sayHello(woman);
    }
 }
plain 复制代码
hello,guy!
hello,guy!

代码清单8-6中的代码实际上是在考验阅读者对重载的理解程度,相信对Java稍有经验的程序员看 完程序后都能得出正确的运行结果,但为什么虚拟机会选择执行参数类型为Human的重载版本呢?在 解决这个问题之前,我们先通过如下代码来定义两个关键概念:

java 复制代码
Human man = new Man();

我们把上面代码中的"Human"称为变量的"静态类型"(Static Type),或者叫"外观类 型"(Apparent Type),后面的"Man"则被称为变量的"实际类型"(Actual Type)或者叫"运行时类 型"(Runtime Type)。静态类型和实际类型在程序中都可能会发生变化,区别是静态类型的变化仅仅 在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类 型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。笔 者猜想上面这段话读者大概会不太好理解,那不妨通过一段实际例子来解释,譬如有下面的代码:

java 复制代码
// 实际类型变化
Human human = (new Random()).nextBoolean() ? new Man() : new Woman();
 // 静态类型变化
sr.sayHello((Man) human)
 sr.sayHello((Woman) human)

对象human的实际类型是可变的,编译期间它完全是个"薛定谔的人",到底是Man还是Woman,必 须等到程序运行到这行的时候才能确定。而human的静态类型是Human,也可以在使用时(如 sayHello()方法中的强制转型)临时改变这个类型,但这个改变仍是在编译期是可知的,两次sayHello() 方法的调用,在编译期完全可以明确转型的是Man还是Woman。

解释清楚了静态类型与实际类型的概念,我们就把话题再转回到代码清单8-6的样例代码中。 main()里面的两次sayHello()方法调用,在方法接收者已经确定是对象"sr"的前提下,使用哪个重载版 本,就完全取决于传入参数的数量和数据类型。代码中故意定义了两个静态类型相同,而实际类型不 同的变量,但虚拟机(或者准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为 判定依据的。由于静态类型在编译期可知,所以在编译阶段,Javac编译器就根据参数的静态类型决定 了会使用哪个重载版本,因此选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到 main()方法里的两条invokevirtual指令的参数中

所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用表 现就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行 的,这点也是为何一些资料选择把它归入"解析"而不是"分派"的原因。

java 复制代码
package org.fenixsoft.polymorphic;
 public class Overload {
    public static void sayHello(Object arg) {
        System.out.println("hello Object");
    }
    public static void sayHello(int arg) {
        System.out.println("hello int");
    }
    public static void sayHello(long arg) {
        System.out.println("hello long");
    }
    public static void sayHello(Character arg) {
        System.out.println("hello Character");
    }
    public static void sayHello(char arg) {
        System.out.println("hello char");
    }
    public static void sayHello(char... arg) {
        System.out.println("hello char ...");
    }
    public static void sayHello(Serializable arg) {
        System.out.println("hello Serializable");
    }
    public static void main(String[] args) {
        sayHello('a');
    }
 }

变长参数的重载优先级是最低的

要注意的是,有一些 在单个参数中能成立的自动转型,如char转型为int,在变长参数中是不成立的[2]。

2.动态分派

了解了静态分派,我们接下来看一下Java语言里动态分派的实现过程,它与Java语言多态性的另外 一个重要体现[3]------重写(Override)有着很密切的关联。我们还是用前面的Man和Woman一起 sayHello的例子来讲解动态分派,请看代码清单8-8中所示的代码。

java 复制代码
package org.fenixsoft.polymorphic;
 /**
 * 方法动态分派演示
 * @author zzm
 */
 public class DynamicDispatch {
    static abstract class Human {
        protected abstract void sayHello();
    }
    static class Man extends Human {
        @Override
        protected void sayHello() {
            System.out.println("man say hello");
        }
    }
    static class Woman extends Human {
        @Override
        protected void sayHello() {
            System.out.println("woman say hello");
        }
    }
    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        man.sayHello();
        woman.sayHello();
        man = new Woman();
        man.sayHello();
    }
}

Java虚拟机是 如何根据实际类型来分派方法执行版本的呢?我们使用javap命令输出这段代码的字节码,尝试从中寻 找答案,输出结果如代码清单8-9所示。

java 复制代码
public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: new           #2                  // class chapter8/DynamicDispatch$Man
         3: dup
         4: invokespecial #3                  // Method chapter8/DynamicDispatch$Man."<init>":()V
         7: astore_1
         8: new           #4                  // class chapter8/DynamicDispatch$Woman
        11: dup
        12: invokespecial #5                  // Method chapter8/DynamicDispatch$Woman."<init>":()V
        15: astore_2
        16: aload_1
        17: invokevirtual #6                  // Method chapter8/DynamicDispatch$Human.sayHello:()V
        20: aload_2
        21: invokevirtual #6                  // Method chapter8/DynamicDispatch$Human.sayHello:()V
        24: new           #4                  // class chapter8/DynamicDispatch$Woman
        27: dup
        28: invokespecial #5                  // Method chapter8/DynamicDispatch$Woman."<init>":()V
        31: astore_1
        32: aload_1
        33: invokevirtual #6                  // Method chapter8/DynamicDispatch$Human.sayHello:()V
        36: return

0~15行的字节码是准备动作,作用是建立man和woman的内存空间、调用Man和Woman类型的实 例构造器,将这两个实例的引用存放在第1、2个局部变量表的变量槽中,这些动作实际对应了Java源 码中的这两行:

java 复制代码
Human man = new Man();
Human woman = new Woman();

接下来的16~21行是关键部分,16和20行的aload指令分别把刚刚创建的两个对象的引用压到栈 顶,这两个对象是将要执行的sayHello()方法的所有者,称为接收者(Receiver);17和21行是方法调 用指令,这两条调用指令单从字节码角度来看,无论是指令(都是invokevirtual)还是参数(都是常量 池中第22项的常量,注释显示了这个常量是Human.sayHello()的符号引用)都完全一样,但是这两句指 令最终执行的目标方法并不相同。那看来解决问题的关键还必须从invokevirtual指令本身入手,要弄清 楚它是如何确定调用方法版本、如何实现多态查找来着手分析才行。根据《Java虚拟机规范》, invokevirtual指令的运行时解析过程[4]大致分为以下几步:

  • 1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
  • 2)如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果 通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
  • 3)否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程
  • 4)如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

正是因为invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的 invokevirtual指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接收者 的实际类型来选择方法版本,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实 际类型确定方法执行版本的分派过程称为动态分派。

** 既然这种多态性的根源在于虚方法调用指令invokevirtual的执行逻辑,那自然我们得出的结论就只 会对方法有效,对字段是无效的,因为字段不使用这条指令**。事实上,在Java里面只有虚方法存在, 字段永远不可能是虚的,换句话说,字段永远不参与多态,哪个类的方法访问某个名字的字段时,该 名字指的就是这个类能看到的那个字段。当子类声明了与父类同名的字段时,虽然在子类的内存中两 个字段都会存在,但是子类的字段会遮蔽父类的同名字段。为了加深理解,笔者又编撰了一份"劣质面 试题式"的代码片段,请阅读代码清单8-10,思考运行后会输出什么结果。

java 复制代码
package org.fenixsoft.polymorphic;
 /**
 * 字段不参与多态
 * @author zzm
 */
 public class FieldHasNoPolymorphic {
    static class Father {
        public int money = 1;
        public Father() {
            money = 2;
            showMeTheMoney();
        }
        public void showMeTheMoney() {
            System.out.println("I am Father, i have $" + money);
        }
    }
    static class Son extends Father {
        public int money = 3;
        public Son() {
            money = 4;
            showMeTheMoney();
        }
        public void showMeTheMoney() {
            System.out.println("I am Son,  i have $" + money);
        }
    }
    public static void main(String[] args) {
        Father gay = new Son();
        System.out.println("This gay has $" + gay.money);
    }
 }

运行后输出结果为:

plain 复制代码
I am Son, i have $0
I am Son, i have $4
This gay has $2

3.单分派与多分派

方法的接收者与方法的参数统称为方法的宗量,这个定义最早应该来源于著名的《Java与模式》 一书。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对 目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

java 复制代码
/**
 * 单分派、多分派演示
 * @author zzm
 */
public class Dispatch {
    static class QQ {}
    static class _360 {}
    public static class Father {
        public void hardChoice(QQ arg) {
            System.out.println("father choose qq");
        }
        public void hardChoice(_360 arg) {
            System.out.println("father choose 360");
        }
    }
    public static class Son extends Father {
        public void hardChoice(QQ arg) {
            System.out.println("son choose qq");
        }
        public void hardChoice(_360 arg) {
            System.out.println("son choose 360");
        }
    }
    public static void main(String[] args) {
        Father father = new Father();
        Father son = new Son();
        father.hardChoice(new _360());
        son.hardChoice(new QQ());
    }
}

运行结果

plain 复制代码
father choose 360
son choose qq

在main()里调用了两次hardChoice()方法,这两次hardChoice()方法的选择结果在程序输出中已经显 示得很清楚了。我们关注的首先是编译阶段中编译器的选择过程,也就是静态分派的过程。这时候选 择目标方法的依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360。这次选择结 果的最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向 Father::hardChoice(360)及Father::hardChoice(QQ)方法的符号引用。因为是根据两个宗量进行选择,所以 Java语言的静态分派属于多分派类型

再看看运行阶段中虚拟机的选择,也就是动态分派的过程。在执行"son.hardChoice(new QQ())"这 行代码时,更准确地说,是在执行这行代码所对应的invokevirtual指令时,由于编译期已经决定目标方 法的签名必须为hardChoice(QQ),虚拟机此时不会关心传递过来的参数"QQ"到底是"腾讯QQ"还是"奇 瑞QQ",因为这时候参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯一可以影响虚 拟机选择的因素只有该方法的接受者的实际类型是Father还是Son。因为只有一个宗量作为选择依据, 所以Java语言的动态分派属于单分派类型

根据上述论证的结果,我们可以总结一句:如今(直至本书编写的Java 12和预览版的Java 13)的 Java语言是一门静态多分派、动态单分派的语言。强调"如今的Java语言"是因为这个结论未必会恒久不 变,C#在3.0及之前的版本与Java一样是动态单分派语言,但在C#4.0中引入了dynamic类型后,就可以 很方便地实现动态多分派。JDK 10时Java语法中新出现var关键字,但请读者切勿将其与C#中的 dynamic类型混淆,事实上Java的var与C#的var才是相对应的特性,它们与dynamic有着本质的区别:var 是在编译时根据声明语句中赋值符右侧的表达式类型来静态地推断类型,这本质是一种语法糖;而 dynamic在编译时完全不关心类型是什么,等到运行的时候再进行类型判断。Java语言中与C#的 dynamic类型功能相对接近(只是接近,并不是对等的)的应该是在JDK 9时通过JEP 276引入的 jdk.dynalink模块[6],使用jdk.dynalink可以实现在表达式中使用动态类型,Javac编译器会将这些动态类 型的操作翻译为invokedynamic指令的调用点。

4.虚拟机动态分派的实现

前面介绍的分派过程,作为对Java虚拟机概念模型的解释基本上已经足够了,它已经解决了虚拟 机在分派中"会做什么"这个问题。但如果问Java虚拟机"具体如何做到"的,答案则可能因各种虚拟机 的实现不同会有些差别。

动态分派是执行非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在接收者类型的 方法元数据中搜索合适的目标方法,因此,Java虚拟机实现基于执行性能的考虑,真正运行时一般不 会如此频繁地去反复搜索类型元数据。面对这种情况,一种基础而且常见的优化手段是为类型在方法 区中建立一个虚方法表(Virtual Method Table,也称为vtable ,与此对应的,在invokeinterface执行时也 会用到接口方法表------Interface Method Table,简称itable),使用虚方法表索引来代替元数据查找以 提高性能[8]。我们先看看代码清单8-11所对应的虚方法表结构示例,如图8-3所示。

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方 法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口如果子类中重写了 这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。在图8-3中,Son重写 了来自Father的全部方法,因此Son的方法表没有指向Father类型数据的箭头。但是Son和Father都没有 重写来自Object的方法,所以它们的方法表中所有从Object继承来的方法都指向了Object的数据类型。

** **为了程序实现方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序 号,这样当类型变换时,仅需要变更查找的虚方法表,就可以从不同的虚方法表中按索引转换出所需 的入口地址。虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把 该类的虚方法表也一同初始化完毕。

上文中笔者提到了查虚方法表是分派调用的一种优化手段,由于Java对象里面的方法默认(即不 使用final修饰)就是虚方法,虚拟机除了使用虚方法表之外,为了进一步提高性能,还会使用类型继 承关系分析(Class Hierarchy Analysis,CHA)、守护内联(Guarded Inlining)、内联缓存(Inline Cache)等多种非稳定的激进优化来争取更大的性能空间,关于这几种优化技术的原理和运作过程,读 者可以参考第11章中的相关内容。

8.4 动态类型语言支持

Java虚拟机的字节码指令集的数量自从Sun公司的第一款Java虚拟机问世至今,二十余年间只新增 过一条指令,它就是随着JDK 7的发布的字节码首位新成员------invokedynamic指令。这条新增加的指 令是JDK 7的项目目标:实现动态类型语言(Dynamically Typed Language)支持而进行的改进之一, 也是为JDK 8里可以顺利实现Lambda表达式而做的技术储备。

8.4.1 动态类型语言

何谓动态类型语言[1]?动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期进行的 ,满足这个特征的语言有很多,常用的包括:APL、Clojure、Erlang、Groovy、 JavaScript、Lisp、Lua、PHP、Prolog、Python、Ruby、Smalltalk、Tcl,等等。那相对地,在编译期就 进行类型检查过程的语言,譬如C++和Java等就是最常用的静态类型语言

如果读者觉得上面的定义过于概念化,那我们不妨通过两个例子以最浅显的方式来说明什么是"类 型检查"和什么叫"在编译期还是在运行期进行"。首先看下面这段简单的Java代码,思考一下它是否能 正常编译和运行?

java 复制代码
public static void main(String[] args) {
    int[][][] array = new int[1][0][-1];
}

上面这段Java代码能够正常编译,但运行的时候会出现NegativeArraySizeException异常。在《Java 虚拟机规范》中明确规定了NegativeArraySizeException是一个运行时异常(Runtime Exception),通俗 一点说,运行时异常就是指只要代码不执行到这一行就不会出现问题。与运行时异常相对应的概念是 连接时异常,例如很常见的NoClassDefFoundError便属于连接时异常,即使导致连接时异常的代码放 在一条根本无法被执行到的路径分支上,类加载时(第7章解释过Java的连接过程不在编译阶段,而在 类加载阶段)也照样会抛出异常。

不过,在C语言里,语义相同的代码就会在编译期就直接报错,而不是等到运行时才出现异常:

java 复制代码
int main(void) {
 int i[1][0][-1];   // GCC拒绝编译,报"size of array is negative"
 return 0;
}

由此看来,一门语言的哪一种检查行为要在运行期进行,哪一种检查要在编译期进行并没有什么 必然的因果逻辑关系,关键是在语言规范中人为设立的约定。

解答了什么是"连接时、运行时",笔者再举一个例子来解释什么是"类型检查",例如下面这一句 再普通不过的代码:

java 复制代码
obj.println("hello world");

虽然正在阅读本书的每一位读者都能看懂这行代码要做什么,但对于计算机来讲,这一行"没头没 尾"的代码是无法执行的,它需要一个具体的上下文中(譬如程序语言是什么、obj是什么类型)才有 讨论的意义。

现在先假设这行代码是在Java语言中,并且变量obj的静态类型为java.io.PrintStream,那变量obj的 实际类型就必须是PrintStream的子类(实现了PrintStream接口的类)才是合法的。否则,哪怕obj属于 一个确实包含有println(String)方法相同签名方法的类型,但只要它与PrintStream接口没有继承关系,代 码依然不可能运行------因为类型检查不合法。

但是相同的代码在ECMAScript(JavaScript)中情况则不一样,无论obj具体是何种类型,无论其 继承关系如何,只要这种类型的方法定义中确实包含有println(String)方法,能够找到相同签名的方 法,调用便可成功。

产生这种差别产生的根本原因是Java 语言在编译期间 却已将println(String)方法完整的符号引用 (本 例中为一项CONSTANT_InterfaceMethodref_info常量)生成出来,作为方法调用指令的参数存储到 Class文件中,例如下面这个样子:

java 复制代码
invokevirtual #4; //Method java/io/PrintStream.println:(Ljava/lang/String;)V

这个符号引用包含了该方法定义在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方 法返回值等信息,通过这个符号引用,Java虚拟机就可以翻译出该方法的直接引用 。而ECMAScript等 动态类型语言与Java有一个核心的差异就是变量obj本身并没有类型,变量obj的值才具有类型,所以编 译器在编译时最多只能确定方法名称、参数、返回值这些信息,而不会去确定方法所在的具体类型 (即方法接收者不固定)。"变量无类型而变量值才有类型"这个特点也是动态类型语言的一个核心特 征

8.4.2 Java与动态类型

遗憾的是Java虚拟机层面对动态类型语言的支持一直都还有所欠缺,主要表现在方法调用方 面:JDK 7以前的字节码指令集中,4条方法调用指令(invokevirtual、invokespecial、invokestatic、 invokeinterface)的第一个参数都是被调用的方法的符号引用(CONSTANT_Methodref_info或者 CONSTANT_InterfaceMethodref_info常量),前面已经提到过,方法的符号引用在编译时产生,而动 态类型语言只有在运行期才能确定方法的接收者。 。这样,在Java虚拟机上实现的动态类型语言就不得 不使用"曲线救国"的方式(如编译时留个占位符类型,运行时动态生成字节码实现具体类型到占位符 类型的适配)来实现,但这样势必会让动态类型语言实现的复杂度增加,也会带来额外的性能和内存 开销。内存开销是很显而易见的,方法调用产生的那一大堆的动态类就摆在那里。而其中最严重的性 能瓶颈是在于动态类型方法调用时,由于无法确定调用对象的静态类型,而导致的方法内联无法有效 进行。在第11章里我们会讲到方法内联的重要性,它是其他优化措施的基础,也可以说是最重要的一 项优化。尽管也可以想一些办法(譬如调用点缓存)尽量缓解支持动态语言而导致的性能下降,但这 种改善毕竟不是本质的。譬如有类似以下代码:

javascript 复制代码
var arrays = {"abc", new ObjectX(), 123, Dog, Cat, Car..}
for(item in arrays){
  item.sayHello();
}

在动态类型语言下这样的代码是没有问题,但由于在运行时arrays中的元素可以是任意类型,即使 它们的类型中都有sayHello()方法,也肯定无法在编译优化的时候就确定具体sayHello()的代码在哪里, 编译器只能不停编译它所遇见的每一个sayHello()方法,并缓存起来供执行时选择、调用和内联,如果 arrays数组中不同类型的对象很多,就势必会对内联缓存产生很大的压力,缓存的大小总是有限的,类 型信息的不确定性导致了缓存内容不断被失效和更新,先前优化过的方法也可能被不断替换而无法重 复使用。所以这种动态类型方法调用的底层问题终归是应当在Java虚拟机层次上去解决才最合适。因 此,在Java虚拟机层面上提供动态类型的直接支持就成为Java平台发展必须解决的问题,这便是JDK 7 时JSR-292提案中invokedynamic指令以及java.lang.invoke包出现的技术背景。

8.4.3 java.lang.invoke包

JDK 7时新加入的java.lang.invoke包[1]是JSR 292的一个重要组成部分,这个包的主要目的是在之前 单纯依靠符号引用来确定调用的目标方法这条路之外,提供一种新的动态确定目标方法的机制,称 为"方法句柄"(Method Handle)。这个表达听起来也不好懂?那不妨把方法句柄与C/C++中的函数指 针(Function Pointer),或者C#里面的委派(Delegate)互相类比一下来理解。举个例子,如果我们要 实现一个带谓词(谓词就是由外部传入的排序时比较大小的动作)的排序函数,在C/C++中的常用做 法是把谓词定义为函数,用函数指针来把谓词传递到排序方法,像这样:

c 复制代码
void sort(int list[], const int size, int (*compare)(int, int))

但在Java语言中做不到这一点,没有办法单独把一个函数作为参数进行传递。普遍的做法是设计 一个带有compare()方法的Comparator接口,以实现这个接口的对象作为参数,例如Java类库中的 Collections::sort()方法就是这样定义的:

c 复制代码
void sort(List list, Comparator c)

不过,在拥有方法句柄之后,Java语言也可以拥有类似于函数指针或者委托的方法别名这样的工 具了。 代码清单8-12演示了方法句柄的基本用法,无论obj是何种类型(临时定义的ClassA抑或是实现 PrintStream接口的实现类System.out),都可以正确调用到println()方法。

java 复制代码
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;

import static java.lang.invoke.MethodHandles.lookup;

/**
 * JSR 292 MethodHandle基础用法演示
 * @author zzm
 */
public class MethodHandleTest {
    static class ClassA {
        public void println(String s) {
            System.out.println(s);
        }
    }
    public static void main(String[] args) throws Throwable {
        Object obj = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA();
        // 无论obj最终是哪个实现类,下面这句都能正确调用到println方法。
        getPrintlnMH(obj).invokeExact("icyfenix");
    }

    private static MethodHandle getPrintlnMH(Object reveiver) throws Throwable {
        // MethodType:代表"方法类型",包含了方法的返回值(methodType()的第一个参数)和具体参数(methodType() 第二个及以后的参数)。
        MethodType mt = MethodType.methodType(void.class, String.class);
        // lookup()方法来自于MethodHandles.lookup,这句的作用是在指定类中查找符合给定的方法名称、方法类型,并且符合调用权限的方法句柄。
        // 因为这里调用的是一个虚方法,按照Java语言的规则,方法第一个参数是隐式的,代表该方法的接收者,也即this指向的对象,
        // 这个参数以前是放在参数列表中进行传递,现在提供了bindTo()方法来完成这件事情。
        return lookup().findVirtual(reveiver.getClass(), "println", mt).bindTo(reveiver);
    }
}

方法getPrintlnMH()中实际上是模拟了invokevirtual指令的执行过程,只不过它的分派逻辑并非固 化在Class文件的字节码上,而是通过一个由用户设计的Java方法来实现。而这个方法本身的返回值 (MethodHandle对象),可以视为对最终调用方法的一个"引用"。以此为基础,有了MethodHandle就 可以写出类似于C/C++那样的函数声明了:

java 复制代码
void sort(List list, MethodHandle compare)

从上面的例子看来,使用MethodHandle并没有多少困难,不过看完它的用法之后,读者大概就会 产生疑问,相同的事情,用反射不是早就可以实现了吗? 确实,仅站在Java语言的角度看,MethodHandle在使用方法和效果上与Reflection有众多相似之 处。不过,它们也有以下这些区别:

  • Reflection和MethodHandle机制本质上都是在模拟方法调用,但是Reflection是在模拟Java代码层次 的方法调用,而MethodHandle是在模拟字节码层次的方法调用在MethodHandles.Lookup上的3个方法 findStatic()、findVirtual()、findSpecial()正是为了对应于invokestatic、invokevirtual(以及 invokeinterface)和invokespecial这几条字节码指令的执行权限校验行为,而这些底层细节在使用 Reflection API时是不需要关心的。
  • Reflection中的java.lang.reflect.Method对象远比MethodHandle机制中的 java.lang.invoke.MethodHandle对象所包含的信息来得多。前者是方法在Java端的全面映像,包含了方法 的签名、描述符以及方法属性表中各种属性的Java端表示方式,还包含执行权限等的运行期信息。而 后者仅包含执行该方法的相关信息。用开发人员通俗的话来讲,Reflection是重量级,而MethodHandle 是轻量级。
  • 由于MethodHandle是对字节码的方法指令调用的模拟,那理论上虚拟机在这方面做的各种优化 (如方法内联),在MethodHandle上也应当可以采用类似思路去支持(但目前实现还在继续完善 中),而通过反射去调用方法则几乎不可能直接去实施各类调用点优化措施

MethodHandle与Reflection除了上面列举的区别外,最关键的一点还在于去掉前面讨论施加的前 提"仅站在Java语言的角度看"之后:Reflection API的设计目标是只为Java语言服务的,而MethodHandle 则设计为可服务于所有Java虚拟机之上的语言,其中也包括了Java语言而已,而且Java在这里并不是主 角。

8.4.4 invokedynamic指令

某种意义上可以说invokedynamic指令与MethodHandle机制的作用是一样的,都是为了解决原有4 条"invoke*"指令方法分派规则完全固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机 转嫁到具体用户代码之中,让用户(广义的用户,包含其他程序语言的设计者)有更高的自由度。而 且,它们两者的思路也是可类比的,都是为了达成同一个目的,只是一个用上层代码和API来实现, 另一个用字节码和Class中其他属性、常量来完成。因此,如果前面MethodHandle的例子看懂了,相信 读者理解invokedynamic指令并不困难。

每一处含有invokedynamic指令的位置都被称作"动态调用点(Dynamically-Computed Call Site)", 这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是变为JDK 7 时新加入的CONSTANT_InvokeDynamic_info常量,从这个新常量中可以得到3项信息:引导方法 (Bootstrap Method,该方法存放在新增的BootstrapMethods属性中)、方法类型(MethodType)和 名称。引导方法是有固定的参数,并且返回值规定是java.lang.invoke.CallSite对象,这个对象代表了真 正要执行的目标方法调用。根据CONSTANT_InvokeDynamic_info常量中提供的信息,虚拟机可以找到 并且执行引导方法,从而获得一个CallSite对象,最终调用到要执行的目标方法上。我们还是照例不依 赖枯燥的概念描述,改用一个实际例子来解释这个过程吧,如代码清单8-13所示。

8.4.5 实战:掌控方法分派规则

invokedynamic指令与此前4条传统的"invoke*"指令的最大区别就是它的分派逻辑不是由虚拟机决 定的,而是由程序员决定。

在Java程序中,可以通过"super"关键字很方便地调用到父类中的方法,但如果要访问祖类的方法 呢?读者在往下阅读本书提供的解决方案之前,不妨自己思考一下,在JDK 7之前有没有办法解决这 个问题。

java 复制代码
class GrandFather {
    void thinking() {
        System.out.println("i am grandfather");
    }
}
class Father extends GrandFather {
    void thinking() {
        System.out.println("i am father");
    }
}
class Son extends Father {
    void thinking() {
       // 请读者在这里填入适当的代码(不能修改其他地方的代码)
       // 实现调用祖父类的thinking()方法,打印"i am grandfather"
    }
}

在拥有invokedynamic和java.lang.invoke包之前,使用纯粹的Java语言很难处理这个问题(使用ASM 等字节码工具直接生成字节码当然还是可以处理的,但这已经是在字节码而不是Java语言层面来解决 问题了),原因是在Son类的thinking()方法中根本无法获取到一个实际类型是GrandFather的对象引用, 而invokevirtual指令的分派逻辑是固定的,只能按照方法接收者的实际类型进行分派,这个逻辑完全固 化在虚拟机中,程序员无法改变。如果是JDK 7 Update 9之前,使用代码清单8-16中的程序就可以直接 解决该问题。

java 复制代码
class Son extends Father {
    // void thinking() {
    //     // 请读者在这里填入适当的代码(不能修改其他地方的代码)
    //     // 实现调用祖父类的thinking()方法,打印"i am grandfather"
    // }

    void thinking() {
        try {
            MethodType mt = MethodType.methodType(void.class);
            MethodHandle mh = lookup().findSpecial(GrandFather.class,
                    "thinking", mt, getClass());
            mh.invoke(this);
            } catch (Throwable e) {
            }
        }

    public static void main(String[] args) {
        (new Son()).thinking();
    }
}

使用JDK 7 Update 9之前的HotSpot虚拟机运行,会得到如下运行结果:

java 复制代码
i am grandfather

但是这个逻辑在JDK 7 Update 9之后被视作一个潜在的安全性缺陷修正了,原因是必须保证 findSpecial()查找方法版本时受到的访问约束(譬如对访问控制的限制、对参数类型的限制)应与使用 invokespecial指令一样,两者必须保持精确对等,包括在上面的场景中它只能访问到其直接父类中的方 法版本。所以在JDK 7 Update 10修正之后,运行以上代码只能得到如下结果:

java 复制代码
i am father

那在新版本的JDK中,上面的问题是否能够得到解决呢?答案是可以的,如果读者去查看 MethodHandles.Lookup类的代码,将会发现需要进行哪些访问保护,在该API实现时是预留了后门 的。访问保护是通过一个allowedModes的参数来控制,而且这个参数可以被设置成"TRUSTED"来绕开 所有的保护措施。尽管这个参数只是在Java类库本身使用,没有开放给外部设置,但我们通过反射可 以轻易打破这种限制。由此,我们可以把代码清单8-16中子类的thinking()方法修改为如下所示的代码 来解决问题:

java 复制代码
void thinking() {
        try {
            MethodType mt = MethodType.methodType(void.class);
            Field lookupImpl = MethodHandles.Lookup.class.getDeclaredField("IMPL_LOOKUP");
            lookupImpl.setAccessible(true);
            MethodHandle mh = ((MethodHandles.Lookup) lookupImpl.get(null)).findSpecial(GrandFather.class,"thinking", mt, getClass());
            mh.invoke(this);
        } catch (Throwable e) {
        }
    }

(实际运行下来发现也没有解决)

8.5 基于栈的字节码解释执行引擎

关于Java虚拟机是如何调用方法、进行版本选择的内容已经全部讲解完毕,从本节开始,我们来 探讨虚拟机是如何执行方法里面的字节码指令的。概述中曾提到过,许多Java虚拟机的执行引擎在执 行Java代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执 行)两种选择,在本节中,我们将会分析在概念模型下的Java虚拟机解释执行字节码时,其执行引擎 是如何工作的。

8.5.1 解释执行

Java语言经常被人们定位为"解释执行"的语言,在Java初生的JDK 1.0时代,这种定义还算是比较 准确的,但当主流的虚拟机中都包含了即时编译器后,Class文件中的代码到底会被解释执行还是编译 执行,就成了只有虚拟机自己才能准确判断的事。再后来,Java也发展出可以直接生成本地代码的编 译器(如Jaotc、GCJ[1],Excelsior JET),而C/C++语言也出现了通过解释器执行的版本(如 CINT[2]),这时候再笼统地说"解释执行",对于整个Java语言来说就成了几乎是没有意义的概念,只 有确定了谈论对象是某种具体的Java实现版本和执行引擎运行模式时,谈解释执行还是编译执行才会 比较合理确切。

无论是解释还是编译,也无论是物理机还是虚拟机,对于应用程序,机器都不可能如人那样阅 读、理解,然后获得执行能力。大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集 之前,都需要经过图8-4中的各个步骤。如果读者对大学编译原理的相关课程还有印象的话,很容易就 会发现图8-4中下面的那条分支,就是传统编译原理中程序代码到目标机器代码的生成过程;而中间的 那条分支,自然就是解释执行的过程。

如今,基于物理机、Java虚拟机,或者是非Java的其他高级语言虚拟机(HLLVM)的代码执行过 程,大体上都会遵循这种符合现代经典编译原理的思路,在执行前先对程序源码进行词法分析和语法 分析处理,把源码转化为抽象语法树(Abstract Syntax Tree,AST)。对于一门具体语言的实现来说, 词法、语法分析以至后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义 的编译器去实现,这类代表是C/C++语言。也可以选择把其中一部分步骤(如生成抽象语法树之前的 步骤)实现为一个半独立的编译器,这类代表是Java语言。又或者把这些步骤和执行引擎全部集中封 装在一个封闭的黑匣子之中,如大多数的JavaScript执行引擎。

在Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法 树生成线性的字节码指令流的过程。因为这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟 机的内部,所以Java程序的编译就是半独立的实现

8.5.2 基于栈的指令集与基于寄存器的指令集

Javac编译器输出的字节码指令流,基本上[1]是一种基于栈的指令集架构(Instruction Set Architecture,ISA),字节码指令流里面的指令大部分都是零地址指令,它们依赖操作数栈进行工 作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二地址指令 集,如果说得更通俗一些就是现在我们主流PC机中物理硬件直接支持的指令集架构,这些指令依赖寄 存器进行工作。那么,基于栈的指令集与基于寄存器的指令集这两者之间有什么不同呢?

举个最简单的例子,分别使用这两种指令集去计算"1+1"的结果,基于栈的指令集会是这样子的:

java 复制代码
iconst_1
iconst_1
iadd
istore_0

两条iconst_1指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈、相加,然后把结果 放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个变量槽中。这种指令流中的指令通常都是 不带参数的,使用操作数栈中的数据作为指令的运算输入,指令的运算结果也存储在操作数栈之中。 而如果用基于寄存器的指令集,那程序可能会是这个样子:

java 复制代码
mov  eax, 1
add  eax, 1

mov指令把EAX寄存器的值设为1,然后add指令再把这个值加1,结果就保存在EAX寄存器里面。 这种二地址指令是x86指令集中的主流,每个指令都包含两个单独的输入参数,依赖于寄存器来访问和 存储数据。

基于栈的指令集主要优点是可移植,因为寄存器由硬件直接提供[2],程序直接依赖这些硬件寄存 器则不可避免地要受到硬件的约束。例如现在32位80x86体系的处理器能提供了8个32位的寄存器,而 ARMv6体系的处理器(在智能手机、数码设备中相当流行的一种处理器)则提供了30个32位的通用寄 存器,其中前16个在用户模式中可以使用。如果使用栈架构的指令集,用户程序不会直接用到这些寄 存器,那就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等)放到 寄存器中以获取尽量好的性能,这样实现起来也更简单一些。栈架构的指令集还有一些其他的优点, 如代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编 译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作)等。

栈架构指令集的主要缺点是理论上执行速度相对来说会稍慢一些,所有主流物理机的指令集都是 寄存器架构[3]也从侧面印证了这点。不过这里的执行速度是要局限在解释执行的状态下,如果经过即 时编译器输出成物理机上的汇编指令流,那就与虚拟机采用哪种指令集架构没有什么关系了。

在解释执行时,栈架构指令集的代码虽然紧凑,但是完成相同功能所需的指令数量一般会比寄存 器架构来得更多,因为出栈、入栈操作本身就产生了相当大量的指令。更重要的是栈实现在内存中, 频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。尽管虚 拟机可以采取栈顶缓存的优化方法,把最常用的操作映射到寄存器中避免直接内存访问,但这也只是 优化措施而不是解决本质问题的方法。因此由于指令数量和内存访问的原因,导致了栈架构指令集的 执行速度会相对慢上一点。

8.5.3 基于栈的解释器执行过程
java 复制代码
public int calc() {
    int a = 100;
    int b = 200;
    int c = 300;
    return (a + b) * c;
 }
java 复制代码
public int calc();
    Code:
        Stack=2, Locals=4, Args_size=1
         0:   bipush  100
         2:   istore_1
         3:   sipush  200
         6:   istore_2
         7:   sipush  300
        10:  istore_3
        11:  iload_1
        12:  iload_2
        13:  iadd
        14:  iload_3
        15:  imul
        16:  ireturn
 }

8.6 本章小结

本章中,我们分析了虚拟机在执行代码时,如何找到正确的方法,如何执行方法内的字节码,以 及执行代码时涉及的内存结构。在第6~8章里面,我们针对Java程序是如何存储的、如何载入(创 建)的,以及如何执行的问题,把相关知识系统地介绍了一遍,第9章我们将一起看看这些理论知识在 具体开发之中的典型应用。

第9章 类加载及执行子系统的案例与实战

代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一 大步。

9.1 概述

在Class文件格式与执行引擎这部分里,用户的程序能直接参与的内容并不太多,Class文件以何种 格式存储,类型何时加载、如何连接,以及虚拟机如何执行字节码指令等都是由虚拟机直接控制的行 为,用户程序无法对其进行改变。能通过程序进行操作的,主要是字节码生成与类加载器这两部分的 功能,但仅仅在如何处理这两点上,就已经出现了许多值得欣赏和借鉴的思路,这些思路后来成为许 多常用功能和程序实现的基础。

9.2 案例分析

9.2.1 Tomcat:正统的类加载器架构

主流的Java Web服务器,如Tomcat、Jetty、WebLogic、WebSphere或其他笔者没有列举的服务器, 都实现了自己定义的类加载器,而且一般还都不止一个。因为一个功能健全的Web服务器,都要解决 如下的这些问题:

  • 部署在同一个服务器上的两个Web应用程序所使用的Java类库可以实现相互隔离。这是最基本的 需求,两个不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求每个类库在一个服务 器中只能有一份,服务器应当能够保证两个独立应用程序的类库可以互相独立使用
  • 部署在同一个服务器上的两个Web应用程序所使用的Java类库可以互相共享。这个需求与前面一 点正好相反,但是也很常见,例如用户可能有10个使用Spring组织的应用程序部署在同一台服务器 上,如果把10份Spring分别存放在各个应用程序的隔离目录中,将会是很大的资源浪费------这主要倒 不是浪费磁盘空间的问题,而是指类库在使用时都要被加载到服务器内存,如果类库不能共享,虚拟 机的方法区就会很容易出现过度膨胀的风险。
  • 服务器需要尽可能地保证自身的安全不受部署的Web应用程序影响。目前,有许多主流的Java Web服务器自身也是使用Java语言来实现的。因此服务器本身也有类库依赖的问题,一般来说,基于安 全考虑,服务器所使用的类库应该与应用程序的类库互相独立。
  • 支持JSP应用的Web服务器,十有八九都需要支持HotSwap功能。我们知道JSP文件最终要被编译 成Java的Class文件才能被虚拟机执行,但JSP文件由于其纯文本存储的特性,被运行时修改的概率远大 于第三方类库或程序自己的Class文件。而且ASP、PHP和JSP这些网页应用也把修改后无须重启作为一 个很大的"优势"来看待,因此"主流"的Web服务器都会支持JSP生成类的热替换,当然也有"非主 流"的,如运行在生产模式(Production Mode)下的WebLogic服务器默认就不会处理JSP文件的变化。

由于存在上述问题,在部署Web应用时,单独的一个ClassPath就不能满足需求了,所以各种Web服 务器都不约而同地提供了好几个有着不同含义的ClassPath路径供用户存放第三方类库,这些路径一般 会以"lib"或"classes"命名。被放置到不同路径中的类库 ,具备不同的访问范围和服务对象,通常每一 个目录都会有一个相应的自定义类加载器去加载放置在里面的Java类库。现在笔者就以Tomcat服务 器 [1]为例,与读者一同分析Tomcat具体是如何规划用户类库结构和类加载器的。

在Tomcat目录结构中,可以设置3组目录(/common/*、/server/和/shared/ ,但默认不一定是开放 的,可能只有/lib/目录存在)用于存放Java类库,另外还应该加上Web应用程序自身的"/WEB INF/"目录,一共4组。把Java类库放置在这4组目录中,每一组都有独立的含义,分别是:

  • 放置在/common目录中。类库可被Tomcat和所有的Web应用程序共同使用。
  • 放置在/server目录中。类库可被Tomcat使用,对所有的Web应用程序都不可见。
  • 放置在/shared目录中。类库可被所有的Web应用程序共同使用,但对Tomcat自己不可见。
  • 放置在/WebApp/WEB-INF目录中。类库仅仅可以被该Web应用程序使用,对Tomcat和其他Web应 用程序都不可见。

为了支持这套目录结构,并对目录里面的类库进行加载和隔离,Tomcat自定义了多个类加载器, 这些类加载器按照经典的双亲委派模型来实现,其关系如图9-1所示。

灰色背景的3个类加载器是JDK(以JDK 9之前经典的三层类加载器为例)默认提供的类加载器, 这3个加载器的作用在第7章中已经介绍过了。而Common类加载器、Catalina类加载器(也称为Server类 加载器)、Shared类加载器和Webapp类加载器则是Tomcat自己定义的类加载器,它们分别加 载/common/、/server/、/shared/*和/WebApp/WEB-INF/*中的Java类库。其中WebApp类加载器和JSP类 加载器通常还会存在多个实例,每一个Web应用程序对应一个WebApp类加载器,每一个JSP文件对应 一个JasperLoader类加载器。

Tomcat加载器的实现清晰易懂,并且采用了官方推荐的"正统"的使用类加载器的方式。如果读者 阅读完上面的案例后,毫不费力就能完全理解Tomcat设计团队这样布置加载器架构的用意,这就说明 你已经大致掌握了类加载器"主流"的使用方式,那么笔者不妨再提一个问题让各位读者思考一下:前 面曾经提到过一个场景,如果有10个Web应用程序都是用Spring来进行组织和管理的话,可以把Spring 放到Common或Shared目录下让这些程序共享。Spring要对用户程序的类进行管理,自然要能访问到用 户程序的类,而用户的程序显然是放在/WebApp/WEB-INF目录中的。那么被Common类加载器或 Shared类加载器加载的Spring如何访问并不在其加载范围内的用户程序呢?如果你读懂了本书第7章的 相关内容,相信回答这个问题一定会毫不费力。

Spring在Common类加载器中加载,但它并不直接去加载用户类(因为用户类在WebApp类加载器的范围内),而是通过线程上下文类加载器(Thread Context ClassLoader)来加载。

  1. 当Tomcat启动一个Web应用时,它会将当前线程的上下文类加载器设置为该Web应用的WebApp类加载器。
  2. Spring在初始化时(比如在加载Bean定义时),会使用当前线程的上下文类加载器(即WebApp类加载器)去加载用户类。
9.2.2 OSGi:灵活的类加载器架构

曾经在Java程序社区中流传着这么一个观点:"学习Java EE规范,推荐去看JBoss源码;学习类加 载器的知识,就推荐去看OSGi源码。"尽管"Java EE规范"和"类加载器的知识"并不是一个对等的概 念,不过,既然这个观点能在部分程序员群体中流传开来,也从侧面说明了OSGi对类加载器的运用确 实有其独到之处。

OSGi [1](Open Service Gateway Initiative)是OSGi联盟(OSGi Alliance)制订的一个基于Java语言 的动态模块化规范(在JDK 9引入的JPMS是静态的模块系统),这个规范最初由IBM、爱立信等公司 联合发起,在早期连Sun公司都有参与。目的是使服务提供商通过住宅网关为各种家用智能设备提供服 务,后来这个规范在Java的其他技术领域也有相当不错的发展,现在已经成为Java世界中"事实上"的动 态模块化标准,并且已经有了Equinox、Felix等成熟的实现。根据OSGi联盟主页上的宣传资料,OSGi 现在的重点应用在智慧城市、智慧农业、工业4.0这些地方,而在传统Java程序员中最知名的应用案例 可能就数Eclipse IDE了,另外,还有许多大型的软件平台和中间件服务器都基于或声明将会基于OSGi 规范来实现,如IBM Jazz平台、GlassFish服务器、JBoss OSGi等。

OSGi中的每个模块(称为Bundle)与普通的Java类库区别并不太大,两者一般都以JAR格式进行 封装[2],并且内部存储的都是Java的Package和Class。但是一个Bundle可以声明它所依赖的Package(通 过Import-Package描述),也可以声明它允许导出发布的Package(通过Export-Package描述)。在OSGi 里面,Bundle之间的依赖关系从传统的上层模块依赖底层模块转变为平级模块之间的依赖,而且类库 的可见性能得到非常精确的控制,一个模块里只有被Export过的Package才可能被外界访问,其他的 Package和Class将会被隐藏起来。

以上这些静态的模块化特性原本也是OSGi的核心需求之一,不过它和后来出现的Java的模块化系 统互相重叠了,所以OSGi现在着重向动态模块化系统的方向发展。在今天,通常引入OSGi的主要理由 是基于OSGi架构的程序很可能(只是很可能,并不是一定会,需要考虑热插拔后的内存管理、上下文 状态维护问题等复杂因素)会实现模块级的热插拔功能,当程序升级更新或调试除错时,可以只停 用、重新安装然后启用程序的其中一部分,这对大型软件、企业级程序开发来说是一个非常有诱惑力 的特性,譬如Eclipse中安装、卸载、更新插件而不需要重启动,就使用到了这种特性。

OSGi之所以能有上述诱人的特点,必须要归功于它灵活的类加载器架构。OSGi的Bundle类加载器 之间只有规则,没有固定的委派关系。例如,某个Bundle声明了一个它依赖的Package,如果有其他 Bundle声明了发布这个Package后,那么所有对这个Package的类加载动作都会委派给发布它的Bundle类 加载器去完成。不涉及某个具体的Package时,各个Bundle加载器都是平级的关系,只有具体使用到某 个Package和Class的时候,才会根据Package导入导出定义来构造Bundle间的委派和依赖

我们可以举一个更具体些的简单例子来解释上面的规则,假设存在Bundle A、Bundle B、Bundle C3个模块,并且这3个Bundle定义的依赖关系如下所示。

  • Bundle A:声明发布了packageA,依赖了java.*的包;
  • Bundle B:声明依赖了packageA和packageC,同时也依赖了java.*的包;
  • Bundle C:声明发布了packageC,依赖了packageA。

那么,这3个Bundle之间的类加载器及父类加载器之间的关系如图9-2所示。

由于没有涉及具体的OSGi实现,图9-2中的类加载器都没有指明具体的加载器实现,它只是一个 体现了加载器之间关系的概念模型,并且只是体现了OSGi中最简单的加载器委派关系。一般来说,在 OSGi里,加载一个类可能发生的查找行为和委派关系会远远比图9-2中显示的复杂,类加载时可能进 行的查找规则如下:

  • 以java.*开头的类,委派给父类加载器加载。
  • 否则,委派列表名单内的类,委派给父类加载器加载。
  • 否则,Import列表中的类,委派给Export这个类的Bundle的类加载器加载。
  • 否则,查找当前Bundle的Classpath,使用自己的类加载器加载。
  • 否则,查找是否在自己的Fragment Bundle中,如果是则委派给Fragment Bundle的类加载器加载。
  • 否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。
  • 否则,类查找失败。

从图9-2中还可以看出,在OSGi中,加载器之间的关系不再是双亲委派模型的树形结构,而是已 经进一步发展成一种更为复杂的、运行时才能确定的网状结构。

9.2.3 字节码生成技术与动态代理的实现

"字节码生成"并不是什么高深的技术,读者在看到"字节码生成"这个标题时也先不必去想诸如 Javassist、CGLib、ASM之类的字节码类库,**因为JDK里面的Javac命令就是字节码生成技术的"老祖 宗",并且Javac也是一个由Java语言写成的程序,它的代码存放在OpenJDK的 jdk.compiler\share\classes\com\sun\tools\javac目录中[**1]。要深入从Java源码到字节码编译过程,阅读Javac 的源码是个很好的途径,不过Javac对于我们这个例子来说太过庞大了。在Java世界里面除了Javac和字 节码类库外,使用到字节码生成的例子比比皆是,如Web服务器中的JSP编译器,编译时织入的AOP框 架,还有很常用的动态代理技术,甚至在使用反射的时候虚拟机都有可能会在运行时生成字节码来提 高执行速度。我们选择其中相对简单的动态代理技术来讲解字节码生成技术是如何影响程序运作的。

相信许多Java开发人员都使用过动态代理,即使没有直接使用过java.lang.reflect.Proxy或实现过 java.lang.reflect.InvocationHandler接口,应该也用过Spring来做过Bean的组织管理。如果使用过Spring, 那大多数情况应该已经不知不觉地用到动态代理了,因为如果Bean是面向接口编程,那么在Spring内 部都是通过动态代理的方式来对Bean进行增强的。动态代理中所说的"动态",是针对使用Java代码实 际编写了代理类的"静态"代理而言的,它的优势不在于省去了编写代理类那一点编码工作量,而是实 现了可以在原始类和接口还未知的时候,就确定代理类的代理行为,当代理类与原始类脱离直接联系 后,就可以很灵活地重用于不同的应用场景之中

java 复制代码
public class DynamicProxyTest {
    interface IHello {
        void sayHello();
    }
    static class Hello implements IHello {
        @Override
        public void sayHello() {
            System.out.println("hello world");
        }
    }
    static class DynamicProxy implements InvocationHandler {
        Object originalObj;
        Object bind(Object originalObj) {
            this.originalObj = originalObj;
            return Proxy.newProxyInstance(originalObj.getClass().getClassLoader(), originalObj.getClass().getInterfaces(), this);
        }
        @Override
        public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
            System.out.println("welcome");
            return method.invoke(originalObj, args);
        }
    }
    public static void main(String[] args) {
        IHello hello = (IHello) new DynamicProxy().bind(new Hello());
        hello.sayHello();
    }
}

在上述代码里,唯一的"黑匣子"就是Proxy::newProxyInstance()方法,除此之外再没有任何特殊之 处。这个方法返回一个实现了IHello的接口,并且代理了new Hello()实例行为的对象。跟踪这个方法的 源码,可以看到程序进行过验证、优化、缓存、同步、生成字节码、显式类加载等操作,前面的步骤 并不是我们关注的重点,这里只分析它最后调用sun.misc.ProxyGenerator::generateProxyClass()方法来完 成生成字节码的动作,这个方法会在运行时产生一个描述代理类的字节码byte[]数组。如果想看一看这 个在运行时产生的代理类中写了些什么,可以在main()方法中加入下面这句:

TODO:那就跟踪一下源码。

java 复制代码
System.getProperties().put("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");

加入这句代码后再次运行程序,磁盘中将会产生一个名为"$Proxy0.class"的代理类Class文件,反 编译后可以看见如代码清单9-2所示的内容:

java 复制代码
package org.fenixsoft.bytecode;
 import java.lang.reflect.InvocationHandler;
 import java.lang.reflect.Method;
 import java.lang.reflect.Proxy;
 import java.lang.reflect.UndeclaredThrowableException;
 public final class $Proxy0 extends Proxy
    implements DynamicProxyTest.IHello
 {
    private static Method m3;
    private static Method m1;
    private static Method m0;
    private static Method m2;
    public $Proxy0(InvocationHandler paramInvocationHandler)
        throws
    {
        super(paramInvocationHandler);
    }
    public final void sayHello()
        throws
    {
        try
        {
            this.h.invoke(this, m3, null);
            return;
        }
        catch (RuntimeException localRuntimeException)
        {
            throw localRuntimeException;
        }
        catch (Throwable localThrowable)
        {
            throw new UndeclaredThrowableException(localThrowable);
        }
    }
    // 此处由于版面原因,省略equals()、hashCode()、toString()3个方法的代码
    // 这3个方法的内容与sayHello()非常相似。
    static
    {
        try
        {
            m3 = Class.forName("org.fenixsoft.bytecode.DynamicProxyTest$IHello").getMethod("sayHello", new Class
            m1 = Class.forName("java.lang.Object").getMethod("equals", new Class[] { Class.forName("java.lang.Ob
            m0 = Class.forName("java.lang.Object").getMethod("hashCode", new Class[0]);
            m2 = Class.forName("java.lang.Object").getMethod("toString", new Class[0]);
            return;
        }
        catch (NoSuchMethodException localNoSuchMethodException)
        {
            throw new NoSuchMethodError(localNoSuchMethodException.getMessage());
        }
        catch (ClassNotFoundException localClassNotFoundException)
        {
            throw new NoClassDefFoundError(localClassNotFoundException.getMessage());
        }
    }
 }

这个代理类的实现代码也很简单,它为传入接口中的每一个方法,以及从java.lang.Object中继承来 的equals()、hashCode()、toString()方法都生成了对应的实现,并且统一调用了InvocationHandler对象的 invoke()方法(代码中的"this.h"就是父类Proxy中保存的InvocationHandler实例变量)来实现这些方法的 内容,各个方法的区别不过是传入的参数和Method对象有所不同而已,所以无论调用动态代理的哪一 个方法,实际上都是在执行InvocationHandler::invoke()中的代理逻辑。

这个例子中并没有讲到generateProxyClass()方法具体是如何产生代理类"$Proxy0.class"的字节码 的,大致的生成过程其实就是根据Class文件的格式规范去拼装字节码,但是在实际开发中,以字节为 单位直接拼装出字节码的应用场合很少见,这种生成方式也只能产生一些高度模板化的代码。对于用 户的程序代码来说,如果有要大量操作字节码的需求,还是使用封装好的字节码类库比较合适。如果 读者对动态代理的字节码拼装过程确实很感兴趣,可以在OpenJDK的 java.base\share\classes\java\lang\reflect目录下找到sun.misc.ProxyGenerator的源码。

1\] 如何获取OpenJDK源码,请参见本书第1章的相关内容。 ##### 9.2.4 Backport工具:Java的时光机器 了解了Retrotranslator和Retrolambda这种逆向移植工具的作用以后,相信读者更关心的是它是怎样 做到的?要想知道Backporting工具如何在旧版本JDK中模拟新版本JDK的功能,首先要搞清楚JDK升 级中会提供哪些新的功能。JDK的每次升级新增的功能大致可以分为以下五类: * 1)对Java类库API的代码增强。譬如JDK 1.2时代引入的java.util.Collections等一系列集合类,在 JDK 5时代引入的java.util.concurrent并发包、在JDK 7时引入的java.lang.invoke包,等等。 * 2)在前端编译器层面做的改进。这种改进被称作语法糖,如自动装箱拆箱,实际上就是Javac编 译器在程序中使用到包装对象的地方自动插入了很多Integer.valueOf()、Float.valueOf()之类的代码;变 长参数在编译之后就被自动转化成了一个数组来完成参数传递;泛型的信息则在编译阶段就已经被擦 除掉了(但是在元数据中还保留着),相应的地方被编译器自动插入了类型转换代码\[3\]。 * 3)需要在字节码中进行支持的改动。如JDK 7里面新加入的语法特性------动态语言支持,就需要 在虚拟机中新增一条invokedynamic字节码指令来实现相关的调用功能。不过字节码指令集一直处于相 对稳定的状态,这种要在字节码层面直接进行的改动是比较少见的。 * 4)需要在JDK整体结构层面进行支持的改进,典型的如JDK 9时引入的Java模块化系统,它就涉 及了JDK结构、Java语法、类加载和连接过程、Java虚拟机等多个层面。 * 5)集中在虚拟机内部的改进。如JDK 5中实现的JSR-133\[4\]规范重新定义的Java内存模型(Java Memory Model,JMM),以及在JDK 7、JDK 11、JDK 12中新增的G1、ZGC和Shenandoah收集器之 类的改动,这种改动对于程序员编写代码基本是透明的,只会在程序运行时产生影响。 上述的5类新功能中,逆向移植工具能比较完美地模拟了前两类,从第3类开始就逐步深入地涉及 了直接在虚拟机内部实现的改进了,这些功能一般要么是逆向移植工具完全无能为力,要么是不能完 整地或者在比较良好的运行效率上完成全部模拟。想想这也挺合理的,如果在语法糖和类库层面可以 完美解决的问题,Java虚拟机设计团队也没有必要舍近求远地改动处于JDK底层的虚拟机嘛。 以枚举为例,尽管在JDK 5中增加了enum关键字,但是Class文件常量池的CONSTANT_Class_info 类型常量并没有发生任何语义变化,仍然是代表一个类或接口的符号引用,没有加入枚举,也没有增 加过"CONSTANT_Enum_info"之类的"枚举符号引用"常量。所以使用enum关键字定义常量,尽管从 Java语法上看起来与使用class关键字定义类、使用interface关键字定义接口是同一层次的,但实际上这 是由Javac编译器做出来的假象,**从字节码的角度来看,枚举仅仅是一个继承于java.lang.Enum、自动生 成了values()和valueOf()方法的普通Java类而已**。 Retrotranslator对枚举所做的主要处理就是把枚举类的父类从"java.lang.Enum"替换为它运行时类库 中包含的"net.sf.retrotranslator.runtime.java.lang.Enum_",然后再在类和字段的访问标志中抹去 ACC_ENUM标志位。当然,这只是处理的总体思路,具体的实现要比上面说的复杂得多。可以想象 既然两个父类实现都不一样,values()和valueOf()的方法自然需要重写,常量池需要引入大量新的来自 父类的符号引用,这些都是实现细节。图9-3是一个使用JDK 5编译的枚举类与被Retrotranslator转换处 理后的字节码的对比图。 ![图9-3 Retrotranslator处理前后的枚举类字节码对比](https://i-blog.csdnimg.cn/img_convert/f82f02496c6fc3e97678dac5314fb169.png) #### 9.3 实战:自己动手实现远程执行功能 不知道读者在做程序维护的时候是否遇到过这类情形:排查问题的过程中,想查看内存中的一些 参数值,却苦于没有方法把这些值输出到界面或日志中。又或者定位到某个缓存数据有问题,由于缺 少缓存的统一管理界面,不得不重启服务才能清理掉这个缓存。类似的需求有一个共同的特点,那就 是只要在服务中执行一小段程序代码,就可以定位或排除问题,但就是偏偏找不到可以让服务器执行 临时代码的途径,让人恨不得在服务器上装个后门。这是项目运维中的常见问题,通常解决类问题有 以下几种途径: * 1)可以使用BTrace\[1\]这类JVMTI工具去动态修改程序中某一部分的运行代码,这部分在第4章有 简要的介绍,类似的JVMTI工具还有阿里巴巴的Arthas\[2\]等。 * 2)使用JDK 6之后提供了Compiler API,可以动态地编译Java程序,这样虽然达不到动态语言的 灵活度,但让服务器执行临时代码的需求是可以得到解决的。 * 3)也可以通过"曲线救国"的方式来做到,譬如写一个JSP文件上传到服务器,然后在浏览器中运 行它,或者在服务端程序中加入一个BeanShell Script、JavaScript等的执行引擎(如Mozilla Rhino\[3\]) 去执行动态脚本。 * 4)在应用程序中内置动态执行的功能。 ##### 9.3.1 目标 我们 希望最终的产品是这样的: * 不依赖某个JDK版本才加入的特性(包括JVMTI),能在目前还被普遍使用的JDK中部署,只要 是使用JDK 1.4以上的JDK都可以运行。 * 不改变原有服务端程序的部署,不依赖任何第三方类库。 * 不侵入原有程序,即无须改动原程序的任何代码。也不会对原有程序的运行带来任何影响。 * 考虑到BeanShell Script或JavaScript等脚本与Java对象交互起来不太方便,"临时代码"应该直接支 持Java语言。 * "临时代码"应当具备足够的自由度,不需要依赖特定的类或实现特定的接口。这里写的是"不需 要"而不是"不可以",当"临时代码"需要引用其他类库时也没有限制,只要服务端程序能使用的类型和 接口,临时代码都应当能直接引用。 * "临时代码"的执行结果能返回到客户端,执行结果可以包括程序中输出的信息及抛出的异常等。 看完上面列出的目标,读者觉得完成这个需求需要做多少工作量呢?也许答案比大多数人所想的 都要简单一些:5个类,250行代码(含注释),大约一个半小时左右的开发时间就可以了,现在就开 始编写程序吧! ##### 9.3.2 思路 在程序实现的过程中,我们需要解决以下3个问题: 如何编译提交到服务器的Java代码? * 对于第一个问题,我们有两种方案可以选择。一种在服务器上编译,在JDK 6以后可以使用 Compiler API,在JDK 6以前可以使用tools.jar包(在JAVA_HOME/lib目录下)中的 com.sun.tools.Javac.Main类来编译Java文件,它们其实和直接使用Javac命令来编译是一样的。这种思路 的缺点是引入了额外的依赖,而且把程序绑死在特定的JDK上了,要部署到其他公司的JDK中还得把 tools.jar带上(虽然JRockit和J9虚拟机也有这个JAR包,但它总不是标准所规定必须存在的)。 * 另外一 种思路是直接在客户端编译好,把字节码而不是Java代码传到服务端,这听起来好像有点投机取巧, 一般来说确实不应该假定客户端一定具有编译代码的能力,也不能假定客户端就有编译出产品所需的 依赖项。但是既然程序员会写Java代码去给服务端排查问题,那么很难想象他的机器上会连编译Java程 序的环境都没有。 如何执行编译之后的Java代码? * 对于第二个问题:要执行编译后的Java代码,让类加载器加载这个类生成一个Class对象,然后反 射调用一下某个方法就可以了(因为不实现任何接口,我们可以借用一下Java中约定俗成的"main()"方 法)。 * 但我们还应该考虑得更周全些:一段程序往往不是编写、运行一次就能达到效果,同一个类可 能要被反复地修改、提交、执行。 * 另外,提交上去的类要能访问到服务端的其他类库才行。还有就是 既然提交的是临时代码,那提交的Java类在执行完后就应当能被卸载和回收掉。 如何收集Java代码的执行结果? * 最后一个问题,我们想把程序往标准输出(System.out)和标准错误输出(System.err)中打印的 信息收集起来。但标准输出设备是整个虚拟机进程全局共享的资源,如果使用 System.setOut()/System.setErr()方法把输出流重定向到自己定义的PrintStream对象上固然可以收集到输 出信息,但也会对原有程序产生影响:会把其他线程向标准输出中打印的信息也收集了。 * 虽然这些并 不是不能解决的问题,不过为了达到完全不影响原程序的目的,我们可以采用另外一种办法:直接在 执行的类中把对System.out的符号引用替换为我们准备的PrintStream的符号引用,依赖前面学习到的知 识,做到这一点并不困难。

相关推荐
Z_z在努力2 小时前
【数据结构】队列(Queue)全面详解
java·开发语言·数据结构
aloha_7892 小时前
新国都面试真题
jvm·spring boot·spring·面试·职场和发展
我不是混子3 小时前
如何保证接口幂等性?
java·后端
_院长大人_3 小时前
阿里云云效将本地的maven相关文件批量推送到阿里云仓库以及使用
java·阿里云·maven
麦兜*3 小时前
Redis 7.0 新特性深度解读:迈向生产级的新纪元
java·数据库·spring boot·redis·spring·spring cloud·缓存
我是华为OD~HR~栗栗呀3 小时前
测试转C++开发面经(华为OD)
java·c++·后端·python·华为od·华为·面试
龙茶清欢3 小时前
最新版 springdoc-openapi-starter-webmvc-ui 常用注解详解 + 实战示例
java·spring boot·ui·spring cloud
智界工具库4 小时前
《IDEA 2025 长效使用指南:2099 年有效期配置实战之JetBrains全家桶有效》
java·ide·intellij-idea