深入理解Java虚拟机06--虚拟机字节码执行引擎

185 阅读8分钟

一.前言

物理机的执行引擎是直接在物理硬件如CPU、操作系统、指令集上运行的,但是对于虚拟机来讲,他的执行引擎由自己实现。 执行引擎有统一的外观(Java虚拟机规范),不同类型的虚拟机都遵循了这一规范,输入字节码文件,解析字节码处理,然后输出结果。

二.运行时栈帧结构

1、栈帧概念

栈帧(Stack Frame)用于支持方法调用和执行的数据结构,包含了局部变量表、操作数栈、动态连接和方法返回地址。

(1)局部变量表大小(max_locals),栈帧深度在编译时已经确定,并写入到了Code属性中;

(2)执行引擎运行的所有字节码指令都只针对当前栈进行操作; 2、局部变量表

局部变量表存储了方法参数以及方法内定义的局部变量。

Slot(变量槽):局部变量表容量最小单位,可以存放32位以内的数据类型; refrence: 直接或者间接找到到该对象在“堆内存”中数据存放的起始地址索引;

直接或者间接找到对象所属数据类型在方法区中存储的类型信息

局部变量表建立在线程的堆栈上,所以操作两个连续的slot是否为原子操作,都不会引起数据安全问题,但是如果是64位的话,不允许任何方式单独访问其中的一个;

this:实例方法(非static)默认第一个(第0位索引)slot为当前对象自己的引用;

slot重用:

当前字节码的pc计数器超出某个变量的作用域,那这个变量的slot可以交给别的变量使用;

影响到正常的Java垃圾回收机制;

赋null:因为上述slot重用的原因,当方法域内前面有局部变量定义了大内存实际不再使用的变量,紧接着后面的代码又是一个耗时的操作,这个时候及时赋null就显得有大的意义。因为一旦触发后,这部分的slot就可以被重用了。看起来就像是方法区内部进行“类gc"操作一样。但是,并不是任何时候都要进行赋null.以恰当的变量作用域来控制变量回收时间才是最优雅的方式,并且赋null值操作在经过JIT编译优化后会被消除掉,这样的话实际是没有任何意义的。

初始值:和类变量不同,局部变量系统不会自动赋初始值,所以没有赋值是无法使用的,编译都无法通过。即使通过,字节码校验阶段也会检查出来而导致类加载失败;

3、操作数栈(Operand Stack)

操作栈,后入先出;

最大深度:Code属性表中的max_stacks;

32位数据类型所占栈容量为1,64位所占容量为2;

栈元素的数据类型必须和栈指令保持一致

两个栈帧之间可以存在一部分的重叠,共享数据,这样在方法调用的时候避免的额外的参数复制。

Java虚拟机的解释执行引擎也是:基于栈的执行引擎;

4、动态连接(Dynamic Linking)

字节码中的方法的调用都是通过常量池中指定方法的符号作为参数

静态解析:这种符号有的是类加载阶段或者首次使用初始化的时候转化为直接的引用

动态连接:另外一部分是在运行时转化为直接引用

5、方法返回地址

退出:

正常退出:遇到返回的字节码指令;

异常退出:本方法异常表中没有匹配的异常;

退出后,恢复上层方法的局部变量表和操作栈,有返回值就把返回值压入上层调用者的栈中;

三.方法调用

定义:确定被调用方法的版本

1、解析

编译器可知,运行期不可变。这类方法的调用成为解析,在类加载阶段进行解析。

静态方法、私有方法、实例构造器方法、父类方法,符合上述条件。特点是:

只能被invokestatic和invokespecial指令调用

不可继承或者重写,编译时已经确定了一个版本。

在类加载时会把符合引用解析为该方法的直接引用。

非虚方法(注意final也是非虚方法,其他的都是虚方法)

2、静态分派

概念:根据静态类型来定位方法的执行版本

典型代表:方法的重载(方法名相同,参数类型不同)

发生时间:编译阶段

3、动态分派

概念:调用invokevirtual时,把常量池中的类方法符号解析到了不同的直接引用上。

典型代表:重写,多态的重要体现

过程:

执行invokevitual指令

在虚方法表(类加载阶段,类变量初始化结束后会初始化虚方法表)中查找方法,没有向上的父类进行查找

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

单分派和多分派:

只有一个宗量作为方法的选择依据,称为单分派。多个,则称为多分派。

当前的Java是静态多分派、动态单分派的语言;

四.动态语言支持

特点:变量无类型,变量的值才有类型

invoke包:Java实现动态语言新增的包

五.指令集

基于栈的指令集

过程:入栈、计算、出栈

优点:

可移植性,不依赖于硬件

代码紧凑

缺点:

速度较慢

产生相当多的指令数量

频繁内存访问

基于寄存器的指令集

代表:x86

六.方法内联

方法内联的方式是通过吧“目标方法”的代码复制到发起调用的方法内,避免真实的方法调用。

内联消除了方法调用的成本,还为其他优化手段建立良好的基础。

编译器在进行内联时,如果是非虚方法,那么直接内联。如果遇到虚方法,则会查询当前程序下是否有多个目标版本可供选择,如果查询结果只有一个版本,那么也可以内联,不过这种内联属于激进优化,需要预留一个逃生门(Guard条件不成立时的Slow Path),称为守护内联。

如果程序的后续执行过程中,虚拟机一直没有加载到会令这个方法的接受者的继承关系发现变化的类,那么内联优化的代码可以一直使用。否则需要抛弃掉已经编译的代码,退回到解释状态执行,或者重新进行编译

七.逃逸分析

逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法里面被定义后,它可能被外部方法所引用,这种行为被称为方法逃逸。被外部线程访问到,被称为线程逃逸。

如果对象不会逃逸到方法或线程外,可以做什么优化?

栈上分配:一般对象都是分配在Java堆中的,对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问堆中存储的对象数据。但是垃圾回收和整理都会耗时,如果一个对象不会逃逸出方法,可以让这个对象在栈上分配内存,对象所占用的内存空间就可以随着栈帧出栈而销毁。如果能使用栈上分配,那大量的对象会随着方法的结束而自动销毁,垃圾回收的压力会小很多。

同步消除:线程同步本身就是很耗时的过程。如果逃逸分析能确定一个变量不会逃逸出线程,那这个变量的读写肯定就不会有竞争,同步措施就可以消除掉。

标量替换:不创建这个对象,直接创建它的若干个被这个方法使用到的成员变量来替换。

八.小结

  在前面我们已经了解到栈帧、方法区的内存时线程私有的,本篇更加详细的讲了方法是怎么找到并执行的。Java虚拟机规范:输入字节码,解析字节码处理,输出结果。首先,栈帧包含了局部变量表、操作数栈、动态连接、方法返回地址。字节码中的方法都是通过常量池中的符号作为参数指定的,有些编译解析确定,有些运行行时转化为直接引用。首先记住,JVM是基于栈的执行引擎。栈有着先入后出的特点,执行引擎的指令也仅执行当前栈。而局部变量表存储了方法内需要的变量信息,是以Slot 为单位进行存储,超出操作域后,原本占用的内存区域可以被其他的局部变量使用,类似“回收”。然后,记住Java是静态多分派,动态单分派的语言。静态分派,如方法的重载。通过方法的参数不同就可以确定要调用哪个方法,这个再编译阶段就定好。动态分派,如方法的重写。执行方法时,有一个虚方法表。这这个表里搜索,自己有就执行自己的,没有向上找父类的。这个是Java实现多态的重要原理。Java也有支持动态语言的invoke包,平时用的较少。