虚拟执行子系统

本贴最后更新于 3040 天前,其中的信息可能已经物是人非

虚拟执行子系统

类文件结构

无关性的基石

JAVA号称一次编译到处执行,这个是由JAVA虚拟机提供的。虚拟机执行字节码。在各种CPU之上提供了一个平台无关的系统抽象。

CLASS类文件的结构

类型名称数量描述
u4 magic 1 魔数,固定为 0xCAFEBABE
u2 minor_version 1  
u2 major_version 1  
u2 constant_pool_count 1 short for CPC
cp_info contant_pool CPC - 1 减一是因为,预留一项作为备用,常量池包含两类常量。一类是逻辑代码用常量,如 字符串,数字等;一类是编译链接用常量,如 指向全限定名类常量的索引(指向 字符串位置),指向声明字段的类或者接口的索引
u2 access_flag 1 用位图表示的 类/接口 层次的访问信息,如 本class是类还是接口,是否public,是否final,是否一个接口,是否一个类 等
u2 this_class 1 指向 常量池的 类常量名称信息定义,定义本类的名称
u2 super_class 1 定义本类的父类
u2 interface_count 1 实现的接口的数量,short for IC
u2 interfaces IC 定义本类实现的接口
u2 fields_count 1 short for FC
field_info fields FC 描述类的 静态字段,实例字段 的属性,包括 access_flag,name_index,decriptor_index,以及attributes
u2 methods_count 1 short for MC
method_info methods MC 与fields类似。具体的方法的字节码存储在 attribute里
u2 attribute_count 1 存储这个类的所有属性,跟常量表的的作用类似,用于被之前定义的各种表引用。例如,method_info表会存储具体执行的字节码到attribute里。attribute是Java虚拟机规范定义最为宽松的一类数据,只要定义的attribute名称没有跟之前虚拟机规范定义的重复,那么就没有问题。

字节码指令介绍

字节码是模仿CPU指令集设计的一个虚拟机指令集。但其中有一个最大的不同就是,CPU大多都是从 寄存器 读取 操作数并进行操作的,而JAVA虚拟机设计的则是面向 操作数栈。

JAVA限制了操作码长度为1个字节,因此,最多有256个指令。

虚拟机执行字节码的伪代码如下

do{
    PC寄存器 + 1;
    根据PC寄存器指示的位置取出要执行的字节码;
    if(对应的字节码存在操作数){
        从字节码流中取出操作数;
    }
    执行操作码定义的操作
    if(执行操作抛出了异常){
        根据异常表更新PC寄存器到特定位置,continue;
    }
}while(还存在需要执行的字节码)

字节码与类型数据

大多数字节码都会指明其操作对应的数据类型如,iload则表示从局部变量表中,加载一个Integer到操作数栈中

并不是所有的类型都会有对应的操作码,如boolean,char,byte,short等的一些操作会与int共用同一个操作码,因为boolean等在编译时或者运行时会变成int类型

加载和存储指令

  • 将一个局部变量加载到操作数栈中
  • 将一个数值从操作数栈存储到局部变量表中
  • 将一个常量加载到操作数栈

运算指令

  • 求余
  • 取反
  • 位移
  • 按位或
  • 按位与
  • 按位异或
  • 自增
  • 比较指令

整数运算只允许在除数为0时抛出异常,其余情况,如溢出是不会抛异常的。至于整数溢出时的运算结果,虚拟机规范没有定义。

浮点数使用 IEEE754规范

浮点数计算不抛出任何异常,产生溢出会有有符号无穷大的表示,无数学意义 会用 NaN表示,所有与NaN进行运算的结果都是NaN

类型转换指令

用于两种不同类型的数值的互相转换,这些转换一般用于实现用户代码的现实类型转换操作,或者用来处理本节开篇所提到的字节码指令集中的数据类型相关指令无法与数据类型一一对应的问题。

类型窄化处理可能会产生一些奇怪的现象。

  • long窄化为int仅仅丢弃高32位
  • 浮点窄化为int/long时
    • 如果浮点值为NaN那么转换结果为0
    • 如果浮点值不为无穷大,那么向零舍入模式取整,得到整数部分,若int/long能表示,那么则结果为对应舍入值
    • 否则则为long/int的最大值或者最小值
  • double窄化为float时,若float不能表示,则为无穷大

类型转换指令永远不抛出异常

对象创建与访问指令

  • 创建类实例指令
  • 创建数组的指令
  • 访问类字段和实例字段指令
  • 把一个数组元素加载到操作数栈的指令
  • 将一个操作数栈的值存储到数组中
  • 取数组长度指令
  • 检查类实例类型的指令

数组 虽然是 对象,但其创建与普通对象创建的指令不一样

操作数栈管理指令

  • 出栈指令
  • 复制并压人
  • 顶端值互换

控制转移指令

  • 条件分支
  • 符合条件分支
  • 无条件分支

用于修改PC寄存器的值

方法调用和返回指令

  • invokevirtual 调用对象的实例方法,根据对象的实际类型进行分派,这个是最常用的方法调用
  • invokeinterface,调用接口方法,会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用(?)
  • invokeSpecial,调用一些需要特殊处理的实例方法,如 实例初始化方法,私有方法和父类方法
  • invokeStatic,调用静态方法
  • invokeDynamic,用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法

方法调用指令与数据类型无关,而方法返回指令是根据返回值的类型区分的

异常处理指令

显示抛出异常的指令。 异常的捕获及处理使用异常表处理

同步指令

monitorenter monitorexit

虚拟机类加载机制

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,转换解析 和 初始化,最终形成可以被虚拟机直接使用的JAVA类型,这就是虚拟机的加载机制

JAVA类型的加载,连接 及 初始化 都是在运行时完成的,这为JAVA的灵活性提供了很大的帮助

类的加载时机

类加载分为以下几个步骤(或者说要干这几件事情,这几件事情是有重叠的部分的,而并非泾渭分明的)

  • 加载
  • 连接
    • 验证
    • 准备
    • 解析
  • 初始化
  • 使用
  • 卸载

加载、验证、准备、初始化、卸载 这几个步骤会根据排序依次开始。但 解析 的执行步骤不是确定的。其有时会在初始化之后再执行解析过程,这是为了支持 JAVA 的 晚期绑定(动态绑定)。

以上的几个步骤只是依次的开始,而不是按部就班的“进行”或者“完成”,因为 通常会在一个 步骤的执行过程中 激活另外一个步骤

以上几个步骤什么时候开始虚拟机规范并没有明确,但虚拟机规范了有5种情况执行必须对类进行 初始化。

  1. 遇到 new,putstatic,getstatic,invokestatic等4个字节码指令时
  2. 使用java.lang.reflect包的方法对类进行反射调用时
  3. 初始化一个类的时候,若父类没有被初始化,则先触发父类初始化
  4. 虚拟机启动时,main方法所在的类会先被初始化
  5. 使用jdk1.7动态语言支持时,如果MethodHandle实例最后的解析结果REF_getStatic,REF_putStatic,REF_invokeStatic的方法句柄,且句柄对应的类没有初始化过,那么则需要先触发初始化

有且只有上述五种情况,引用类才会被初始化,这5种情况称为对类的 主动引用,除此之外对类的引用称为 被动引用,被动引用不会触发类的初始化。

被动引用的例子:

  1. 通过子类引用父类的静态字段,子类不会被初始化
  2. 通过数组定义来引用类,不会触发类的初始化
  3. 引用其他类的常量,并不会触发对应类的初始化

总而言之,只要无需初始化也能保证正确的结果,那么久不会进行初始化

加载

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

本阶段是开发人员可控性最大的一个阶段。

对于 数组类,其由JAVA虚拟机直接创建,而无需类加载器创建。数组类创建规则如下:

  1. 如果数组的组件为引用类型,那么调用 组件的加载过程去加载这个组件类型。数组将在加载组件的类加载器上被标识
  2. 数组组件不是引用类型,那么,JAVA虚拟机会将数组C标志位与引导类加载器关联
  3. 数组类的可见性与他的组件的可见性一致,若为非引用类型,可见性则为Public

加载 与 连接的部分内容(一部分 验证动作) 是交叉进行的

验证

目的是为了确保CLASS文件的字节流中包含的信息符合当前虚拟机的规范,不会危害虚拟机自身的安全 验证内容:

  1. 文件格式验证:主要目的是保证输入的字节流能正确的解析并存储于方法区内,格式上符合一个JAVA类型的信息要求。通过验证后,字节流会进入方法去中进行存储,后续3个验证阶段直接基于方法区存储进行
    • 是否以魔数0xCAFEBABE开头
    • 主次版本号是否在当前虚拟机处理范围内
    • 长两次的常量中是否有不被支持的常量类型
    • 指向常量的各种索引值是否有指向不存在的常量或者不符合类型的常量
    • CONSTANT_UTF8_INFO型常量中是否有指向不存在的常量或者不符合类型的常量
    • CLASS文件中各个部分及文件本身是否有被删除的或附加的其他信息
    • ......
  2. 元数据验证 对字节码的描述信息进行语义分析,确保其描述的信息符合JAVA语言规范要求
    • 这个类是否有父类(除了Object类外都应该有父类)
    • 这个类的父类是否继承了不允许被继承的类
    • 如果这个类不是抽象类,是否实现了其父类或者接口中要求实现的所有方法
    • 类中的字段、方法是否与父类产生矛盾
    • ......
  3. 字节码验证 通过数据流和控制流分析确定程序语义是合法的,符合逻辑的,如果一个类方法体没有通过字节码验证,那肯定是有问题的,但如果一个方法体通过了字节码验证,也不能说明其一定就是安全的。为提高字节码验证的效率,1.6后在Code属性中加入了StackMapTable。具体原理不太清楚...以后补全
    • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,不会出现类似:在操作数栈上放了int类型的数据,使用时却按照long类型来加载入本地变量表中
    • 确保跳转指令不会跳转到方法体以外的字节码指令中
    • 保证方法体中的类型转换是有效的
    • ......
  4. 符号引用验证 最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在链接的第三个阶段——解析阶段中发生,将校验对类引用的各种外部符号
    • 符号引用中通过字符串描述的全限定名是否能找到对应的类
    • 在指定类中是否存在符合方法描述及简单名称所描述的方法和字段
    • 符号引用的类、字段、方法的访问性是否可被当前类访问

类验证很重要,但并非必须,其可通过配置关闭。

准备

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

静态非final类变量会被初始化为默认初始值(0,null,false等值)。 而final静态字段则会直接变成对应设置的值

解析

将常量池的符号引用替换成直接引用的过程。

符号引用:以一组符号来描述所引用的对象,如全限定名
直接引用:可以是 1、直接指向目标的指针 2、相对偏移量 3、一个能间接定位的句柄等 取决于虚拟机的实现

虚拟机规范未指定解析发生的具体时间,但要求在执行以下16个字节码前,需对所使用的符号引用进行了解析

  • checkcast
  • getfield
  • getstatic
  • instanceof
  • invokedynamic
  • invokeinterface
  • incokespecial
  • invokestatic
  • invokevirtual
  • ldc
  • ldc_w
  • multinaewarray
  • new
  • putfield
  • putstatic

对于非invokedynamic指令对应的符号引用可以缓存下解析的结果。invokedynamic在每次实际运行时进行一次解析

解析动作主要对应 类或接口、字段、类方法、接口方法、方法类型、方法句柄 和 点用点限定符

  1. 类或者接口的解析(所处类为D,将符号N解析为直接引用C)

    • 如果类C不是数组,那么将会把代表N的全限定名传递给D的类加载器 进行加载。
    • 如果C是一个数组,且数组元素为对象,则按照上一点对数组元素进行加载,
    • 上述步骤进行完后C已经是一个有效的类或者接口了,但在解析完成前,需要对直接饮用的访问权限进行检查
  2. 字段解析(解析字段前,首先会解析 字段表内 class_index项中索引的CONSTANT_Class_info符号进行解析,也就是字段所属的类或者接口进行解析),设字段所属的接口或者类为C,则解析过程如下:

    • 如果C本身就包含了简单名称以及类型都匹配的字段,则返回这个字段的直接引用
    • 否则,如果C中实现了接口,则按照继承关系依次从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述都与目标相匹配的字段,则返回其直接引用,查找结束
    • 否则,如果C不是OBJECT的话,将会按照继承关系从下往上查找
    • 否则查找失败
    • 若查找成功,最后则会对权限进行检查 以上只是虚拟机规范的建议,实际的虚拟机可能不允许 接口 及 实现类同时出现同一名称的字段
  3. 类方法解析(与字段一样,也要先解析出方法对应的类)

    • 类方法和接口方法的常量引用是分开的,若类方法的Class字段指向接口类,那么就会抛出异常
    • 在类C中检查是否有简单名称以及描述符都与目标匹配的方法,如有则解析结束
    • 在父类则中递归查找,如有则解析结束
    • 否则,则在其实现的接口列表及其父接口中查找,若找到,则说明该类是一个抽象类,抛出AbstractMethodError
    • 否则,抛出NoSuchMethodError
    • 若解析成功,最后会检查可访问性。
  4. 接口方法解析(先解析出接口对应的CLASS)

    • 如果接口方法的Class指向类,会抛异常
    • 然后在接口C中查找是否有简单名称和描述符都与目标匹配的方法,若有,则解析结束
    • 否则,在接口C的父类接口中查找
    • 否则 抛出异常。
    • 接口方法都是PUBLIC的,因此不会抛出权限检测的异常

初始化

执行用户代码定义的类初始化方法<clint>()

  • 本方法有编译器自动收集所有类变量的赋值操作以及 static{}块的代码而生成的一个方法,收集的顺序与代码书写的顺序有关
  • 虚拟机会保证父类的<clint>()方法在子类之前进行
  • 本方法不是必须的,若无需初始化,本方法可不生成
  • 接口不能使用static{}块,但依然需要<clint>()为静态变量赋值。因接口没有static{}块,因此初始化接口时,无需初始化其父接口。父接口只需在使用到其字段时,才进行初始化。
  • 虚拟机能保证该方法在多线程环境中正确加锁,只有一个线程会执行初始化方法,其他的线程则会阻塞等待,最终保证初始化方法只执行一遍。若类初始化方法执行很长时间,有可能导致多线程阻塞。因此,也需慎用在类初始化方法中的代码。

类加载器

虚拟机团队把,通过类的全限定名获取类的二进制数据流的操作放到虚拟机外部进行,为开发人员提供了极大的便捷。实现这个动作的代码模块称为类加载器。

类与类加载器

加载的类与类加载器是绑定的,也就是说,即使是同一份 二进制CLASS文件,通过不同的类加载器加载,那么,这个加载出来的类也是不一样的。

双亲委派模型

对JAVA虚拟机来讲,只存在两种类加载器

  1. 启动类加载器(Bootstrap Classloader),其用C++实现(限于HOTSPOT),是虚拟机的一部分
  2. 其他类加载器,用JAVA语言实现,独立于虚拟机外部,继承自抽象类java.lang.ClassLoader

从程序员的角度来讲,加载器可分为3种

  1. Bootstrap Classloader.用于加载 JAVA_HOME/lib目录(或者-Xbootclasspath指定目录)中虚拟机认识的jar包。该类加载器无法直接被使用。
  2. Extension Classloader.扩展类加载器,负责加载JAVA_HOME/lib/ext目录(或者java.ext.dirs系统变量指定目录)中的内容。该类加载器可以直接使用
  3. Application Classloader,应用程序类加载器。负责加载用户路径(Classpath)里的类
  4. 程序员自定义的类加载器

双亲委派模型:优先使用parent加载对应的类,parent无法加载才自己尝试加载。这里的parent并不通过继承实现,而通过组合实现。其好处是,双亲委派模型会保证一些共有的类都被同一个类加载器加载,不会引起一些奇怪的现象。

若要遵循双亲委派模型,那么,实现ClassLoader的抽象方法时,重写findClass即可。

破坏双亲委托模型的例子:

  1. 若需要破坏双亲委托模型,则需重写loadClass。如OSGI等技术,对于一些公共的类,依然走双亲委托模式。对于需要实现热替换的一组类,则用它们专用的类加载器加载,而不委托到更上层。
  1. JNDI服务是由启动类加载器加载的,然而,JNDI需要加载由各种厂商实现的SPI,明显,SPI是不可能由启动类加载器加载的。因此JAVA设计团队设计了一个不太优雅的方案:线程上下文加载器。这个加载器会从父线程中继承过来,若父线程没有指定,则默认使用 应用程序类加载器。程序员也可用Thread类里setContextClassLoader()进行设置

虚拟机字节码执行引擎

运行时栈帧结构

栈帧是虚拟机栈的元素,每一个栈帧代表一个方法。最顶层的栈帧代表的是 虚拟机引擎目前正在执行的方法,所有字节码操作都针对当前的栈帧

栈帧包含以下内容

  • 大小确定的局部变量表
  • 大小确定的操作数栈
  • 动态连接
  • 返回地址

局部变量表

其大小由编译时确定,分析局部变量的存活时间,可确定的获得局部变量表所需的最大大小。

局部变量表的基本单位成为SLOT。 虚拟机规范给SLOT的要求是 能存下 boolean,byte,short,char,int,flow,referance,returnAddress等数据。 这些数据都能用32位字节保存下。但虚拟机并非限定只能用32位存储这些东西。虚拟机实现也可以用64位保存以上的数据。

returnAddress变量基本已被废弃,之前是用于处理异常的,但现在的异常处理是通过 异常表进行 因此,本类基本废弃。

对于long,double等64位数据将占用两个slot。(占用两个SLOT与对long,double赋值时并非原子操作并无直接关系,因为这里的slot都是在一个线程内变更的,不涉及并发问题。但其确实可以类比出 为啥long,double等赋值并非原子操作的原因)

虚拟机访问局部变量表是通过类似数组的索引。从0开始。如果需要访问double,long等元素,则需要指定两个slot

如果局部变量表对应的方法是一个实例方法,那么局部变量表第0个元素则存储的是this。之后的局部变量依次存储入参。

如果一个引用已经离开了它的作用域,但其值并没有被其他局部变量给覆盖,那么其有可能影响对引用指向的对象的GC,因为GC ROOT里依然有对应引用。

操作数栈

虚拟机的字节码执行所需的参数基本都从这里取出。操作数栈的深度也是可以在编译时通过程序代码分析得出。

操作数栈的元素可以放下任意的JAVA元素,包括long及double。long及double占用的栈的大小为2。

概念模型中,相邻的两个栈帧是相互独立的,但在大多数实现中,底层的 操作数栈 和 高层的局部变量表会有重叠,重叠的这部分就是传入的入参。

动态连接

每一个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用。

这个引用能帮助 动态连接时 解析出 直接引用

本内容不确定(建立栈帧后,动态解析完后才确定 操作数栈 及 局部变量表 的大小???)

方法返回地址

方法结束后需要返回到上层调用开始的地方,因此,需要存储下这个地址。

一个正常的返回操作应该如下:弹出执行完的栈帧,如果有返回值,则将返回值压入 调用者的操作数栈中,将PC修改为返回地址(调用位置+1的指令位置)。

附加信息

虚拟机相关的一些实现

方法调用

方法调用不等于方法执行,方法调用仅仅是确认需要执行的是哪个方法,不涉及方法具体的执行过程。

解析

CALSS中对于方法的引用 是符号引用。因此,在执行方法前,需要将其修改为直接引用。

一部分方法的引用可以在解析过程中就确定,如 静态方法、类匿名方法、final修饰的方法、构造器方法。

另外一部分则需要在运行时才能确定其直接引用。

在 解析过程中 就能确定直接引用的方法的调用,称为解析。

相关的字节码指令为 invokestatic,invokespecial

final修饰的方法调用使用Invokevirtual,但其执行的方法的直接引用 也是在解析过程确定的。

分派

静态分派

对应的是JAVA里的重载(overload),overload选择方法是在编译时根据静态类型进行抉择的。

动态分派

对应的是JAVA里的重写(override),override选择方法是在运行时根据动态类型进行抉择的

invokevirtual执行过程是动态分派的过程,其执行顺序如下:

  1. 找到栈顶调用对象对应的类,记作C
  2. 查找类型C中有没有 签名与 符号引用 相等的方法,如有且通过权限校验,那么就返回该直接阴阳
  3. 若没找到,则在C的父类中按找2的形式去找对应方法。
  4. 若最终也没找着,就抛异常。

通常情况下,上述方法查找过程只会执行一遍,然后保存到类的虚方法表里。下次确定类后,就可以直接调用类里虚方法表里指定的直接引用

单分派 多分派

方法的接收者 以及 方法的参数 称为 方法的宗量。

根据分派时依据 宗量的个数,可将分派 分为 单分派 以及 多分派。

静态分派 会根据 接收者静态类型 以及 参数决定分派的方法,其为多分派

动态分派 方法的签名已经确定,只能根据 接收者 类型进行分派,因此为单分派

动态语言支持

动态类型语言

动态类型语言与动态语言、弱类型语言不是一个概念。

动态类型语言:类型检查的主体在运行时而非在编译时。即,我要调用某一个 固定签名的方法,方法的接收者不会在编译时就限定必须为 某个类 或者 某个接口,而可以在运行时检查,若对应的方法接收者有对应的方法即可。

动态语言:可以在运行时修改 类的结构的 语言。

JDK1.7与动态类型

新增java.lang.invoke包支持动态类型 新增字节码 invokedynamic支持(分派方式由程序员决定)

类加载及执行子系统实战

TOMCAT正统的类加载器架构

所谓正统,指代的是使用 双亲委派模型。

TOMCAT有以下需求:

  • 放在/COMMON目录中:类库可被tomcat和所有的WEB应用程序共同使用
  • 放在/SERVER目录中:类库可被TOMCAT使用,对所有的WEB应用都不可见
  • 放在/shared目录中:类库可被所有的WEB应用共同使用,但对TOMCAT自身不可见
  • 放在/WEBAPP/WEB-INF目录里,类库仅可被当前WEB应用可见,其他应用不可见
  • JSP可以替换,而无需重启。

上述需求可用双亲委派模型实现。具体形式就不描述了。

OSGI:灵活的类加载器

OSGI的模块称为Bundle,其与普通JAVA类库区别不大,都可以JAR形式封装。但Bundle可以声明其依赖的JavaPackage,也可以声明其EXPORT的Package.

每个BUNDLE都会对应一个类加载器,这个类加载器加载类的规则如下:

  • 以java.*开头的,都为派给父类加载器加载
  • 否则,委派列表内的类给父加载器加载
  • 否则 IMPORT的类,委派给EXPORT这个类的BUNDLE的类加载器加载
  • 否则 查找Bundle的ClassPath使用自己的类加载器加载
  • 否则 查找是否在自己的Fragmen BUndle中,如果是,则委派给FragmentBundle的类加载器加载
  • 否则,查找Dynamic Import列表的Bundle,委派给对应的 Bundle的类加载器加载
  • 否则查找失败。
  • JVM

    JVM(Java Virtual Machine)Java 虚拟机是一个微型操作系统,有自己的硬件构架体系,还有相应的指令系统。能够识别 Java 独特的 .class 文件(字节码),能够将这些文件中的信息读取出来,使得 Java 程序只需要生成 Java 虚拟机上的字节码后就能在不同操作系统平台上进行运行。

    180 引用 • 120 回帖 • 3 关注
  • 方法
    5 引用 • 8 回帖
  • 字节码
    3 引用 • 2 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...