8.1运行时栈帧结构
栈帧:包含局部变量、操作数栈、动态链接、方法返回地址等
8.1.1 局部变量表
局部变量表是一组存放变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。
局部变量表的容量以变量槽(Variable Slot)为最小单位,每个变量槽能够存放一个boolean 、char 、byte、short、int、float、referenc或returnAddress类型的数据这8种数据类型
reference: 作用,一.根据引用可以直接或间接的查找对象在Java堆种的数据存放的起始地址或索引,二是根据引用直接或间接地查找对象所属数据类型在方法区中的存储的类型信息
returnAddress:为字节码 jsr、jsr_w、ret服务的
当一个方法被调用,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递
不要在方法中为了加快gc回收赋null值:一是从编码角度讲,以恰当的作用域来控制变量回收时间才是最优雅的解决办法;二是从执行角度来讲,使用赋值null来优化内存回收是建立在对字节码执行引擎概念模型的理解上的
赋null值的操作在经过即时编译器优化后几乎是一定会被当作无效操作来清除的
8.1.2 操作数栈
也称为操作栈 LIFO 先入后出
同局部变量表一样,操作数栈的最大深大在编译的时候被写入到Code属性max_stacks属性。
操作数栈可以是java的任意基本数据类型
32位数据类型所占的栈容量位1,64位数据类型所占的栈容量位2
两个栈帧会有部分重叠:局部变量表共享区域
Java虚拟机的解释执行引擎被称为:“基于栈的执行引擎”,里的“栈”就是操作数栈
8.1.3 动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持调用过程中的动态连接。
静态解析:指向方法的符号引用一部分会在类加载阶段或者第一次使用的时候就被转化位直接引用,这种转化被称为静态解析
动态连接:另一部分在每一次运行期间都转化为直接引用,这部分称为动态连接
8.1.4 方法返回地址
8.2 方法调用
方法调用不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还未涉及方法内部的具体运行过程。
8.3 解析和分派
#方法接收者
方法接收者:方法所在的具体类型
如下demo:
Father gay = new Son();
gay在编译期的类型是Father,在运行期的实际类型是Son,当调用showMeTheMoney时,方法的所在具体类型就是Son
public class FieldHasNoPolymorphic { static class Father { public int money = 1; public Father() { money = 2; showMeTheMoney(); } public void showMeTheMoney() { System.out.println("I am Father, i have $" + money); } } static class Son extends Father { public int money = 3; public Son() { money = 4; showMeTheMoney(); } public void showMeTheMoney() { System.out.println("I am Son, i have $" + money); } } public static void main(String[] args) { Father gay = new Son(); System.out.println("this gay has $" + gay.money); } }
# 符号引用和直接引用
首先来了解一下符号引用和直接引用的概念:
Java类从加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用、卸载,共七个阶段。
注意:加载、验证、准备、初始化这四个阶段发生的顺序是固定的,而解析阶段在某些情况下位于初始化之后。此外,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。
其中解析阶段主要是虚拟机将常量池中的符号引用转化为直接引用的过程。那么什么是符号引用和直接引用呢?
符号引用:以一组符号来描述所引用的目标,可以是任何形式的字面量(整数、字符串等),只要是能无歧义的定位到目标就好。符号引用与虚拟机的布局无关。
直接引用:即可以指向目标的指针、相对偏移量或者是一个能直接或间接定位到目标的句柄。直接引用与虚拟机的布局相关,不同的虚拟机对于相同的符号引用所翻译出来的直接引用一般是不同的。如果有了直接引用,那么直接引用的目标一定是被加载到了内存中。
解析和分派
说完了符号引用和直接引用我们回归正题!
方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪个方法),暂时还未涉及方法内部的具体运行过程。一切方法调用在.claa文件中存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(即直接引用)。
方法调用形式有:解析和分派。且两者之间的关系并不是二选一的排他关系,他们是在不同层次上去筛选、确定目标方法的过程。
解析(Resolution) |
解析(Resolution)
调用目标在程序写好、编译器进行编译那一刻就已经确定下来。这类方法的调用被称为解析。
调用不同类型的方法,字节码指令集里设计了不同的指令。JVM支持以下5条方法调用字节码指令,分别是:
- invokestatic。用于调用静态方法。
- invokespecial。用于调用实例构造方法<init>()方法、私有方法和父类中的方法。
- invokevirtual。用于调用所有虚方法。
- invokeinterface。用于调用接口方法,会在运行时再确定一个实现该接口的对象。
- invokedynamic。先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。
前面 4 条调用指令,分派逻辑都固化在 JVM 内部,而 invokedynamic
指令的分派逻辑是由用户设定的引导方法来决定的。
只要能被 invokestatic
和 invokespecial
指令调用的方法,都可以在解析阶段中确定唯一的调用版本,Java 中符合这个条件的方法有静态方法
、私有方法
、实例构造器
、父类方法
4 种,再加上被 final
修饰的方法(尽管它使用 invokevirtual 调用),这5种方法调用会在类加载时就可以把符号引用解析为该方法的直接引用。这些方法统称为「非虚方法(Non-Virtual Method)」,与此相反,其他方法则被称为「虚方法(Virtual Method)」。
解析调用一定是个静态的过程,在编译期就完全确定,在类加载的解析阶段就会把涉及的符号引用全部变为明确的直接引用,不必延迟到运行期再去完成。
分派(Dispactch) |
分派调用相对于解析调用要复杂得多,按照状态来分可分为静态分派和动态分派,
众所周知,Java 是一门面向对象的程序语言,Java 具备面向对象的 3 个基本特征:继承、封装和多态。分派调用过程将会揭示多态特性的一些最基本的体现,如「重载(Overload)」和「覆盖(Override)」在 JVM 中是如何实现的,这里的实现当然不是语法上该如何实现,而是 JVM 如何根据相关信息定位到应该执行的方法版本。
1. 静态分派
首先来看下面的这个例子:
public class StaticDispatch { static abstract class OperatingSystem {} static class Linux extends OperatingSystem {} static class MacOS extends OperatingSystem {} public void run(OperatingSystem OS){ System.out.println("hello,OS"); } public void run(Linux linux){ System.out.println("hello,Linux"); } public void run(MacOS macOS){ System.out.println("hello,MacOS"); } public static void main(String[] args) { StaticDispatch sr = new StaticDispatch(); OperatingSystem linux = new Linux(); OperatingSystem macOS = new MacOS(); sr.run(linux); sr.run(macOS); } }
运行结果:
hello,OS
hello,OS
StaticDispatch
类中有多个重载 run()
方法,传入的参数类型分别为父类OperatingSystem
、子类Linux
和子类MacOS
,main()
方法中的代码为「父类引用指向子类对象」。
之所以运行结果为“hello,OS”
,是因为 JVM 选择执行的是参数类型为 OperatingSystem 的重载版本。在解决这个问题之前,我们先通过以下代码来定义两个关键概念:静态类型
、实际类型
。
OperatingSystem linux = new Linux();
这里的 “OperatingSystem”
称为变量的 「静态类型(Static Type)」,或者叫「外观类型(Apparent Type)」,后面的 “Linux” 则被称为变量的 「实际类型(Actual Type)」或者叫「运行时类型(Runtime Type)」。
静态类型
和实际类型
在程序中都可能会发生变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期
就可知的;而实际类型变化的结果在运行期
才能确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。下面通过一个例子来帮助大家对上面这段话进行理解:
// 实际类型的变化 OperatingSystem os = (new Random()).nextBoolean() ? new Linux() : new MacOS(); // 静态类型的变化 sr.run((linux) os) sr.run((macOS) os)对象 os 的实际类型是可变的,编译期间它是不确定的,到底是 Linux 还是 MacOS,必须等到程序运行到这一行的时候才能够确定。而 os 的静态类型是OperatingSystem,也可以在使用时(如下面run()方法中的强转)临时改变这个类型,但是这个改变在编译器仍然是可知的,两次 run() 方法的调用,在编译期完全可以明确转型的是 linux 还是 macOS。
解释清楚了静态类型与实际类型的概念,我们接着言归正传。在最初的StaticDispatch
类中,main()
方法里面的两次run()
方法调用,在接收者已经确实是对象 “sr” 的前提下,使用哪个重载版本,完全取决于传入参数的数量和数据类型。而 JVM (或者准确地说是编译器)在重载
时是通过参数的静态类型而不是实际类型作为判断依据的。
静态类型在编译期可知,所以在编译阶段,Javac 编译器就根据参数的静态类型决定会使用哪个重载版本,因此选择了run(OperatingSystem)
作为调用目标,并把这个方法的符号引用写到main()
方法里的两条invokevirtual
指令中。
所有依赖静态类型来决定方法执行版本的分派动态,都称为「静态分派」。静态分派最典型的应用表现就是方法的重载。
静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的,这点也是为何一些资料把它归入「解析」而不是「分派」的原因。
需要注意的是,Javac 编译器虽然能确定出方法的重载版本,但是很多情况下这个重载版本并不是“唯一”的,往往只能确定一个“相对更加合适的”版本。
2. 动态分派
动态分派的实现过程与重写(Override)有着很密切的关系。
同样采用上面的例子:
public class StaticDispatch { static abstract class OperatingSystem { protected abstract void run(); } static class Linux extends OperatingSystem { @Override protected void run(){ System.out.println("hello,Linux"); } } static class MacOS extends OperatingSystem { @Override protected void run(){ System.out.println("hello,MacOS"); } } public static void main(String[] args) { OperatingSystem linux = new Linux(); OperatingSystem macOS = new MacOS(); linux.run(); // ① macOS.run(); // ② linux = new MacOS(); linux.run(); // ③ } }
运行结果:
hello,Linux // ① hello,MacOS // ② hello,MacOS // ③
仔细思考,不难发现,这里选择调用的方法版本不再是根据静态类型决定的,而是实际类型。这也就是平时我们所说的多态-同样的静态类型,调用同样的方法名,却产生了不同的效果
。
那么,JVM 是如何根据实际类型来分派方法执行版本的呢?
因为 invokevirtual 指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的 invokevirtual
指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接收者的实际类型来选择方法版本,这个过程就是Java语言中重载的本质。
我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
另外有个问题是:字段会不会产生多态?
答案是:不会!
因为这种多态性的根源在于虚方法调用指令 invokevirtual
的执行逻辑,那么,就可以得出结论:其只对方法有效,对字段是无效的,因为字段不使用这条指令。
事实上,在 Java 里面存在虚方法,而字段永远不可能是虚的,换句话说,字段永远不参与多态,哪个类的方法访问某个名字的字段时,该名字指的就是这个类能看到的那个字段。当子类声明了与父类同名的字段时,虽然在子类内存中两个字段都会存在,但是子类的字段会遮蔽父类的同名字段。
为了加深理解,请看如下代码:
public class DynamicDispatch { static abstract class Father { public int money = 1; public Father(){ money = 2; showMeTheMoney(); } public void showMeTheMoney(){ System.out.println("I am Father,I have $" + money); } } static class Son extends Father { public int money = 3; public Son(){ money = 4; showMeTheMoney(); } public void showMeTheMoney(){ System.out.println("I am Son,I have $" + money); System.out.println("---"); } } public static void main(String[] args) { Father gay = new Son(); System.out.println("This gay has $" + gay.money); } }
运行结果:
I am Son,I have $0 --- I am Son,I have $4 --- This gay has $2
小盆友,你是否有很多问号???
这是因为Son
这个类在创建的时候,首先隐式地调用了Father
的构造函数,而Father
构造函数中对showMeTheMoney()
的调用是一次虚方法调用,实际执行的版本是Son:: showMeTheMoney()
方法,所以输出的是"I am Son"
。而这时候虽然父类的money
字段已经被初始化为 2 了,但Son:: showMeTheMoney()
方法中访问的却是子类的money
字段,这时,结果自然还是 0(因为它要到子类的构造函数执行时才会被初始化),main()
的最后一句话通过静态类型访问到了父类的money
,输出了2。
3.单分派与多分派
方法的接收者与方法的参数统称为方法的宗量
Java语言的静态分派属于多分派类型
Java语言的动态的分派属于单分派类型
8.3 动态类型语言
动态类型语言:关键特征是它的类型检查的主体过程是在运行期而不是编译器进行的.
编译器在编译期时最多只能确定方法名称 参数和返回值,而不会去确定方法所在的具体类型(即方法的接收者不固定)变量无类型而变量值有类型
第三方
8.4 基于栈的字节码解释执行引擎
解释执行:通过解释器执行
编译执行:通过即时编译器产生本地代码执行
基于栈的指令集:优点时可移植,代码相对更加紧凑 编译器实现更加简单; 缺点:理论上的执行速度相对来说会稍慢一些
基于寄存器的指令集