文章目录
PC寄存器
- PC寄存器用来储存指向下一条指令的地址,也即将要执行的代码。由执行引擎读取下一条指令。
- 它是一块很小的内存空间,几乎可以忽略不记。也是运行速度最快的存储区域。
- PC寄存器是线程私有的。
- 分支,循环,跳转,异常处理,线程恢复都需要依赖计数器完成。
- 它是唯一一个没有规定OutOfMemoryError情况的区域
使用PC寄存器存储字节码指令地址有什么用呢?
为什么使用PC寄存器记录当前线程的执行地址呢?
因为CPU需要不停的切换各个线程,这时候切换回来以后,就要知道接着从哪开始继续执行。JVM字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。
为什么PC寄存器是线程私有的?
因为在只有一核的情况下,若干个线程是交替执行的,在线程与线程之间切换的时候,需要记录每个线程执行到那一步了,每一个线程都有一个PC寄存器才能记录每一个线程要执行的行号。
虚拟机栈
- 栈是运行时的单位,堆是存储的单位。
- 每个线程在创建的时候都会创建一个虚拟机栈,其内部保存一个个的栈帧。
- 主管Java程序的运行,它保存方法的局部变量(8种基本数据类型,对象的引用地址)、部分结果、并产于方法的调用和返回。
- 不会GC,固定大小,超过允许的最大容量,会抛出StackOverFlowError,动态扩展,无法申请到足够的内存时会抛出OutOfMemoryError
设置栈的大小
-Xss256k
栈帧的运行原理
-
JVM直接对Java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循“先进后出”/“后进先出”原则。
-
在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame) ,与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class) 。
-
执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
-
如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。
-
不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。
-
如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
-
Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。
栈帧的内部结构
局部变量表(Local Variables)
特点
-
局部变量表也被称为局部变量数组或本地变量表
-
定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用( reference),以及returnAddress类型。
-
由于局部变量表是建立在线程栈上的,是线程私有的,因此不存在数据安全问题
-
局部变量表所需容量的大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。
-
方法嵌套调用的次数由栈的大小决定。方法的参数越多,局部变量表越大,就会占用更多的栈空间,方法嵌套调用的次数就会越少。
-
局部变量表中的变量只在当前方法中有效。在方法执行时,虚拟机通过局部变量表完成参数值传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
Slot
-
参数值的存放总是在局部变量数组的index0开始,到数组长度-1的索引结束。
-
局部变量表,最基本的存储单元是Slot (变量槽)
-
局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress(方法返回值)类型的变量。
-
在局部变量表里,32位以内的类型只占用一个slot (包括returnAddress类型),64位的类型(long和double)占用两个slot。
➢byte 、short 、char 在存储前被转换为int,boolean 也被转
换为int,0表示false , 非0表示true。
➢long和double 则占据两个Slot。 -
JVM会为局部变量表中每一个Slot分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值
-
当一个实例方法被调用,它的方法参数和方法内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个Slot上
-
访问64bit的局部变量只需要使用前一个索引即可(比如long,double)
-
如果当前方法是构造方法或实例方法创建的,该对象的引用this将会存放在index为0的slot处,其余参数按照顺序继续排列。
-
栈帧中的局部变量表中的槽位slot是可以重复利用的,如果一个局部变量过了其作用域,那么在其作用域之后声明的新的局部变量就很可能会复用过期局部变量的槽位,从而达到节省资源的目的。
补充:
-
在栈帧中,与性能调优关系最为密切的部分就是前面提到的局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。
-
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
示例
示例1(静态方法)
验证:
long和double 则占据两个Slot。
它的方法参数和方法内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个Slot上
访问64bit的局部变量只需要使用前一个索引即可
代码:
package com.ambitfly.java;
public class JVMStackLocalVariablesTest1 {
public static void main(String[] args) {
method1(8,true);
}
public static int method1(int num1,boolean flag){
int num2 = 300;
long num3 = 9000l;
double d = 5.5;
float f = 6.5f;
int num4 = 6;
return num1;
}
}
method1指令:
0: sipush 300
3: istore_2
4: ldc2_w #3 // long 9000l
7: lstore_3
8: ldc2_w #5 // double 5.5d
11: dstore 5
13: ldc #7 // float 6.5f
15: fstore 7
17: bipush 6
19: istore 8
21: iload_0
22: ireturn
LocalVariableTable(局部变量表):
Start Length Slot Name Signature
0 23 0 num1 I
0 23 1 flag Z
4 19 2 num2 I
8 15 3 num3 J
13 10 5 d D
17 6 7 f F
21 2 8 num4 I
示例2(构造方法和实例方法)
验证:
如果当前方法是构造方法或实例方法创建的,该对象的引用this将会存放在index为0的slot处
栈帧中的局部变量表中的槽位slot是可以重复利用的,如果一个局部变量过了其作用域,那么在其作用域之后声明的新的局部变量就很可能会复用过期局部变量的槽位,从而达到节省资源的目的。
package com.ambitfly.java;
public class JVMStackLocalVariablesTest2 {
public static void main(String[] args) {
}
public void method1(){
{
int a = 9;
a++;
}
int b = 3;
String s = "sss";
}
}
构造方法指令:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
method1指令:
0: bipush 9
2: istore_1
3: iinc 1, 1
6: iconst_3
7: istore_1
8: ldc #2 // String sss
10: astore_2
11: return
LocalVariableTable(构造方法局部变量表):
Start Length Slot Name Signature
0 1 0 args [Ljava/lang/String;
LocalVariableTable(method1局部变量表):
Start Length Slot Name Signature
3 3 1 a I
0 12 0 this Lcom/ambitfly/java/JVMStackLocalVariablesTest2;
8 4 1 b I
11 1 2 s Ljava/lang/String;
构造方法和实例方法method的Slot[0]位置都是this变量
可以在method的局部变量表里看出,变量a和b公用了Slot[1]的位置
操作数栈 (Operand stack)
特点
-
操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈和出栈。
➢ 某些字节码指令将值压入操作数栈,其余字节码指令将操作数取出栈。使用他们进行计算后再将结果压入栈。
➢ 比如,执行复制、交换、求和等操作
-
操作数栈主要用于保存计算过程的中间结果,同时作为计算过程中变量的临时存储空间。
-
操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会被随之创建出来,这个方法的操作数栈是空的。
-
每一个操作数栈都会拥有一个明确的栈深度用于储存数值,其所需的做大深度在编译器就确定好了,保存在方法的Code属性中,为max_stack的值。
-
栈中的元素可以是任意的Java数据结构。
➢ 32bit的类型占用一个栈单位深度
➢ 64bit的类型占用两个栈单位深度
-
操作数栈并非采用访问索引的方式进行数据访问的,而是只能通过标准的入栈(push),出栈(pop)操作完成数据访问。
-
如果被调用的方法带有返回值,其返回值会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条要执行的字节码指令。
-
操作数栈中的元素类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
-
另外,我们说Java虚拟机的解析引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。
示例
验证:
栈中的元素可以是任意的Java数据结构。
➢ 32bit的类型占用一个栈单位深度
➢ 64bit的类型占用两个栈单位深度
如果被调用的方法带有返回值,其返回值会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条要执行的字节码指令。
代码
package com.ambitfly.java;
public class OperandStackTest {
public static long method1(){
int i = 8;
long j = 9l;
long k = i + j;
return k;
}
public static void method2(){
int a = 3;
long b = method1();
}
}
字节码指令
public class com.ambitfly.java.OperandStackTest
minor version: 0
major version: 49
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #5 // com/ambitfly/java/OperandStackTest
super_class: #6 // java/lang/Object
interfaces: 0, fields: 0, methods: 3, attributes: 1
Constant pool:
#1 = Methodref #6.#26 // java/lang/Object."<init>":()V
... //常量池省略
#29 = Utf8 java/lang/Object
{
public com.ambitfly.java.OperandStackTest();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/ambitfly/java/OperandStackTest;
public static long method1();
descriptor: ()J
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=4, locals=5, args_size=0
0: bipush 8
2: istore_0
3: ldc2_w #2 // long 9l
6: lstore_1
7: iload_0
8: i2l
9: lload_1
10: ladd
11: lstore_3
12: lload_3
13: lreturn
LineNumberTable:
line 6: 0
line 7: 3
line 8: 7
line 9: 12
LocalVariableTable:
Start Length Slot Name Signature
3 11 0 i I
7 7 1 j J
12 2 3 k J
public static void method2();
descriptor: ()V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=0
0: iconst_3
1: istore_0
2: invokestatic #4 // Method method1:()J
5: lstore_1
6: return
LineNumberTable:
line 13: 0
line 14: 2
line 16: 6
LocalVariableTable:
Start Length Slot Name Signature
2 5 0 a I
6 1 1 b J
}
method1方法的操作数栈深度为4,开始的时候很疑惑,不应该是3吗?看了method1的指令后知道,int i 入栈后执行了i2l命令,通过查询官方文档知道i2l指令就是把int型数据转换成long类型的数据。所以,i变量入栈后就占用了两个栈深度,long j也占用两个栈深度。
而method2方法的操作数栈深度为2,是因为method2调用了method1方法,method1方法的返回的数为long类型,如果被调用的方法带有返回值,其返回值会被压入当前栈帧的操作数栈中,所以method2方法的操作数栈深度为2。
栈顶缓存技术
基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch) 次数和内存读/写次数。
由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存(ToS,Top-of-stack Cashing)技术,将栈项元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。
动态链接 (Dynamic Linking)
特点
- 每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接。比如invokedynamic指令
- 在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用保存在.class文件的常量池里。比如:描述一个方法调用另外一个方法时,就是通过常量池中指向方法符号的引用来实现的,那么动态链接的作用就是将符号引用转换为调用方法的直接引用。
静态链接和动态链接
●静态链接:
当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。
●动态链接:
如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。
早期绑定和晚期绑定
对应的方法的绑定机制为:早期绑定(Early Binding) 和晚期绑定(Late Binding) 。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。
●早期绑定:
早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
●晚期绑定:
如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。
虚方法和非虚方法
- 如果方法在编译器就确定了具体的调用版本,这个版本是在运行时不可变的。这样的方法称为非虚方法。
- 静态方法,私有方法,final方法,实例构造器,都是非虚方法,其他方法称为非虚方法。
虚拟机提供了一下几种方法调用指令:
- invokestatic 调用静态方法
- invokespecial 调用<init>方法 、私有及其父类方法
- invokevirtual 调用所有虚方法,除了被final修饰的方法
- invokeinterface 调用接口方法
其中最复杂的要属 invokevirtual 指令,它涉及到了多态的特性,使用 virtual dispatch 做方法调用。
virtual dispatch 机制会首先从 receiver(被调用方法的对象)的类的实现中查找对应的方法,如果没找到,则去父类查找,直到找到函数并实现调用,而不是依赖于引用的类型。
方法表(Method Table)
介绍了虚分派,接下来介绍是它的一种实现方式 – 方法表。类似于 C++的虚函数表 vtbl。
在有的 JVM 实现中,使用了方法表机制实现虚分派,而有时候,为了节省内存可能不采用方法表的实现。
不要被方法表这个名字迷惑,它并不是记录所有方法的表。它是为虚分派服务,不会记录用 invokestatic 调用的静态方法和用 invokespecial 调用的构造函数和私有方法。
JVM 会在链接类的过程中,给类分配相应的方法表内存空间。每个类对应一个方法表。这些都是存在于 method area 区中的。这里与 C++略有不同,C++中每个对象的第一个指针就是指向了相应的虚函数表。而 Java 中每个对象索引到对应的类,在对应的类数据中对应一个方法表。
一种方法表的实现如下:
父类的方法比子类的方法先得到解析,即父类的方法相比子类的方法位于表的前列。
表中每项对应于一个方法,索引到实际方法的实现代码上。如果子类重写了父类中某个方法的代码,则该方法第一次出现的位置的索引更换到子类的实现代码上,而不会在方法表中出现新的项。
JVM 运行时,当代码索引到一个方法时,是根据它在方法表中的偏移量来实现访问的。(第一次执行到调用指令时,会执行解析,将符号索引替换为对应的直接索引)。
由于 invokevirtual 调用的方法在对应的类的方法表中都有固定的位置,直接索引的值可以用偏移量来表示。(符号索引解析的最终目的是完成直接索引:对象方法和对象变量的调用都是用偏移量来表示直接索引的)
动态类型语言和静态类型语言
动态类型语言和静态类型语言两者之间的区别就在于对类型检查是在编译器还是运行期,满足前者就是静态类型语言,满足后者就是动态类型语言。
静态类型语言是判断变量自身的类型信息,动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征。比如Java就是静态类型语言,String s = “aaa”,而js就是动态类型语言,var s = “name”,通过“name”这个值确定它是支付串类型的。
方法返回地址(Return Address)
当一个方法被执行后,有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation
Completion )。
另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虛拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。
无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一-些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
关于方法里的局部变量线程问题
- 在不考虑同步加锁的情况下,一个线程是否安全取决于当前方法操作局部变量时是否有别的线程的当前方法在操作,如果有,就是线程不安全的,否者,就是线程安全的。
- 第一种情况,如果局部变量是在当前方法内声明的,并且无返回值,那么这个变量就是线程安全的。
- 第二种情况,如果局部变量是在当前方法参数处拿到的,它就有可能被别的线程使用,就是线程不安全的。
- 第三种情况,如果局部变量是在当前方法内声明的,但有返回值,并且这个局部变量不是final的,它就是线程不安全的,如果是final的,也是线程安全的。