JVM类加载机制

类文件结构

虚拟机和字节码存储格式是实现语言无关性的基础。

class文件是一组以8个字节为基础单位的二进制流,大端形式。

class文件其实也可以在内存、网络等媒介中存在。

  • 魔数:CA FE BA BE

  • 常量池:表类型,占用空间大。存放字面量和符号引用。

    • 字面量:类似于常量,如文本字符串。

    • 符号引用:含各种描述符、句柄等。

    • 表结构以常量类型标志位u1开始。

    • 对应的17种类型各自有独立的数据结构。

  • 访问标志:识别类/接口层次的访问信息(public/abstract/final)

  • 类索引、父类索引、接口索引集合。用于确定继承关系。

  • 字段表集合。描述接口或类中声明的变量。包括:作用域、常量池引用、属性表。

    字段:类级变量、实例级变量,不包括方法内部声明的局部变量。

  • 方法表集合。结构大致同字段表。

    方法的特征签名:不包括返回值。

  • 属性表集合。用于描述某些场景专有的信息。

类加载机制

加载在什么时机发生呢?逼不得已的时候(主动引用)

  • 主动引用:new、设置static字段、调用类的静态方法、初始化一个父类还没初始化的类(初始化父类)、反射调用。

  • 被动引用:子类引用父类静态字段不会导致子类初始化。

接口与类不同的点在于,只有真正使用到父接口时,才初始化

类加载过程

加载

完成三件事情:

  1. 通过类的全限定名获取定义此类的二进制字节流

  2. 将字节流所代表的静态存储结构转换为方法区的运行时数据结构

  3. 在内存中生成一个代表该类的 Class 对象,作为方法区的数据访问入口

对于非数组类型的加载,开发人员可以定义自己的类加载器。对于数组类,由Java虚拟机直接在内存中动态构造,是引用类则递归加载组件,否则再经过引导类加载器关联(确定类型)。

验证

这是连接阶段的第一步,包括:文件格式验证、元数据验证、字节码验证、符号引用验证。验证阶段图 from Javaguide

准备

正式为类中定义的静态(不包括实例变量)变量分配方法区内存和置初始值。

定义时的赋值指令会被放在 <clinit>() 方法中,在初始化时执行,即只有在初始化后才等于非初始值。

解析

常量池中的符号引用替换为直接引用。对于

  • 类/接口:非数组 - 加载器;数组 - 虚拟机生成

  • 字段:解析索引符号,否则搜索父类引用。

  • 方法:类型匹配则查找父类引用、匹配方法,否则失败

初始化

是执行初始化方法 <clinit> ()方法的过程。这个方法是编译后自动生成的:

  • 包括变量赋值动作、静态语句块(不可访问)。顺序同代码顺序

  • 父类初始化先行,初始化阶段不能进行元素访问

类加载器

类加载器,即获取所需的类的动作的代码。

类与类加载器

只有在同一个加载器加载下,比较两个类是否相等才有意义。

这里的“相等”,包括equals isAssignableFrom isInstance方法的返回结果。

双亲委派模型

Java虚拟机存在两种不同的类加载器。一种是启动类加载器,来自机器自身;另一种是其他所有的类加载器,独立存在于机器外部并继承于ClassLoader抽象类。image-20220226222047767

其工作过程是:一个类加载器收到类加载的请求,先不自己尝试加载类,而是先将请求委派给父类加载器(loadClass)完成。(所有的类加载请求最终都应该传送到启动类加载器)只有当父类加载器反馈无法完成加载时,子加载器才会尝试自己去自己负责的区域完成加载(findClass)。

会先自底向上检查类是否被加载,再自上而下地尝试加载类。

优点:使得Object类在任何类加载器环境中均为同一个类。

破坏双亲委派模型

自定义加载器的话,需要继承 ClassLoader

如果不想打破双亲委派模型,重写 ClassLoader 类中的 findClass() 方法即可。

模块化系统下的类加载器

Java 9 引入了模块化特性。模块module就是代码和数据的封装体,代码是指一些包括类型的Packages。Package是一些类路径名字的约定,而模块是一个或多个Packages组成的一个封装体。module可以实现有限范围内的代码public访问权限,将代码公开区分为:模块外部有限范围的公开访问和模块内部的公开访问。

模块化与之对应地进行了加载器的更新处理。

字节码执行引擎

运行时栈帧结构

局部变量表

存在方法参数和方法内部定义的局部变量,其容量以变量槽为最小单位。

为节省空间,变量槽可重用。如果当前字节码PC超出变量的作用域,这个变量的槽可交给别的变量来重用。

只要其他变量没有使用这部分 Slot 区域,这个变量就还保存在那里,而对于垃圾回收,需要局部变量表中的 Slot 不再存在关于 placeholder 的引用。

局部变量不像类变量存在准备阶段,无默认值,定义时建议赋予初始值

操作数栈

与字节码指令序列严格匹配,每个栈容量为32位(double类型数据栈容量为2)

两个栈帧之间,各自对应的栈顶(操作栈共享区域)和栈底(局部变量表共享区域)重叠,可实现数据共享。

动态连接

常量池中大量的符号引用,一部分在类加载阶段或首次使用时即转换成直接引用,另外一部分将在运行期间转换为直接引用。后者即称为动态连接。

方法返回地址

方法被退出,只有两种方式:字节码返回指令、异常。

方法调用

方法调用不是说方法被执行,其任务是确定调用哪一个方法(版本)。与之对应地,涉及了多态的机制。

解析

类加载的解析阶段就会将一部分符号引用转化为直接引用,即确定了方法的调用版本。对于这些调用目标在代码中写好、编译时即确定的方法调用过程就叫做解析调用。

解析调用一定是个静态过程。

分派

相对地,分派是一个动态过程。

静态分派(重载)

  • 静态类型:其变化仅仅在使用时发生,静态类型本身不会被改变。

  • 实际类型:编译阶段是不定量,直到运行时才能确定。

  • 重载时,是以静态类型作为判定依据的。重载就是编译时多态的一个例子。我们通常所说的多态指的都是运行时多态,也就是编译时不确定究竟调用哪个具体方法,运行时才能确定。

  • 依赖静态类型决定方法运行版本的分派,为静态分派。

动态分派(重写)

从所有者逐级向上寻找对应的方法。在运行期确定方法执行版本的分派称为动态分派。这种多态性的根源在于虚方法调用指令invokevirtual执行逻辑。

虚方法:使用了 virtual 修饰符,这种函数或方法可以被子类继承和覆盖,通常使用动态分派实现。函数可以给出目标函数的定义,但该目标的具体指向在编译期可能无法确定。不允许再有 static、abstract 或者 override 修饰符。

需要记住,字段永远不参加多态。Java中有虚方法存在,则字段不可能是虚的。当子类声明了与父类同名的字段时,在子类内存中两个字段均会存在,而子类的字段会遮蔽父类的同名字段。

如何实现呢?

动态分派在虚拟机中执行得非常频繁,为减少频繁搜索元数据,一种优化策略是虚方法表,存放各个方法的实际入口地址,再根据是否存在重写进行下一步处理。

单分派与多分派

除了静态分派和动态分派的分类方式,还可以根据宗量将方法分派分为单分派和多分派两类。

宗量:方法的接收者与方法的参数的统称。

静态分派属于多分派,根据方法接收者的静态类型和方法参数类型两个宗量进行选择。

动态分派属于单分派,只根据方法接收者的实际类型一个宗量进行选择。

动态类型语言支持

动态类型语言就是类型检查的主体过程在运行期进行,而非编译期的编程语言。如:JavaScript/PHP/Python。

Java引入指令invokedynamicinvoke包,使得一些方法调用可以“跨域”调用。Lambda 表达式就是通过 invokedynamic 指令实现的。

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

中间的分支就是解释执行过程

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

Javac编译器输出的字节码指令流,基本上是一种基于栈的ISA,大部分依赖操作数栈进行工作。(基于寄存器:二地址,依赖寄存器)

优点:可移植、代码紧凑、编译简单(所需的空间都在栈上操作)

缺点:理论上执行速度稍慢。

解释器执行过程

中间变量都以操作数栈的出入栈作为信息交换途径。

案例:Tomcat

为解决web应用的可见性问题,其重写了类加载器:


参考: Github TangBean的思维导图

《深入理解JAVA虚拟机》

最后更新于

这有帮助吗?