来自尚硅谷宋红康老师讲解的JVM:bilibili链接
官网:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.5.2
虚拟机栈出现的背景
初步印象:有不少Java开发人员一提到Java内存结构,就会非常粗粒度地将JVM中的内存理解为仅有Java堆(heap)和Java栈(stack),为什么?因为C语言就是这样划分的
内存中的栈和堆
虚拟机栈基本内容
面试题:开发中遇到的异常有哪些?
空指针异常、数组越界异常、类型转换异常等
虚拟机栈中可能存在的异常
Java虚拟机规范允许Java虚拟机栈的大小是动态的或者是固定不变的。
StackOverflowError演示
public class StackErrorTest {
private static int count = 1;
public static void main(String[] args) {
main(args);
}
}
设置栈内存大小
栈中存储什么?
复习
栈运行原理
JVM直接对Java栈的操作只有两个,就是对栈帧的入栈和出栈,遵循"先进后出"/“后进先出”原则。
在一条活动现场中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。
执行引擎运行的所有字节码指令只针对当前栈帧操作。
如果在改方法中调用了其他方法,对应的新的栈帧就会被创建出来,放在栈的顶端,称为新的当前帧。
不同线程中锁包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。
如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会对齐当前栈帧,使得前一个栈帧重新成为当前栈帧。
Java方法有两种放回函数的方式,一种是正常的函数返回,使用return指令;另一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。
栈帧的内部结构
局部变量表也被称为 局部变量数组 或 本地变量表
定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各种基本数据类型、对象引用(reference),以及returnAddress类型。
由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
局部变量表所需要的容量大小是在编译期(前端编译)确定下来的,并保存在方法的Code属性的maximun local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。
代码演示(代码1)
public class LocalVariablesTest {
private int count = 0;
public static void main(String[] args) {
LocalVariablesTest test = new LocalVariablesTest();
int num = 10;
test.test1();
}
// 练习:
public static void testStatic() {
LocalVariablesTest test = new LocalVariablesTest();
Date date = new Date();
int count = 10;
System.out.println(count);
// 因为this变量不存在于当前方法的局部变量表中!!
// System.out.println(this.count);
}
//关于Slot的使用的理解
public LocalVariablesTest() {
this.count = 1;
}
public void test1() {
Date date = new Date();
String name1 = "atguigu.com";
test2(date, name1);
System.out.println(date + name1);
}
public String test2(Date dateP, String name2) {
dateP = null;
name2 = "songhongkang";
double weight = 130.5; // 占据两个slot
char gender = '男';
return dateP + name2;
}
public void test3() {
this.count++;
}
public void test4() {
int a = 0;
{
int b = 0;
b = a + 1;
}
// 变量c使用之前已经销毁的变量b占据的slot的位置
int c = a + 1;
}
/*
变量的分类:按照数据类型分:① 基本数据类型 ② 引用数据类型
按照在类中声明的位置分:① 成员变量:在使用前,都经历过默认初始化赋值
类变量: linking的prepare阶段:给类变量默认赋值 ---> initial阶段:给类变量显式赋值即静态代码块赋值
实例变量:随着对象的创建,会在堆空间中分配实例变量空间,并进行默认赋值
② 局部变量:在使用前,必须要进行显式赋值的!否则,编译不通过
*/
public void test5Temp() {
int num;
// System.out.println(num); // 错误信息:变量num未进行初始化
}
}
我们可以看到局部变量表的大小为3(Maximum local variables)
方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用的次数越多。对于一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧越大,以满足方法调用锁需要传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用的次数会减少。
局部变量表中的变量只在当前方法中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
字节码内部结构剖析:依据代码1
关于Slot的理解
关于非静态方法(实例方法)和构造函数局部变量表中的this
关于long和double占用两个slot
Slot的重复利用
变量的分类方式
补充说明
栈的实现:可以使用数组或链表来实现,JVM中的操作数栈使用数组实现
每一个独立的栈帧处理包含局部变量表以外,还包含一个后进先出(Last - In - First Out)的操作数栈,也可以称之为表达式栈(Expression Stack)。
操作数栈:在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)/出栈(pop)。
操作数栈:主要用于保存计算过程的中间结果,同时作为计算过程中的变量临时的存储空间。
操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作栈式空的。
每一个操作数栈都会拥有一个明确的站深度用于存储数值,其所需的最大深度在编译期间(前端编译)就定义好了,保存在方法的Code属性中,为max_stack的值。
栈中的任意一个元素都可以是任意类型的Java数据类型。
操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成数据的一次数据访问。
如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。
操作数栈中元素的数据类型必须与字节码的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。
public class OperandStackTest {
public void testAddOperation() {
//byte、short、char、boolean:都以int型来保存
byte i = 15;
int j = 8;
int k = i + j;
}
}
javap解析后的结果如下:
程序员面试过程中, 常见的i++和++i 的区别,放到字节码篇章时再介绍。
public class OperandStackTest {
public void add(){
//第1类问题:
int i1 = 10;
i1++;
int i2 = 10;
++i2;
//第2类问题:
int i3 = 10;
int i4 = i3++;
int i5 = 10;
int i6 = ++i5;
//第3类问题:
int i7 = 10;
i7 = i7++;
int i8 = 10;
i8 = ++i8;
//第4类问题:
int i9 = 10;
int i10 = i9++ + ++i9;
}
}
如上图,我们已经讲过了 局部变量表 和 操作数栈
动态链接:又称为指向运行时常量池的方法引用
每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法能够实现动态链接(Dynamic Linking)。比如invokedynamic指令
在Java原文件被编译成字节码文件中,所有的变量和方法引用都作为符号引用(Symbolic Referemce)保存在class文件的常量池里。比如:描述一个方法掉调用了另外的方法时,就是通过常量池中指向这些方法的符号引用来表示的,那么动态链接的做作用就是为了将这些符号引用转换为调用方法的直接引用。
public class DynamicLinkingTest {
int num = 10;
public void methodA(){
System.out.println("methodA()....");
}
public void methodB(){
System.out.println("methodB()....");
methodA();
num++;
}
}
动态链接的优点
为什么需要常量池呢?
在JVM中,将符号引用转化为调用方法的直接引用于方法的绑定机制相关。
对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定是一个字段、方法、类在符号引用被替换成直接引用的过程,这仅仅发生一次。
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(); // 表现为:早期绑定 invokespecial
}
public Cat(String name) {
this(); // 表现为:早期绑定 invokespecial
}
@Override
public void eat() {
super.eat(); // 表现为:早期绑定 invokespecial
System.out.println("猫吃鱼");
}
@Override
public void hunt() {
System.out.println("捕食耗子,天经地义");
}
}
public class AnimalTest {
public void showAnimal(Animal animal) {
animal.eat(); // 表现为:晚期绑定 invokevirtual
}
public void showHunt(Huntable h) {
h.hunt(); // 表现为:晚期绑定 invokevirtual
}
}
随着高级语言的横空出世,类似于Java一样的基于面向对象的编程语言如今越来越多,尽管这类编程语言在语法风格上存在一定的差别,但是它们彼此之间始终保持着一个共性,那就是支持封装、继承、多态等面向对象特性,既然这一类编程语言具备多态特性,那么自然也就具备早期绑定和晚期绑定两种绑定方式。
Java中的任何一个普通方法(不用static修饰,或final修饰)其实都具备虚函数的特征,他们相当于C++语言中的虚函数(C++中则需要使用关键字virtual来显式定义)。如果Java程序不希望某个方法拥有虚函数特征时,则可以使用final来标记这个方法。
虚方法和非虚方法
虚拟机中提供 了以下几条方法调用指令:
前四条指令固话在虚拟机内部,方法的调用执行不可认为敢于,而invokedynamic指令则支持用户确定方法版本,其中invokestatic和invokespecial指令调用的方法称为非虚方法,其余的(final修饰的除外)称为虚方法。
/**
* 解析调用中非虚方法、虚方法的测试
* invokestatic指令和invokespecial指令调用的方法称为非虚方法
*/
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();
}
关于invokedynamic指令
/**
* 体会invokedynamic指令
*/
@FunctionalInterface
interface Func {
public boolean func(String str);
}
public class Lambda {
public void lambda(Func func) {
return;
}
public static void main(String[] args) {
Lambda lambda = new Lambda();
Func func = s -> {
return true;
};
lambda.lambda(func);
lambda.lambda(s -> {
return true;
});
}
}
方法重写的本质
虚方法表
在面向对象的编程中,会很频繁的使用到动态分派,如果每次动态分派的过程找那个都要重新在类的方法元数据区中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,JVM采用在类的方法区建立一个虚方法表(virtual method table)(非虚方法不会出现在表中)来实现。使用索引表来代替查找。
每个类中都有一个虚方法表,表中存放着各个方法的实际入口。
那么虚方法表什么时候被创建?
举例1
举例2
/**
* 虚方法表的举例
*/
interface Friendly {
void sayHello();
void sayGoodbye();
}
class Dog {
public void sayHello() { }
public String toString() { return "Dog"; }
}
class Cat implements Friendly {
public void eat() { }
public void sayHello() { }
public void sayGoodbye() { }
protected void finalize() { }
public String toString(){ return "Cat"; }
}
class CockerSpaniel extends Dog implements Friendly {
public void sayHello() { super.sayHello(); }
public void sayGoodbye() { }
}
public class VirtualMethodTable {
}
Dog:
CockerSpaniel
Cat
存放调用该方法的pc寄存器的值。
一个方法的结束,有两种方式:
无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc寄存器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址时要通过异常表来确定,栈帧一般不会保存这部分信息。
当一个方法开始执行后,只有两种方式可以退出这个方法:
执行引擎遇到一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口。
在方法执行过程中遇到了异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。简称异常完成出口。
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何返回值。
举例栈溢出的情况?
调整栈大小,就能保证不出现溢出吗?
分配的栈内存越大越好吗?
垃圾回收是否会涉及到虚拟机栈?
方法中定义的局部变量是否是线程安全?
具体问题具体分析
例子
/**
* 面试题:
* 方法中定义的局部变量是否线程安全?具体情况具体分析
*
* 何为线程安全?
* 如果只有一个线程才可以操作此数据,则必是线程安全的。
* 如果有多个线程操作此数据,则此数据是共享数据。如果不考虑同步机制的话,会存在线程安全问题。
*
*/
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);
System.out.println(s);
}
}