由于跨平台性的设计,Java 的指令都是根据栈来设计的。不同平台 CPU 架构不同,所以不能设计为基于寄存器的。
栈 vs 堆:
栈
:解决的是程序运行
的问题,即程序如何执行,或者说如何处理数据。堆
:解决的是数据存储
的问题,即数据怎么放,放哪里虚拟机栈的生命周期:
虚拟机栈的作用:
栈的特点:
# 栈
-Xss128k 设置每个线程的栈大小(包括初始大小以及栈的动态扩展行为)
-XX:ThreadStackSize=128k 设置每个线程的初始栈大小(只影响每个线程的初始栈大小)
在不同的 Java 虚拟机实现中,对于栈的大小可以有不同的处理方式,可以是 动态扩展的 或 固定不变的:
固定不变的 Java 虚拟机栈
在虚拟机启动时就确定了。在这种情况下,虚拟机会为每个线程分配固定大小的栈空间,并且不会随着程序的运行而动态调整。这样的实现可能会更简单、更高效,但是如果栈空间不足,就有可能导致栈溢出异常(StackOverflowError
)。
动态扩展的 Java 虚拟机栈
虚拟机栈的大小可以根据程序的需求动态增长。这种情况下,虚拟机会根据需要动态地调整栈的大小,以满足程序运行时的需求。这样可以在一定程度上避免因为栈空间不足而导致的栈溢出问题。不过,动态扩展也可能带来一些性能开销。
对于 HotSpot VM,一般来说,虚拟机栈的大小是固定的,这个固定的大小由 -Xss
参数来设置。
如果 线程请求分配的栈容量 超过 虚拟机栈允许的最大容量,就会抛出StackOverflowError
异常。(常见于递归)
package stack;
/**
* 栈超出最大深度:StackOverflowError
* - 默认情况下:count:31857
* - VM options 设置栈的大小:-Xss512k count:4924
*/
public class StackSOF {
private int stackLength = 1;
// 递归
public void recursion() {
stackLength++;
recursion();
}
public static void main(String[] args) {
StackSOF stackSOF = new StackSOF();
try {
stackSOF.recursion();
} catch (Throwable e) {
System.out.println("当前栈深度:" + stackSOF.stackLength);
e.printStackTrace();
}
}
}
在尝试扩展的时候无法申请到足够的内存,就会抛出OutOfMemoryError
异常。
在创建新线程的时候没有足够的内存去创建对应的虚拟机栈,也会抛出OutOfMemoryError
异常。
以下代码示例谨慎使用,可能会引起电脑卡死
package stack;
/**
* 栈内存溢出: OOM
* VM options 设置栈的大小:-Xss2m
**/
public class StackOOM {
public static void main(String[] args) {
StackOOM stackOOM = new StackOOM();
stackOOM.stackLeakByThread();
}
// 不断创建线程 -> 不断创建Java虚拟机栈 -> 不断申请内存 -> 内存溢出
public void stackLeakByThread() {
while (true) {
Thread t = new Thread(new Runnable() {
public void run() {
dontStop();
}
});
t.start();
}
}
private void dontStop() {
while (true) {
}
}
}
Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread
at java.lang.Thread.start0(Native Method)
at java.lang.Thread.start(Thread.java:719)
at stack.StackOOM.stackLeakByThread(StackOOM.java:22)
at stack.StackOOM.main(StackOOM.java:11)
每个线程在创建时,都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的 Java 方法调用
每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在。
在这个线程上正在执行的每个方法都各自对应一个栈帧(Stack Frame)。
栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。
一个线程,一个时间点上,只会有一个活动的栈帧,这个栈帧被称为 当前栈帧
(Current Frame)
当前栈帧
相对应的方法就是当前方法
(Current Method)当前类
(Current Class)执行引擎 运行的所有字节码指令 只针对 当前栈帧 进行操作。
JVM 直接对 Java 栈的操作只有两个,就是对 栈帧的压栈和出栈,遵循「先进后出」「后进先出」的 原则。
当前栈帧
)新的当前栈帧
。注意:不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。
栈帧是用来存储数据和部分过程结果的数据结构,每个栈帧中存储着:
有些地方将 动态链接、方法返回地址、附加信息 统称为 帧数据区。
每个线程都有自己各自的栈,并且每个栈里面都有很多栈帧,栈帧的大小主要是由 局部变量表 和 操作数栈 决定的。
局部变量表
用于存储方法中的局部变量,包括方法参数
以及在方法中定义的局部变量
。
线程私有
的,因此 不存在数据安全问题。当前方法
的调用中有效。方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。局部变量表是一个数组结构,每个元素都存储一个局部变量的值。最基本的存储单元是 Slot(变量槽)
32 位以内的类型只占用一个 Slot(包括 returnAddress 类型),64 位的类型占用两个 Slot。
引用数据类型(32位),占用一个 Slot
int类型(32位),占用一个 Slot
byte、short、char 在存储前被转换为 int,boolean 也被转换为 int,0 表示 false,非 0 表示 true。
long类型 和 double类型(64位),占用两个 Slot
JVM 会为局部变量表中的每一个 Slot 都分配一个访问索引
,通过这个索引即可成功访问到指定的局部变量值。
当方法被调用时,方法参数
和 方法中定义的局部变量
会 按照顺序 被复制到局部变量表中的每一个 Slot 上。
如果是
构造方法
或实例方法
,Index=0 的 Slot 存放的是对象引用this
,其余的参数 按照顺序 继续排列。
栈帧中的局部变量表中的槽位是可以复用的,如果一个局部变量过了其作用域,那么在其作用域之后声明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。
public class SlotReusingTest {
public void localVar1() {
int a = 0;
int b = 0;
}
public void localVar2() {
{
int a = 0;
}
int b = 0; // 此时的b就会复用a的槽位
}
}
根据变量在类中声明的位置,可以分为成员变量
和局部变量
:
成员变量
:在使用前,都经历过默认初始化赋值。
链接-准备
阶段,会给静态变量设置默认初始值(在初始化
阶段显示赋值)局部变量
:在使用前,必须进行显示赋值,否则,编译不通过!public class LocalVariablesTest {
static int a;
public static void main(String[] args) {
System.out.println(a); // 默认值0
}
public void test() {
int a;
System.out.println(a); // 编译报错
}
}
在栈帧中,与性能调优关系最为密切的部分就是局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
操作数栈就是 JVM 执行引擎的一个工作区。我们说 Java 虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。
操作数栈 主要用于存放方法执行过程中的 操作数
和 中间结果
注意事项:
操作数栈并非采用访问索引的方式来进行数据访问,而是只能通过标准的入栈和出栈操作来完成一次数据访问。
每个操作数栈都有一个明确的栈深度用于存储数值,最大深度 max_stack 在编译期就定义好了,保存在方法的 Code 属性中。
操作数栈中的任何一个元素都可以是任意的 Java 数据类型,但是元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在
编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
public void testAddOperation() {
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
基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数 和 内存读/写次数。
由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。
为了解决这个问题,HotSpot JVM 的设计者们提出了栈顶缓存技术(Top Of Stack Cashing)
寄存器指令更少,执行速度快。
动态链接主要服务一个方法需要调用其他方法的场景。
Java源文件编译成class文件时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池中。
Java虚拟机栈中,每一个栈帧内部都包含一个 指向运行时常量池中该栈帧所属方法的引用,包含这个引用的目的,就是为了支持方法调用过程中的动态链接(Dynamic Linking)。比如:invokedynamic指令
当一个方法要调用其他方法时,就是通过常量池中指向方法的符号引用来表示的,动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
public class DynamicLinkingTest {
int num = 10;
public void methodA(){
System.out.println("methodA()....");
}
public void methodB(){
System.out.println("methodB()....");
methodA();
num++;
}
}
$ javap -v DynamicLinkingTest.class
...
Constant pool:
#1 = Methodref #9.#23 // java/lang/Object."":()V
#2 = Fieldref #8.#24 // stack/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 // stack/DynamicLinkingTest.methodA:()V
#8 = Class #32 // stack/DynamicLinkingTest
#9 = Class #33 // java/lang/Object
#10 = Utf8 num
#11 = Utf8 I
#12 = Utf8
#13 = Utf8 ()V
#14 = Utf8 Code
#15 = Utf8 LineNumberTable
#16 = Utf8 LocalVariableTable
#17 = Utf8 this
#18 = Utf8 Lstack/DynamicLinkingTest;
#19 = Utf8 methodA
#20 = Utf8 methodB
#21 = Utf8 SourceFile
#22 = Utf8 DynamicLinkingTest.java
#23 = NameAndType #12:#13 // "":()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 stack/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
...
public void methodB();
descriptor: ()V
flags: (0x0001) 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
由反编译后的字节码指令可以看出:
methodB 方法中通过 invokevirtual #7
指令调用了 methodA
往上面翻,找到常量池中的定义:
#7 = Methodref #8.#31 // stack/DynamicLinkingTest.methodA:()V
#8 = Class #32 // stack/DynamicLinkingTest
#32 = Utf8 stack/DynamicLinkingTest
#31 = NameAndType #19:#13 // methodA:()V
#19 = Utf8 methodA
#13 = Utf8 ()V
结论:通过 invokevirtual #7
指令找到需要调用的 DynamicLinkingTest 中的 methodA 方法,并进行调用,返回值为void
方法返回地址:存放调用该方法的 pc 寄存器的值(当前栈帧的方法执行结束后,要执行的下一条指令)
当一个方法开始执行后,只有两种方式可以退出这个方法:
正常退出:
执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口;
一个方法在正常调用完成之后,究竟需要使用哪一个返回指令,还需要根据方法返回值的实际数据类型而定。
在字节码指令中,返回指令包含:
ireturn
:当返回值是boolean,byte,char,short和int类型时使用lreturn
:long类型freturn
:float类型dreturn
:double类型areturn
:引用类型return
:返回值类型为void的方法、实例初始化方法,类和接口的初始化方法使用。异常退出:
异常表
,方便在发生异常的时候处理对应类型异常的代码。无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置 PC 寄存器值等,让调用者方法继续执行下去。
正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。
public class ReturnCommandTest {
public byte methodByte() {return 0;}
public short methodShort() {return 0;}
public int methodInt() {return 0;}
public long methodLong() {return 0L;}
public float methodFloat() {return 0.0f;}
public double methodDouble() {return 0.0;}
public char methodChar() {return 'a';}
public boolean methodBoolean() {return true;}
public String methodString() {return null;}
public void methodVoid() {}
static {System.out.println("666");}
}
这里可以自行通过Jclasslib查看,比较简单就不展示了。
package stack;
import java.io.FileReader;
import java.io.IOException;
public class ExceptionExitTest {
public void method1() {
try {
method2(true);
} catch (IOException e) {
e.printStackTrace();
}
}
public void method2(boolean flag) throws IOException {
FileReader fileReader = new FileReader("");
fileReader.close();
}
}
public void method1();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=2, args_size=1
0: aload_0
1: iconst_1
2: invokevirtual #2 // Method method2:(Z)V
5: goto 13
8: astore_1
9: aload_1
10: invokevirtual #4 // Method java/io/IOException.printStackTrace:()V
13: return
Exception table:
from to target type
0 5 8 Class java/io/IOException
LineNumberTable:
line 10: 0
line 13: 5
line 11: 8
line 12: 9
line 14: 13
LocalVariableTable:
Start Length Slot Name Signature
9 4 1 e Ljava/io/IOException;
0 14 0 this Lstack/ExceptionExitTest;
StackMapTable: number_of_entries = 2
frame_type = 72 /* same_locals_1_stack_item */
stack = [ class java/io/IOException ]
frame_type = 4 /* same */
public void method2(boolean) throws java.io.IOException;
descriptor: (Z)V
flags: (0x0001) ACC_PUBLIC
Code:
stack=3, locals=3, args_size=2
0: new #6 // class java/io/FileReader
3: dup
4: ldc #7 // String
6: invokespecial #8 // Method java/io/FileReader."":(Ljava/lang/String;)V
9: astore_2
10: aload_2
11: invokevirtual #9 // Method java/io/FileReader.close:()V
14: return
LineNumberTable:
line 17: 0
line 18: 10
line 19: 14
LocalVariableTable:
Start Length Slot Name Signature
0 15 0 this Lstack/ExceptionExitTest;
0 15 1 flag Z
10 5 2 fileReader Ljava/io/FileReader;
Exceptions:
throws java.io.IOException
可以看到其中 Exception table 部分:
Exception table:
from to target type
0 5 8 Class java/io/IOException
含义:如果字节码指令0~5行出现异常,且异常类型为Class java/io/IOException,则执行第8行的字节码指令
栈帧可能还包括一些额外的信息,如异常处理信息、synchronized 同步块信息等,这些信息的具体内容取决于方法的执行情况和 Java 虚拟机的实现。
在JVM中,将符号引用转换为调用方法的直接引用,与方法的绑定机制相关
静态链接:
被调用的目标方法在编译期确定,且运行期保持不变。
这种情况下将 调用方法的符号引用 转换为 直接引用 的过程称之为 静态链接。
动态链接:
被调用的目标方法在编译期无法确定,只能在程序运行期将 调用方法的符号引用 转换为 直接引用,
由于这种引用转换过程具备动态性,因此也被称之为动态链接。
绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,只会发生一次。
早期绑定(Early Binding)
被调用的目标方法在编译期确定,且运行期保持不变,可直接将这个方法与所属的类型进行绑定。
晚期绑定(Late Binding)
被调用的目标方法在编译期无法确定,只能在程序运行期根据实际的类型绑定相关的方法。
面向对象的语言都具备多态特性,那么自然也就具备 早期绑定 和 晚期绑定 两种绑定方式。
这里回顾一下多态的前提:类的继承关系 + 方法的重写
class Animal {
public void eat() {
System.out.println("动物进食");
}
}
interface Huntable {
void hunt();
}
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(); // 晚期绑定
}
}
static方法
、final方法
、private方法
、构造方法
、super.父类方法
实例方法
、没有加super.的父类方法
、接口方法
动态类型语言 和 静态类型语言 的区别:
说的再直白一点就是:
Java属于静态类型语言,Java7中增加了一个invokedynamic
指令,这是Java为了实现【动态类型语言】支持而做的一种改进。
Java7中增加的动态语言类型支持的本质是对Java虚拟机规范的修改,而不是对Java语言规则的修改。增加了虚拟机中的方法调用,
最直接的受益者就是运行在Java平台的动态语言的编译器。
普通调用指令
invokestatic
:调用static方法
,解析阶段确定唯一方法版本(非虚方法)invokespecial
:调用构造方法
、private方法
、super.父类方法
,解析阶段确定唯一方法版本(非虚方法)invokevirtual
:调用 final方法
、实例方法
、没有加super.的父类方法
(除了final方法都是虚方法)invokeinterface
:调用 接口方法
(虚方法)动态调用指令
invokedynamic
:动态解析出需要调用的方法,然后执行前四条指令固化在虚拟机内部,方法的调用执行不可人为干预。而invokedynamic
指令则支持由用户确定方法版本。
class Father {
public static void showStatic(String str) {}
public final void showFinal() {}
public void showCommon() {}
}
interface MethodInterface {
void methodA();
}
public class Son extends Father {
public Son() {
// 父类构造器:invokespecial
super();
}
public Son(int age) {
// 子类构造器:invokespecial
this();
}
// 不是重写的父类的静态方法,因为静态方法不能被重写!
public static void showStatic() {}
private void showPrivate() {}
public void info() {}
// 非虚方法
public void nonVirtualMethod() {
// 父类的static方法:invokestatic
super.showStatic();
// 子类的static方法:invokestatic
showStatic();
// 父类的实例方法:invokespecial
super.showCommon();
// 子类的private方法:invokespecial
showPrivate();
// 父类的final方法:invokevirtual(由于final修饰不能被子类重写,虽然这里是invokevirtual,也认为是非虚方法)
showFinal();
// 加上super.之后:invokespecial(明确是父类方法了)
super.showFinal();
// 子类的final方法:invokevirtual
sonFinal();
}
// 虚方法
public void virtualMethod() {
// 没有加super.的父类方法:invokevirtual
// 没有显示的加super.,编译器认为可能会调用子类的showCommon方法(即使这里son子类没有重写,也会认为)
// 如果显示加上super.,就是invokespecial了
showCommon();
// 子类的普通方法:invokevirtual
info();
// 接口的方法:invokeinterface(编译器认为会调用实现类的方法,因此是虚方法)
MethodInterface in = null;
in.methodA();
}
}
在Java7中并没有提供直接生成invokedynamic
指令的方法,需要借助ASM这种底层字节码工具来产生invokedynamic
指令。直到Java8的Lambda表达式的出现,invokedynamic
指令 在Java中才有了直接的生成方式。
@FunctionalInterface
interface Func {
boolean func(String str);
}
public class Lambda {
public void useLambda(Func func) {}
public static void main(String[] args) {
Lambda lambda = new Lambda();
Func func = s -> true;
lambda.useLambda(func);
lambda.useLambda(v -> false);
}
}
动态分派(Dynamic Dispatch)是面向对象编程中多态性的一种实现方式,也被称为运行时多态。它指的是在程序运行时根据对象的实际类型来确定调用哪个版本的方法。(通常与继承和重写相关联)
动态分派使得程序能够根据对象的实际类型来决定方法的调用,而不是根据引用变量的类型。
在动态分派中,调用方法的选择是基于对象的运行时类型而不是编译时类型。这意味着在编译时无法确定调用哪个方法,而是在运行时根据对象的实际类型动态决定。
具体流程如下:
java.lang.IllegalAccessError
异常java.lang.AbstractMethodError
异常。IllegalAccessError
程序试图访问或修改一个属性或调用一个方法,这个属性或方法,你没有权限访问。
一般的,这个会引起编译器异常。这个错误如果发生在运行时,就说明一个类发生了不兼容的改变。
比如,你把应该有的jar包放从工程中拿走了,或者Maven中存在jar包冲突
在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标,就可能影响到执行效率。(上面动态分派的过程,我们可以看到如果子类找不到,还要从下往上找其父类,非常耗时)
因此,为了提高性能,JVM在类的方法区建立一个虚方法表(virtual method table),使用索引表来代替查找(非虚方法不在表中)
那么,虚方法表是什么时候被创建的呢?
【例1】
【例2】
interface Friendly {
void sayHello();
void sayGoodbye();
}
class Dog {
public void sayHello() {}
public String toString() { return "Dog"; }
}
class CockerSpaniel extends Dog implements Friendly {
public void sayHello() { super.sayHello(); }
public void sayGoodbye() {}
}
CockerSpaniel 的 虚方法表如下所示:主要注意一下 toString()
方法(本身没重写,父类重写了,所以没有指向Object)