Android修炼系列(七),方法调用,背后的秘密
在前篇已经讲解了类是如何被加载的? 和 对象是如何被分配和回收的?,本节主要看下,方法又是如何被调用和执行的?
栈帧
栈帧是虚拟机 栈内存 中的元素,是支持虚拟机进行方法调用和方法执行的数据结构。其内存储了方法的局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。每个方法的调用开始到执行完毕,都对应了栈帧在栈里的入栈到出栈的过程。
一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,这个栈帧所关联的方法称为当前方法。执行引擎运行的所有字节码指令都只针对于当前栈帧进行操作。
下文主要从局部变量表、动态连接、操作数栈三个方面进行介绍。
局部变量表
局部变量表是一个存储变量值的空间,存放着我们熟悉的方法参数和方法内的局部变量。它的大小,在程序编译时就被确定下来了,并被写入到了方法表的Code属性之中。前文我们知道,Class文件就是一组以8位字节为单位的2进制流,各项数据是严格按照特定顺序紧凑的排列在了Class文件之中。而方法表即在如下位置:
Code属性出现在方法表的属性集合之中,但也不是所有的方法表都存在这个属性,如接口和抽象类的方法就不存在Code属性。其中Code属性内的max_locals就定义了方法所需要的分配局部变量表的最大容量。而局部变量表又以变量槽Slot为最小单位,存放着我们如下的数据类型的数据:
其中reference类型表示对一个对象实例的引用,还记得对象的成员变量的引用吗?只不过一个在栈内存中,一个在栈帧的局部变量表的code属性中。
注意局部变量表中第0位索引的Slot默认用于传递方法所属对象实例的引用,接下来开始按照方法参数的顺序来给参数分配Slot(从1开始),最后再根据方法体内定义的变量顺序和作用域来分配其余的Slot。
动态连接
前面讲过,Class文件的常量池主要存放两大类常量:字面常量和符号引用。其中符号引用包括:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。
我们方法调用中的目标方法在Class文件里就是一个常量池中的符号引用,字节码中的方法调用指令就以常量池中的指向方法的符号引用作为参数。举个栗子:
10: invokevirtual #22 //Method ...A.hello:()V
复制代码
invokevirtual就是调用指令,参数是常量池中第22项的常量,注释显示了这个常量是A.hello()的符号引用。
java代码在javac编译的时候,并不会有“连接”的步骤,而是在虚拟机加载Class文件的时候进行“动态连接”。也就是说Class文件不会保存各个方法字段的内存入口地址(直接引用),所以虚拟机是无法直接使用的。这就要求在虚拟机运行时,从虚拟机获得符号引用,再在类创建时或运行时解析为直接引用。
在类加载的解析阶段,就会有一部分符号引用被直接被转化为直接引用,这类转换称为静态解析,这类方法都符合“编译期可知,运行期不可变”,符合这个条件的有静态方法、私有方法、实例构造器、父类方法、final方法,也就是我们常称的非虚方法。另一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
每个栈帧都包含有一个指向运行时常量池的该栈帧所属方法的符号引用,持有这个引用就是为了支持方法调用过程中的动态连接。
操作数栈
操作数栈也叫操作栈,是一个后入先出的栈结构。操作数栈的的最大深度也在编译的时候就被写入到了Code属性之中的max_stacks数据项中。操作数栈的每个数据元素可以是任意的java数据类型,包括long和double,其中32位的数据类型占栈容量为1,64位数据类型占栈容量为2。
方法的调用并不等同于方法的执行,方法调用阶段的唯一任务,就是确定要调用的是哪一个方法,还不涉及方法内部的具体运行过程。而在方法的执行过程中,会通过各种字节指令往操作栈中写入和提取内容,也就是出栈/入栈操作。这些编译器编译的字节码指令都被存放在了方法属性集合中的Code属性里面了。
这些指令操作包括将局部变量表的Slot数据推入栈顶,也可将栈内的数据出栈并存入Slot中,也可通过指令将数据出栈操作再入栈等等。以下面方法为栗子:
public int a() {
int a = 10;
int b = 20;
return (a + b) * 100;
}
复制代码
通过javap -c 查看其字节码如下:
public int a();
Code:
0: bipush 10
2: istore_1
3: bipush 20
5: istore_2
6: iload_1
7: iload_2
8: iadd
9: bipush 100
11: imul
12: ireturn
复制代码
在方法刚刚执行的时候,操作栈是空的。
:0 首先执行偏移地址为0的指令,bipush指令的作用是将单字节的整数型常量值 10 推入操作数栈顶:
:2 执行偏移地址为2的指令,istore_1的指令是将操作栈顶的整数型值出栈并存放在第一个局部变量Slot中。后续的2条指令是一样的,将 b:10 存放在局部变量Slot中。
:6 执行偏移地址6的指令,iload_1的作用是将局部变量表第1个Slot中的整形值复制到操作栈顶:
:7 同理,执行偏移地址7的指令,iload_1的作用是将局部变量表第2个Slot中的整形值复制到操作栈顶:
:8 执行偏移地址8的指令,iadd指令的作用将操作数栈中头两个栈元素出栈,做整形加法,然后把结果重新入栈。即在iadd执行完毕后,元素10、20出栈,相加结果30会重新入栈:
:9 执行偏移地址为9的指令,bipush指令的作用是将单字节的整数型常量值 100 推入操作数栈顶:
:11 执行偏移地址为11的指令,imul指令是将操作栈顶两个元素出栈,并做乘法运算,然后将结果重新入栈,与iadd操作一样:
:12 执行偏移地址为12的指令,ireturn指令,它将结束方法执行并将操作栈的整型值返回此方法的调用者。到此为止,此方法执行结束。
好了,本文到这里,关于方法是如何被JVM调用和执行的介绍就结束了。如果本文对你有用,来点个赞吧,大家的肯定也是阿呆i坚持写作的动力。
参考
1、周志明,深入理解JAVA虚拟机:机械工业出版社
作者:矛盾的阿呆i
链接:https://juejin.cn/post/6945253090056470541
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。