虚拟机字节码执行引擎
栈帧
概述
栈帧也叫过程活动记录,是编译器用来进行方法调用和方法执行的一种数据结构,它是虚拟机运行时数据区域中的虚拟机栈的栈元素。
栈帧中包括了局部变量表,操作数栈,动态链接和方法返回地址以及额外的一些附加信息,在编译过程中,局部变量表的大小已经确定,操作数栈深度也已经确定,因此栈帧在运行的过程中需要分配多大的内存是固定的,不受运行时影响。
对于没有逃逸的对象也会在栈上分配内存,对象的大小其实在运行时也是确定的,因此即使出现了栈上内存分配,也不会导致栈帧改变大小。
一个线程中,可能调用链会很长,很多方法都同时处于执行状态。对于执行引擎来讲,活动线程中,只有栈顶的栈帧是最有效的,称为当前栈帧,这个栈帧所关联的方法称为当前方法。执行引擎所运行的字节码指令仅对当前栈帧进行操作。
结构
运行时栈帧结构
局部变量表
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序被编译成Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的最大局部变量表的容量。
局部变量表的容量以变量槽(Slot)为最小单位,32位虚拟机中一个Slot可以存放一个32位以内的数据类型(boolean、byte、char、short、int、float、reference和returnAddress八种),对于64位的 long 和 double 变量而言,虚拟机会为其分配两个连续的 Slot 空间。
-
局部变量没有初始值,必须要赋值;全局变量则不需要
-
局部变量表中得slot是可重用的,方法体定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超过了某个变量的作用域,那么这个变量对应的Slot就可以交给其他变量使用。
这样的设计不仅仅是为了节省栈空间,在某些情况下Slot的复用会直接影响到系统的垃圾收集行为。
例如如下代码:
package com.jvm.bytecode.java02;
public class GTTest {
public static void main(String[] args) {
byte[] _64M = new byte[1024 * 1024 * 64];
System.gc();
}
}
// 运行结果:----------------------------
[0.003s][warning][gc] -XX:+PrintGC is deprecated. Will use -Xlog:gc instead.
[0.012s][info ][gc] Using G1
[0.125s][info ][gc] GC(0) Pause Young (Concurrent Start) (G1 Humongous Allocation) 3M->0M(128M) 1.517ms
[0.125s][info ][gc] GC(1) Concurrent Cycle
[0.153s][info ][gc] GC(1) Pause Remark 66M->66M(128M) 0.611ms
[0.153s][info ][gc] GC(1) Pause Cleanup 66M->66M(128M) 0.027ms
[0.155s][info ][gc] GC(2) Pause Full (System.gc()) 66M->65M(128M) 2.365ms
//从运行结果分析,发现System.gc()运行后并没有回收掉这64M的内存。
[0.155s][info ][gc] GC(1) Concurrent Cycle 30.044ms
“_64M"的内存没有回收掉能说的过去,因为在执行System.gc()时,变量”__64M"还处于作用域之内,虚拟机自然不敢回收掉该内存。我们把代码修改如下:
package com.jvm.bytecode.java02;
public class GTTest {
public static void main(String[] args) {
{
byte[] _64M = new byte[1024 * 1024 * 64];
}
System.gc();
}
}
// 运行结果:----------------------------
[0.003s][warning][gc] -XX:+PrintGC is deprecated. Will use -Xlog:gc instead.
[0.013s][info ][gc] Using G1
[0.136s][info ][gc] GC(0) Pause Young (Concurrent Start) (G1 Humongous Allocation) 3M->0M(128M) 1.762ms
[0.136s][info ][gc] GC(1) Concurrent Cycle
[0.163s][info ][gc] GC(1) Pause Remark 65M->65M(128M) 0.654ms
[0.166s][info ][gc] GC(2) Pause Full (System.gc()) 65M->65M(128M) 2.439ms
//从运行结果分析,发现System.gc()运行后并没有回收掉这64M的内存。
[0.166s][info ][gc] GC(1) Concurrent Cycle 29.510ms
"_64M"的所用域被限制在了块中,执行System.gc()的时候。p已经不能被访问了。可还是没有被回收。
再次修改代码如下:
package com.jvm.bytecode.java02;
public class GTTest {
public static void main(String[] args) {
{
byte[] _64M = new byte[1024 * 1024 * 64];
}
int a = 0;
System.gc();
}
}
// 运行结果:----------------------------
[0.004s][warning][gc] -XX:+PrintGC is deprecated. Will use -Xlog:gc instead.
[0.013s][info ][gc] Using G1
[0.146s][info ][gc] GC(0) Pause Young (Concurrent Start) (G1 Humongous Allocation) 3M->0M(128M) 1.588ms
[0.146s][info ][gc] GC(1) Concurrent Cycle
[0.174s][info ][gc] GC(1) Pause Remark 65M->65M(128M) 0.592ms
[0.174s][info ][gc] GC(1) Pause Cleanup 65M->65M(128M) 0.023ms
[0.180s][info ][gc] GC(2) Pause Full (System.gc()) 65M->0M(8M) 6.373ms
//内存被回收了
[0.181s][info ][gc] GC(1) Concurrent Cycle 34.160ms
局部变量"_64M"能否被回收的根本原因就是:局部变量表中得Slot是否还存有关于"__64M"数组对象的引用。第一次修改,代码虽然离开了64M的作用域,但在此之后,没有任何对局部变量表的读写操作,_64M 原本所占用的Slot还没有被其他变量所复用,所以作为GC Roots 一部分的局部变量表让然保持对它的关联。这种关联没有被及时打断,在绝大部分情况下都很轻微。但如果遇到一个方法,其后面的代码有一些耗时很长的操作,而前面又占用了大量的内存,实际上已经不会在被使用的变量,手工将其设置为NULL值(用来代替int x=0)把变量对应的局部变量表Slot情况,就不是一个毫无意义的操作,这种操作可以作为 一种在及特殊情形(对象暂用内存大,此方法的栈帧长时间不能被回收,方法调用次数达不到JIT编译条件)下得“奇技” 来使用。但不应当对赋null值操作有过多的依赖,也没有必要把它当做一个普遍的编码方法来推广,以恰当的变量作用域来控制变量回收时间才是最优雅的解决方法。
- jvm不会给局部变量赋初始值,只给全局变量赋初始值。
- slot复用会打断slot中变量与对象的引用,这种关联被打断后,垃圾回收才会生效。
- 虽然块中的变量不可能在块外被访问,妥妥的垃圾了,但是slot和对象的关联仍然保持,不会成为垃圾。
操作数栈
- 常称为操作栈
- 后入先出栈。
- Class 文件的Code 属性的 max_stacks 指定了执行过程中最大的栈深度。
- Java 虚拟机的解释执行引擎称为”基于栈的执行引擎“,这里的栈就是指操作数栈。
- 理解为java虚拟机栈中的一个用于计算的临时数据存储区。
- 运算的地方,大多数指令都在操作数栈弹栈运算,然后结果压栈
**案例:**演示了虚拟机是如何把两个int类型的局部变量相加,再把结果保存到第三个局部变量
- iload_0 // 存储在局部变量中索引为0的整数压入操作数栈中
- iload_1 // 存储在局部变量中索引为1的整数压入操作数栈中
- iadd // 从操作数栈中弹出那两个整数相加,再将结果压入操作数栈
- istore_2 // 操作数栈中弹出结果,并把它存储到局部变量区索引为2的位置
package com.jvm.bytecode.java02;
public class Test {
public static int add(int a,int b){
return a+b;
}
public static void main(String[] args) {
int a = 100;
int b = 98;
int c = a+b;
System.out.println(c);
}
}
//------------------------
Code:
stack=2, locals=4, args_size=1
0: bipush 100
2: istore_1
3: bipush 98
5: istore_2
6: iload_1
7: iload_2
8: iadd
9: istore_3
10: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
13: iload_3
14: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
17: return
动态连接
- 每个栈帧都包含一个执行运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)
- Class 文件中存放了大量的符号引用,字节码中的方法调用指令就是以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或第一次使用时转化为直接引用,这种转化称为静态解析。另一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
返回地址
方法调用时通过一个指向方法的指针指向方法的地址,方法返回时将回归到调用处,那个地方是返回地址。
当一个方法开始执行以后,只有两种方法可以退出当前方法:
- 当执行遇到返回指令,会将返回值传递给上层的方法调用者,这种退出的方式称为正常完成出口(Normal Method Invocation Completion),一般来说,调用者的PC计数器可以作为返回地址。
- 当执行遇到异常,并且当前方法体内没有得到处理,就会导致方法退出,此时是没有返回值的,称为异常完成出口(Abrupt Method Invocation Completion),返回地址要通过异常处理器表来确定。
当方法返回时,可能进行3个操作:
- 恢复上层方法的局部变量表和操作数栈
- 把返回值压入调用者调用者栈帧的操作数栈
- 调整 PC 计数器的值以指向方法调用指令后面的一条指令
附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现。在实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。
方法调用
方法调用并不等同于方法的执行,方法调用阶段的唯一任务就是确定被调用方法的版本(考虑多态情况)。
解析调用
所有的方法调用中的目标方法在Class文件中都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分引用转化为直接引用,这种解析的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期间是不变的。换句话说,调用方法在编译器进行编译时就必须确定下来。
这类方法主要包括:
-
静态方法:与类直接关联
-
私有方法:后者在外部不可被访问
这两种方法都不能通过继承或者别的方式重写其他版本,因此他们都适合在类的加载阶段进行解析。
在Java虚拟机里提供了5调方法调用字节码指令
- invokestatic:调用静态方法。
- invokespecial:调用实例构造器< init>方法,私有方法和父类方法。
- invokevirtual:调用所有的虚方法。()
- invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
- invokedynamic:现在运行时动态解析调用点限定符所引用的方法,然后再执行该方法。
通过invokestatic和invokespecial指令调用的方法,可以在解析阶段确定唯一的调用版本,符合这种条件的有静态方法、私有方法、实例构造器和父类方法这4种,它们在类加载时会把符号引用解析为该方法的直接引用。
package com.jvm.bytecode.java02;
public class HelloWorld {
public static void sayHello(){
System.out.println("hello");
}
public static void main(String[] args) {
HelloWorld.sayHello();
}
}
可以发现实例构造器是通过invokespecial指令调用的, sayHello方法是通过invokestatic指令调用的。
通过invokestatic和invokespecial指令调用的方法,可以称为非虚方法,其余情况称为虚方法,不过有一个特例,即被final关键字修饰的方法,虽然使用invokevirtual指令调用,由于它无法被覆盖重写,所以也是一种非虚方法。
非虚方法的调用是一个静态的过程,由于目标方法只有一个确定的版本,所以在类加载的解析阶段就可以把符合引用解析为直接引用,而虚方法的调用是一个分派的过程,有静态也有动态,可分为静态单分派、静态多分派、动态单分派和动态多分派。
类加载时要解析.class文件版本
静态分派调用
静态分派发生在代码的编译阶段。针对于方法的重载
package com.jvm.bytecode.java02;
public class HelloWorld {
static class Parent{}
static class Child1 extends Parent{ }
static class Child2 extends Parent{}
public void sayHello(Parent parent){
System.out.println("parent sayHello");
}
public void sayHello(Child1 child1){
System.out.println("child1 sayHello");
}
public void sayHello(Child2 child2){
System.out.println("child2 sayHello");
}
public static void main(String[] args) {
//Parent是变量的静态类型,Parent,Child1,Child2是变量的实际类型(actual type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;
Parent parent = new Parent();
Parent parent1 = new Child1();
Parent parent2 = new Child2();
HelloWorld helloWorld = new HelloWorld();
helloWorld.sayHello(parent);
helloWorld.sayHello(parent1);
helloWorld.sayHello(parent2);
helloWorld.sayHello((Child2) parent2);
}
}
//---------------------
parent sayHello
parent sayHello
parent sayHello
child2 sayHello
通过字节码指令,可以发现四次hello方法都是通过invokevirtual指令进行调用,而且前三次调用的是参数为Parent类型的sayHello方法,最后一次进行强转后,调用Child2类型的sayHello方法。
静态分派调用
- 依赖静态类型来定位方法执行的版本的分派动作成为静态分派。
- 静态分派的典型应用是方法重载,
- 静态分派发生在编译期间,动作是由编译器发出的。
注意:在编译阶段,Java编译器会根据参数的静态类型决定调用哪个重载版本,但在有些情况下,重载的版本不是唯一的,这样只能选择一个“更加合适的版本”进行调用,所以不建议在实际项目中使用这种模糊的方法重载。
package com.jvm.bytecode.java02;
public class Demo {
public void sayHello(int a){
System.out.println("int");
}
public void sayHello(long a){
System.out.println("long");
}
public void sayHello(char a){
System.out.println("char");
}
public void sayHello(Object a){
System.out.println("Object");
}
public void sayHello(short a){
System.out.println("short");
}
public void sayHello(boolean a){
System.out.println("boolean");
}
public void sayHello(float a){
System.out.println("float");
}
public static void main(String[] args) {
new Demo().sayHello('a');
}
}
//-----------------------
char
优先匹配到char方法,其次是int,long,float,Objedt, short…
Error:(34, 19) java: 对于sayHello(char), 找不到合适的方法
方法 com.jvm.bytecode.java02.Demo.sayHello(short)不适用
(参数不匹配; 从char转换到short可能会有损失)
方法 com.jvm.bytecode.java02.Demo.sayHello(boolean)不适用
(参数不匹配; char无法转换为boolean)
动态分派调用
在运行期间根据参数的实际类型确定方法执行版本的过程称为动态分派,动态分派和多态性中的重写(override)有着紧密的联系。
package com.jvm.bytecode.java02;
public class Demo {
static class Parent{
public void sayHello(){
System.out.println("Hello");
}
}
static class Child1 extends Parent{
@Override
public void sayHello() {
System.out.println("Child1");
}
}
static class Child2 extends Parent{
@Override
public void sayHello() {
System.out.println("Child2");
}
}
public static void main(String[] args) {
Parent p1 = new Child1();
Parent p2 = new Child2();
p1.sayHello();
p2.sayHello();
}
}
//----------------------------
Child1
Child2
从指令看完全一样,但最终执行的目标方法却不相同,这得从invokevirtual指令的多态查找说起了,invokevirtual指令在运行时分为以下几个步骤:
- 找到操作数栈的栈顶元素所指向的对象的实际类型,记为C;
- 如果类型C中存在描述符和简单名称都相符的方法,则进行访问权限验证,如果验证通过,则直接返回这个方法的直接引用,否则返回java.lang.IllegalAccessError异常;
- 如果类型C中不存在对应的方法,则按照继承关系,从下往上依次对类型C的各父类进行搜索和验证,进行第2步的操作;
- 如果各个父类也没对应的方法,则抛出异常AbstractMethodError;
由于invokevirtual指令在执行的第一步就对运行的时候的接收者的实际类型进行查找,所以上面两次调用的invokevirtual指令都能成功找到实际类型的sayhello()方法,然后把类方法的符号引用解析到不同的直接引用上面,这也是重写的体现。
动态语言支持
静态类型的语言在非运行阶段,变量的类型是可以确定的,也就是说变量是有类型的
动态类型语言在非运行阶段,变量的类型是无法确定的,也就是变量是没有类型的,但是值是有类型的,也就是运行期间可以确定变量的值的类型
支持JavaScript
package com.jvm.bytecode.java02;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
public class Demo01 {
public static void main(String[] args) throws Exception {
ScriptEngineManager sem = new ScriptEngineManager();
ScriptEngine se = sem.getEngineByName("JavaScript");
Object obj = se.eval("function add(a,b){return a+b} add(2,3);");
System.out.println(obj);
}
}
//----------------------------
Warning: Nashorn engine is planned to be removed from a future JDK release
5