[JVM]了断局:字节码执行引擎

以你之姓@ 2022-12-01 03:38 331阅读 0赞

Table of Contents

一.前言

二.运行时栈帧结构

1.局部变量表

2.操作数栈

3.动态连接

4.方法返回地址

5.附加信息

三.方法调用

1.方法调用

2.解析


一.前言

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

二.运行时栈帧结构

每个线程都有自己的pc寄存器(ProgramCounter) 和Java虚拟机栈(JVM Stack) 。

Java虚拟机栈又由栈帧(Stack Frame, 后面简称帧) 构成,帧中保存方法执行的状态, 包括局部变量表(Local Variable) 和操作数栈(Operand Stack) 等。 在任一时刻, 某一线程肯定是在执行某个方法。 这个方法叫作该线程的当前方法; 执行该方法的帧叫作线程的当前帧; 声明该方法的类叫作当前类。 如果当前方法是Java方法, 则pc寄存器中存放当前正在执行的
Java虚拟机指令的地址, 否则, 当前方法是本地方法, pc寄存器中的值没有明确定义。

Java虚拟机以方法作为最基本的执行单元, “栈帧”(Stack Frame) 则是用于支持虚拟机进行方法调用和方法执行背后的数据结构, 它也是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。 栈帧存储了方法的局部变量表操作数栈动态连接方法返回地址等信息.每一个方法从调用开始至执行结束的过程, 都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

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

1.局部变量表

局部变量表(Local Variables Table) 是一组变量值的存储空间, 用于存放方法参数和方法内部定义的局部变量。 在Java程序被编译为Class文件时, 就在方法的Code属性的 max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。局部变量表的容量以变量槽(Variable Slot) 为最小单位. 一个变量槽可以存放一个32位以内的数据类型, Java中占用不超过32位存储空间的数据类型有boolean、
byte、 char、 short、 int、 float、 reference[1]和returnAddress这8种类型。

reference类型表示对一个对象实例的引用, 《Java虚拟机规范》 既没有说明它的长度, 也没有明确指出这种
引用应有怎样的结构。 但是一般来说, 虚拟机实现至少都应当能通过这个引用做到两件事情:

一是从根据引用直接或间接地查找到对象在Java堆中的数据存放的起始地址或索引,

二是根据引用直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息, 否则将无法实现《Java语言规范》 中定义的语法约定。

returnAddress类型目前已经很少见了, 它是为字节码指令jsr、 jsr_w和ret服务的, 指向了一条字节码指令的地址, 某些很古老的Java虚拟机曾经使用这几条指令来实现异常处理时的跳转, 但现在也已经全部改为采用异常表来代替了。

对于64位的数据类型, Java虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间。

Java语言中明确的64位的数据类型只有long和double两种。

局部变量表是建立在线程堆栈中的, 属于线程私有的数据, 无论读写两个连续的变量槽是否为原子操作, 都不会引起数据竞争和线程安全问题.

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

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

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

示例1.

  1. public class LocalConstantPool {
  2. public static void main(String[] args) {
  3. byte[] placeholder = new byte[64 * 1024 * 1024];
  4. System.gc();
  5. }
  6. }

代码很简单, 向内存填充了64MB的数据, 然后通知虚拟机进行垃圾
收集。 我们在虚拟机运行参数中加上“-verbose: gc”来看看垃圾收集的过程, 发现在
System.gc()运行后并没有回收掉这64MB的内存,运行结果:

  1. [GC (System.gc()) 68157K->66040K(251392K), 0.0125002 secs]
  2. [Full GC (System.gc()) 66040K->65956K(251392K), 0.0099327 secs]

因为在执行System.gc()时, 变量placeholder还处于作用域之内, 虚拟机自然不敢回收掉placeholder的内存。

示例2:

  1. public class LocalConstantPool {
  2. public static void main(String[] args) {
  3. {
  4. byte[] placeholder = new byte[64 * 1024 * 1024];
  5. }
  6. System.gc();
  7. }
  8. }

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

  1. [GC (System.gc()) 69468K->66040K(251392K), 0.0013854 secs]
  2. [Full GC (System.gc()) 66040K->65956K(251392K), 0.0058345 secs]

示例3.

  1. public class LocalConstantPool {
  2. public static void main(String[] args) {
  3. {
  4. byte[] placeholder = new byte[64 * 1024 * 1024];
  5. }
  6. int a = 1 ;
  7. System.gc();
  8. }
  9. }

结果

  1. [GC (System.gc()) 68157K->66072K(251392K), 0.0120745 secs]
  2. [Full GC (System.gc()) 66072K->420K(251392K), 0.0070919 secs]

placeholder能否被回收的根本原因就是:

局部变量表中的变量槽是否还存有关于placeholder数组对象的引用。 第一次修改中, 代码虽然已经离开了placeholder的作用域, 但在此之后, 再没有发生过任何对局部变量表的读写操作,placeholder原本所占用的变量槽还没有被其他变量所复用, 所以作为GC Roots一部分的局部变量表仍然保持着对它的关联。 这种关联没有被及时打断, 绝大部分情况下影响都很轻微。 但如果遇到一个方法, 其后面的代码有一些耗时很长的操作, 而前面又定义了占用了大量内存但实际上已经不会再使用的变量, 手动将其设置为null值(用来代替那句int a=0, 把变量对应的局部变量槽清空) 便不见得是一个绝对无意义的操作, 这种操作可以作为一种在极特殊情形(对象占用内存大、 此方法的栈帧长时间不能被回收、 方法调用次数达不到即时编译器的编译条件) 下的“奇技”来使用。 Java语言的一本非常著名的书籍《Practical Java》 中将把“不使用的对象应手动赋值为null”作为一条推荐的编码规则. 但是不推荐这么使用, 因为在实际情况中, 即时编译才是虚拟机执行代码的主要方式, 赋null值的操作在经过即时编译优化后几乎是一定会被当作无效操作消除掉的, 这时候将变量设置为null就是毫无意义的行为。 字节码被即时编译为本地代码后, 对GC Roots的枚举也与解释执行时期有显著差别.

如果一个局部变量定义了但没有赋初始值, 那它是完全不能使用的。

2.操作数栈

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

当一个方法刚刚开始执行的时候, 这个方法的操作数栈是空的, 在方法的执行过程中, 会有各种字节码指令往操作数栈中写入和提取内容, 也就是出栈和入栈操作。

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配, 在编译程序代码的时候, 编译器必须要严格保证这一点, 在类校验阶段的数据流分析中还要再次验证这一点。再以上面的iadd指令为例, 这个指令只能用于整型数的加法, 它在执行时, 最接近栈顶的两个元素的数据类型必须为int型, 不能出现一个long和一个float使用iadd命令相加的情况。

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

watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3poYW5nbG9uZ180NDQ0_size_16_color_FFFFFF_t_70

3.动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用, 持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking) 。Class文件的常量池中存有大量的符号引用, 字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。 这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用, 这种转化被称为静态解析。 另外一部分将在每一次运行期间都转化为直接引用, 这部分就称为动态连接。

4.方法返回地址

当一个方法开始执行后, 只有两种方式退出这个方法。

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

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

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

5.附加信息

《Java虚拟机规范》 允许虚拟机实现增加一些规范里没有描述的信息到栈帧之中, 例如与调试、 性能收集相关的信息, 这部分信息完全取决于具体的虚拟机实现.

三.方法调用

1.方法调用

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

2.解析

所有方法调用的目标方法在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)

Java中的非虚方法除了使用invokestatic、 invokespecial调用的方法之外还有一种, 就是被final修饰的实例方法。 虽然由于历史设计的原因, final方法是使用invokevirtual指令来调用的, 但是因为它也无法被覆盖, 没有其他版本的可能, 所以也无须对方法接收者进行多态选择, 又或者说多态选择的结果肯定是唯一的。 在《Java语言规范》 中明确定义了被final修饰的方法是一种非虚方法

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

#

根据情况, 自动转型能继续发生多次, 按照char>int>long>float>double的顺序转型进行匹配, 但不会匹配到byte和short类型的重载, 因为char到byte或short的转型是不安全

发表评论

表情:
评论列表 (有 0 条评论,331人围观)

还没有评论,来说两句吧...

相关阅读

    相关 [JVM]了断: 类加载机制

    一.类加载的时机 二. 有且只有六种情况必须立即对类进行“初始化” 三.类加载的过程 1.加载 2.验证 1.文件格式验证 2.元数据验证 3.字节码验证 4.

    相关 字节执行引擎

    栈帧: 局部变量表:编译时确定大小,slot(一般32bit,可存放reference用于找到对象和类型数据) 连续2slot(64bit,long,double,可以

    相关 JVM—虚拟机字节执行引擎

    执行引擎是Java虚拟机最为核心的组成部分之一. 虚拟机是一个相对于物理机的概念, 两种及其都有代码执行能力, 其区别是物理机的执行引擎是直接建立在处理器, 硬件, 指令集和操