JVM 四虚拟机栈
虚拟机栈出现的背景
由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。
内存中的栈与堆
有不少Java开发人员一提到Java内存结构,就会非常粗粒度地将JVM中的内存区理解为仅有Java堆(heap)和Java栈(stack),他们的关系是,栈是运行时的单位,而堆是存储的单位
● 栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。
● 堆解决的是数据存储的问题,即数据怎么放,放哪里
Java虚拟机栈是什么?
Java虚拟机栈(Java Virtual Machine Stack),早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的Java方法调用,是线程私有的。它的生命周期和线程一致(上一章已经说过的了,栈是线程独有的)
作用
主管Java程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。
栈的特点
栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。
JVM直接对Java栈的操作只有两个:
● 每个方法执行,伴随着进栈(入栈、压栈)
● 执行结束后的出栈工作
对于栈来说不存在垃圾回收问题(栈存在溢出的情况)
栈中可能出现的异常
Java 虚拟机规范允许Java栈的大小是动态的或者是固定不变的
● 如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError 异常。
● 如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个 OutOfMemoryError 异常。
案例(我们仅演示第一点,因为第二个问题需要在我们虚拟机内存不够的时候才会报)
/**
* 测试结果,main递归执行11417次栈溢出
*
* 设置栈的大小为:-Xss256k
*
* 修改后只递归2000次便溢出,修改生效
*
*
*/
public class StackTest {
public static int count;
public static void main(String[] args) {
System.out.println(count);
count++;
main(args);
}
}
栈中存储什么?
每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在。在这个线程上正在执行的每个方法都各自对应一个栈帧(Stack Frame)。栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。
JVM直接对Java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循“先进后出”/“后进先出”原则。
在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。
执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。
不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。
如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。
/**
* 测试手段:三个方法嵌套运行debug
* 打印如下:
* main方法开始执行
* method1 begin
* method2 begin
* method3 begin
* method3 end
* method2 will end(wait return)
* method1 end
* main方法正常结束
*/
public class StackWorkTest {
public static void main(String[] args) throws Exception{
System.out.println("main方法开始执行");
StackWorkTest stackWorkTest = new StackWorkTest();
try {
stackWorkTest.method01();
}catch (Exception e) {
e.printStackTrace();
}
System.out.println("main方法正常结束");
}
//整体是嵌套关系
public void method01(){
System.out.println("method1 begin");//执行到这里时method1是当前栈帧
method02();
System.out.println("method1 end");//执行到这里(回到method1时)method1是当前栈帧
// int i = 10 / 0;
return; //这里写和不写都是一样的,void方法最终都会有默认的return;当然我们可以通过return;使得方法提前结束
}
public int method02(){
System.out.println("method2 begin");
int i = 10;
int v = (int) method03();
System.out.println("method2 will end(wait return)");
return v;
}
public double method03(){
System.out.println("method3 begin");
double i = 20.0;
System.out.println("method3 end");
return i;
}
}
结束方式:return or exception without try-catch,上面是方法正常结束的例子
package com.sobot.net.jvm;
/**
* 异常时打印如下
*
* main方法开始执行
* method1 begin
* method2 begin
* method3 begin
* method3 end
* method2 will end(wait return)
* method1 end
* Exception in thread "main" java.lang.ArithmeticException: / by zero
* at com.sobot.net.jvm.StackWorkTest.method01(StackWorkTest.java:39)
* at com.sobot.net.jvm.StackWorkTest.main(StackWorkTest.java:30)
*/
public class StackWorkTest {
public static void main(String[] args) throws Exception{
System.out.println("main方法开始执行");
StackWorkTest stackWorkTest = new StackWorkTest();
// try {
// stackWorkTest.method01();
// }catch (Exception e) {
// e.printStackTrace();
// }
stackWorkTest.method01();
System.out.println("main方法正常结束");
}
//整体是嵌套关系
public void method01(){
System.out.println("method1 begin");//执行到这里时method1是当前栈帧
method02();
System.out.println("method1 end");//执行到这里(回到method1时)method1是当前栈帧
int i = 10 / 0;
return;
}
public int method02(){
System.out.println("method2 begin");
int i = 10;
int v = (int) method03();
System.out.println("method2 will end(wait return)");
return v;
}
public double method03(){
System.out.println("method3 begin");
double i = 20.0;
System.out.println("method3 end");
return i;
}
}
栈帧的内部结构
每个栈帧中存储着:
● 局部变量表(Local Variables)
● 操作数栈(operand Stack)(或表达式栈)
● 动态链接(DynamicLinking)(或指向运行时常量池的方法引用)
● 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
● 一些附加信息
并行每个线程下的栈都是私有的,因此每个线程都有自己各自的栈,并且每个栈里面都有很多栈帧,栈帧的大小主要由局部变量表 和 操作数栈决定的,而栈中栈帧的数目又受到栈帧大小的影响
局部变量表(Local Variables)
● 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress类型。
● 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
● 局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。
● 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。 参考上一个案例,默认main方法可以嵌套执行11400次,但我们改了栈的大小为256k时仅能嵌套2000多次
● 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
案例
package com.sobot.net.jvm;
import java.util.Date;
//局部变量表的大小在编译时就已经确定下来了
//普通方法入栈后栈中局部变量表都默认会有this,静态方法局部变量表没有this
public class LocalVariableTest {
public LocalVariableTest() {
}
public LocalVariableTest(int count) {
this.count = count;
}
private int count = 0;
public static void main(String[] args) {
LocalVariableTest localVariableTest = new LocalVariableTest();
int num = 10;
localVariableTest.test();
}
public void test(){
Date date = new Date();
int b = 10;
double a = 10.0;
String name = "atgwqqqqqqw";
String weight = "asau";
//如果不声明test变量,即这行代码变为test(date, name);时,局部变量表就没有test这个变量了
String test = test(date, name);
System.out.println(date + name);
}
public static void staticTest(){
LocalVariableTest localVariableTest = new LocalVariableTest();
Date date = new Date();
int count = 10;
System.out.println(count);
//不能使用this,因为this变量不在当前方法的局部变量表里
// System.out.println(this.count);
}
public String test(Date date,String str) {
return date + str;
}
public void test3(Date date,String str) {
this.count++;
}
public void test4() {
int a = 0;
{
int b = 0;
b = a + 1;
}
int c = a + 1;
}
}
main方法中的局部变量如下
起始pc指局部变量从什么时候生效的,数字代表的是字节码的行号,参考字节码如下
首先args参数从最开始,也就是字节码第0行就开始生效了,而localVariableTest在字节码第8行生效,num参数在第11行生效,长度即代表生效范围
计算公式为字节码长度减去起始PC
关于Slot的理解
● 局部变量表,最基本的存储单元是Slot(变量槽)
● 参数值的存放总是在局部变量数组的index0开始,到数组长度-1的索引结束。
● 局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量。
● 在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型(long和double)占用两个slot。
● byte、short、char 在存储前被转换为int,boolean也被转换为int,0表示false,非0表示true。
● JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值
● 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个slot上
● 如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用前一个索引即可。(比如:访问long或doub1e类型变量)
● 如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的slot处,其余的参数按照参数表顺序继续排列。
关于占用曹的大小,我们以上个案例中的test方法演示,如下
int型占用一个槽(43-42),同理String类型的double类型都是占用两个槽
栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。如上述案例中的test4方法,如下
我们可以看出方法test4中的变量b在出了括号后就已经无效了,所以b所占有的槽(index=2)就会重新分配给变量c,毕竟局部变量表的本质是一个数组结构,大小不会随笔变动,所以重复利用是最好的版本
总结
* 变量的分类
* 按数据类型分类:基本数据类型和引用数据类型
* 按照类中声明位置
* 成员变量
* * 类变量(静态成员变量),被类加载器加载后,在link的prepare阶段默认赋值,initalize阶段显示复杂(直接在声明类变量时赋值或者在静态代码快赋值)
* * 实例变量,随着对象的创建在堆中进行赋值,如果没有进行显示赋值(比如构造方法为空或者压根都没有重写构造方法)那就默认赋值
* 局部变量:使用前必须进行显示赋值,大家一试便知
补充说明
- 在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。因为局部变量表有很多引用类型的局部变量,这本质上是引用,执行了堆中真实存储的对象,所以这个地方如果处理不当也是容易引发OOM的;
- 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
4.4. 操作数栈(Operand Stack)
每一个独立的栈帧除了包含局部变量表以外,还包含一个后进先出(Last-In-First-Out)的 操作数栈,也可以称之为表达式栈(Expression Stack)
操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)和 出栈(pop)
● 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈
● 比如:执行复制、交换、求和等操作
操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的。
每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的Code属性中,为max_stack的值。栈中的任何一个元素都是可以任意的Java数据类型
● 32bit的类型占用一个栈单位深度
● 64bit的类型占用两个栈单位深度
操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问
如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。
案例代码
public void testAddOperation() {
//byte,short,boolen,short都是按int类型进行保存
byte i = 15;
int j = 8;
int k = i + j;
}
反编译其字节码文件如下
0 bipush 15
2 istore_1
3 bipush 8
5 istore_2
6 iload_1
7 iload_2
8 iadd
9 istore_3
10 return
执行的第一步bipush命令:bipush命令时把int型变量(byte,short,boolen,short都是按int类型进行保存)入栈,然后pc寄存器保存当前命令行即为0
istore_1 是把栈顶的操作数保存到局部变量表中索引位置为1的地方,然后pc寄存器保存当前命令行即为2,后续同理,不再对pc寄存器做说明了
第3,4行命令是继续把j=8这个变量入栈然后存储到到局部变量表中索引位置为2的地方
第5,6行iload命令则是分别在局部变量表中索引为1,2的位置分别取出15和8入栈
在栈中做运算,执行iadd命令,iadd命令的执行需要依赖于执行引擎调用cpu来运算,然后再把结果入栈
再把栈中变量值为23的变量k存储到局部变量吧第3个位置上
然后就是return方法结束
补充:这里是void方法,执行到最后就通过return指令方法就正常结束了,如果是带返回值的类型,那么就里的return xxx除了起到结束方法(结束方法也意味着该方法对应栈帧的局部变量表和操作数栈的清空)的作用(同return)还会把返回值xxx返回给他的上一个调用方法的栈中,也是先入栈,随后通常会再存到局部变量表,这里也要注意
public int testAddOperation() {
byte i = 15;
int j = 8;
return 10;
}
public int testAddOperation2() {
byte i = 15;
int j = 8;
return 10;
}
public void test(){
int i = testAddOperation();
testAddOperation2();
}
test()是int型方法,第一行我们定义了int i = testAddOperation(),这样会存入局部变量表的第一个位置里,第二行testAddOperation2();由于没有定义局部变量来接收返回值,所以不会存入局部变量表,如下test方法
0 aload_0 #刚开始时就把上一个栈帧中方法testAddOperation的返回值加载到当前栈帧(test方法)操作数栈中
1 invokevirtual #2 <com/sobot/net/jvm/StackTest.testAddOperation : ()I>
4 istore_1 #把变量i存入局部变量表的第一个位置里
5 aload_0 #把上一个栈帧中方法testAddOperation2的返回值加载到当前栈帧(test方法)操作数栈中
6 invokevirtual #3 <com/sobot/net/jvm/StackTest.testAddOperation2 : ()I>
9 pop
10 return
前面提过,基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数。
由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存(Tos,Top-of-Stack Cashing)技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。
为什么需要运行时常量池呢?
常量池的作用:就是为了提供一些符号和常量,便于指令的识别
案例
public class DynamicLinkingTest {
int num = 10;
public void methodA() {
System.out.println("MethodA");
}
public void methodB() {
System.out.println("MethodB");
methodA();
num++;
}
}
javap -v命令编译后
public class DynamicLinkingTest
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #9.#20 // java/lang/Object."<init>":()V
#2 = Fieldref #8.#21 // DynamicLinkingTest.num:I
#3 = Fieldref #22.#23 // java/lang/System.out:Ljava/io/PrintStream;
#4 = String #24 // MethodA
#5 = Methodref #25.#26 // java/io/PrintStream.println:(Ljava/lang/String;)V
#6 = String #27 // MethodB
#7 = Methodref #8.#28 // DynamicLinkingTest.methodA:()V
#8 = Class #29 // DynamicLinkingTest
#9 = Class #30 // java/lang/Object
#10 = Utf8 num
#11 = Utf8 I
#12 = Utf8 <init>
#13 = Utf8 ()V
#14 = Utf8 Code
#15 = Utf8 LineNumberTable
#16 = Utf8 methodA
#17 = Utf8 methodB
#18 = Utf8 SourceFile
#19 = Utf8 DynamicLinkingTest.java
#20 = NameAndType #12:#13 // "<init>":()V
#21 = NameAndType #10:#11 // num:I
#22 = Class #31 // java/lang/System
#23 = NameAndType #32:#33 // out:Ljava/io/PrintStream;
#24 = Utf8 MethodA
#25 = Class #34 // java/io/PrintStream
#26 = NameAndType #35:#36 // println:(Ljava/lang/String;)V
#27 = Utf8 MethodB
#28 = NameAndType #16:#13 // methodA:()V
#29 = Utf8 DynamicLinkingTest
#30 = Utf8 java/lang/Object
#31 = Utf8 java/lang/System
#32 = Utf8 out
#33 = Utf8 Ljava/io/PrintStream;
#34 = Utf8 java/io/PrintStream
#35 = Utf8 println
#36 = Utf8 (Ljava/lang/String;)V
{
int num;
descriptor: I
flags:
public DynamicLinkingTest();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: bipush 10
7: putfield #2 // Field num:I
10: return
LineNumberTable:
line 3: 0
line 4: 4
public void methodA();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #4 // String MethodA
5: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 6: 0
line 7: 8
public void methodB();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=1, args_size=1
0: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #6 // String MethodB
5: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: aload_0
9: invokevirtual #7 // Method methodA:()V
12: aload_0
13: dup
14: getfield #2 // Field num:I
17: iconst_1
18: iadd
19: putfield #2 // Field num:I
22: return
LineNumberTable:
line 10: 0
line 11: 8
line 12: 12
line 13: 22
}
}
我们来分析methodA的调用,首先是调用了命令
9: invokevirtual #7 // Method methodA:()V
我们看 #7这个符号引用类似是方法引用 Methodref类型,又引用了 #8和#28
#7 = Methodref #8.#28 // DynamicLinkingTest.methodA:()V
#8是引用的class类型,即相当于引用当前的类的一个指针
#8 = Class #29 // DynamicLinkingTest
#29这个引用 只能在代表当前类
#29 = Utf8 DynamicLinkingTest
回到#28,即方法名与类型
#28 = NameAndType #16:#13 // methodA:()V
#16和#13分别如下,含义很直白标识方法名和类型
#13 = Utf8 ()V
#16 = Utf8 methodA
这就是方法A的完整流程,
总之,类在加载的时候会在编译时把所需要的常量加载到运行时常量池里,然后方法在当前栈帧执行时就会通过一系列的符号引用指向运行时常量池
比如着一些最常见的
#29 = Utf8 DynamicLinkingTest
#30 = Utf8 java/lang/Object
#31 = Utf8 java/lang/System
#32 = Utf8 out
#33 = Utf8 Ljava/io/PrintStream;
#34 = Utf8 java/io/PrintStream
#35 = Utf8 println
#36 = Utf8 (Ljava/lang/String;)V
这样最主要的时提高资源的复用性,因为栈是线程共享的,高并发环境下如果每个栈都要把这些资源加载一份那是不可能做到的,所以通过一个小小的引用地址(起到指针作用)来引用这些资源是很好理解的,运行时常量池对于一个类中的各线程来说时需要共享的,那就比如会存储在一个从类角度上时共享的区域即方法区中
动态链接、方法返回地址、附加信息 : 有些地方被称为帧数据区,每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。比如:invokedynamic指令
在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里。比如:字节码文件中描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
常量池的作用:可以总结为就是为了提供一些符号和常量,便于指令的识别
方法的调用:解析与分配
在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关
静态链接
当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时,这种情况下降调用方法的符号引用转换为直接引用的过程称之为静态链接
动态链接
如果被调用的方法在编译期无法被确定下来,只能够在程序运行期将调用的方法的符号转换为直接引用,由于这种引用转换过程具备动态性,因此也被称之为动态链接。
静态链接和动态链接不是名词,而是动词,这是理解的关键。
早期绑定
早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
4.8.4. 晚期绑定
如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。
‘’
public class Aniaml {
public void eat(){
System.out.println("动物进食");
}
}
interface Huntable {
void hunt();
}
class Dog extends Aniaml implements Huntable{
@Override
public void hunt() {
System.out.println("多管闲事");
}
public void eat(){
System.out.println("够吃骨头");
}
}
class Cat extends Aniaml implements Huntable{
public Cat(String name) {
this();//典型的早期绑定
}
public Cat() {
super();//典型的早期绑定
}
@Override
public void hunt() {
System.out.println("天经地义");
}
public void eat(){
super.eat(); //典型的早期绑定
System.out.println("猫吃耗子");
}
}
class AniamlTest {
public void showAnimal(Aniaml aniaml){
aniaml.eat();//表现为晚期绑定
}
public void showHunt(Huntable hunt){
hunt.hunt();//接口那更是晚期绑定
}
}
对 AniamlTest进行编译,关注里面的两个方法
showAnimal
0 aload_1
1 invokevirtual #2 <com/sobot/net/jvm/Aniaml.eat : ()V>
4 return
showHunt
0 aload_1
1 invokeinterface #3 <com/sobot/net/jvm/Huntable.hunt : ()V> count 1
6 return
可以看出invokevirtual和 invokeinterface都是典型的虚方法调用,与之相对的是,我们关注Cat类中
两个init方法分别如下
0 aload_0
1 invokespecial #1 <com/sobot/net/jvm/Cat.<init> : ()V>
4 return
0 aload_0
1 invokespecial #2 <com/sobot/net/jvm/Aniaml.<init> : ()V>
4 return
eat方法(主要关注里面对父类方法的调用super.eat();)
0 aload_0
1 invokespecial #6 <com/sobot/net/jvm/Aniaml.eat : ()V>
4 getstatic #3 <java/lang/System.out : Ljava/io/PrintStream;>
7 ldc #7 <猫吃耗子>
9 invokevirtual #5 <java/io/PrintStream.println : (Ljava/lang/String;)V>
12 return
如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。随着高级语言的横空出世,类似于Java一样的基于面向对象的编程语言如今越来越多,尽管这类编程语言在语法风格上存在一定的差别,但是它们彼此之间始终保持着一个共性,那就是都支持封装、继承和多态等面向对象特性,既然这一类的编程语言具备多态特悄,那么自然也就具备早期绑定和晚期绑定两种绑定方式。Java中任何一个普通的方法其实都具备虚函数的特征,它们相当于C语言中的虚函数(C中则需要使用关键字virtual来显式定义)。如果在Java程序中不希望某个方法拥有虚函数的特征时,则可以使用关键字final来标记这个方法。
虚方法和非虚方法
如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法。反之即为非虚方法
总结
动态连接 => 晚期绑定 =>虚方法 => 调用虚方法invokeVirtual(子类重写了父类的方法,final方法),invokeinterface(接口方法),这里注意一个坑,fianl方法是非虚方法但字节码中显示依然是使用的invokeVirtual,
静态连接 => 晚期绑定 =>非虚方法 => 调用非虚方法invokestatic(调用静态方法),invokespecial(实例构造方法,私有方法,父类方法),fianl方法(对应invokeVirtual),都是非虚方法都是非虚方法
动态调用指令:invokedynamic:动态解析出需要调用的方法,然后执行
● JVM字节码指令集一直比较稳定,一直到Java7中才增加了一个invokedynamic指令,这是Java为了实现「动态类型语言」支持而做的一种改进。
● 但是在Java7中并没有提供直接生成invokedynamic指令的方法,需要借助ASM这种底层字节码工具来产生invokedynamic指令。直到Java8的Lambda表达式的出现,invokedynamic指令的生成,在Java中才有了直接的生成方式。
● Java7中增加的动态语言类型支持的本质是对Java虚拟机规范的修改,而不是对Java语言规则的修改,这一块相对来讲比较复杂,增加了虚拟机中的方法调用,最直接的受益者就是运行在Java平台的动态语言的编译器。
动态类型语言和静态类型语言两者的区别就在于对类型的检查是在编译期还是在运行期,满足前者就是静态类型语言,反之是动态类型语言。说的再直白一点就是,静态类型语言是判断变量自身的类型信息;动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征
我们分别以java,python,js来举例
String str = "abc";
int a = 10;
float f = 10.0f;
long l = 10l;
...............
我们可以看出java定义变量必须指明变量类型,而js则不需要指明变量类型,只需要用var来定义一个变量
var a = 10;
var str = ‘b’;
................
而python则是更绝
info = 100.0;
案例代码
interface Func{
public boolean func(String str);
}
public class Lamda{
public void method(Func func) {
return;
}
public static void main(String[] args) {
Lamda lamda = new Lamda();//invokespecial
//创建func的实例传入method
Func func = str -> {
return true;
};
lamda.method(func);
//直接以匿名的方式传入进来
lamda.method(str -> {
return true;
});
}
}
这里可能有些难理解,但结合动态语言的本质特点是编译时不定死类型而是在运行时才考虑类型,在回到我们代码
Lamda lamda = new Lamda();//invokespecial
//创建func的实例传入method
Func func = str -> {
return true;
};
lamda.method(func);
//直接以匿名的方式传入进来
lamda.method(str -> {
return true;
});
编译时我们根本就无法确定等号右边部分(匿名函数表达式)对象的类型,只有在运行时才能获取,这就已经是符合动态语言特点了,所以对应的func这个方法的调用类型为invokedynamic,主要还是因为对调用这个方法的引用func完全无法在编译期间确认下来类型
注意:可能有人感觉虚方法和动态调用比较像,它们间的确有共同点就是,总结为一个字就是晚,即编译期间无法下定论,运行时才见真章,但虚方法是站在方法调用角度的而动态调用是站在对象创建角度来说的,这是本质区别
方法返回地址(return address)
存放调用该方法的pc寄存器的值。一个方法的结束,有两种方式:
● 正常执行完成
● 出现未处理的异常,非正常退出
无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
当一个方法开始执行后,只有两种方式可以退出这个方法:
- 执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口; 一个方法在正常调用完成之后,究竟需要使用哪一个返回指令,还需要根据方法返回值的实际数据类型而定。在字节码指令中,返回指令包含ireturn(当返回值是boolean,byte,char,short和int类型时使用),lreturn(Long类型),freturn(Float类型),dreturn(Double类型),areturn。另外还有一个return指令声明为void的方法,实例初始化方法,类和接口的初始化方法使用。
- 在方法执行过程中遇到异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,简称异常完成出口。
方法执行过程中,抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码
案例代码
public void method1() {
vretrun();
try {
method2();
} catch (Exception e) {
e.printStackTrace();
}
}
private void method2() throws IOException {
FileReader fileReader = new FileReader("d://test.txt");
char[] buffer = new char[1024];
int len = 0;
while((len = fileReader.read(buffer)) != -1) {
String s = new String(buffer, 0, len);
System.out.println(s);
}
fileReader.close();
}
查看方法1中的异常表,注意方法2是把异常给抛出去了,所以没有异常表,方法2把异常抛给了方法1,方法1没有继续抛给它的上一个调用方法而是通过try-catch进线处理,所以方法1有异常表,如下
含义是字节码4到8行范围内有捕获到异常那就调整到第11行,然后我们参考
字节码和行号对应关系发现
其实含义就是try-catch包裹的代码块中的代码出现问题后直接跳转到catch块内进行处理
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。