共计 4508 个字符,预计需要花费 12 分钟才能阅读完成。
Java 虚拟机以办法作为根本的执行单元,“栈帧(Stack Frame)”则是用于反对 Java 虚拟机进行办法调用和办法执行的根本数据结构。每一个栈帧中都蕴含了局部变量表、操作数栈、动静链接、办法返回地址和一些额定的附加信息(比方与调试、性能手机相干的信息)。
1)局部变量表
局部变量表(Local Variables Table)用来保留办法中的局部变量,以及办法参数。当 Java 源代码文件被编译成 class 文 java 培训件的时候,局部变量表的最大容量就曾经确定了。
咱们来看这样一段代码。
public class LocalVaraiablesTable {
private void write(int age) {String name = "小王";}
}
write() 办法有一个参数 age,一个局部变量 name。
而后用 Intellij IDEA 的 jclasslib 查看一下编译后的字节码文件 LocalVaraiablesTable.class。能够看到 write() 办法的 Code 属性中,Maximum local variables(局部变量表的最大容量)的值为 3。
按理说,局部变量表的最大容量应该为 2 才对,一个 age,一个 name,为什么是 3 呢?
当一个成员办法(非静态方法)被调用时,第 0 个变量其实是调用这个成员办法的对象援用,也就是那个赫赫有名的 this。调用办法 write(18),实际上是调用 write(this, 18)。
点开 Code 属性,查看 LocalVaraiableTable 就能够看到具体的信息了。
第 0 个是 this,类型为 LocalVaraiablesTable 对象;第 1 个是办法参数 age,类型为整形 int;第 2 个是办法外部的局部变量 name,类型为字符串 String。
当然了,局部变量表的大小并不是办法中所有局部变量的数量之和,它与变量的类型和变量的作用域无关。当一个局部变量的作用域完结了,它占用的局部变量表中的地位就被接下来的局部变量取代了。
来看上面这段代码。
public static void method() {
// ①
if (true) {
// ②
String name = "缄默王二";
}
// ③
if(true) {
// ④
int age = 18;
}
// ⑤
}
method() 办法的局部变量表大小为 1,因为是静态方法,所以不须要增加 this 作为局部变量表的第一个元素;
②的时候局部变量有一个 name,局部变量表的大小变为 1;
③的时候 name 变量的作用域完结;
④的时候局部变量有一个 age,局部变量表的大小为 1;
⑤的时候局 age 变量的作用域完结;
对于局部变量的作用域,《Effective Java》中的第 57 条倡议:
将局部变量的作用域最小化,能够加强代码的可读性和可维护性,并升高出错的可能性。
在此,我还有一点要揭示大家。为了尽可能节俭栈帧耗用的内存空间,局部变量表中的槽是能够重用的,就像 method() 办法演示的那样,这就意味着,正当的作用域有助于进步程序的性能。
局部变量表的容量以槽(slot)为最小单位,一个槽能够包容一个 32 位的数据类型(比如说 int,当然了,《Java 虚拟机标准》中没有明确指出一个槽应该占用的内存空间大小,但我认为这样更容易了解),像 float 和 double 这种明确占用 64 位的数据类型会占用两个紧挨着的槽。
来看上面的代码。
public void solt() {
double d = 1.0;
int i = 1;
}
用 jclasslib 能够查看到,solt() 办法的 Maximum local variables 的值为 4。
为什么等于 4 呢?带上 this 也就 3 个呀?
查看 LocalVaraiableTable 就明确了,变量 i 的下标为 3,也就意味着变量 d 占了两个槽。
2)操作数栈
同局部变量表一样,操作数栈(Operand Stack)的最大深度也在编译的时候就确定了,被写入到了 Code 属性的 maximum stack size 中。当一个办法刚开始执行的时候,操作数栈是空的,在办法执行过程中,会有各种字节码指令往操作数栈中写入和取出数据,也就是入栈和出栈操作。
来看上面这段代码。
public class OperandStack {
public void test() {add(1,2);
}
private int add(int a, int b) {return a + b;}
}
OperandStack 类共有 2 个办法,test() 办法中调用了 add() 办法,传递了 2 个参数。用 jclasslib 能够看到,test() 办法的 maximum stack size 的值为 3。
这是因为调用成员办法的时候会将 this 和所有参数压入栈中,调用结束后 this 和参数都会一一出栈。通过「Bytecode」面板能够查看到对应的字节码指令。
aload_0 用于将局部变量表中下标为 0 的援用类型的变量,也就是 this 加载到操作数栈中;
iconst_1 用于将整数 1 加载到操作数栈中;
iconst_2 用于将整数 2 加载到操作数栈中;
invokevirtual 用于调用对象的成员办法;
pop 用于将栈顶的值出栈;
return 为 void 办法的返回指令。
再来看一下 add() 办法的字节码指令。
iload_1 用于将局部变量表中下标为 1 的 int 类型变量加载到操作数栈上(下标为 0 的是 this);
iload_2 用于将局部变量表中下标为 2 的 int 类型变量加载到操作数栈上;
iadd 用于 int 类型的加法运算;
ireturn 为返回值为 int 的办法返回指令。
操作数中的数据类型必须与字节码指令匹配,以下面的 iadd 指令为例,该指令只能用于整形数据的加法运算,它在执行的时候,栈顶的两个数据必须是 int 类型的,不能呈现一个 long 型和一个 double 型的数据进行 iadd 命令相加的状况。
3)动静链接
每个栈帧都蕴含了一个指向运行时常量池中该栈帧所属办法的援用,持有这个援用是为了反对办法调用过程中的动静链接(Dynamic Linking)。
来看上面这段代码。
public class DynamicLinking {
static abstract class Human {protected abstract void sayHello();
}
static class Man extends Human {
@Override
protected void sayHello() {System.out.println("男人哭吧哭吧不是罪");
}
}
static class Woman extends Human {
@Override
protected void sayHello() {System.out.println("山下的女人是老虎");
}
}
public static void main(String[] args) {Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = new Woman();
man.sayHello();}
}
大家对 Java 重写有理解的话,应该能看懂这段代码的意思。Man 类和 Woman 类继承了 Human 类,并且重写了 sayHello() 办法。来看一下运行后果:
男人哭吧哭吧不是罪
山下的女人是老虎
山下的女人是老虎
这个运行后果很好了解,man 的援用类型为 Human,但指向的是 Man 对象,woman 的援用类型也为 Human,但指向的是 Woman 对象;之后,man 又指向了新的 Woman 对象。
从面向对象编程的角度,从多态的角度,咱们对运行后果是很好了解的,但站在 Java 虚拟机的角度,它是如何判断 man 和 woman 该调用哪个办法的呢?
用 jclasslib 看一下 main 办法的字节码指令。
第 1 行:new 指令创立了一个 Man 对象,并将对象的内存地址压入栈中。
第 2 行:dup 指令将栈顶的值复制一份并压入栈顶。因为接下来的指令 invokespecial 会消耗掉一个以后类的援用,所以须要复制一份。
第 3 行:invokespecial 指令用于调用构造方法进行初始化。
第 4 行:astore_1,Java 虚拟机从栈顶弹出 Man 对象的援用,而后将其存入下标为 1 局部变量 man 中。
第 5、6、7、8 行的指令和第 1、2、3、4 行相似,不同的是 Woman 对象。
第 9 行:aload_1 指令将第局部变量 man 压入操作数栈中。
第 10 行:invokevirtual 指令调用对象的成员办法 sayHello(),留神此时的对象类型为 com/itwanger/jvm/DynamicLinking$Human。
第 11 行:aload_2 指令将第局部变量 woman 压入操作数栈中。
第 12 行同第 10 行。
留神,从字节码的角度来看,man.sayHello()(第 10 行)和 woman.sayHello()(第 12 行)的字节码是完全相同的,但咱们都晓得,这两句指令最终执行的指标办法并不相同。
到底产生了什么呢?
还得从 invokevirtual 这个指令着手,看它是如何实现多态的。依据《Java 虚拟机标准》,invokevirtual 指令在运行时的解析过程能够分为以下几步:
①、找到操作数栈顶的元素所指向的对象的理论类型,记作 C。
②、如果在类型 C 中找到与常量池中的描述符匹配的办法,则进行拜访权限校验,如果通过则返回这个办法的间接援用,查找完结;否则返回 java.lang.IllegalAccessError 异样。
③、否则,依照继承关系从下往上一次对 C 的各个父类进行第二步的搜寻和验证。
④、如果始终没有找到适合的办法,则抛出 java.lang.AbstractMethodError 异样。
也就是说,invokevirtual 指令在第一步的时候就确定了运行时的理论类型,所以两次调用中的 invokevirtual 指令并不是把常量池中办法的符号援用解析到间接援用上就完结了,还会依据办法接受者的理论类型来抉择办法版本,这个过程就是 Java 重写的实质。咱们把这种在运行期依据理论类型确定办法执行版本的过程称为动静链接。
4)办法返回地址
当一个办法开始执行后,只有两种形式能够退出这个办法:
失常退出,可能会有返回值传递给下层的办法调用者,办法是否有返回值以及返回值的类型依据办法返回的指令来决定,像之前提到的 ireturn 用于返回 int 类型,return 用于 void 办法;还有其余的一些,lreturn 用于 long 型,freturn 用于 float,dreturn 用于 double,areturn 用于援用类型。
异样退出,办法在执行的过程中遇到了异样,并且没有失去妥善的解决,这种状况下,是不会给它的下层调用者返回任何值的。
无论是哪种形式退出,在办法退出后,都必须返回到办法最后被调用时的地位,程序能力继续执行。一般来说,办法失常退出的时候,PC 计数器的值会作为返回地址,栈帧中很可能会保留这个计数器的值,异样退出时则不会。
办法退出的过程实际上等同于把以后栈帧出栈,因而接下来可能执行的操作有:复原下层办法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整 PC 计数器的值,找到下一条要执行的指令等。