【JVM系列】深入理解Java虚拟机(JVM)的核心技术 :从程序计数器到栈帧结构(二、Java虚拟机栈探秘)
文章目录
- 【JVM系列】深入理解Java虚拟机(JVM)的核心技术 :从程序计数器到栈帧结构(二、Java虚拟机栈探秘)
- 程序计数器
- 1. 基本概念
- 2. 栈帧内部结构原理分析
- 2.1 基本概念
- 2.2 局部变量表
- 2.3 Slot(变量槽--index)
- 2.4 jclasslib分析字节码
- 2.5 变量槽的复用
- 2.6 局部变量表(的作用)总结
- 2.7 操作数栈分析
- 2.8 ++i与i++的底层原理
- 2.9 栈溢出(StackOverflowError)
- 2.10 动态链接
- 2.11 方法出口
【JVM系列】深入理解Java虚拟机(JVM)的核心技术 :从程序计数器到栈帧结构(二、Java虚拟机栈探秘)
JVM内存结构
程序计数器
-
程序计数器(Program Counter Register)是一块较小的内存空间,可以看作是当前线程所执行字节码的行号指示器,指向下一个将要执行的指令代码,由执行引擎来读取下一条指令。更确切的说,一个线程的执行,是通过字节码解释器改变当前线程的计数器的值,来获取下一条需要执行的字节码指令,从而确保线程的正确执行。
-
为了确保线程切换后(上下文切换)能恢复到正确的执行位置,每个线程都有一个独立的程序计数器,各个线程的计数器互不影响,独立存储。也就是说程序计数器是线程私有的内存。如果线程执行 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果执行的是 Native 方法,计数器值为Undefined。
-
程序计数器不会发生内存溢出(OutOfMemoryError即OOM)问题。
程序计数器作用:记录下一个 jvm 指令的执行地址
1. 基本概念
1.Java 虚拟机栈也是线程私有的,它的⽣命周期和线程相同,描述的是 Java⽅法执⾏的内存模型,每次⽅法调⽤的数据都是通过栈传递的。
2.虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧[1](Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息,每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
相关代码:
public static void main(String[] args) {a();
}public static void a() {b();
}public static void b() {c();
}public static void c() {
}
方法压栈与出栈:
栈会遵循先进后出原则 每个方法会创建一个栈帧,在栈帧中存放该方法对应的局部变量表
Idea 调试分析栈帧:
2. 栈帧内部结构原理分析
2.1 基本概念
在每一个方法对应的栈帧中都会有自己独立的:栈帧包含方法的所有信息
-
局部变量表:存放当前方法对应的局部变量;
-
操作数栈:或表达式栈;
-
动态链接:或指向运行时常量池的方法引用;
-
方法出口:或方法正常退出或者异常退出的定义;
2.2 局部变量表
局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
public class Test02 {public static void main(String[] args) {String str = "zhaoli";int j = 20;double d = 66.66;boolean b = true;}public int a() {int i = 10;int j = 20;int k = (i + j) * 2;return k;}public static int b() {int ii = 10;int jj = 20;int kk = (ii + jj) * 2;return kk;}
}
-
先将这个
.java
文件编译成.class
文件 -
在
cmd
窗口cd
到.class
文件所在目录 -
使用
javap -v Test02
命令得到汇编代码
main()对应的局部变量表
方法传递的参数也会在局部变量表中存储
a()对应的局部变量表
如果我们的方法是实例方法或者是构造方法(函数),则jvm
默认会在局部变量表中创建 一个当前对象变量名称为 this
, 存入在我们当前方法对应的局部方法表中第0个位置 这样我们就可以在实例方法中使用 this
,相当于默认添加了局部变量a(Test02 this);
,静态方法不会
b()对应的局部变量表
2.3 Slot(变量槽–index)
局部变量表最基本的存储单元就是变量槽。
在局部变量表里,32位以内的类型只占用一个slot
(包括returnAddress
类型),64位的类型(long
和double
)占用两个slot
。
注意:short
,byte
,boolean
等数据也占用一个变量槽,因为jvm
会在存储时将上述变量转为int
类型(变量槽是最基本存储单元,无法分割,只能整个使用)。
public static void main(String[] args) {String str = "zhaoli";int j = 20;double d = 66.66;boolean b = true;
}
JVM
会为局部变量表中每一个变量分配变量槽,并记录其的存储位置,比如main
函数方法传递了String [] args
数组,变量args
就存储在index
为0
的变量槽中,变量d
因为为64
位,需要占用两个变量槽(3
和4
),变量b
因为D
占用了两个变量槽,所以直接从index5
处开始存储。
2.4 jclasslib分析字节码
-
idea 安装使用
- 打开idea 中的 settings > plugins 搜索 jclasslib 插件 进行安装 重启生效
- 重启后点击view > 选择show bytecode with jclasslib
-
jclasslib git地址:https://github.com/ingokegel/jclasslib
不赋值(在代码块中赋值)的变量,在局部变量表中不显示,但局部变量表仍会为其预留位置。
public void test() {int a;int b;int c;
}
2.5 变量槽的复用
public static void test() {int a = 0;{int b=30;System.out.println("zhaoli");}int c = 0;
}
此处原因就是JVM
对变量槽有一个复用性为,当变量b
超出其作用域后不再生效,所以变量c
直接占据了b
的位置,所以局部变量表中会少一个位置。
2.6 局部变量表(的作用)总结
- 局部变量表只对已确定一定有值的变量和方法参数进行记录,在程序执行中得以直接使用;
- 存放当前方法入参和局部变量槽
0
开始计算long
和double
占用2
个槽位; - 如果我们的方法是为实例方法和构造函数,则在局部变量表中槽
0
的位置默认存放当前对象,变量名是为this
; - 如果代码块结束(作用域结束) ,jvm会对变量槽有一个复用的行为,以便于节省空间。
2.7 操作数栈分析
public int compute() {int a = 10;int b = 20;int c = (a + b) * 10;return c;
}
iconst_0
:将int类型的0值压入操作数栈 …
iconst_5
:将int类型的5值压入操作数栈
istore_1
: 弹出操作数栈顶的值赋给局部变量表下标为1的变量
iload_1
: 将局部变量表下标为1的位置存储的值压入操作数栈
iinc 1 by 1
:取局部变量表下标为1的位置存储的值加上1
istore_1
:弹出操作数栈顶的值赋给局部变量表下标为1的变量
底层汇编代码:
0: bipush 10 ## 将一个8位带符号整数压入栈 102: istore_1 局部变 量表中槽1的位置存入10;3: bipush 20 ## 将一个8位带符号整数压入栈 205: istore_2 局部变量表中槽2的位置存入20;6: iload_1 从局部变量表中槽1的位置 获取 变量a=10;7: iload_2 从局部变量表中槽2的位置 获取 变量b=20;8: iadd iadd 执行int类型的加法 10+20 9: bipush 10 ## 将一个8位带符号整数压入栈 10
11: imul imul 执行int类型的乘法30*10
12: istore_3 局部变量表中槽3的位置存入300 c=300;
13: iload_3 最后返回局部变量表中槽3的位置
14: ireturn
2.8 ++i与i++的底层原理
i++
是先赋值,然后再自增;++i
是先自增,后赋值。
i++
是直接在局部变量表加的,没有在操作数栈里运算
public static void c(){int i=0;int z=i++;System.out.println(z);
}public static void d(){int i=0;int z=++i;System.out.println(z);
}
## i++0: iconst_01: istore_02: iload_03: iinc 0, 16: istore_17: getstatic #410: iload_1Start Length Slot Name Signature2 13 0 i I7 8 1 z I## ++i0: iconst_01: istore_02: iinc 0, 15: iload_06: istore_17: getstatic #4 10: iload_111: invokevirtual #514: returnLineNumberTable:line 37: 0line 38: 2line 39: 7line 40: 14LocalVariableTable:Start Length Slot Name Signature2 13 0 i I7 8 1 z Ib
不同点就在 2: iload_0 3: iinc 0, 1
和 2: iinc 0, 1 5: iload_0
中
i++
与++i
底层区别
-
i++
先将局部变量表中的值压入(放入)到操作数栈中,在直接对局部变量中做+1
操作。 -
++i
先将局部变量表中的值做+1
的操作,在将局部变量表中加1之后的结果压入到操作数栈中。
2.9 栈溢出(StackOverflowError)
StackOverflowError
代表的是,当栈深度超过虚拟机分配给线程的栈大小时就会出现此error
。
public class StackOverFlow {private int i;public void plus() {i++;plus();}/*** 设置 -Xss128k* @param args*/public static void main(String[] args) {StackOverFlow stackOverFlow = new StackOverFlow();try {stackOverFlow.plus();} catch (Error e) {System.out.println("Error:stack length:" + stackOverFlow.i);e.printStackTrace();}}
}
在栈空间内存中是否会发生线程安全问题呢?
在讨论栈空间内存与线程安全问题时,我们需要理解栈空间的主要作用和线程安全的基本概念。
-
栈空间:在程序运行时,栈空间主要用于存放函数调用时的局部变量、函数参数、返回地址等临时数据。当一个函数或方法执行完毕后,这些数据就会被自动清理出栈。
-
线程安全:线程安全是指在一个多线程环境中,多个线程并发访问共享资源时,不会导致数据的不一致或其他错误状态。为了保证线程安全,通常需要使用同步机制(如互斥锁、信号量等)来控制对共享资源的访问。
在栈空间内存中本身是不容易发生线程安全问题的,因为每个线程都有自己独立的栈空间,不同线程之间不能直接访问对方栈中的数据。这意味着,如果某个变量是在栈上分配的,并且仅由创建它的线程所访问,那么它就不会有线程安全问题。
然而,在实际编程过程中,可能会有一些情况导致间接的线程安全问题,例如:
- 如果一个线程在其栈上定义了一个指向堆上共享资源的指针或引用,并且将这个指针或引用传递给了其他线程,那么这些线程就可能并发地修改该共享资源,从而引发线程安全问题。
- 如果栈上的数据结构(如数组)被用来引用或包含堆上的共享对象,并且这些对象没有适当的同步保护,则可能会出现线程安全问题。
总之,栈空间本身是线程隔离的,但如果涉及到共享数据(尤其是堆上的数据),则需要采取措施来确保线程安全。
2.10 动态链接
动态链接:每个栈帧都保存了一个可以指向当前方法所在类的运行时常量池;
目的是: 当前方法中如果需要调用其他方法的时候, 能够从运行时常量池中找到对应的符号引用, 然后将符号引用转换为直接引用,之后就能直接调用对应方法,这就是动态链接。
2.11 方法出口
方法出口定义了方法正常退出或者异常退出的方式。每种退出方式都会导致栈帧被销毁,同时伴随着可能的控制流转移、栈顶元素的弹出以及局部变量表的清空。
-
正常退出:当方法执行完最后一条指令或者执行到诸如
return
之类的返回指令时,方法即正常退出。此时,方法返回值(如果是有的话)将被压入调用者栈帧的操作数栈中,然后控制权返回给上一层方法。 -
异常退出:如果方法执行过程中出现了异常,并且这个异常没有在方法内部被捕获处理,那么方法就会异常退出。此时,虚拟机会寻找一个合适的异常处理器来处理这个异常。如果找不到合适的处理器,那么这个异常最终会上抛给更高层次的调用者,直到被处理或者导致程序终止。
无论哪种退出方式,一旦方法退出,其对应的栈帧就会从虚拟机栈中弹出,为新的方法调用腾出空间。这是由于栈的特性决定的——先进后出(LIFO),即最后进入的方法最先退出。这样的机制保证了方法调用的正确性和内存使用的高效性。