• java
  • go
  • 数据库
  • linux
  • 中间件
  • 书
  • 源码
  • 夕拾

  • java
  • go
  • 数据库
  • linux
  • 中间件
  • 书
  • 源码
  • 夕拾

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

目录

  • 目录
  • 类文件结构
    • 字节码指令
      • 字节码与数据类型
      • 加载和存储指令
      • 运算指令
      • 类型转换
      • 对象创建与访问指令
      • 操作数栈管理指令
      • 控制转移指令
      • 方法调用和返回指令
      • 异常处理指令
        • 同步指令
  • 虚拟机类加载机制
    • 类加载时机
    • 类加载过程
      • 加载
      • 验证
      • 准备
      • 解析
      • 初始化
    • 类加载器
      • 类与类加载器
      • 双亲委派
    • java模块化系统
  • 虚拟机字节码执行
    • 运行时栈帧结构
      • 局部变量表
      • 操作数栈
      • 动态链接
      • 方法返回地址
    • 方法调用
      • 解析
      • 分派
  • 动态类型语言
    • java与动态类型
    • 解释执行引擎
      • 解释执行
      • 基于栈的指令集与基于寄存器的指令集

类文件结构

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

Class文件格式采用一种类似于C语言结构体的伪结构来存储数据, 这种伪结构中只有两种数据类型: “无符号数”和“表”。

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

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

字节码指令

字节码与数据类型

加载和存储指令

  • 将一个局部变量加载到操作栈: 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_<i>、 lconst_、 fconst_、 dconst_
  • 扩充局部变量表的访问索引的指令: wide

运算指令

  • 加法指令: iadd、 ladd、 fadd、 dadd
  • 减法指令: isub、 lsub、 fsub、 dsub
  • 乘法指令: imul、 lmul、 fmul、 dmul
  • 除法指令: idiv、 ldiv、 fdiv、 ddiv
  • 求余指令: irem、 lrem、 frem、 drem
  • 取反指令: ineg、 lneg、 fneg、 dneg
  • 位移指令: ishl、 ishr、 iushr、 lshl、 lshr、 lushr
  • 按位或指令: ior、 lor
  • 按位与指令: iand、 land
  • 按位异或指令: ixor、 lxor
  • 局部变量自增指令: iinc
  • 比较指令: dcmpg、 dcmpl、 fcmpg、 fcmpl、 lcmp

类型转换

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。 窄化类型转换可能会导致转换结果产生不同的正负号、 不同的数量级的情况, 转换过程很可能会导致数值的精度丢失。

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虚拟机规范》 中明确规定数值类型的窄化转换指令永远不可能导致虚拟机抛出运行时异常

对象创建与访问指令

  • 创建类实例的指令: new
  • 创建数组的指令: newarray、 anewarray、 multianewarray
  • 访问类字段(static字段, 或者称为类变量) 和实例字段(非static字段, 或者称为实例变量) 的指令: getfield、 putfield、 getstatic、 putstatic
  • 把一个数组元素加载到操作数栈的指令: baload、 caload、 saload、 iaload、 laload、 faload、
    daload、 aaload
  • 将一个操作数栈的值储存到数组元素中的指令: bastore、 castore、 sastore、 iastore、 fastore、dastore、 aastore
  • 取数组长度的指令: arraylength
  • 检查类实例类型的指令: instanceof、 checkcast

操作数栈管理指令

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

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

控制转移指令

  • 条件分支: ifeq、 iflt、 ifle、 ifne、 ifgt、 ifge、 ifnull、 ifnonnull、 if_icmpeq、 if_icmpne、 if_icmplt、if_icmpgt、 if_icmple、 if_icmpge、 if_acmpeq和if_acmpne
  • 复合条件分支: tableswitch、 lookupswitch
  • 无条件分支: goto、 goto_w、 jsr、 jsr_w、 ret

方法调用和返回指令

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

异常处理指令

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

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

同步指令

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

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

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

虚拟机类加载机制

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

在Java语言里面, 类型的加载、 连接和初始化过程都是在程序运行期间完成的, 这种策略让Java语言进行提前编译会面临额外的困难, 也会让类加载时稍微增加一些性能开销,但是却为Java应用提供了极高的扩展性和灵活性

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

image.png

类加载时机

关于在什么情况下需要开始类加载过程的第一个阶段“加载”, 《Java虚拟机规范》 中并没有进行强制约束, 这点可以交给虚拟机的具体实现来自由把握。

但是对于初始化阶段, 《Java虚拟机规范》则是严格规定了有且只有六种情况必须立即对类进行“初始化”(而加载、 验证、 准备自然需要在此之前开始) :

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

接口与类真正有所区别的是前面讲述的六种“有且仅有”需要触发初始化场景中的第三种:当一个类在初始化时, 要求其父类全部都已经初始化过了, 但是一个接口在初始化时, 并不要求其父接口全部都完成了初始化, 只有在真正使用到父接口的时候(如引用接口中定义的常量) 才会初始化。

对于这六种会触发类型进行初始化的场景, 《Java虚拟机规范》 中使用了一个非常强烈的限定语——“有且只有”, 这六种场景中的行为称为对一个类型进行主动引用。 除此之外, 所有引用类型的方式都不会触发初始化, 称为被动引用。

可使用-XX:+TraceClassLoading观察类加载信息

类加载过程

加载

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

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

《Java虚拟机规范》 对这三点要求其实并不是特别具体, 留给虚拟机实现与Java应用的灵活度都是相当大的。 例如“通过一个类的全限定名来获取定义此类的二进制字节流”这条规则, 它并没有指明二进制字节流必须得从某个Class文件中获取, 确切地说是根本没有指明要从哪里获取、 如何获取。

  1. 从zip中获取,是称为jar,war,ear格式的基础
  2. 网络中获取,web applet
  3. 运行时生成,java.lang.reflect.Proxy中,就是用了Proxygenerator.generateProxyClass()来为特定接口生成形式为*$Proxy的代理类的二进制字节流
  4. 其他文件生成,jsp,由jsp文件生成对应的class文件
  5. 数据库中读取
  6. 加密文件中获取
    ….

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

验证

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

准备

正式为类中定义的变量(静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。

从概念上讲, 这些变量所使用的内存都应当在方法区中进行分配, 但必须注意到方法区本身是一个逻辑上的区域, 在JDK 7及之前, HotSpot使用永久代来实现方法区时, 实现是完全符合这种逻辑概念的; 而在JDK 8及之后, 类变量则会随着Class对象一起存放在Java堆中, 这时候“类变量在方法区”就完全是一种对逻辑概念的表述了

误区:

  1. 进行内存分配的变量只有类变量,而不包括实例变量
  2. 初始值是该类型的“零值”,因为还没执行任何java方法,value赋值的操作要到类的初始化阶段才会被执行。

image.png

解析

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

初始化

类加载过程的最后一个步骤,之前介绍的几个类加载动作里,除了加载阶段用户引用程序可以通过自定义类加载器的方式局部参与外,其余动作都有jvm主导控制。

进行准备阶段时, 变量已经赋过一次系统要求的初始零值, 而在初始化阶段, 则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。 我们也可以从另外一种更直接的形式来表达: 初始化阶段就是执行类构造器<clinit>()方法的过程。 <clinit>()并不是程序员在Java代码中直接编写的方法, 它是Javac编译器的自动生成物, 但我们非常有必要了解这个方法具体是如何产生的, 以及<clinit>()方法执行过程中各种可能会影响程序运行行为的细节, 这部分比起其他类加载过程更贴近于普通的程序开发人员的实际工作

<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块) 中的语句合并产生的, 编译器收集的顺序是由语句在源文件中出现的顺序决定的, 静态语句块中只能访问到定义在静态语句块之前的变量, 定义在它之后的变量, 在前面的静态语句块可以赋值, 但是不能访问

<clinit>()方法与类的构造函数(虚拟机视角中实例构造器<init>())不同,不需要显示地调用父类构造器,java虚拟机会保证在子类的<clinit>()方法执行前,父类的<clinit>()方法已经执行完毕.因此jvm中第一个被执行的<clinit>()方法的类型肯定是Object

由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作.

1
2
3
4
5
6
7
8
9
10
11
static class P {
static int a = 1;
static {
a = 2;
}
}

static class C extends P {
static int b = a; // b=2
}

接口中不能使用静态语句块, 但仍然有变量初始化的赋值操作, 因此接口与类一样都会生成<clinit>()方法。 但接口与类不同的是, 执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,因为只有当父接口中定义的变量被使用时, 父接口才会被初始化。 此外, 接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。

Java虚拟机必须保证一个类的<clinit>()方法在多线程环境中被正确地加锁同步, 如果多个线程同时去初始化一个类, 那么只会有其中一个线程去执行这个类的<clinit>()方法, 其他线程都需要阻塞等待, 直到活动线程执行完毕<clinit>()方法。 如果在一个类的<clinit>()方法中有耗时很长的操作, 那就可能造成多个进程阻塞, 在实际应用中这种阻塞往往是很隐蔽的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static class DeadLoopClass {
static {
if (true) {
sout(currentTHreadId)
while(true){}
}
}
}

public static void main(String[] args) {
// 其中一条线程会阻塞
new Thread(() -> new DeadLoopClass()).start();
new Thread(() -> new DeadLoopClass()).start();
}

类加载器

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

类与类加载器

类加载器虽然只用于实现类的加载动作,但它在java程序中起到的作用却远超类加载阶段.

对任意一个类,都必须有加载它的类加载器和这个类本身一起共同确定java虚拟机中的唯一性.每一个类加载器,都拥有一个独立名称空间.(相等指的是类对象的equals()方法,isAssignableForm()方法,isInstance()方法返回的结果,也包括了使用instanceof关键字做对象所属关系判定等情况.)

双亲委派

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

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

三层类加载器

  1. bootstrap class loader:负责存放在<java_home>\lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且java虚拟机能够识别的(识别指文件名识别,rt.jar,tools.jar,名字不符合的类库即使放入到指定该路径,也不会被加载)类库加载到虚拟机的内存中.
    启动类加载器无法被java程序直接引用,如果在自定义的加载器中需要把加载请求委派给引导类加载器去处理,直接使用null代替.
  2. Extension class loader:这个类加载器是在类sun.misc.Launcher$ExtClassLoader中以java代码的形式实现的,负责加载<java_home>\lib\ext目录中,或者被java.ext.dirs系统变量指定的路径中所有的类库.故名思意’扩展类加载器’是jdk的开发团队允许用户将具有通用性的类库放在ext目录以扩展java se的功能,在jdk9之后,这种扩展机制被模块化带来的天然扩展能力取代.
  3. Application class loader:这个类加载器是由sun.misc.lanucher$AppClassLoader来实现.由于应用程序类加载器是ClassLoader类中的getSystemClassLoader()方法的返回值,有些场景也被称为系统类加载器.它负责加载用户类路径(class path)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下,这个就是程序中默认的类加载器。

JDK 9之前的Java应用都是由这三种类加载器互相配合来完成加载的, 如果用户认为有必要, 还可以加入自定义的类加载器来进行拓展, 典型的如增加除了磁盘位置之外的Class文件来源, 或者通过类加载器实现类的隔离、 重载等功能。

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

java模块化系统

java模块化(java platform module system,JPMS)能够实现模块化得关键目标—可配置得封装个锂基酯。jdk9得模块化不仅仅像之前得jar包一样充当代码得容器,除了代码之外。还包含:

  1. 依赖其他模块得列表
  2. 导出的包的列表,即其他模块可以使用的列表
  3. 开放的包列表,即其他模块可反射访问模块的列表。
  4. 使用的服务列表
  5. 提供服务的实现列表i哦

解决的问题:

  1. 解决了之前基于类路径(class path)来查找以来的可靠性问题。如果启用了模块化进行封装, 模块就可以声明对其他模块的显式依赖, 这样Java虚拟机就能够在启动时验证应用程序开发阶段设定好的依赖关系在运行期是否完备, 如有缺失那就直接启动失败, 从而避免了很大一部分由于类型依赖而引发的运行时异常。(并不是说模块化下就不可能出现ClassNotFoundExcepiton这类异常了, 假如将某个模块中的、 原本公开的包中把某些类型移除, 但不修改模块的导出信息, 这样程序能够顺利启动, 但仍然会在运行期出现类加载异常。)
  2. 可配置的封装隔离机制还解决了原来类路径上跨JAR文件的public类型的可访问性问题。 JDK 9中的public类型不再意味着程序的所有地方的代码都可以随意访问到它们, 模块提供了更精细的可访问性控制, 必须明确声明其中哪一些public的类型可以被其他哪一些模块访问

虚拟机字节码执行

运行时栈帧结构

栈帧(stack frame) 每一个栈帧都包括了局部变量表、 操作数栈、 动态连接、 方法返回地址和一些额外的附加信息

image.png

局部变量表

局部变量表(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位虚拟机中的一致.

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

操作数栈

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

动态链接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用, 持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking) 。

方法返回地址

当一个方法开始执行后, 只有两种方式退出这个方法。 第一种方式是执行引擎遇到任意一个方法返回的字节码指令, 这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者或者主调方法) , 方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定, 这种退出方法的方式称为“正常调用完成”(Normal Method Invocation Completion) 。

另外一种退出方式是在方法执行的过程中遇到了异常, 并且这个异常没有在方法体内得到妥善处理。 无论是Java虚拟机内部产生的异常, 还是代码中使用athrow字节码指令产生的异常, 只要在本方法的异常表中没有搜索到匹配的异常处理器, 就会导致方法退出, 这种退出方法的方式称为“异常调用完成(Abrupt Method Invocation Completion) ”。 一个方法使用异常完成出口的方式退出, 是不会给它的上层调用者提供任何返回值的。

无论采用何种退出方式, 在方法退出之后, 都必须返回到最初方法被调用时的位置, 程序才能继续执行, 方法返回时可能需要在栈帧中保存一些信息, 用来帮助恢复它的上层主调方法的执行状态。一般来说, 方法正常退出时, 主调方法的PC计数器的值就可以作为返回地址, 栈帧中很可能会保存这个计数器值。 而方法异常退出时, 返回地址是要通过异常处理器表来确定的, 栈帧中就一般不会保存这部分信息

方法调用

方法调用并不等同于方法中的代码被执行, 方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法) , 暂时还未涉及方法内部的具体运行过程。 在程序运行时, 进行方法调用是最普遍、 最频繁的操作之一。

个特性给Java带来了更强大的动态扩展能力, 但也使得Java方法调用过程变得相对复杂, 某些调用需要在类加载期间, 甚至到运行期间才能确定目标方法的直接引用。

解析

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

分派

动态类型语言

动态类型语言的关键特征是他的类型检查的主体过程是在运行期而不是编译期。

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中的各个步骤。 如果读者对大学编译原理的相关课程还有印象的话, 很容易就会发现图8-4中下面的那条分支, 就是传统编译原理中程序代码到目标机器代码的生成过程; 而中间的那条分支, 自然就是解释执行的过程。

image.png

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

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

Javac编译器输出的字节码指令流, 基本上[1]是一种基于栈的指令集架构(Instruction SetArchitecture, ISA) , 字节码指令流里面的指令大部分都是零地址指令, 它们依赖操作数栈进行工作。(解释执行时效率会慢,即时编译不会。有点为可移植)

与之相对的另外一套常用的指令集架构是基于寄存器的指令集, 最典型的就是x86的二地址指令集, 如果说得更通俗一些就是现在我们主流PC机中物理硬件直接支持的指令集架构, 这些指令依赖寄存器进行工作

【第二部分】自动内存管理-gc器
nmap
  1. 1. 目录
  2. 2. 类文件结构
    1. 2.1. 字节码指令
      1. 2.1.1. 字节码与数据类型
      2. 2.1.2. 加载和存储指令
      3. 2.1.3. 运算指令
      4. 2.1.4. 类型转换
      5. 2.1.5. 对象创建与访问指令
      6. 2.1.6. 操作数栈管理指令
      7. 2.1.7. 控制转移指令
      8. 2.1.8. 方法调用和返回指令
      9. 2.1.9. 异常处理指令
        1. 2.1.9.1. 同步指令
  3. 3. 虚拟机类加载机制
    1. 3.1. 类加载时机
    2. 3.2. 类加载过程
      1. 3.2.1. 加载
      2. 3.2.2. 验证
      3. 3.2.3. 准备
      4. 3.2.4. 解析
      5. 3.2.5. 初始化
    3. 3.3. 类加载器
      1. 3.3.1. 类与类加载器
      2. 3.3.2. 双亲委派
    4. 3.4. java模块化系统
  4. 4. 虚拟机字节码执行
    1. 4.1. 运行时栈帧结构
      1. 4.1.1. 局部变量表
      2. 4.1.2. 操作数栈
      3. 4.1.3. 动态链接
      4. 4.1.4. 方法返回地址
    2. 4.2. 方法调用
      1. 4.2.1. 解析
      2. 4.2.2. 分派
  5. 5. 动态类型语言
    1. 5.1. java与动态类型
    2. 5.2. 解释执行引擎
      1. 5.2.1. 解释执行
      2. 5.2.2. 基于栈的指令集与基于寄存器的指令集
© 2023 haoxp
Hexo theme