JVM对于方法的执行是基于栈的,方法调用——入栈,方法调用完毕——出栈,了解JVM的运行时栈结构,有助于我们更加深入的分析、理解字节码和方法调用的执行过程。
而对于方法调用的学习,可以帮助我们从字节码层面了解方法的重载和重写调用的规则。
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
在编译程序代码时,栈帧需要最大多大的局部变量表、最深多深的操作数栈都已经完全确定,并写入方法表Code属性中,因此一个栈帧需要分配多少内存,不会受程序运行期数据的影响。
一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。但是对于执行引擎来说,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧关联的方法称为当前方法,定义这个方法的类称为当前类。对局部变量表和操作数栈的各种操作,通常都指的是对当前栈帧的对局部变量表和操作数栈进行的操作。
如果当前方法调用了其他方法,或者当前方法执行结束,那这个方法的栈帧就不再是当前栈帧了。当一个新的方法被调用,一个新的栈帧也会随之而创建,并且随着程序控制权移交到新的方法而成为新的当前栈帧。当方法返回的之际,当前栈帧会传回此方法的执行结果给前一个栈帧,在方法返回之后,当前栈帧就随之被丢弃,前一个栈帧就重新成为当前栈帧了。
栈帧是线程私有的数据,不可能在一个栈帧之中引用另外一条线程的栈帧。
典型的虚拟机栈帧结构如下图所示:
局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。局部变量表中的变量只在当前方法调用中有效, 当方法调用结束后, 随着方法栈帧的销毁, 局部变量表也会随之销毁。
在class类编译的时候,某个方法的局部变量表的最大容量,就在方法的 Code 属性的 max_locals 数据项中确定了下来,而局部变量则是保存在Code属性内的LocalVariableTable属性表中。
局部变量表的容量以变量槽(Variable Slot,下称 Slot)为最小单位,虚拟机规范中并没有明确指明一个 Slot 应占用的内存空间大小,只是很有导向性地说到每个 Slot 都应该能存储一个 boolean、byte、char、short、int、float、reference 或 returnAddress 类型的数据,这 8 种数据类型,都可以使用 32 位或更小的物理内存来存放,在 Java 虚拟机的数据类型中,64 位的数据类型只有 long 和 double 两种,关于这几种局部变量表中的数据有两点需要注意:
在方法执行时,虚拟机是使用局部变量表完成参数变量列表的传递过程,如果是实例方法,那么局部变量表中的每 0 位索引的 Slot 默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字 “this” 来访问这个隐藏的局部变量,其余参数则按照参数列表的顺序来排列,占用从 1 开始的Slot位置,参数表分配完毕后,再跟进方法体内部定义的变量顺序和作用域来分配其余的 Slot。
需要注意的是局部变量并不存在如类变量的"准备"阶段,类变量会在类加载的时候经过“准备”和“初始化”阶段,即使程序员没有为类变量在 “初始化” 赋予初始值,也还是会在"准备"阶段赋予系统的类型默认值,但是局部变量不会这样,局部变量表没有"准备"阶段,所以需要程序员手动的为局部变量赋予初始值。
由于局部变量表在栈帧之中,会占用栈空间内存, 因此,如果方法的参数和局部变量较多,使得局部变量膨胀,从而每一次方法数调用就会占用更多的栈空间,最终导致方法数的嵌套调用(比如递归)次数减少。
public class TestStackDeep {
private static int count = 0;
/**
* 该方法内部有更多的局部变量,方法的最大递归调用次数将会更少
* @param a
* @param b
* @param c
*/
public static void recursion(long a, long b, long c) {
long e = 1, f = 2, g = 3, h = 4, i = 5, k = 6, q = 7, x = 8, y = 9, z = 10;
count++;
recursion(a, b, c);
}
/**
* 该方法内部有更少的局部变量,方法的最大递归调用次数将会更多
*/
public static void recursion() {
count++;
recursion();
}
public static void main(String[] args) {
try {
//recursion(); //切换分别注释这两个方法,运行,观察count的值
recursion(0, 0, 0);
} finally {
System.out.println(count);
}
}
}
运行后,可以的看出来,局部变量更少的方法的递归调用深度可以更深。
每一个局部变量都有自己的作用范围(作用字节码范围),为了尽可能节省栈帧空间, 局部变量表中的变量所在的Slot是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值巳经超出了某个变量的作用域,那这个变量对应的Slot就可以交给其他变量使用。
public class SoltReuse {
public static void solt1() {
//a、b变量作用域都是该方法
int a = 1;
System.out.println(a);
int b = 1;
}
public static void solt2() {
//a变量作用域在该方法的代码块之中
{
int a = 1;
System.out.println(a);
}
//b变量在a变量作用域之后
int b = 1;
}
public static void main(String[] args) {
}
}
使用jclasslib打开class文件,找到两个方法的局部变量表:
可以看到,solt2方法的局部变量表Solt实现了复用。
局部变量表的变量也被垃圾回收器作为根节点来判断,只要被局部变量表直接或间接引用到的对象都不会被回收。在某些情况下,Slot的复用会直接影响到系统的垃圾收集行为。
如下案例,vm参数设置为 -XX:+PrintGC,分别运行下面几个方法。
public class SoltGC {
public void SoltGC0() {
System.gc();
}
public void SoltGC1() {
byte[] b = new byte[5 * 1024 * 1024];
System.gc();
}
public void SoltGC2() {
byte[] b = new byte[5 * 1024 * 1024];
b = null;
System.gc();
}
public void SoltGC3() {
{
byte[] b = new byte[5 * 1024 * 1024];
}
System.gc();
}
public void SoltGC4() {
{
byte[] b = new byte[5 * 1024 * 1024];
}
int c = 10;
System.gc();
}
public void SoltGC5() {
SoltGC1();
System.gc();
}
public static void main(String[] args) {
new SoltGC().SoltGC5();
}
}
其中solt0()方法用作对照。
运行solt0(),本人GC信息为:
[GC (System.gc()) 5202K->848K(249344K), 0.0011430 secs] [Full GC
(System.gc()) 848K->651K(249344K), 0.0046617 secs]
在空方法时,Young GC回收大约5000k,以此作为对照。后面的例子需要排除5000k
运行solt1(),本人GC信息为:
[GC (System.gc()) 10322K->6000K(249344K), 0.0029231 secs] [Full GC
(System.gc()) 6000K->5776K(249344K), 0.0044659 secs]
可以看到,Young GC后还剩下6000k,说明byte数组所占用的内存没有被回收,因为byte数组被局部变量b引用,因此没有回收内存。
运行solt2(),本人GC信息为:
[GC (System.gc()) 10322K->912K(249344K), 0.0011081 secs] [Full GC
(System.gc()) 912K->680K(249344K), 0.0048601 secs]
在垃圾回收前,先将变量b置为null,这样byte就没有了引用。
可以看到,Young GC后还剩下1000k左右,Young GC时把byte数组回收了。
运行solt3(),本人GC信息为:
[GC (System.gc()) 10322K->6000K(249344K), 0.0036167 secs] [Full GC
(System.gc()) 6000K->5800K(249344K), 0.0049001 secs]
我们在变量b的作用域之后进行了垃圾回收,由于变量b的作用域已经结束了,按理说GC应该会回收数组的内存,但是发现byte数组的内存并没有被回收,这是为什么呢?
代码虽然已经离开了变量b的作用域,但在此之后,没有任何对屁部变量表的读写操作——变量b原本所占用的Slot还没有被其他变量复用,所以作为Gc Roots 根节点一部分的局部变量表仍然保持对它的关联,这种关联没有被及时打断,因此内存没有被回收。
运行solt4(),本人GC信息为:
[GC (System.gc()) 10322K->848K(249344K), 0.0014418 secs] [Full GC
(System.gc()) 848K->656K(249344K), 0.0048550 secs]
可以看到内存被回收了,因为垃圾回收时在变量b的作用域之外,并且声明了新变量c,此时变量c会复用变量b的槽位,对数组的引用此时被测底清除,因此随后的GC可以回收数组的内存。
运行solt5(),本人GC信息为:
[GC (System.gc()) 10322K->6000K(249344K), 0.0030734 secs] [Full GC
(System.gc()) 6000K->5800K(249344K), 0.0046043 secs]
[GC (System.gc()) 5800K->5800K(249344K), 0.0006343 secs] [Full GC
(System.gc()) 5800K->680K(249344K), 0.0041057 secs]
可以看到内存在外部方法调用GC方法时被回收了,虽然SoltGC1()犯法不会回收内存,但是SoltGC1()方法返回后,它对应的栈帧也被销毁了,自然局部变量表的的局部变量也不存在了,因此在第二个GC时,数组的内存可以被回收了。
操作数栈也常被称为操作栈,它也是一个后入先出的栈结构。许多的字节码都需要通过操作数栈进行参数传递,因此它主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
当一个方法刚刚开始执行的时候,操作数栈是空的,在方法执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈、入栈操作。操作数栈中的数据类型必须与字节码指令序列匹配,在编译程序代码时,编译时必须严格保证这一点,在类校验阶段的数据流分析中还要在此验证这一点。
例如,整数加法的字节码指令 iadd 在运行的时候,需要保证操作数栈栈顶两个元素存入int 类型的值。iadd 会取出栈顶两个元素,然后相加,之后把结果再存入操作数栈。
同局部变量表一样,操作数栈的最大深度也是在编译时期就写入到方法表的 Code 属性的 max_stacks 数据项中。操作数栈的每一个元素可以是可以是任意 Java 数据类型,包括 long 和 double,long 和 double 的数据类型占用两个单位的栈深度,其他数据类型则占用以个单位的栈深度。
虚拟机的执行引擎又被称为“基于栈的执行引擎”,其中的“栈”就是操作数栈。
两个栈帧作为虚拟机栈的元素,理论上是完全相互独立的。但在大多数虚拟机的实现里,都会做一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的操作数栈和上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时可以共用一部分数据,无需进行额外的参数复制传递,重叠过程如下图所示:
除了局部变量表和操作数栈外,Java 栈帧还需要一些数据来支持动态链接、方法返回地址等信息,他们统称为栈帧信息。
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接(Dynamic Linking)。Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候就转化为直接引用,这种转化称为静态解析。另一部分在每次运行期间转化为直接引用,这部分称为动态链接。
一个方法开始执行后,只有两种方式可以退出这个方法:
方法退出时,需要返回到方法被调用的位置,程序才能继续执行。方法正常退出时,调用者的 PC 计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值;而方法异常退出时,返回地址是要通过异常器表来确定的,栈帧中一般不会保存这部分信息,在返回调用方法中后会在调用方法中抛出相同的异常,并尝试查找调用方法的异常表,来解决这个异常。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:
方法调用即指确认调用哪个方法的过程,并不是指执行方法的过程。 由于在 java 代码编译成 class 文件之后,在 class 文件中存储的是方法的符号引用(方法在常量池中的符号),并不是方法的直接引用(方法在内存布局中的入口地址),所以需要在加载或运行阶段才会确认目标方法的直接引用。
在类加载的解析阶段,会将一部分方法符号引用转化为直接引用,这种解析成立的前提是:方法在程序运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期间不可变。换句话说,调用目标在程序写好,编译器编译时就可以确定下来了。这类方法调用称为解析。
在 Java 语言中符合“编译期可知,运行期不可变”的方法主要包含静态方法和私有方法两大类。前者与类型直接关联,后者在外部不可见,这两种方法各自的特点决定了他们不可能通过继承或别的方式重写其他版本,因此适合在类加载阶段进行解析。
Java 虚拟机规范里提供了 5 条方法调用字节码指令:
- invokestatic:调用静态方法。
- invokespecial:调用实例构造器方法、私有方法和父类方法。
- invokevirturl:调用(实例)虚方法。
- invokeinterface:调用接口方法,会在运行时确定一个实现此接口的对象。
- invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的 4 条调用指令,分派逻辑是固化在 Java 虚拟机内部的。而invokedynamic 的分派逻辑由用户设定的引导方法决定。
只要是被 invokestatic 和 invokespecial 指令调用的方法,都可以在解析阶段确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器方法、父类方法,他们在类加载的时候就会把符号引用转化为直接引用。这一类方法被称为非虚方法,相对的其他方法就是虚方法(final 方法除外)。
除了使用 invokestatic 和 invokespecial 调用的方法之外,还有一种就是被 final 修饰的方法。虽然 final 方法是使用 invokevirtual 来调用的,但由于它无法被覆盖,又没有其他版本,所以无需对方法接收者进行多态选择。在 Java 虚拟机规范中,明确说明了 final 方法是非虚方法。
解析调用是一个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期才去完成。而分派 (Dispatch )调用则可能是静态的,也可能是动态的,根据分派的宗量数又可以分为单分派、多分派。这两类分派方式的两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派 4 种分派组合情况。
Java 是一门面向对象的程序语言,因为 Java 具备面向对象的基本特征:继承、封装、多态。分派调用将会揭示多态性特征的一些最基本的体现,比如“重载”和“重写”在 Java 虚拟机之中是如何实现的。
案例
public class StaticDispatch {
/*几个重载方法*/
public void sayHello(Human guy) {
System.out.println("hello guy");
}
public void sayHello(Man guy) {
System.out.println("hello gentleman");
}
public void sayHello(Woman guy) {
System.out.println("hello lady");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch sr = new StaticDispatch();
//hello guy
sr.sayHello(man);
//hello guy
sr.sayHello(woman);
}
static abstract class Human {
}
static class Man extends Human {
}
static class Woman extends Human {
}
}
该程序运行结果如下:
hello, guy
hello, guy
为什么会选择参数类型为 Human 的重载方法呢?在解决这个问题前,我们先看两个重要的概念。
Human man = new Man();
对于上面的代码,Human 是变量的静态类型,而 Man 是变量的实际类型。 变量的静态类型是编译期就可以确定的,而实际类型需要等到运行时才能确定。虚拟机(准确的说是编译器)在重载时是通过参数的静态类型而非实际类型来作为判定依据的。因为静态类型是编译期可知的,javac 编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了 sayHello(Human) 作为调用目标,并把这个方法的符号引用写到了 invokevirtual 的参数中。
利用 javap -v StaticDispatch.class查看字节码文件可以验证这一点:
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派,静态分派的典型应用是方法重载。静态分派发生在编译期间,因此确定静态分派的动作实际上不是由虚拟机来执行的。
另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯一的”,往往只能确定一个“更加合适的”版本。 这种模糊的结论在由0和1构成的计算机世界中算是比较“稀罕”的事情,产生这种模糊结论的主要原因是字面量不需要定义,所以字面量没有显示的静态类型,它的静态类型只能通过语言上的规则去理解和推断。
下面代码演示了何为“更加合适的”版本:
/**
* 重载方法匹配优先级
*
* @author lx
*/
public class Overload {
public static void sayHello(Object arg) {
System.out.println("hello Object");
}
public static void sayHello(int arg) {
System.out.println("hello int");
}
public static void sayHello(long arg) {
System.out.println("hello long");
}
public static void sayHello(Character arg) {
System.out.println("hello Character");
}
public static void sayHello(char arg) {
System.out.println("hello char");
}
public static void sayHello(char... arg) {
System.out.println("hello char...");
}
public static void sayHello(long... arg) {
System.out.println("hello Character...");
}
public static void sayHello(Serializable arg) {
System.out.println("hello Serializable");
}
public static void main(String[] args) {
sayHello('a');
}
}
直接运行代码,上面会输出: hello char
因为‘a’是一个char类型的数据,自然会寻找参数类型为char的重载方法。
如果注释掉sayHello(char arg)方法,那输出会变为: hello int
这时发生了一次自动类型转化,‘a’除了可以代表一个字符串,还可以代表数字97(字符‘a’的Unicode数值为十进制数字97),因此参数类型为int的重载也是合适的。
继续注释掉sayHello(int arg)方法,那输出会变为: hello long
这时发生了两次自动类型转换,‘a’转型为整数97之后,进一步转型为长整数97L,匹配了参数类型为long的重载。笔者在代码中没有写其他的类型如float、double等的重载,不过实际上自动转型还能继续发生多次,按照char->int->long->float->double的顺序转型进行匹配。但不会匹配到byte和short类型的重载,因为char到byte或short的转型是不安全的。
继续注释掉sayHello(long arg)方法,那输出会变为: hello Character
这时发生了一次自动装箱,‘a’被包装为它的封装类型java.lang.Character,所以匹配到了参数类型为Character的重载。
继续注释掉sayHello(Character arg)方法,那输出会变为: hello Serializable
出现hello Serializable,是因为java.lang.Serializable是java.lang.Character类出现的一个接口,当自动装箱之后,发现还是找不到装箱类,但是找到了装箱类实现了的接口类型,所以紧接着又发生一次自动转型。char可以转型成int,但是Character是绝对不会转型为Integer的,它只能安全地转型为它实现的接口或父类。Character还实现了另外一个接口java.lang.Comparable,如果同时出现两个参数分别为Serializable和Comparable的重载方法,那它们在此时的优先级是一样的。编译器无法确定要自动转型为哪种类型,会提示类型模糊,拒绝编译。程序必须在调用时显示地指令字面量的静态类型,如:sayHello((Comparable)’'a),才能编译通过。
下面继续注释掉sayHello(Serializable arg)方法,输出会变为: hello Object
这时是char装箱后转型为父类了,如果有多个父类,那将在继承关系中从下往上开始搜索,越接近上层的优先级越低。即使方法调用传入的参数值为null时,这个规则仍然适用。
把sayHello(Object arg)也注释掉,输出将会变为: hello char…
7个重载方法已经被注释得只剩一个了,可见变长参数的重载优先级是最低的,这时候字符‘a’被当做一个数组元素.
静态方法会在类加载期就进行解析,而静态方法显然也是可以拥有重载版本的,选择重载版本的过程也是通过静态分派完成的。
补充: 使用idea开发的工作者,如果不知道具体使用的那个一版本的方法,可以将鼠标放在调用方法上,然后按住ctrl,然后左键点击该方法,就会自动跳转到具体调用的方法处。
动态分派和多态性的另一个重要体现:重写 Override 有着密切的关联。 先看一下动态分派的例子:
/**
* 动态分派
*/
public class DynamicDispatch {
static abstract class Human {
/**
* 父类方法
*/
protected abstract void sayHello();
}
static class Man extends Human {
/**
* 重写方法
*/
@Override
protected void sayHello() {
System.out.println("man say hello");
}
}
static class Woman extends Human {
/**
* 重写方法
*/
@Override
protected void sayHello() {
System.out.println("woman say hello");
}
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
//man say hello
man.sayHello();
//woman say hello
woman.sayHello();
//改变实际类型不改变静态类型
man = new Woman();
//woman say hello
man.sayHello();
}
}
执行程序,输出如下所示:
hello man
hello woman
hello woman
虚拟机是怎样去调用哪个方法的?显然这里是不能根据静态类型来决定的,因为静态类型同样都是Human的两个变量man和woman在调用sayHello()方法时执行了不同的行为,并且变量man在两次调用中执行了不同的方法。导致这个现象的原因很明显,是这两个变量的实际类型不同,Java虚拟机是如何根据实际类型来分派方法执行版本的呢?使用javap -v DynamicDispatch.class命令输出这段代码的字节码,尝试从中寻找答案:
可以看到,字节码中执行 DynamicDispatch$Human.sayHello 的是 invokevirtual 指令,执行之前通过 aload_1 和 aload_2 把相关对象从局部变量表复制到了操作栈栈顶。invokevirtual 指令的运行时解析过程大致分为以下几个步骤:
由于 invokevirtual 指令的第一步就是在运行期确定接收者的实际类型,所以两次调用中的 invokevirtual 指令把相同的类符号引用解析到了不同的直接引用上,这个过程就是 Java 语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的过程称为动态分派。
方法的接收者与方法的参数统称为方法的宗量,根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。 单分派是根据一个宗量对目标方法进行选择,多分派是根据多余一个宗量对目标方法进行选择。
在 Java 语言中静态分派要同时考虑实际类型和方法参数,所以 Java 语言中的静态分派属于多分派类型。而在执行 invokevirtual 指令时,唯一影响虚拟机选择的只有实际类型,所以 Java 语言中的动态分派属于单分派类型。
由于动态分派是非常频繁的动作,而动态分派在方法版本选择过程中又需要在方法元数据中搜索合适的目标方法,虚拟机实现出于性能的考虑,通常不直接进行如此频繁的搜索,而是采用优化方法。
其中一种“稳定优化”手段是:在类的方法区中建立一个虚方法表(Virtual Method Table, 也称vtable, 与此对应,也存在接口方法表——Interface Method Table,也称itable)。使用虚方法表索引来代替元数据查找以提高性能。其原理与C++的虚函数表类似。
虚方法表中存放的是各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类中该方法相同,都指向父类的实现入口。虚方法表一般在类加载的连接阶段进行初始化。
参考
《Java虚拟机规范 JavaSE8版》
《深入理解Java虚拟机》
《实战Java虚拟机》
如果有什么不懂或者需要交流,各位可以留言。另外,希望点赞、收藏、关注一下,我将不间断更新Java各种教程!