虚拟机栈 是线程运行 java 方法所需的数据,指令、返回地址。其实在我们实际的代码中,一个线程是可以运行多个方法的。
package sandwich;
/**
* @author sandwich
* @date 2021/3/7
*/
public class MethodAndStack {
public static void main(String[] args) {
A();
}
private static void A(){
B();
System.out.println("Executing A method");
}
private static void B(){
C();
System.out.println("Executing B method");
}
private static void C() {
System.out.println("Executing C method");
}
}
#执行结果
Executing C method
Executing B method
Executing A method
Process finished with exit code 0
这段代码很简单,就是起一个 main 方法,在 main 方法运行中调用 A 方法,A 方法中调用 B 方法,B 方法中运行 C 方法。 我们把代码跑起来,线程 1 来运行这段代码, 线程 1 跑起来,就会有一个对应 的虚拟机栈,同时在执行每个方法的时候都会打包成一个栈帧。 比如 main 开始运行,打包一个栈帧送入到虚拟机栈。
C 方法运行完了,C 方法出栈,接着 B 方法运行完了,B 方法出栈、接着 A 方法运行完了,A 方法出栈,最后 main 方法运行完了,main 方法这个栈帧就 出栈了。 这个就是 Java 方法运行对虚拟机栈的一个影响。虚拟机栈就是用来存储线程运行方法中的数据的。而每一个方法对应一个栈帧。
虚拟机栈
栈的数据结构:先进后出(FILO)的数据结构,
虚拟机栈的作用:在 JVM 运行过程中存储当前线程运行方法所需的数据,指令、返回地址。
虚拟机栈是基于线程的:哪怕你只有一个 main() 方法,也是以线程的方式运行的。在线程的生命周期中,参与计算的数据会频繁地入栈和出栈,栈的生 命周期是和线程一样的。 虚拟机栈的大小缺省为 1M,可用参数 –Xss 调整大小,例如-Xss256k。 参数官方文档(JDK1.8):https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
-Xsssize
Sets the thread stack size (in bytes). Append the letter k or K to indicate KB, m or M to indicate MB, g or G to indicate GB. The default value depends on the platform:
Linux/ARM (32-bit): 320 KB
Linux/i386 (32-bit): 320 KB
Linux/x64 (64-bit): 1024 KB
OS X (64-bit): 1024 KB
Oracle Solaris/i386 (32-bit): 320 KB
Oracle Solaris/x64 (64-bit): 1024 KB
The following examples set the thread stack size to 1024 KB in different units:
-Xss1m
-Xss1024k
-Xss1048576
This option is equivalent to -XX:ThreadStackSize.
虚拟机栈这个内存也不是无限大,它有大小限制,默认情况下是 1M。 如果我们不断的往虚拟机栈中入栈帧,但是就是不出栈的话,那么这个虚拟机栈就会爆掉。
以下是一个递归调用,A方法的栈帧一直入栈,直至超出虚拟机栈的大小,还没有开始出栈调用就爆了
程序计数器
指向当前线程正在执行的字节码指令的地址。只占用较小的内存空间,当前线程执行的字节码的行号指示器;各线程之间独立存储,互不影响。
程序计数器 是一块很小的内存空间,主要用来记录各个线程执行的字节码的地址,例如,分支、循环、跳转、异常、线程恢复等都依赖于计数器。 由于 Java 是多线程语言,当执行的线程数量超过 CPU 核数时,线程之间会根据时间片轮询争夺 CPU 资源。如果一个线程的时间片用完了,或者是其它原因导致这个线程的 CPU 资源被提前抢夺,那么这个退出的线程就需要单独的一个程序计数器,来记录下一条运行的指令。
因为 JVM 是虚拟机,内部有完整的指令与执行的一套流程,所以在运行 Java 方法的时候需要使用程序计数器(记录字节码执行的地址或行号),如 果是遇到本地方法(native 方法),这个方法不是 JVM 来具体执行,所以程序计数器不需要记录了,这个是因为在操作系统层面也有一个程序计数器, 这个会记录本地代码的执行的地址,所以在执行 native 方法时,JVM 中程序计数器的值为空(Undefined)。
另外程序计数器也是 JVM 中唯一不会 OOM(OutOfMemory)的内存区域。
为什么设置程序技术器?
主要是为了服务操作系统时间片轮转机制。
当这个线程的字节码执行到第3行是,这个线程的时间片用完了,cpu就会去执行其他线程,程序计数器还是停留在这个地址。等cpu重新回到这个线程还知道从这个地址开始执行。
由于三明治大学主要是搞单片机,嵌入式开发的, 所以可以从更底层原理理解。它这时候是被中断了去执行其他线程了。计算机有很多中断,每个中断的优先级不同。更高优先级的中断被触发之后,可以打断当前的线程,先去执行更高优先级的线程。等更高优先级的任务完成后再回到这个线程,它能从程序计数器记录的地址码地址开始执行。
三明治的计算机是四核八线程的
逻辑处理器的数量是8个
同一时间理论上只能同时执行8个线程。但是计算机可能需要处理无数线程,就是需要靠这种时间片和中断机制实现不同线程之间的切换,回到原线程是还能通过程序计数器所记录的地址码开始执行。但是这种时间片的时间是极其短的,用户无感知, 就像是一个线程从来未被中断一样。
栈帧执行对内存区域的影响
对 class 进行反汇编 javap –c XXXX.class 字节码助记码解释地址:https://cloud.tencent.com/developer/article/1333540
package sandwich;
/**
* @author sandwich
* @date 2021/3/7
*/
public class Person {
private int work() {
int i = 1;
int j = 2;
return (i + j) * 3;
}
public static void main(String[] args) {
Person person = new Person();
int z = person.work();
System.out.println("result=" + z);
person.hashCode();
}
}
//反汇编后得到以下代码
PS D:\git\test\target\classes\sandwich> javap -c .\Person.class
Compiled from "Person.java"
public class sandwich.Person {
public sandwich.Person();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
public static void main(java.lang.String[]);
Code:
0: new #2 // class sandwich/Person
3: dup
4: invokespecial #3 // Method "":()V
7: astore_1
8: aload_1
9: invokespecial #4 // Method run:()I
12: pop
13: aload_1
14: invokevirtual #5 // Method java/lang/Object.hashCode:()I
17: pop
18: return
经测试,这不是我想要的反汇编代码,因为我想看看work()方法的执行过程
我把work()方法由private改成public再试一下
package sandwich;
/**
* @author sandwich
* @date 2021/3/7
*/
public class Person {
public int work() {
int i = 3;
int j = 5;
return (i + j) * 10;
}
public static void main(String[] args) {
Person person = new Person();
int z = person.work();
System.out.println("result=" + z);
person.hashCode();
}
}
//反汇编后得到以下代码
PS D:\git\test\target\classes\sandwich> javap -c .\Person.class
Compiled from "Person.java"
public class sandwich.Person {
public sandwich.Person();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
public int work();
Code:
0: iconst_3 //将int型(3)推送至栈顶
1: istore_1 //将操作数栈顶的int(3)保存到第一个局部变量中。指令可以读作:将栈顶 X(i l f d a)类型的数值 保存到 第(操作数+1)个 局部变量中
2: iconst_5 //将int型(5)推送至栈顶
3: istore_2 //将操作数栈顶的int(5)保存到第二个局部变量中
4: iload_1 //将第一个局部变量int(3)加载到操作栈顶。指令可以读作:将第(操作数+1)个 X(i l f d a)类型局部变量,推送至栈顶
5: iload_2 //将第二个局部变量int(5)加载到操作栈顶
6: iadd //int类型加法指令
7: bipush 10 //指令可以读作:将类型X的值xxx 推送至栈顶 或者是 将 行号为xxx的常量推送至栈顶
9: imul //int类型乘法指令
10: ireturn //返回int值
public static void main(java.lang.String[]);
Code:
0: new #2 // class sandwich/Person
3: dup
4: invokespecial #3 // Method "":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method run:()I
12: istore_2
13: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream;
16: new #6 // class java/lang/StringBuilder
19: dup
20: invokespecial #7 // Method java/lang/StringBuilder."":()V
23: ldc #8 // String result=
25: invokevirtual #9 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
28: iload_2
29: invokevirtual #10 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
32: invokevirtual #11 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
35: invokevirtual #12 // Method java/io/PrintStream.println:(Ljava/lang/String;)
38: aload_1
39: invokevirtual #13 // Method java/lang/Object.hashCode:()I
42: pop
43: return
由此可知private方法会被反汇编成invokespecial,并不暴露出来。 要想完全反汇编需要改成public方法。
可以利用反汇编代码去深入理解更多底层原理
左边的字节码行号是字节码中相对于work()方法的偏移量,也可以理解成偏移地址。
我已经对work()的反汇编方法添加了注释, 有兴趣可以配合一下局部变量表和操作数栈读一下。
在 JVM 中,基于解释执行的这种方式是基于栈的引擎,这个说的栈,就是操作数栈。
我比较倾向于把操作数栈理解成内存中用于和cpu交换数据的缓存
虚拟机栈
每个线程私有的,线程在运行时,在执行每个方法的时候都会打包成一个栈帧,存储了局部变量表,操作数栈,动态链接,方法出口等信息,然后放入栈。每个时刻正在执行的当前方法就是虚拟机栈顶的栈桢。方法的执行就对应着栈帧在虚拟机栈中入栈和出栈的过程。 栈的大小缺省为 1M,可用参数 –Xss 调整大小,例如-Xss256k
在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的 Code 属性之中,因此一个栈帧需要分 配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
局部变量表: 顾名思义就是局部变量的表,用于存放我们的局部变量的。首先它是一个 32 位的长度,主要存放我们的 Java 的八大基础数据类型,一般 32 位就可以存放下,如果是 64 位的就使用高低位占用两个也可以存放下,如果是局部的一些对象,比如我们的 Object 对象,我们只需要存放它的一个引用 地址即可。(基本数据类型、对象引用、returnAddress 类型)
操作数据栈:存放我们方法执行的操作数的,它就是一个栈,先进后出的栈结构,操作数栈,就是用来操作的,操作的的元素可以是任意的 java 数据类 型,所以我们知道一个方法刚刚开始的时候,这个方法的操作数栈就是空的,操作数栈运行方法是会一直运行入栈/出栈的操作
动态连接: Java 语言特性多态(需要类加载、运行时才能确定具体的方法,后续动态分派有详细的讲解)
完成出口(返回地址): 正常返回:(调用程序计数器中的地址作为返回)
三步曲:
恢复上层方法的局部变量表和操作数栈、
把返回值(如果有的话)压入调用者栈帧的操作数栈中、
调整程序计数器的值以指向方法调用指令后面的一条指令、
异常的话:(通过异常处理表<非栈帧中的>来确定)
运行时数据区及 JVM 的整体内存结构
本地方法栈
本地方法栈 跟 Java 虚拟机栈的功能类似,Java 虚拟机栈用于管理 Java 函数的调用,而本地方法栈则用于管理本地方法的调用。但本地方法并不是 用 Java 实现的,而是由 C 语言实现的(比如 Object.hashcode 方法)。
本地方法栈是和虚拟机栈非常相似的一个区域,它服务的对象是 native 方法。你甚至可以认为虚拟机栈和本地方法栈是同一个区域。
虚拟机规范无强制规定,各版本虚拟机自由实现 ,HotSpot 直接把本地方法栈和虚拟机栈合二为一 。
方法区
方法区(Method Area)是可供各条线程共享的运行时内存区域。它存储了每一个类的结构信息,例如运行时常量池(Runtime Constant Pool) 字段和方法数据、构造函数和普通方法的字节码内容、还包括一些在类、实例、接口初始化时用到的特殊方法
方法区是 JVM 对内存的“逻辑划分”,在 JDK1.7 及之前很多开发者都习惯将方法区称为“永久代”,是因为在 HotSpot 虚拟机中,设计人员使用了永久代来实现了 JVM 规范的方法区。在 JDK1.8 及以后使用了元空间来实现方法区。
元空间
方法区与堆空间类似,也是一个共享内存区,所以方法区是线程共享的。假如两个线程都试图访问方法区中的同一个类信息,而这个类还没有装入 JVM,那么此时就只允许一个线程去加载它,另一个线程必须等待。
在 HotSpot 虚拟机、Java7 版本中已经将永久代的静态变量和运行时常量池转移到了堆中,其余部分则存储在 JVM 的非堆内存中,而 Java8 版本 已经将方法区中实现的永久代去掉了,并用元空间(class metadata)代替了之前的永久代,并且元空间的存储位置是本地内存。
元空间大小参数:
jdk1.7 及以前(初始和最大值):-XX:PermSize;-XX:MaxPermSize;
jdk1.8 以后(初始和最大值):-XX:MetaspaceSize; -XX:MaxMetaspaceSize
jdk1.8 以后大小就只受本机总内存的限制(如果不设置参数的话)
JVM 参数参考:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
Java8 为什么使用元空间替代永久代,这样做有什么好处呢?
官方给出的解释是:移除永久代是为了融合 HotSpot JVM 与 JRockit VM 而做出的努力,因为 JRockit 没有永久代,所以不需要配置永久代。
永久代内存经常不够用或发生内存溢出,抛出异常 java.lang.OutOfMemoryError: PermGen。这是因为在 JDK1.7 版本中,指定的 PermGen 区大小为8M,由于 PermGen 中类的元数据信息在每次 FullGC 的时候都可能被收集,回收率都偏低,成绩很难令人满意;还有为 PermGen 分配多大的空间很难 确定,PermSize 的大小依赖于很多因素,比如,JVM 加载的 class 总数、常量池的大小和方法的大小等。
运行时常量池
运行时常量池(Runtime Constant Pool)是每一个类或接口的常量池(Constant_Pool)的运行时表示形式,它包括了若干种不同的常量:从编 译期可知的数值字面量到必须运行期解析后才能获得的方法或字段引用。
运行时常量池是方法区的一部分。运行时常量池相对于 Class 常量池的另外一个重要特征是具备动态性(Class 常量池在类加载章节会具体讲)。
堆
堆是 JVM 上最大的内存区域,我们申请的几乎所有的对象,都是在这里存储的。我们常说的垃圾回收,操作的对象就是堆。
堆空间一般是程序启动时,就申请了,但是并不一定会全部使用。堆一般设置成可伸缩的。
随着对象的频繁创建,堆空间占用的越来越多,就需要不定期的对不再使用的对象进行回收。这个在 Java 中,就叫作 GC(Garbage Collection)。
那一个对象创建的时候,到底是在堆上分配,还是在栈上分配呢?这和两个方面有关:对象的类型和在 Java 类中存在的位置。
Java 的对象可以分为基本数据类型和普通对象。 对于普通对象来说,JVM 会首先在堆上创建对象,然后在其他地方使用的其实是它的引用。比如,把这个引用保存在虚拟机栈的局部变量表中。 对于基本数据类型来说(byte、short、int、long、float、double、char),有两种情况。
当你在方法体内声明了基本数据类型的对象,它就会在栈上直接分配。其他情况,都是在堆上分配。
堆大小参数:
-Xms:堆的最小值;
-Xmx:堆的最大值;
-Xmn:新生代的大小;
-XX:NewSize;新生代最小值;
-XX:MaxNewSize:新生代最大值;
例如- Xmx256m
直接内存(堆外内存)
直接内存有一种更加科学的叫法,堆外内存。
JVM 在运行时,会从操作系统申请大块的堆内存,进行数据的存储;
同时还有虚拟机栈、本地方法栈和程序计数器,这块称之为栈区。
操作系统剩余的内存也就是堆外内存。
它不是虚拟机运行时数据区的一部分,也不是 java 虚拟机规范中定义的内存区域;如果使用了 NIO,这块区域会被频繁使用,在 java 堆内可以用directByteBuffer 对象直接引用并操作;
这块内存不受 java 堆大小限制,但受本机总内存的限制,可以通过-XX:MaxDirectMemorySize 来设置(默认与堆内存最大值一样),所以也会出现 OOM 异 常。
应为直接内存不属于虚拟机运行时数据器区的,它使用完之后不会被JVM自动回收,需要主动回收。否则容易发生内存溢出。