5_运行时数据区概述
运行时数据区概述
内存是非常重要的系统资源,承载着操作系统和应用程序的实时运行。JVM内存布局规定了Java在运行过程中内存申请、分配、管理的策略,保证了JVM高效稳定的运行。不同的JVM对内存的划分方式和管理机制存在着部分差异。结合JVM虚拟机规范,来讨论下经典的JVM内存布局。
Java虚拟机定义了若干种程序运行期间会使用到的数据区,其中有一些会随着虚拟机的启动而创建,随着虚拟机的退出而销毁。另外一些则是与线程一一对应的,这些与线程对应的数据区域会随着线程的开始而创建,线程的结束而销毁。对于一个进程而言,它的方法区和堆内存只有一份,但是PC、本地方法栈、虚拟机栈组数和线程数相关。如下图:灰色的为单独线程私有的,红色的为多个线程共享的:
关于JVM里面常见的线程:
- 虚拟机线程:这种线程的操作是需要JVM到达安全点才会出现,这些操作必须在不同的线程中,发生的原因是他们都需要JVM到达安全点,这样堆才不会变化。
- 周期任务线程:这种线程是时间周期事件的体现(比如中断),他们一般用于周期性操作的调度执行。
- GC线程:提供垃圾回收支持,专门提供GC的。
- 编译线程:这种线程在运行时会将字节码编译成本地代码
- 信号调度处理:这种线程接收信号并发送给JVM,在它内部通过调用适当的方法进行处理。
程序计数器PC
JVM中的PC寄存器是对物理PC寄存器的抽象模拟。PC寄存器用来存储指向下一条指令的地址,也就是即将要执行下一条指令代码的内存地址,由执行引擎读取下一条指令。
在JVM规范中,每个线程都有自己的程序计数器PC,是线程私有的,生命观周期与线程生命周期保持一致。
任何时刻一个线程都只有一个方法运行,也就是所谓的当前方法,程序计数器PC会存储当前线程正在执行的Java方法的JVM指定地址,或者,如果是在执行native(C/C++层面)方法,则是未指定值(undefined)。
字节码解释工作是通过改变这个计数器的值来取下一条要执行的指令。
编写如下代码:
public class PCRegisterTest {
public static void main(String[] args) {
int i = 10;
int j = 20;
int k = i + j;
}
}
调用javap -v PCRegisterTest.class命令生成反编译后的代码如下:
public class com.lily.PCRegisterTest
minor version: 0
major version: 65
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #7 // com/lily/PCRegisterTest
super_class: #2 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Class #8 // com/lily/PCRegisterTest
#8 = Utf8 com/lily/PCRegisterTest
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcom/lily/PCRegisterTest;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 i
#19 = Utf8 I
#20 = Utf8 j
#21 = Utf8 k
#22 = Utf8 SourceFile
#23 = Utf8 PCRegisterTest.java
{
public com.lily.PCRegisterTest();
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/lily/PCRegisterTest;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: bipush 10
2: istore_1 //定义一个值为10的变量存在索引为1的位置处
3: bipush 20
5: istore_2 //定义一个值为20的变量存在索引为2的位置处
6: iload_1 //取出索引为1的变量值
7: iload_2 //取出索引为2的变量值
8: iadd //进行相加操作
9: istore_3 //结果存放在索引为3的位置处
10: return //方法返回(程序结束运行)
LineNumberTable:
line 5: 0
line 6: 3
line 7: 6
line 8: 10
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 args [Ljava/lang/String;
3 8 1 i I
6 5 2 j I
10 1 3 k I
}
SourceFile: "PCRegisterTest.java"
两个常见的问题
- 使用PC寄存器存储字节码指令地址有什么用?
- 为什么使用PC寄存器来记录当前线程执行的地址呢?
因为CPU需要不停的切换各个线程,这时候切换回来之后,就得知道接着从哪里开始继续执行;JVM的字节码解释器就需要通过改变PC的值才知道下一条指令的地址在哪里。
- PC寄存器为什么被设置为线程私有?
为了能够准确地记录各个线程正在执行的当前字节码地址最好的办法就是为每个线程分配一个程序计数器PC,这样就不会出现干扰的情况。
虚拟机栈
虚拟机栈概述
Java的指令是根据栈来设计的,可以实现跨平台、指令集比较小、但性能比较低下。
栈是运行时的单位,而堆是存储的单位。
Java虚拟机栈,早起也叫Java栈,每个线程在创建时都会创建一个虚拟机栈,其内部保存了一个个的栈帧,对应一次次的方法调用。Java虚拟机栈是线程私有的,Java虚拟机栈的生命周期和线程一致。作用是主管Java程序的运行,它保存方法的局部变量、部分结果、并参与方法的调用和返回。
关于栈的特点:
- 栈是一种快速有效的分配方式,访问速度仅次于程序计数器PC。
- JVM直接对Java栈的操作只有两个:
- 每个方法执行,伴随着压栈
- 执行结束后的出栈
- 对于栈来说不存在垃圾回收和OOM问题。
栈的存储单元(栈内部的结构)
- 每一个线程都有自己的栈,栈中的数据都是以栈帧的格式存储的。
- 在这个线程上,正在执行的每个方法都对应一个栈帧。
- 栈帧是一块内存区域,是一个数据集,维系着方法执行过程中的各种数据信息。
- JVM直接对Java栈的操作只有两个,就是对栈帧的压栈和出栈。
- 在一条活动线程上,一个时间点上,只会有一个活动的栈帧。即只有当前执行的方法的栈帧是有效的,这个栈帧被称为当前栈帧(处于栈顶位置),与当前栈帧对应的方法是当前方法,定义这个方法的类称之为当前类。
- 执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
- 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前栈帧。
实例代码:
package com.lily;
public class StackFrameTest {
public static void main(String[] args) {
StackFrameTest stackFrameTest = new StackFrameTest();
stackFrameTest.method1();
}
public void method1() {
System.out.println("method1开始执行...");
method2();
System.out.println("method1结束执行...");
}
public int method2() {
System.out.println("method2开始执行...");
int i = 10;
int m = (int) method3();
System.out.println("method2即将结束执行...");
return i + m;
}
public double method3() {
System.out.println("method3开始执行...");
double j = 12.2;
System.out.println("method3即将执行结束...");
return j;
}
}
结果如下:
method1开始执行...
method2开始执行...
method3开始执行...
method3即将执行结束...
method2即将结束执行...
method1结束执行...
栈运行原理:
- 不同线程所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧中引用另一个线程的栈帧。
- 如果当前方法调用了其他方法,方法返回之际,,当前栈帧会传回此方法的执行结果给前一个栈帧,接着虚拟机就会丢弃当前栈帧,式的前一个栈帧成为当前栈帧。
- Java方法有两种返回函数的方法,一种是正常的函数返回,使用return指令;另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。
栈帧的内部结构
每个栈帧中存储着:
- 局部变量表
- 操作数栈(表达式栈)
- 动态链接:指向运行时常量池的引用
- 方法返回地址
- 附加信息
局部变量表(local variables)
-
局部变量表也称之为局部变量数组或本地变量表
-
定义为一个一维数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括基本数据类型,对象引用,以及返回地址类型等。
-
由于局部变量是来建立在线程的栈上,是线程的私有数据,因此不存在线程安全问题。
-
局部变量表所需的容量大小是在编译期间决定下来的,并保证在方法的Code属性maximum local variables数据项中,在方法运行期间不会改变局部变量表的大小的。
-
局部变量表,最基本的存储单元是Slot(变量槽),其中32位数据类型占用1个槽,64位数据类型占用2个槽。
-
栈帧中的局部变量表中的槽位是可以重复利用的,如果一个局部变量过了其作用域,那么在其作用域之后声明的新变量就会很有可能复用过期局部变量的槽位,从而达到节省资源的目的。
操作数栈(Operand Stack)
每一个独立的栈帧除了包含局部变量表以外,还包含一个后进先出的操作数栈,也可也可以称之为表达式栈。
- 如果被调用的方法有返回值的话,其返回值会被压入当前当前栈帧的的操作数栈中。并更新PC寄存器中下一条需要执行的字节码指令。
- Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。
- 操作数栈,主要用于保存计算过程的临时中间结果,同时作为计算过程中变量临时的存储空间。
- 操作数栈是随着方法i的调用开始的,当一个方法刚开始执行的时候,一个新的栈帧会被创建出来,这个方法的操作数栈也是空的。
- 栈的空间大小是在编译期间就确定好了,保存在方法的Code属性中,定义为max_stack的值。
- 栈中的任意一个元素都是可以任意的Java数据类型:
- 32bit的类型占用一个栈单位深度
- 64bit的类型占用两个栈单位深度
- 操作数栈只能通过入栈(Push)和出栈(Pop)操作完成一次数据访问。
- 如果被调用的额放方法带有返回值的话,返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器值。
查看如下代码:
public class TestAddOperation {
public TestAddOperation() {
}
public static void main(String[] args) {
}
public void testAddOperation() {
int i = 15;
int j = 8;
int var10000 = i + j;
}
}
执行命令: javap -v TestAddOperation.class
结果如下:
PS C:\Users\Yihao\IdeaProjects\JVMDemo\out\production\chapter4\com\lily> javap -v TestAddOperation.class
Classfile /C:/Users/Yihao/IdeaProjects/JVMDemo/out/production/chapter4/com/lily/TestAddOperation.class
Last modified 2024年3月15日; size 553 bytes
SHA-256 checksum a556496312e1906fd3570914de8a920142d4152ceb9f165d261efe690b477a16
Compiled from "TestAddOperation.java"
public class com.lily.TestAddOperation
minor version: 0
major version: 65
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #7 // com/lily/TestAddOperation
super_class: #2 // java/lang/Object
interfaces: 0, fields: 0, methods: 3, attributes: 1
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Class #8 // com/lily/TestAddOperation
#8 = Utf8 com/lily/TestAddOperation
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcom/lily/TestAddOperation;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 testAddOperation
#19 = Utf8 i
#20 = Utf8 I
#21 = Utf8 j
#22 = Utf8 k
#23 = Utf8 SourceFile
#24 = Utf8 TestAddOperation.java
{
public com.lily.TestAddOperation();
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/lily/TestAddOperation;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 6: 0
LocalVariableTable:
Start Length Slot Name Signature
0 1 0 args [Ljava/lang/String;
public void testAddOperation();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
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
LineNumberTable:
line 9: 0
line 10: 3
line 11: 6
line 12: 10
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 this Lcom/lily/TestAddOperation;
3 8 1 i I
6 5 2 j I
10 1 3 k I
}
SourceFile: "TestAddOperation.java"
PS C:\Users\Yihao\IdeaProjects\JVMDemo\out\production\chapter4\com\lily>
关于i++和++i在字节码层面有什么区别?
public void add() {
int i1 = 10;
i1++;
int i2 = 20;
++i2;
int i3 = 30;
int i4 = i3++;
int i5 = 40;
int i6 = ++i4;
}
public void add();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=7, args_size=1
0: bipush 10
2: istore_1
3: iinc 1, 1
6: bipush 20
8: istore_2
9: iinc 2, 1
12: bipush 30
14: istore_3
15: iload_3
16: iinc 3, 1
19: istore 4
21: bipush 40
23: istore 5
25: iinc 4, 1
28: iload 4
30: istore 6
32: return
LineNumberTable:
line 15: 0
line 16: 3
line 18: 6
line 19: 9
line 21: 12
line 22: 15
line 23: 21
line 24: 25
line 25: 32
LocalVariableTable:
Start Length Slot Name Signature
0 33 0 this Lcom/lily/TestAddOperation;
3 30 1 i1 I
9 24 2 i2 I
15 18 3 i3 I
21 12 4 i4 I
25 8 5 i5 I
32 1 6 i6 I
}
栈顶缓存技术(Top of Stack Cashing)
基于栈式的JVM虚拟机完成存取指令需要频繁的对内存进行读写,这必然会影响到执行效率,为此JVM虚拟机的设计者提出了栈顶缓存技术,将栈顶元素全部缓存在物理CPU寄存器中,以此降低对内存的读写次数,提高执行引擎的执行效率。
动态链接(指向运行时常量池的方法引用)
每一个栈帧内部都含有一个指向运行时常量池该栈帧所属方法的引用,包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接。
在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用保存在class文件的常量池里。比如:描述一个方法调用了另外的其它方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。在如下代码中:
package com.lily;
public class DynamicClinking {
private int num = 10;
public void methodA() {
System.out.println("methodA...");
}
public void methodB() {
System.out.println("methodB...");
methodA();
num++;
}
}
对如上代码进行反编译,得到常量池和方法A和方法B的反编译代码如下:
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Fieldref #8.#9 // com/lily/DynamicClinking.num:I
#8 = Class #10 // com/lily/DynamicClinking
#9 = NameAndType #11:#12 // num:I
#10 = Utf8 com/lily/DynamicClinking
#11 = Utf8 num
#12 = Utf8 I
#13 = Fieldref #14.#15 // java/lang/System.out:Ljava/io/PrintStream;
#14 = Class #16 // java/lang/System
#15 = NameAndType #17:#18 // out:Ljava/io/PrintStream;
#16 = Utf8 java/lang/System
#17 = Utf8 out
#18 = Utf8 Ljava/io/PrintStream;
#19 = String #20 // methodA...
#20 = Utf8 methodA...
#21 = Methodref #22.#23 // java/io/PrintStream.println:(Ljava/lang/String;)V
#22 = Class #24 // java/io/PrintStream
#23 = NameAndType #25:#26 // println:(Ljava/lang/String;)V
#24 = Utf8 java/io/PrintStream
#25 = Utf8 println
#26 = Utf8 (Ljava/lang/String;)V
#27 = String #28 // methodB...
#28 = Utf8 methodB...
#29 = Methodref #8.#30 // com/lily/DynamicClinking.methodA:()V
#30 = NameAndType #31:#6 // methodA:()V
#31 = Utf8 methodA
#32 = Utf8 Code
#33 = Utf8 LineNumberTable
#34 = Utf8 LocalVariableTable
#35 = Utf8 this
#36 = Utf8 Lcom/lily/DynamicClinking;
#37 = Utf8 methodB
#38 = Utf8 SourceFile
#39 = Utf8 DynamicClinking.java
public void methodA();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #13 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #19 // String methodA...
5: invokevirtual #21 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 8: 0
line 9: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 this Lcom/lily/DynamicClinking;
public void methodB();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=3, locals=1, args_size=1
0: getstatic #13 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #27 // String methodB...
5: invokevirtual #21 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: aload_0
9: invokevirtual #29 // Method methodA:()V
12: aload_0
13: dup
14: getfield #7 // Field num:I
17: iconst_1
18: iadd
19: putfield #7 // Field num:I
22: return
LineNumberTable:
line 12: 0
line 13: 8
line 14: 12
line 15: 22
LocalVariableTable:
Start Length Slot Name Signature
0 23 0 this Lcom/lily/DynamicClinking;
对于方法B调用方法A这一步中:
29(方法的引用) -> #8(本类) and #30,其中 #30 -> #31(方法名method) and #6(返回值类型void)
方法调用
在JVM当中,将符号引用转换为调用方法的直接引用与方法的绑定机制有关。
- 静态链接:当一个字节码文件被装进到JVM内部,如果被调用的目标方法在编译器可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。
- 动态链接:如果被调用的方法在编译期间无法被确定下来,也就是说,只能够在程序运行期间将调用方法的引用符号转换为直接引用,由于这种引用在转换过程中具备动态性,因此也被称之为动态链接。
关于虚方法和非虚方法:
- 虚方法:
- 非虚方法:
- 如果方法在编译期间就能确定具体的调用版本,这个版本在运行时是不可变的,这样的方法称之为非虚方法。
- 静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法(不涉及多态)。
- 其他方法称之为非虚方法(涉及多态-->类的继承和方法的重写)。
方法返回地址
用于存放调用该方法的pc寄存器的值。一个方法的结束,有两种方式:
- 正常执行完毕
- 出现未处理的异常,非正常退出。
无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者pc寄存器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
实际上,方法的返回退出就是栈帧出栈的过程,此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈。设置PC寄存器等等,**目的是为了让调用者方法能够继续执行的下去。 **
正常完成退出和异常完成退出的区别在于异常完成退出不会给上层调用者产生任何的返回值。
当执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层发方法的调用者,简称正常完成出口
一些附加相关信息
栈帧中还允许携带与Java虚拟机实现相关的一些附加信息。例如,对程序调试提供支持的信息。
一些相关面试题:
-
举例说明栈溢出的情况?
- 栈溢出(StckOverflowError)--> 栈帧溢出
- 通过-Xss来设置栈的大小,当整个物理内存空间不足,此时再扩容栈空间就会导致OOM异常
-
调整栈大小,就能保证不溢出吗?
不能。
- 假如出现无限递归,仍然会导致溢出。
-
分配的栈内存越大越好吗?
不是,栈空间太大会导致其它空间变小
-
垃圾回收是否会涉及到虚拟机栈?
不会,
-
方法中定义的局部变量是否线程安全?
具体问题具体分析!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构