方法栈
方法栈并不是某一个 JVM 的内存空间,而是我们描述方法被调用过程的一个逻辑概念。
在同一个线程内,T1()调用T2():
- T1()先开始,T2()后开始;
- T2()先结束,T1()后结束。
堆和栈概述
从英文单词角度来说
- 栈:stack
- 堆:heap
从数据结构角度来说
- 栈和堆一样:都是先进后出,后进先出的数据结构
从 JVM 内存空间结构角度来说
- 栈:通常指 Java 方法栈,存放方法每一次执行时生成的栈帧。
- 堆:JVM 中存放对象的内存空间。包括新生代、老年代、永久代等组成部分。
栈帧
栈帧存储的数据
方法在本次执行过程中所用到的局部变量、动态链接、方法出口等信息。栈帧中主要保存3 类数据:
本地变量(Local Variables):输入参数和输出参数以及方法内的变量。
栈操作(Operand Stack):记录出栈、入栈的操作。
栈帧数据(Frame Data):包括类文件、方法等等。
栈帧的结构
- 局部变量表:方法执行时的参数、方法体内声明的局部变量
- 操作数栈:存储中间运算结果,是一个临时存储空间
- 帧数据区:保存访问常量池指针,异常处理表
栈帧工作机制
当一个方法 A 被调用时就产生了一个栈帧 F1,并被压入到栈中,
A 方法又调用了 B 方法,于是产生栈帧 F2 也被压入栈,
B 方法又调用了 C 方法,于是产生栈帧 F3 也被压入栈,
……
C 方法执行完毕后,弹出 F3 栈帧;
B 方法执行完毕后,弹出 F2 栈帧;
A 方法执行完毕后,弹出 F1栈帧;
……
遵循“先进后出”或者“后进先出”原则。
图示在一个栈中有两个栈帧:
栈帧 2 是最先被调用的方法,先入栈,
然后方法 2 又调用了方法 1,栈帧 1 处于栈顶的位置,
栈帧 2 处于栈底,执行完毕后,依次弹出栈帧 1 和栈帧 2,
线程结束,栈释放。
每执行一个方法都会产生一个栈帧,保存到栈的顶部,顶部栈就是当前方法,该方法执行完毕后会自动将此栈帧出栈。
典型案例
请预测下面代码打印的结果:34
int n = 10;
n += (n++) + (++n);
System.out.println(n);
实际执行结果:32
使用 javap 命令查看字节码文件内容:
D:\record-video-original\day03\code>javap -c Demo03JavaStackExample.class
Compiled from "Demo03JavaStackExample.java"
public class Demo03JavaStackExample{
public Demo03JavaStackExample();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>: ()V
4: returnpublic static void main(java.lang.String[]);
Code:
0: bipush 10
2: istore_1
3: iload_1
4: iload_1
5: iinc 1, 1
8: iinc 1, 1
11: iload_1
12: iadd
13: iadd
14: istore_1
15: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
18: iload_1
19: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
22: return
}
内存执行过程分析:
栈溢出异常
异常名称
java.lang.StackOverflowError
异常产生的原因
下面的例子是一个没有退出机制的递归:
public class StackOverFlowTest {
public static void main(String[] args) {
methodInvokeToDie();
}
public static void methodInvokeToDie() {
methodInvokeToDie();
}
}
抛出的异常信息:
Exception in thread "main" java.lang.StackOverflowError at com.atguigu.jvm.test.StackOverFlowTest.methodInvokeToDie(StackOverFlowTest.java:10) at com.atguigu.jvm.test.StackOverFlowTest.methodInvokeToDie(StackOverFlowTest.java:10) at com.atguigu.jvm.test.StackOverFlowTest.methodInvokeToDie(StackOverFlowTest.java:10) at com.atguigu.jvm.test.StackOverFlowTest.methodInvokeToDie(StackOverFlowTest.java:10) at com.atguigu.jvm.test.StackOverFlowTest.methodInvokeToDie(StackOverFlowTest.java:10)
原因总结:方法每一次调用都会在栈空间中申请一个栈帧,来保存本次方法执行时所需要用到的数据。但是一个没有退出机制的递归调用,会不断申请新的空间,而又不释放空间,这样迟早会把当前线程在栈内存中自己的空间耗尽。
栈空间的线程私有验证
提出问题
某一个线程抛出『栈溢出异常』,会导致其他线程也崩溃吗?从以往的经验中我们判断应该是不会,下面通过代码来实际验证一下。
代码
new Thread(()->{
while(true) {
try {
TimeUnit.SECONDS.sleep(2);
System.out.println(Thread.currentThread().getName() + " working");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "thread-01").start();
new Thread(()->{
while(true) {
try {
TimeUnit.SECONDS.sleep(2);
// 递归调用一个没有退出机制的递归方法
methodInvokeToDie();
System.out.println(Thread.currentThread().getName() + " working");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "thread-02").start();
new Thread(()->{
while(true) {
try {
TimeUnit.SECONDS.sleep(2);
System.out.println(Thread.currentThread().getName() + " working");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "thread-03").start();
结论:02 线程抛异常终止后,01 和 03 线程仍然能够继续正常运行,说明 02 抛异常并没有影响到 01 和 03,说明线程对栈内存空间的使用方式是彼此隔离的。每个线程都是在自己独享的空间内运行,反过来也可以说,这个空间是当前线程私有的。
堆空间
堆空间工作机制
- 新创建的对象会被放在Eden区
- 当Eden区中已使用的空间达到一定比例,会触发Minor GC
- 每一次在Minor GC中没有被清理掉的对象就成了幸存者
- 幸存者对象会被转移到幸存者区
- 幸存者区分成from区和to区
- from区快满的时候,会将仍然在使用的对象转移到to区
- 然后from和to这两个指针彼此交换位置
口诀:复制必交换,谁空谁为to
- 如果一个对象,经历15次GC仍然幸存,那么它将会被转移到老年代
- 如果幸存者区已经满了,即使某个对象尚不到15岁,仍然会被移动到老年代
- 最终效果:
- Eden区主要是生命周期很短的对象来来往往
- 老年代主要是生命周期很长的对象,例如:IOC容器对象、线程池对象、数据库连接池对象等等
- 幸存者区作为二者之间的过渡地带
- 关于永久代:
- 从理论上来说属于堆
- 从具体实现上来说不属于堆
永久代在各个JDK版本之间的演变
永久代 | 常量池 | |
---|---|---|
≤JDK1.6 | 有 | 在方法区 |
=JDK1.7 | 有,但开始逐步“去永久代” | 在堆 |
≥JDK1.8 | 无 | 在元空间 |
方法区、元空间、永久代之间关系
堆、栈、方法区之间关系
堆溢出异常
异常名称
java.lang.OutOfMemoryError,也往往简称为 OOM。
异常信息
- Java heap space:针对新生代、老年代整体进行Full GC后,内存空间还是放不下新产生的对象
- PermGen space:方法区中加载的类太多了(典型情况是框架创建的动态类太多,导致方法区溢出)
我们可以参考下面的控制台日志打印:
[GC (Allocation Failure) 4478364K->4479044K(5161984K), 4.3454766 secs] [Full GC (Ergonomics) 4479044K->3862071K(5416448K), 39.3706285 secs] [Full GC (Ergonomics) 4410423K->4410422K(5416448K), 27.7039534 secs] [Full GC (Ergonomics) 4629575K->4621239K(5416448K), 24.9298221 secs] [Full GC (Allocation Failure) 4621239K->4621186K(5416448K), 29.0616791 secs] Exception in thread "main" java.lang.OutOfMemoryError: Java heap space at java.util.Arrays.copyOf(Arrays.java:3210) at java.util.Arrays.copyOf(Arrays.java:3181) at java.util.ArrayList.grow(ArrayList.java:261) at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:235) at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:227) at java.util.ArrayList.add(ArrayList.java:458) at com.atguigu.jvm.test.JavaHeapTest.main(JavaHeapTest.java:16)
小练习
测试代码
查看下面程序在每个步骤中内存的状态:
public class Review {
// 静态变量,类变量
public static Review review = new Review();
public void showMessage() {
// 局部变量
Review reviewLocal = new Review();
}
// 程序入口
public static void main(String[] args) {
// 局部变量
Review reviewMain = new Review();
// 通过局部变量调用对象的方法
reviewMain.showMessage();
// 手动 GC
System.gc();
}
}
各状态分析
栈和堆的区别
堆和栈的区别主要体现在以下几个方面。
- 内存分配方式
栈(stack)和堆(heap)都是内存中的一段区域,但它们的内存分配方式是不同的。栈是由程序自动创建和释放的,通常用于存储函数调用时的临时变量、函数的返回地址等信息。而堆则是由程序员手动申请和释放的,通常用于存储程序中需要动态分配的内存(如动态数组、对象等)。
- 内存管理方式
栈的内存分配是按照“后进先出”的原则进行的,即最后一个进入栈的变量最先被释放。因此,栈中的内存管理是由系统自动完成的,程序员不需要过多考虑内存的分配和释放问题。堆的内存管理则需要程序员自行负责,使用完毕后必须手动释放,否则会导致内存泄漏或其他问题。
- 内存大小
栈的容量较小,一般只有几百KB到几MB的空间,具体容量由操作系统和编译器决定。相对而言,堆用于存储较大的数据结构,大小一般比栈要大得多,可以动态扩展内存空间。但是,因为堆需要手动管理内存,如果不及时释放,会导致内存泄漏,进而影响系统性能。
- 访问速度
因为栈的内存分配是系统自动完成的,所以访问速度相对堆更快。栈中的数据直接存放在系统内存中,而访问堆中的数据需要通过指针进行间接访问,会造成一定的时间损耗。此外,在多线程环境下,由于栈的线程独享,所以不会发生竞争问题。而堆则需要考虑多线程并发访问时的同步和互斥机制。
- 应用场景
栈适合用于存储局部变量和函数调用,主要用于内存的临时分配;而堆适合用于存储需要动态分配和管理的数据结构,如动态数组、字符串、对象等。在实际开发中,应该根据具体的应用场景选择合适的内存分配方式。