文章目录
前言
语雀地址:https://www.yuque.com/yangxiaofei-vquku/wmp1zm/mggrlr
一、虚拟机栈的概述
1、虚拟机栈出现的背景
由于jvm跨平台的设计,java的指令都是基于栈来设计的。不同平台的CUP架构不同,所以不能设计为基于寄存器的。基于栈的指令级架构相对于基于寄存器的指令集架构优点是跨平台,指令集小,编译器容易实现,每个指令都是相对于栈顶栈帧和栈顶栈帧操作数栈的操作,缺点是性能下降,实现同样的功能需要更多指令(jvm的指令集架构)。
2、虚拟机栈的概述
- 栈是线程私有的运行时数据区,在jvm中每个线程都会包涵一个虚拟机栈
- 每调用一个方法就在栈中创建一个栈桢,标准的栈结构,先进后出,方法执行时压栈,方法结束后出栈
- 每个栈桢中包涵局部变量表、操作数占、动态链接、方法返回地址
- 栈是一种快速有效的分配储存方式,访问速度仅次于程序计数器
- 对于栈来说不存在垃圾回收问题,因为栈桢会随着方法的结束出栈并销毁
3、虚拟机栈中的常见异常
由于java虚拟机规范允许java栈的大小是动态扩展的或者固定不变的所以除了StackOverflowError之外栈中也可能出现OutOfMemoryError。
- 如果采用固定大小的java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定,如果线程请求- 分配的栈容量超过java虚拟机栈允许的最大容量,就会抛出StackOverflowError异常
- 如果java虚拟栈可以动态扩展,并且在尝试扩展的时候无法申请到足够内存;或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈那java虚拟机将会抛出OutOfMemoryError异常。
二、栈的存储单位
栈中的存储单位是栈桢,在这个线程上正在执行的每个方法都各自对应一个栈桢。
- 在一条活动的线程中,一个时间点上,只会有一个活动的栈桢,即只有当前正在执行的方法的栈桢是有效的,这个栈桢被称为当前栈桢,与当前栈桢对应的方法称为当前方法,定义这个方法的类被称为当前类
- 程序计数器保存和执行引擎运行的所有字节码指令都是只针对当前栈桢
- 如果在当前方法里调用了其他方法,对应的新的栈桢会被创建出来放在栈顶,称为新的当前栈桢
- 不同线程中包含的栈桢是不允许相互引用的,即不能从一个栈桢中引用另一个线程中的栈桢
- 如果当前方法调用了其他方法,方法返回之际,当前栈桢会传回此方法的执行结果给前一个栈桢,接着,虚拟机会丢弃当前栈桢,使得前一个栈桢重新成为当前栈桢
- Java方法有两种返回函数的方式,一种是使用return指令正常返回(void方法也会默认执行return指令);另外一种是抛出异常。不管使用哪种方式,都会导致方法结束栈桢被弹出
※局部变量表
1.局部变量表概况
- 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括基本数据类型、对象引用(reference),以及returnAddress类型。(基础数据类型正常保存,boolean、char等转换成对于数字,引用类型保存地址值也是二进制数字)
- 局部变量表是建立在线程栈上的,是线程私有数据,因此不会存在数据安全问题
- 局部变量表所需的容量大小是在编译期间就确定下来的,并保存在方法的Code属性maxmun local variables中,在方法运行期间是不会改变局部变量表大小的。
用jclasslib查看更明显
- 方法嵌套的次数受栈的大小的限制(-Xss可配置),一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,他的参数和局部变量越多,局部变量表就膨胀,它的栈桢就越大,进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。(所以在栈大小固定的情况下适当减少局部变量以减小栈桢大小可以增加栈的深度)
- 局部变量表中的变量只在当前方法调用中有效,在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着栈桢的销毁,局部变量表也会随之销毁
- 局部变量表占用了一个栈桢的绝大部分空间
- 局部变量表中的变量时垃圾回收的根节点GCROOT,只有被任意栈桢的局部变量表直接或间接引用的对象都不会被回收
2. 变量槽Slot
- 变量槽Slot是局部变量表的基本存储单位,在局部变量表里32位以内(小于4字节)的类型值占用一个slot包括(byte、short、int、float、char、boolean、引用类型指针reference),64位的(8字节)占用两个slot(long和double)
- 当一个实例方法被调用时候,他的方法参数和方法体内部定义的局部变量都会按照顺序被赋值到局部变量表的没一个slot上
- 如果需要访问局部变量表中一个64bit的局部变量值是只需要使用前一个索引即可,例如:一个double类型变量a占用了变量槽4、5,在获取是该变量时访问变量槽索引4即可
- 如果是非静态方法,那该方法栈桢的局部变量表index为0的solt会存放该对象的引用this
- 局部变量表中的变量槽是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后声明的新的局部变量就很可能会复用过期局部变量的槽位,从而达到节省资源的目的(一个方法里并不是局部变量多就一定局部变量表大,槽位可以重复利用,例如:if、for等语法块中声明的变量,作用域仅在当前代码块并不是整个方法)。
3.局部变量和成员变量
变量的分类
变量按照类型分:基本数据类型变量、引用数据类型变量
变量按照类中位置分:成员变量(类变量、实例变量)、局部变量
这里着重对比成员变量和局部变量的区别:
成员变量的类变量在类装载的链接-准备进行赋默认值,实例变量在对象创建时会进行赋默认值,所以不用进行显式赋值也可以使用,但是成员变量不会被赋默认值,所以声明时必须显式赋值才可以使用。
※操作数栈
1.操作数栈概述
操作数栈,主要用于保存计算过程的临时变量和中间结果
操作数栈的深度在编译器即可确定,保存在方法的code属性中,为max_stack的值
- 操作数栈中一个槽位也是4字节32bit,所以同局部变量表一样byte、short、int、float、char、boolean、引用类型指针reference占用一个栈深度,double和long占用两个栈深度
- 操作数栈是栈结构不能像局部变量表一样通过下表索引访问数据,只能通过入栈push和出栈pop操作来完成数据访问
- 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈桢的操作数栈
首先store_n命令的意思是指将操作数类型为的栈顶元素出栈并放入局部变量表的第n个变量槽中,pop是将操作数栈的栈顶元素出栈销毁。
method1()字节码:整个过程操作数栈最大深度为1
0 sipush 1024 将1024压入操作数栈顶,此时操作数栈中只有1024
3 istore_1 操作数栈顶元素出栈放入局部变量表第1个变量槽,此时操作数栈为空
4 aload_0 将局部变量表0号变量槽的元素压如操作数栈顶,此时操作数栈中只有个this(0号位置是this,压栈目的是为了调用this.returnInt)
5 invokevirtual #2 调用this.returnInt方法并将操作数栈中的this出栈,把returnInt的返回值10压如操作数栈顶,此时操作数栈中只有10
8 istore_2 将操作数栈的栈顶元素出栈并放入局部变量表的2号槽位,此时操作数栈为空
9 return 返回结果方法当前栈桢被弹出销毁
method2字节码:
0 sipush 1024 将1024压入操作数栈顶,此时操作数栈中只有1024
3 istore_1 操作数栈顶元素出栈放入局部变量表第1个变量槽,此时操作数栈为空
4 aload_0 将局部变量表0号变量槽的元素压如操作数栈顶,此时操作数栈中只有个this(0号位置是this,压栈目的是为了调用this.returnInt)
5 invokevirtual #2 调用this.returnInt方法并将操作数栈中的this出栈,把returnInt的返回值10压如操作数栈顶,此时操作数栈中只有10
8 pop 将操作数栈的栈顶元素出栈销毁
9 return 返回结果方法当前栈桢被弹出销毁
由上图method1和method2可以看出调用又返回值的方法时会将方法的返回值压如当前栈桢的操作数栈
- 我们说Java虚拟机的执行引擎是基于栈的执行引擎,其中的栈指的就是操作数栈
2.栈顶缓存技术
基于栈式的架构的虚拟机所使用的零地址指令更加紧凑,单完成一项操作的时候必然需要使用更多的入栈和出栈指令,虚拟机栈也是存在于内存中,这就意味着将需要更多的指令分派次数和内存读/写次数,频繁的执行内存读/写操作必然会影响执行速度,为了解决这一问题HotSpot JVM的设计者们提出了栈顶缓存(Top-of-Stack-Caching)技术,将栈顶元素(或栈顶周边)元素缓存到物理CUP的寄存器中,以此降低对内存的读写次数,提升执行引擎的执行效率。
有关栈顶缓存技术需要关注两个核心问题:
- 缓存了栈顶附近的多少个元素?如果缓存了n个元素,那么就叫n-TOS caching;
- 缓存带有多少种“状态”?如果有n种状态那么就叫n-state TOS caching。
先看n-TOS caching
从抽象数据结构来举例操作数栈的实现:可以想像把Java标准库自带的那个java.util.Stack包装一下,假如实现栈顶缓存技术的逻辑如下:压如操作数栈的栈顶元素优先存到CUP的寄存器中。(Stack为原始操作数栈,StackWith1TOSCA为支持栈顶缓存的操作数栈)
import java.util.EmptyStackException;
import java.util.Stack;
public class StackWith1TOSCA<E> {
private enum TosState {
NOT_CACHED,
CACHED;
}
// 内存中的操作数栈(内存)
private Stack<E> theStack = new Stack<E>();
// 栈顶元素(寄存器)
private E topOfStackElement; // the cache
// 是否有缓存,在1-TOS中即表示栈顶是否有数据
private TosState state = TosState.NOT_CACHED;
// 向操作数栈中压如元素
public void push(E elem) {
// 如果栈顶已有元素,
if (state == TosState.CACHED) {
// 将栈顶元素压入内存中真正的操作数栈
theStack.push(topOfStackElement);
}
// 将寄存器中栈顶数据替换为新压人的栈顶元素
topOfStackElement = elem;
// 设置state为CACHE代表栈顶有元素
state = TosState.CACHED;
}
// 操作数栈的栈顶元素出栈
public E pop() {
// 如果状态为NOT_CACHE也就是无栈顶元素,此时操作数栈为空无法出栈
if (state == TosState.NOT_CACHED) throw new EmptyStackException();
// 将CPU寄存器中的栈顶元素取出
E result = topOfStackElement;
// 内存中的栈桢操作数栈是否为空
if (theStack.isEmpty()) {
// 此时操作数栈为空,栈顶元素弹出为空
state = TosState.NOT_CACHED;
topOfStackElement = null;
} else {
// 将内存中操作数栈的栈顶元素去除放入寄存器
topOfStackElement = theStack.pop();
}
return result;
}
}
那么如果有这样的Java代码:
static void foo(Object o) {
Object temp = o;
}
对于字节码指令为
用不支持栈顶缓存的操作数栈Stack执行上面字节码指令,执行了2次内存读取两次内存写入,jvm操作为
// 从局部变量表读取写入操作数栈——》一次内存读取,一次内存写入
stack.push(locals[0]);
// 从操作数栈中读取写入局部变量表——》一次内存读取,一次内存写入
locals[1] = stack.pop();
用支持栈顶缓存技术的操作数栈StackWith1TOSCA执行上面字节码指令,执行了一次内存读取,一次寄存器写入,一次寄存器读取,一次内存写入,jvm操作为
// 从局部变量表读取,写入寄存器——》一次内存读取,一次寄存器写入
topOfStackElement = locals[0];
// 从寄存器读取,写入局部变量表——》一次寄存器读取,一次内存写入
locals[1] = topOfStackElement;
从上述例子可以看出将栈顶元素缓存寄存器有效的减少了内存的读写次数,而如果选择把栈顶附近的若干个元素缓存在寄存器里的话,在频繁的计算逻辑中将大幅度提升性能。
再看n-state TOS caching
前面的StackWith1TOSCA例子里可以看到已经有“state”的概念出现:我们必须要知道当前在缓存里到底有没有值,不然就无从判断压栈/出栈时数据该何去何从了。这个例子用了两种状态,NOT_CACHED和CACHED;对于不关心栈里元素类型的stack caching来说,1-TOS用两种状态就够用了。
实际上“状态”可以记录许多东西,取决于到底要实现怎样的TOSCA。
一个例子:如果我们现在不用1-TOS,而用3-TOS caching的话,那很明显我们的“状态”不但要记录“有没有缓存栈顶元素”,还得记录“到底栈顶附近的三个元素到底放在哪个变量里了”。
另一个例子:如果我们的栈需要跟踪栈里的元素的类型,同时我们使用1-TOS caching的话,那就意味着要记录的“状态”里必须记住栈顶元素是什么类型的。HotSpot VM的解释器就是这样的例子,它虽然只用了1-TOS caching,但它的TosState却有9种有效值。也就是说这个解释器的TOSCA可以描述为1-TOS, 9-state caching。
大家可以想像一个n > 1的n-TOS如果跟带类型的TOSCA结合起来状态数量的膨胀速度会有多快。
实际上多数虚拟机就算用了stack caching也只会用1-TOS,因为简单高效;大不了1-TOS外带类型。
也有复杂一些的例子,例如Sun JDK 1.1.x里的解释器在x86上的实现,它用的是2-TOS, 3-state caching。
3.i++和++i问题
public static void method3(){
int i=100;
i=i++;
System.out.println(i);//100
}
上面一段代码输出值为100,这个很令人费解,下面将在字节码指令层面分析输出100的原因
0 bipush 100 将100压入操作数栈栈顶
2 istore_0 将操作数栈栈顶元素取出放入局部变量表0号变量槽,此时操作数栈为空
3 iload_0 将局部变量表0号槽位元素压入操作数栈栈顶
4 iinc 0 by 1 将局部变量表0号槽位元素加1,此时局部变量表为101
7 istore_0 将操作数栈栈顶元素取出放入局部变量表0号变量槽,此时操作数栈为空(这步很关键,覆盖率原先的101)
…
所以最终打印的i=100。
※动态链接
动态链接保存的是:当前类运行时常量池引用
如图所示栈桢中保存了当前方法的字节码指令,只要用到常量池的指令后面都会带有#n,这是在编译时确定的,与字节码中常量池相对应,字节码常量池在类加载后变成运行时常量池保存在方法区。当该栈桢运行这些字节码时就需要访问当前类的常量池,在动态链接中存储的就是当前类的运行时常量池的引用,在指令首次执行时常量池中保存的还是符合引用,需要被转换成符合引用对于的直接引用。
※方法返回地址
存放调用该方法的程序计数器的值,即当前栈桢的上一个栈桢调用当前栈桢的那行指令的地址。
作用就是当方法正常执行结束时,能够顺利回到上一个方法调用该方法的位置继续执行。如果是异常退出返回地址要通过异常表来确定,栈桢中不会保存这部分信息。
三、方法的调用
1.早起绑定和晚期绑定
⭕️早期绑定
早期绑定就是指被调用的目标方法如果在编译器可知,且运行期保持不变时,即可将这个方法与其所属的类型进行绑定,早期绑定的方法也被称为非虚方法。
早期绑定的好处是编译期触发,能够提早知道代码错误、提高程序运行效率。
⭕️晚期绑定
如果被调用的方法在编译期无法被确定下来,只能够在程序运行期间确定实际的类型,这种绑定方式被称为晚期绑定,晚期绑定的方法都是虚方法。
晚期绑定的好处是,晚期绑定是多态是设计模式的基础,能够降低耦合性,提升程序的复用性。
2.虚方法和非虚方法
⭕️非虚方法&虚方法定义
静态方法、私有方法、final方法、构造方法、父类方法都是非虚方法,其中前四种是无法被子类及其派生类重写的,父类方法在由于java只能允许单继承所以这五种方法在编译器就能确定具体的调用版本,且在运行期保持不变。其余方法均为虚方法。
⭕️方法调用字节码指令
- invokestatic:调用静态方法,编译阶段确定唯一方法版本.
- invokespecial:构造方法、私有方法、父类方法,编译阶段确定唯一方法版本
- invokevirtual :调用所有虚方法(除去final修饰的方法)
- invokeinterface:调用接口方法
invokestatic、invokespecial再加上final修饰的invokevirtual为字节码指令代表调用非虚方法;invokevirtual(除去final修饰的方法)、invokeinterface代表调用虚方法
package jvm.memory.stack;
/**
* 方法调用:前期绑定后期绑定,虚函数非虚函数指令
*/
public class MethodCall {
public static class Father{
/**
* 静态方法 非虚方法
*/
public static void staticMethod(){
}
/**
* 常量方法 非虚方法
*/
public final void finalMethod(){
}
/**
* 正常方法可以被继承重新,虚方法
*/
public void method(){
}
/**
* 私有方法,非虚方法
*/
private void privateMethod(){
}
}
public static class Son extends Father{
public Son() {
// 非虚方法
super();
}
public Son(String name){
// 非虚方法
this();
}
/**
* 正常方法可以被继承重新,虚方法
* 子类已经重新
*/
public void method(){
System.out.println("子类重写");
}
/**
* 私有方法,非虚方法
*/
private void privateMethod(){
}
public void demo(){
// 非虚
staticMethod();// invokestatic
finalMethod();// invokevirtual
privateMethod();// invokespecial
// 虚
method();// invokevirtual
}
}
}
还有一个invokedynamic的动态调用指令,代表动态解析出需要调用的方法然后执行,在JDK8之前需要ASM这种字节码工具才能触发,JDK8以后在调用Lambda表达式会触发
package jvm.memory.stack;
/**
* Invokedynamic 动态调用指令
*/
public class InvokedynamicDemo {
private String name;
public InvokedynamicDemo(String name){
this.name=name;
}
public void method(Fun fun){
System.out.println(fun.execute(name));
}
public interface Fun{
public String execute(String name);
}
public static void main(String[] args) {
InvokedynamicDemo invokedynamic=new InvokedynamicDemo("小明");
// invokedynamic
Fun fun=name -> {return name+"来自与一个函数接口0";};
// fun1声明没用到函数表达式所以不是invokedynamic
Fun fun1=new Fun() {
@Override
public String execute(String name) {
return name+"来自与一个函数接口1";
}
};
invokedynamic.method(fun);
invokedynamic.method(fun1);
// invokedynamic
invokedynamic.method(name -> {
return name+"来自与一个函数接口2";
});
}
}
最后输出结果:
小明来自与一个函数接口0
小明来自与一个函数接口1
小明来自与一个函数接口2
3.虚方法表
⭕️为什么要有虚方法表
要想了解为什么需要虚方法表需要先明确虚方法调用时需要做那些事情,由于虚方法时运行被重写的再多态的情景下,只有运行时才能确认被调用方法是父类定义还是子类定义的。在方法调用前首先把调用方法的对象实例压入栈顶,具体流程如下:
- 找到操作数栈顶的第一个元素记作类型C
- 如果在C类型中找到与该方法名称与方法签名都符合且访问权限允许的方法则直接返回该方法直接引用,结束查找
- 如果为找到,则按照继承关系依次查找C的各个父类,有则返回
- 如果始终没找到则抛出java.lang.AbstractMethodError
在面向对象的开发中会频繁使用虚方法的调用,如果每次调用都这样查找一遍势必影响性能,为了提高性能,JVM为每一个类在解析阶段在方法区中创建一个虚方法表,记录了类中各虚方法真实的直接引用。(不包括非虚方法,因为非虚方法编译期间即可明确方法版本归属)
⭕️虚方法表举例
package jvm.memory.stack;
/**
* 虚方法表
*/
public class VirtualMethodTable {
public class Person{
public void love(){
System.out.println("爱好世界和平");
}
@Override
public String toString() {
return "我是人类";
}
}
public class Father extends Person{
@Override
public void love() {
System.out.println("爱好读书");
}
public void work(){
System.out.println("努力工作");
}
}
public class Son extends Father{
@Override
public void love() {
System.out.println("爱好编程");
}
@Override
public void work() {
System.out.println("努力学习");
}
}
}