文章目录
- 前言
- 运行时数据区域
- 1.程序计数器
- 定义
- 特点
- 总结
- 2.虚拟机栈
- 2.1 定义
- 局部变量表 ★
- 操作数栈
- 动态链接
- 方法返回地址(方法出口)
- 2.2 栈内存溢出
- 演示栈内存溢出 java.lang.StackOverflowError
- 2.3问题辨析
- 1. 垃圾回收是否涉及栈内存?
- 2. 栈内存分配越大越好吗?
- 3. 方法内的局部变量是否线程安全?
- 3.本地方法栈
前言
对于 Java 程序员来说,在虚拟机自动内存管理机制下,不再需要像 C/C++那样为每一个 new 操作去写对应的 delete/free 操作,不容易出现内存泄漏和内存溢出问题。正是因为 Java 程序员把内存控制权利交给 Java 虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会是一个非常艰巨的任务。
运行时数据区域
Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域,用于存储程序执行期间所需的各种信息。这些区域可以分为线程共享和线程私有两种类型。主要有程序计数器、虚拟机栈、本地方法栈、堆、方法区等区域,每个部分承担着不同的功能和角色。
JDK 1.8 和之前的版本略有不同,我们这里以 JDK 1.7 和 JDK 1.8 这两个版本为例介绍。
JDK 1.7
JDK1.8
线程私有的:
- 程序计数器
- 虚拟机栈
- 本地方法栈
线程共享的:
- 堆
- 方法区
- 直接内存 (非运行时数据区的一部分)
Java 虚拟机规范对于运行时数据区域的规定是相当宽松的。以堆为例:堆可以是连续空间,也可以不连续。堆的大小可以固定,也可以在运行时按需扩展 。虚拟机实现者可以使用任何垃圾回收算法管理堆,甚至完全不进行垃圾收集也是可以的。
1.程序计数器
定义
Program Counter Register 程序计数器(物理上通过寄存器实现计数功能)
作用:是记住下一条jvm指令的执行地址
特点
- 是线程私有的(当线程1时间片内没有执行完,下一条指令会保存到程序计数器,切换执行其他线程)
- 程序计数器不会存在内存溢出
- 程序计数器是唯一不会抛出OutOfMemoryError的内存区域。
总结
程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。
另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
从上面的介绍中我们知道了程序计数器主要有两个作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
⚠️ 注意:程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
2.虚拟机栈
2.1 定义
Java Virtual Machine Stacks (Java 虚拟机栈)
- JVM 中每个线程运行时需要的内存包括虚拟机栈、程序计数器、本地方法栈等,其中虚拟机栈用于存储方法调用的栈帧。
- 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
- 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
与程序计数器一样,Java 虚拟机栈(简称栈)也是线程私有的,它的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。
栈绝对算的上是 JVM 运行时数据区域的一个核心,除了一些 Native 方法调用是通过本地方法栈实现的(后面会提到),其他所有的 Java 方法调用都是通过栈来实现的(也需要和其他运行时数据区域比如程序计数器配合)。
方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。可以说每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。和数据结构上的栈类似,两者都是先进后出的数据结构,只支持出栈和入栈两种操作。
局部变量表 ★
局部变量表 主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。
- 这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个。
- 局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
- 注意,这里说的“大小”是指变量槽的数量,虚拟机真正使用多大的内存空间(譬如按照1个变量槽占用32个比特、64个比特,或者更多)来实现一个变量槽,这是完全由具体的虚拟机实现自行决定的事情。
操作数栈
操作数栈 是一个后进先出(LIFO)的栈,用于在方法执行过程中进行数据的计算和操作。例如,在执行算术运算时,操作数(临时变量)会被压入操作数栈,计算结果也会从操作数栈中弹出。
动态链接
动态链接 主要服务一个方法需要调用其他方法的场景。Class 文件的常量池里保存有大量的符号引用比如方法引用的符号引用。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。
动态链接的作用就是在方法调用时,通过动态链接可以将符号引用转换为调用方法的直接引用,这个过程也被称为 动态连接 。
方法返回地址(方法出口)
记录了方法执行完毕后返回的地址,以便线程能够返回到调用该方法的位置继续执行。
栈空间虽然不是无限的,但一般正常调用的情况下是不会出现问题的。不过,如果函数调用陷入无限循环的话,就会导致栈中被压入太多栈帧而占用太多空间,导致栈空间过深。那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。
Java 方法有两种返回方式,一种是 return 语句正常返回,一种是抛出异常。不管哪种返回方式,都会导致栈帧被弹出。也就是说, 栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。
除了 StackOverFlowError 错误之外,栈还可能会出现OutOfMemoryError错误,这是因为如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。
简单总结一下程序运行中栈可能会出现两种错误:
- StackOverFlowError: 若栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。
- OutOfMemoryError: 如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛OutOfMemoryError异常。
在《深入理解 Java 虚拟机》第三版中解释到:
2.2 栈内存溢出
- 栈帧过多导致栈内存溢出(递归)
java.lang.stackOverflowError
- 栈帧过大导致栈内存溢出
演示栈内存溢出 java.lang.StackOverflowError
通过 -Xss256k 可以设置栈内存大小
public class Demo01 {private static int count;public static void main(String[] args) {try {method1();} catch (Throwable e) {e.printStackTrace();System.out.println(count);}}private static void method1() {count++;method1();}
}
点击Edit
点击Modify options
点击Add VM options
输入-Xss256k
配置后运行发现3816次就会报错java.lang.StackOverflowError
2.3问题辨析
1. 垃圾回收是否涉及栈内存?
不涉及,栈帧内存在调用完后会自动回收掉。
详细说明:
- 栈内存:栈内存是线程私有的,用于存储方法调用的栈帧(包括局部变量、操作数栈、动态链接、方法出口等)。栈内存的生命周期与线程绑定,线程结束时栈内存会自动释放。
- 垃圾回收(GC):垃圾回收主要针对堆内存(Heap)和方法区(Metaspace),用于回收不再使用的对象和类信息。
- 栈内存的管理:栈内存的分配和释放是自动的,由 JVM 负责。当一个方法调用结束时,其对应的栈帧会被弹出并销毁,局部变量也会随之消失。因此,栈内存不需要垃圾回收。
2. 栈内存分配越大越好吗?
不是,栈内存的分配需要根据实际应用场景进行调整,既不能过小(避免栈溢出),也不能过大(避免内存浪费和线程数限制)。
详细说明:
- 栈内存过大的问题:
- 内存浪费:如果栈内存分配过大,但实际使用较少,会导致内存浪费。
- 线程数限制:栈内存是线程私有的,每个线程都会占用独立的栈内存。如果栈内存过大,会导致可创建的线程数减少(因为总内存有限)。
- 栈溢出风险:虽然栈内存过小可能导致栈溢出(StackOverflowError),但栈内存过大并不能解决深层递归或方法调用过多的问题,反而会浪费资源。
- 栈内存过小的问题:
- 栈溢出:如果栈内存过小,可能会导致栈溢出错误(StackOverflowError),尤其是在递归调用或方法调用链过长时。
3. 方法内的局部变量是否线程安全?
如果方法内局部变量没有逃离方法的作用访问(如对象引用为参数值、返回值),它是线程安全的
如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全
3.本地方法栈
因为java代码有一定限制,不能直接跟操作系统底层联系,所以需要C或C++来和操作系统底层联系,这些本地方法运行时使用的内存为本地方法栈。
和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种错误。