4.1 虚拟机栈的概述
4.1.1 虚拟机栈的特点
由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。
优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。
4.1.2 内存中的堆与栈
栈是运行时的单位,堆是存储的单位。
- 栈解决程序的运行问题
- 堆解决的是数据存储的问题
4.1.3 虚拟机栈的基本特点
Java虚拟机栈(Java Virtual Machine Stack),早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的Java方法调用,是线程私有的。
一个线程对应一个虚拟机栈,一个栈帧对应一个方法
生命周期:生命周期和线程一致
栈的特点:
栈是一种快速有效的分配存储方式,访问速度仅次于罹序计数器。
JVM直接对Java栈的操作只有两个:
- 每个方法执行,伴随着进栈(入栈、压栈)
- 执行结束后的出栈工作
对于栈来说不存在垃圾回收问题(栈存在溢出的情况)
4.1.4 虚拟机栈中会出现哪些异常
-
如果采用固定大小的Java虚拟机栈,那每一个线程的Java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError 异常。
-
如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那Java虚拟机将会抛出一个 OutOfMemoryError 异常。
栈溢出异常的示例
public class StackErrorTest { private static int count = 1; public static void main(String[] args) { System.out.println(count); count++; main(args); } }
方法中自己调用自己,那么就会出现栈溢出异常
可以通过-Xss调节栈的大小:
设置栈的大小: -Xss256k : count : 2465
4.2 栈的存储单位
4.2.1. 栈中存储什么?
每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在。
4.2.2 栈的运行原理
栈中只有两个操作,分别是出栈和入栈,特点是“先进后出/后进先出”。
Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出
在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。
执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
4.2.3 栈帧的内部结构
栈中的内部结构有:局部变量表,方法返回值,操作数栈(表达式栈),动态链接(运行时常量池的方法引用),一些附加消息。
4.3 局部变量表
4.3.1 基本概述
作用(定义):一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量。
存在的问题:①线程私有,不存在数据安全的问题 ②局部变量作用范围是当前代码块 ③所需容量大小是编译的时候就确定下来的
4.3.2 有关slot的理解
-
局部变量表,最基本的存储单元是Slot(变量槽)
-
局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量。
-
如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的slot处,其余的参数按照参数表顺序继续排列。
package com.atguigu.java.slottest; public class Test { public void test1(){ int num=1; int[] value={1,2}; double val=12.2; long adc=1323233; } public static void test2(){ int num=1; int num2=2; System.out.println(this.num); } public Test(){ int num=3; } public void test3(){ { int a = 0; System.out.println(a); } //此时的b就会复用a的槽位 int b = 0; } }
注意:上面的static代码块会有错误,一个重大原因是static方法中不会存储this这个对象引用,对应的反编译(解析)的结果是:
注意:这是Test1()的,由于没有static修饰,因此局部变量表中有this关键词
double类型占两位,因此序号(索引)直接从3跳到5
起始PC表示起始的位置,与长度共同构成了这个数据的作用范围
第二个由于有static修饰,因此没有this了
构造器索引为0的位置也是this
第三个显示了复位,b会复位到a槽位,所以索引依旧从1开始
4.3.2 静态变量与局部变量的对比
我们知道类变量表有两次初始化的机会,第一次是在“准备阶段”,执行系统初始化,对类变量设置零值,另一次则是在“初始化”阶段,赋予程序员在代码中定义的初始值。
和类变量初始化不同的是,局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用。
总而言之全静态变量使用时可以不进行初始化,局部变量使用的时候必须进行初始化
这个代码是错误的,没有对局部变量进行初始化
4.3.3 注意事项
在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
4.4 操作数栈
每一个独立的栈帧除了包含局部变量表以外,还包含一个后进先出(Last-In-First-Out)的 操作数栈,也可以称之为表达式栈(Expression Stack)
操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)和 出栈(pop)
与局部变量表一样,均以字长为单位的数组。不过局部变量表用的是索引,操作数栈是弹栈/压栈来访问。操作数栈可理解为java虚拟机栈中的一个用于计算的临时数据存储区。
存储的数据与局部变量表一致含int、long、float、double、reference、returnType,操作数栈中byte、short、char压栈前(bipush)会被转为int。
数据运算的地方,大多数指令都在操作数栈弹栈运算,然后结果压栈。
java虚拟机栈是方法调用和执行的空间,每个方法会封装成一个栈帧压入占中。其中里面的操作数栈用于进行运算,当前线程只有当前执行的方法才会在操作数栈中调用指令(可见java虚拟机栈的指令主要取于操作数栈)。
栈中的任何一个元素都是可以任意的Java数据类型
- 32bit的类型占用一个栈单位深度
- 64bit的类型占用两个栈单位深度
4.5 代码追踪
public class OperandStackTest { public void testAddOperation() { //byte、short、char、boolean:都以int型来保存 byte i = 15; int j = 8; int k = i + j; // int m = 800; } }
这几张图要好好好好看!!!!
4.6 栈顶缓存技术
总结来说一句话:栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。
4.7 动态链接(运行时常量池中该栈帧所属方法的引用)
注意这张图
javap反编译后的结果:
DynamicLinkingTest.class
Last modified 2022-9-26; size 712 bytes
MD5 checksum e56913c945f897c7ee6c0a608629bca8
Compiled from "DynamicLinkingTest.java"
public class com.atguigu.java1.DynamicLinkingTest
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #9.#23 // java/lang/Object."<init>":()V
#2 = Fieldref #8.#24 // com/atguigu/java1/DynamicLinkingTest.num:I
#3 = Fieldref #25.#26 // java/lang/System.out:Ljava/io/PrintStream;
#4 = String #27 // methodA()....
#5 = Methodref #28.#29 // java/io/PrintStream.println:(Ljava/lang/String;)V
#6 = String #30 // methodB()....
#7 = Methodref #8.#31 // com/atguigu/java1/DynamicLinkingTest.methodA:()V
#8 = Class #32 // com/atguigu/java1/DynamicLinkingTest
#9 = Class #33 // java/lang/Object
#10 = Utf8 num
#11 = Utf8 I
#12 = Utf8 <init>
#13 = Utf8 ()V
#14 = Utf8 Code
#15 = Utf8 LineNumberTable
#16 = Utf8 LocalVariableTable
#17 = Utf8 this
#18 = Utf8 Lcom/atguigu/java1/DynamicLinkingTest;
#19 = Utf8 methodA
#20 = Utf8 methodB
#21 = Utf8 SourceFile
#22 = Utf8 DynamicLinkingTest.java
#23 = NameAndType #12:#13 // "<init>":()V
#24 = NameAndType #10:#11 // num:I
#25 = Class #34 // java/lang/System
#26 = NameAndType #35:#36 // out:Ljava/io/PrintStream;
#27 = Utf8 methodA()....
#28 = Class #37 // java/io/PrintStream
#29 = NameAndType #38:#39 // println:(Ljava/lang/String;)V
#30 = Utf8 methodB()....
#31 = NameAndType #19:#13 // methodA:()V
#32 = Utf8 com/atguigu/java1/DynamicLinkingTest
#33 = Utf8 java/lang/Object
#34 = Utf8 java/lang/System
#35 = Utf8 out
#36 = Utf8 Ljava/io/PrintStream;
#37 = Utf8 java/io/PrintStream
#38 = Utf8 println
#39 = Utf8 (Ljava/lang/String;)V
{
int num;
descriptor: I
flags:public com.atguigu.java1.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 7: 0
line 9: 4
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 this Lcom/atguigu/java1/DynamicLinkingTest;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 12: 0
line 13: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 this Lcom/atguigu/java1/DynamicLinkingTest;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 16: 0
line 18: 8
line 20: 12
line 21: 22
LocalVariableTable:
Start Length Slot Name Signature
0 23 0 this Lcom/atguigu/java1/DynamicLinkingTest;
}
SourceFile: "DynamicLinkingTest.java"
为什么需要运行时常量池?
因为在不同的方法,都可能调用常量或者方法,所以只需要存储一份即可,节省了空间
常量池的作用:就是为了提供一些符号和常量,便于指令的识别
4.8 方法的调用,解析和分配
4.8.1 静态链接(早期绑定)
被调用的目标方法在编译期可知,且运行期保持不变时
4.8.2 动态链接(晚期绑定)
如果被调用的方法在编译期无法被确定下来,只能够在程序运行期将调用的方法的符号转换为直接引用,由于这种引用转换过程具备动态性,因此也被称之为动态链接
4.8.3 虚方法和非虚方法
如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法。
静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法。其他方法称为虚方法。
虚拟机中提供了以下几条方法调用指令:
- invokestatic:调用静态方法,解析阶段确定唯一方法版本
- invokespecial:调用方法、私有及父类方法,解析阶段确定唯一方法版本
- invokevirtual:调用所有虚方法
- invokeinterface:调用接口方法
- invokedynamic:动态解析出需要调用的方法,然后执行
前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而invokedynamic指令则支持由用户确定方法版本。其中invokestatic指令和invokespecial指令调用的方法称为非虚方法,其余的(fina1修饰的除外)称为虚方法。
示例代码:
package com.atguigu.java2; /** * 说明早期绑定和晚期绑定的例子 * @author shkstart * @create 2020 上午 11:59 */ class Animal{ public void eat(){ System.out.println("动物进食"); } } interface Huntable{ void hunt(); } class Dog extends Animal implements Huntable{ @Override public void eat() { System.out.println("狗吃骨头"); } @Override public void hunt() { System.out.println("捕食耗子,多管闲事"); } } class Cat extends Animal implements Huntable{ public Cat(){ super();//表现为:早期绑定 } public Cat(String name){ this();//表现为:早期绑定 } @Override public void eat() { super.eat();//表现为:早期绑定 System.out.println("猫吃鱼"); } @Override public void hunt() { System.out.println("捕食耗子,天经地义"); } } public class AnimalTest { public void showAnimal(Animal animal){ animal.eat();//表现为:晚期绑定 } public void showHunt(Huntable h){ h.hunt();//表现为:晚期绑定 } }
上面的例子,具体说明了什么方法是早期绑定,什么是晚期绑定
package com.atguigu.java2; /** * 解析调用中非虚方法、虚方法的测试 * * invokestatic指令和invokespecial指令调用的方法称为非虚方法 * @author shkstart * @create 2020 下午 12:07 */ class Father { public Father() { System.out.println("father的构造器"); } public static void showStatic(String str) { System.out.println("father " + str); } public final void showFinal() { System.out.println("father show final"); } public void showCommon() { System.out.println("father 普通方法"); } } public class Son extends Father { public Son() { //invokespecial super(); } public Son(int age) { //invokespecial this(); } //不是重写的父类的静态方法,因为静态方法不能被重写! public static void showStatic(String str) { System.out.println("son " + str); } private void showPrivate(String str) { System.out.println("son private" + str); } public void show() { //invokestatic showStatic("atguigu.com"); //invokestatic super.showStatic("good!"); //invokespecial showPrivate("hello!"); //invokespecial super.showCommon(); //invokevirtual showFinal();//因为此方法声明有final,不能被子类重写,所以也认为此方法是非虚方法。 //虚方法如下: //invokevirtual showCommon(); info(); MethodInterface in = null; //invokeinterface in.methodA(); } public void info(){ } public void display(Father f){ f.showCommon(); } public static void main(String[] args) { Son so = new Son(); so.show(); } } interface MethodInterface{ void methodA(); }
这是虚方法和非虚方法
4.8.4 方法重写的本质
Java 语言中方法重写的本质:
- 找到操作数栈顶的第一个元素所执行的对象的实际类型,记作C。
- 如果在类型C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError 异常。
- 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出java.1ang.AbstractMethodsrror异常。
4.8.5 虚方法标
在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,JVM采用在类的方法区建立一个虚方法表 (virtual method table)(非虚方法不会出现在表中)来实现。使用索引表来代替查找。
4.8.6 方法的返回地址
方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
说白了就是方法返回的时候,返回到哪里。
4.9 一些附加信息
4.10 栈相关的面试题
1.栈溢出的具体例子:
方法不停递归调用,但是没有出口,可以用-Xss设置栈的大小
2.调整栈大小是否一定会导致栈溢出?
这个很显然,不停的递归调用,一定不会导致栈的溢出
3.栈是不是越大越好?
显然不是,栈越大,那么可以运行的线程就会越来越小
4.垃圾回收是否设计虚拟机栈?
显然,垃圾回收不涉及虚拟机栈,这是因为栈只有出栈入栈的操作
4.方法中定义的局部变量是否线程安全?
看下面代码:
package com.atguigu.java3; /** * 面试题: * 方法中定义的局部变量是否线程安全?具体情况具体分析 * * 何为线程安全? * 如果只有一个线程才可以操作此数据,则必是线程安全的。 * 如果有多个线程操作此数据,则此数据是共享数据。如果不考虑同步机制的话,会存在线程安全问题。 * @author shkstart * @create 2020 下午 7:48 */ public class StringBuilderTest { int num = 10; //s1的声明方式是线程安全的 public static void method1(){ //StringBuilder:线程不安全 StringBuilder s1 = new StringBuilder(); s1.append("a"); s1.append("b"); //... } //sBuilder的操作过程:是线程不安全的 public static void method2(StringBuilder sBuilder){ sBuilder.append("a"); sBuilder.append("b"); //... } //s1的操作:是线程不安全的 public static StringBuilder method3(){ StringBuilder s1 = new StringBuilder(); s1.append("a"); s1.append("b"); return s1; } //s1的操作:是线程安全的 public static String method4(){ StringBuilder s1 = new StringBuilder(); s1.append("a"); s1.append("b"); return s1.toString(); } public static void main(String[] args) { StringBuilder s = new StringBuilder(); new Thread(() -> { s.append("a"); s.append("b"); }).start(); method2(s); } }
首先注意:之所以用StringBuilder的原因是应为,StringBuilder是线程不安全的,而StringBuffer是线程安全的
Method2和Method3是线程不安全的原因是应为,他们逃逸了
第四个方法,是应为toString()方法本来就new String()不会导致变量逃逸