《深入理解 Java 虚拟机》笔记(3) 虚拟机执行系统

Class 文件

Java 通过将程序编译为平台无关的字节码,储存于 Class 文件中,实现了程序运行的平台无关性。

Class 文件结构

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

根据 Java 虚拟机规范的规定,Class 文件格式采用一种类似于 C 语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表。

无符号数属于基本的数据类型,以 u1、u2、u4、u8 来分别代表 1 个字节、2 个字节、4 个字节和 8 个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码构成字符串值。

表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以 _info 结尾。表用于描述有层次关系的复合结构的数据,整个 Class 文件本质上就是一张表,它由下表所示的数据项构成。

Class 文件格式
Class 文件格式

无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的数据项的形式,这时称这一系列连续的某一类型的数据为某一类型的集合。

  1. 魔数与 Class 文件的版本

    每个 Class 文件的头 4 个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的 Class 文件。

    紧接着魔数的 4 个字节存储的是 Class 文件的版本号:第 5 和第 6 个字节是次版本号(Minor Version),第 7,8 个字节是主版本号(Major Version)。Java 的版本号是从 45r(JDK 1.1)开始的,JDK 1.1 之后的每个 JDK 大版本发布主版本号向上加 1(JDK 1.0~1.1 使用了 45.0~45.3 的版本号),高版本的 JDK 能向下兼容以前版本的 Class 文件,但不能运行以后版本的 Class 文件,即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的 Class 文件。

  2. 常量池

    紧接在版本号后面的是常量池,由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项 u2 类型的数据,代表常量池容量计数值(constantpoolcount)。常量池的技术是从 1 开始的,所以常量池的容量为 constantpoolcount - 1。constantpoolcount 为 0 时表示「不引用任何一个常量池项目」。

    常量池主要存放:

    • 字面量(Literal):文本字符串、final 常量值等
    • 符号引用(Symbolic References),包括
      • 类和接口的全限定名(Fully Qualified Name)
      • 字段的名称和描述符(Descriptor)
      • 方法的名称和描述符

    Java 代码是在虚拟机加载 Class 文件时进行动态链接的,虚拟机在运行期从常量池中获得字段和符号引用后,再在类创建或者运行时解析、翻译到具体的内存地址。

    常量池中的每一项常量都是一个表,表的第一位是一个是 u1 类型的标志位,代表当前常量的类型。

    常量池的项目类型
    常量池的项目类型

    而每一个项目又有其自身的结构:

    常量池中 14 种项的结构.png
    常量池中 14 种项的结构.png
  3. 访问标志

    常量池后面的 2 字节是访问标志(accessflags),用于识别照片一些类或接口层次的访问信息,包括:这个 Class 是类还是接口;是否定义为 public 类型;是否定义为 abstract 类型;如果是类的话,是否被声明为 final 等。 access_flags 中一共有 16 个标志位可以使用,当前只定义了其中 8 个 1,没有使用到的标志位要求一律为 0。

    访问标志
    访问标志
  4. 类索引、父类索引与接口索引集合

    类索引(thisclass)和父类索引(superclass)都是一个 u2 类型的数据,而接口索引集合(interfaces)是一组 u2 类型的数据的集合,Class 文件中由这三项数据来确定这个类的继承关系。 类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。它们各自指向一个类型为 ~CONSTANT_Class_info 的类描述符常量,通过 ~CONSTANT_Class_info 类型的常量中的索引值可以找到定义在 CONSTANT_Utf8_info 类型的常量中的全限定名字符串。 对于接口索引集合,入口的第一项—— u2 类型的数据为接口计数器(interfacescount),表示索引表的容量。如果该类没有实现任何接口,则该计数器值为 0,后面接口的索引表不再占用任何字节。

  5. 字段表集合

    字段表(fieldinfo)用于描述接口或者类中声明的变量。字段(field)包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。

    字段表结构
    字段表结构

    字段修饰符放在 access_flags 项目中:

    字段访问标志
    字段访问标志

    name_indexdescriptor_index 都是对常量池的引用,分别代表着字段的简单名称以及字段和方法的描述符。

    • 全限定名:把类全名中的'.'替换成'/',一般在末尾加入一个';'表示全限定名结束
    • 简单名称:指没有类型和参数修饰的方法或者字段名称。比如 inc() 方法的简单名称是 inc
    • 字段描述符:符根据描述符规则,基本数据类型(byte、char、double、float、int、long、short、boolean)以及代表无返回值的 void 类型都用一个大写字符来表示,而对象类型则用字符 'L' 加对象的全限定名来表示。对于数组类型,每一维度将使用一个前置的 '[' 字符来描述,如一个定义为 java.lang.String[][] 类型的二维数组,将被记录为:"[[Ljava/lang/String;"
    • 方法描述符:按照先参数列表,后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号"()"之内。如方法~java.lang.String toString()~ 的描述符为 "()Ljava/lang/String;"
  6. 方法表集合

    方法表的结构与字段表几乎一样

    方法表结构
    方法表结构
  7. 属性表集合

    在 Class 文件、字段表、方法表都可以携带自己的属性表(attributeinfo)集合,以用于描述某些场景专有的信息。

    对于每个属性,它的名称需要从常量池中引用一个 CONSTANTUtf8info 类型的常量来表示,而属性值的结构则是完全自定义的,只需要通过一个 u4 的长度属性去说明属性值所占用的位数即可。一个符合规则的属性表应该满足下表中定义的结构:

    属性表结构
    属性表结构
    1. Code 属性

      Java 程序 方法体 中的代码经过 Javac 编译器处理后,最终变为字节码指令存储在 Code 属性内。Code 属性出现在方法表的属性集合之中,但并非所有的方法表都必须存在这个属性,譬如接口或者抽象类中的方法就不存在 Code 属性,如果方法表有 Code 属性存在,那么它的结构将如下表所示:

      Code 属性表结构
      Code 属性表结构
      1. 异常处理表

        Code 属性的字节码指令之后的是显示异常处理表。异常表是 Java 使用异常表而非简单的跳转指令来实现异常处理机制。

        当字节码在第 start_pc 行到第 end_pc 行之间(不含第 end_pc 行)出现了类型为 catch_type 或者其子类的异常( catch_type 为指向一个 CONSTANT_Class_info 型常量的索引),则转到第 handler_pc 行继续处理。当 catch_type 的值为 0 时,代表任意异常情况都需要转向到 handler_pc 处进行处理。

        异常表结构
        异常表结构
    2. Exceptions 属性

      Exceptions 属性的作用是列举出方法中可能抛出的受查异常(Checked Excepitons),也就是方法描述时在 throws 关键字后面列举的异常。

      以下出现的是可选属性,不是运行时必需的属性,但默认会生成到 Class 文件之中,可以在 javac 中使用 -g:none-g:lines/vars/source... 来取消生成这些信息。

    3. LineNumberTable 属性(可选)

      LineNumberTable 属性用于描述 Java 源码行号与字节码行号「字节码的偏移量」之间的对应关系。如果选择不生成 LineNumberTable 属性,对程序运行产生的最主要的影响就是当抛出异常时,堆栈中将不会显示出错的行号,并且在调试程序的时候,也无法按照源码行来设置断点。

    4. LocalVariableTable 属性(可选)

      LocalVariableTable 属性用于描述栈帧中局部变量表中的变量与 Java 源码中定义的变量之间的关系。如果没有生成这项属性,最大的影响就是当其他人引用这个方法时,所有的参数名称都将会丢失,IDE 将会使用诸如 arg0,arg1 之类的占位符代替原有的参数名,

      在 JDK 1.5 引入泛型之后,LocalVariableTable 属性增加了一个「姐妹属性」:LocalVariableTypeTable。由于泛型的类型擦除,需要用这个属性描述泛型的类型。

    5. SourceFile 属性(可选)

      SourceFile 属性用于记录生成这个 Class 文件的源码文件名称。

    6. ConstantValue 属性

      ConstantValue 属性的作用是通知虚拟机自动为静态变量赋值。只有被 static 关键字修饰的变量(类变量)才可以使用这项属性。

      Java 的两种类内变量赋值方法:

      • int x = 1: 在实例构造器 <init> 方法中进行
      • static int x = 1: 在类构造器 <clinit> 方法中或者使用 ConstantValue 属性

      对于 static 变量的初始化,目前 Sun Javac 编译器的选择是:如果同时使用 finalstatic 来修饰一个变量,并且这个变量的数据类型是基本类型或者 java.lang.String 的话,就生成 ConstantValue 属性来进行初始化,如果这个变量没有被 final 修饰,或者并非基本类型及字符串,则将会选择在 <clinit> 方法中进行初始化。

    7. InnerClasses 属性

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

字节码指令

java 虚拟机的指令由以下格式的字节码指令构成:操作码(Opcode 1 字节)[操作数(Oprand)],具有以下两个特点:

  • 由于 Java 虚拟机采用面向操作数栈而不是寄存器的架构,所以大多数的指令都不包含操作数,只有一个操作码。
  • Class 文件中放弃了对操作数的长度做对齐,节约了填充和间隔符号需要的空间,但是为了在运行时从字节中重建出具体数据的结构,损失了一些执行效率。

虚拟机类加载机制

Java 中类型的加载、连接和初始化过程都是在程序运行期间完成的。这提高了 Java 程序的灵活性。

类加载过程

  1. 类加载时机

    Java 中一个类的整个生命周期如下图所示:

    类的生命周期
    类的生命周期

    加载、验证、准备、初始化和卸载这 5 个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 语言的动态绑定特性。

    那么,在什么情况下一个类需要被加载呢?虚拟机规范规定了有且只有 5 种情况必须立即对类进行“初始化”(此前加载、验证和准备阶段自然需要先开始)

    1. 遇到 new~、~getstatic~、~putstaticinvokestatic 这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这 4 条指令的最常见的 Java 代码场景是:使用 new 关键字实例化对象的时候、读取或设置一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
    2. 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
    3. 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
    4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。
    5. 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic~、~REF_putStatic~、~REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

    接口的加载过程与类加载过程稍有一些不同:接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。

  2. 加载阶段

    在加载阶段,虚拟机需要完成以下 3 件事情:

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

    加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区中的数据存储格式由虚拟机实现自行定义。然后在内存中实例化一个 java.lang.Class 类的对象(并没有明确规定是在 Java 堆中,对于 HotSpot 虚拟机而言,Class 对象比较特殊,它虽然是对象,但是存放在方法区里面),这个对象将作为程序访问方法区中的这些类型数据的外部接口。

  3. 验证阶段

    验证是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

    验证阶段大致可以分为以下四个步骤:

    1. 文件格式验证: 验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理(如 magic number, 主、次版本号等的检验)
    2. 元数据验证: 对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求(比如一个类是否有父类、其父类是否继承了不允许被继承的类、是否实现了父类或接口中要求实现的方法等)
    3. 字节码验证:主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。这一部分比较复杂,不具体展开
    4. 符号引用验证: 对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验
  4. 准备阶段

    准备阶段是正式为类变量(static 修饰的变量)分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。

    在通常情况下,类变量在准备阶段会被分配默认的零值而非赋值语句右边的表达式。因为后者由 <clinit>()putstatic 指令执行,所以要在初始化阶段进行。但是当类变量被 final 修饰时,会被初始化为 ConstantValue 属性所指定的值。

  5. 解析阶段

    解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

    • 符号引用(Symbolic Refernces): 用来定位到引用目标的字面量,与目标在内存中的位置无关。
    • 直接引用(Direct Refernces): 直接指向目标的指针、相对偏移量或者能间接定位到目标的句柄。
  6. 初始化

    初始化阶段是执行类构造器 <clinit>() 方法的过程。

    <clinit>() 方法由类中所有成员的赋值动作和静态语句块按顺序合并产生。静态语句块可以为在其后定义的变量赋值,但是不能访问。

类加载器

Java 将「通过一个类的全限定名来获取描述此类的二进制字节流」这个动作放到 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。完成这个过程的模块被称为「类加载器」。

Java 虚拟机中的任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。换言之,两个类必须由同一个类加载器加载才可能是相等的。

双亲委派模型

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

从开发人员的角度,系统提供的类加载器可以分为以下三种:

  • 启动类加载器(Bootstrap ClassLoader): 类将器负责将存放在 <JAVAHOME>/lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的类库加载到虚拟机内存中。此加载器无法被 Java 程序直接引用。
  • 扩展类加载器(Extension ClassLoader): 负载加载 <JAVAHOME>/lib/ext 目录中或者 java.ext.dirs 系统变量所指定的路径中的所有类库。
  • 应用程序类加载器(Application ClassLoader): 负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
双亲委派模型
双亲委派模型

上图展示了类加载器的双亲委派模型。在此模型中,除了顶层的启动类加载器外,其余每个类加载器都有自己的福类加载器。父子关系以组合而非继承的方式实现。

双亲委派模型的工作模式是:每个类加载器都会讲加载请求委派给父类加载器完成,如果父类无法完成,再由子加载器尝试加载。