JVM 运行时数据区域也叫内存布局,但需要注意的是它和Java 内存模型((Java Memory Model,简称JMM)完全不同,属于完全不同的两个概念,它由以下5大部分组成:
1. Java虚拟机栈(线程私有)
Java虚拟机栈(Java Virtual Machine Stack,简称JVM栈)是Java内存管理模型中的一个重要部分。它与线程紧密相关,属于线程私有的内存区域,每个线程在创建时都会分配一个对应的JVM栈,用来存储该线程运行过程中所需的局部变量、操作数栈、动态链接、方法出口等信息。
1.1 JVM栈的结构
JVM栈由一系列的栈帧(Stack Frame)组成,每个栈帧对应着一次方法的调用。当一个方法被调用时,JVM会为这个方法创建一个新的栈帧,并将其推入线程的JVM栈顶。方法执行完毕后,栈帧会被弹出,释放掉对应的内存。
每个栈帧主要包含以下几个部分:
- 局部变量表:存放方法参数和局部变量,变量以slot(槽位)形式存储。
- 操作数栈:用于执行字节码指令时的临时数据存储。大多数字节码指令会对操作数栈进行操作,例如数据压栈、弹栈、计算等。
- 动态链接:指向常量池中与该方法相关的符号引用,可以在方法调用中解析出实际的调用目标。
- 方法返回地址:保存方法返回后需要跳转到的地址。
1.2 线程私有性
JVM栈是线程私有的,也就是说每个线程都有独立的JVM栈,线程之间的栈是互不干扰的。由于栈是与线程绑定的,当线程结束时,其对应的JVM栈也会随之销毁。正因为栈是线程私有的,所以不需要考虑线程同步问题。
1.3 局部变量表
局部变量表的大小在编译时就已经确定,表中的每一个元素对应着一个局部变量。局部变量表存储的变量包括:
- 方法的参数
- 方法内部声明的局部变量
基本数据类型(如int、float等)会直接存储在局部变量表中,而对象引用类型(如引用数组或对象)则存储引用地址。
1.4 栈帧的生命周期
栈帧的生命周期与方法调用的生命周期一致。栈帧在方法调用时创建,在方法执行完毕后销毁,遵循后进先出的规则。
1.5 栈溢出(StackOverflowError)
由于JVM栈的大小是有限的,当递归调用过深或方法嵌套过多,可能会导致栈的深度超过其限制,触发StackOverflowError
。
1.6 栈内存分配
JVM栈可以通过JVM启动参数进行配置:
-Xss
:设置每个线程栈的大小。通过合理配置这个参数,可以控制栈内存的分配,减少或避免栈溢出错误。
2. 本地方法栈(线程私有)
本地方法栈(Native Method Stack)与Java虚拟机栈类似,都是Java虚拟机(JVM)为线程分配的线程私有内存区域。不同的是,本地方法栈主要用于支持本地方法的执行,而Java虚拟机栈是为执行Java方法服务的。
与JVM栈一样,本地方法栈也是线程私有的,每个线程在调用本地方法时,会有自己独立的本地方法栈。本地方法栈的作用类似于C语言中的栈结构,主要用于存储本地方法的局部变量、操作数、返回地址等信息。
3. 程序计数器(线程私有)
程序计数器(Program Counter Register,简称PC寄存器)是Java虚拟机(JVM)内存模型中的一个重要组成部分。与Java虚拟机栈和本地方法栈一样,程序计数器也是线程私有的,每个线程都有自己独立的程序计数器。它在JVM中主要用于跟踪当前线程所执行的字节码指令,是JVM实现多线程的基础之一。
3.1 程序计数器的作用
程序计数器的主要作用是保证线程切换时,能够恢复正确的执行位置。
具体作用包括:
- 记录当前字节码执行的位置:它指示下一条将要执行的字节码指令,帮助JVM知道当前线程需要执行什么操作。
- 线程恢复执行位置:当线程上下文切换时,程序计数器可以帮助线程在被重新调度时恢复到之前的正确位置继续执行。
3.2 程序计数器的特点
- 线程私有:每个线程都有自己的程序计数器,用于记录当前线程执行字节码的地址。
- 小内存空间:程序计数器所占的内存非常小,因为它只需要存储当前指令的地址。
- 无内存溢出问题 :与Java虚拟机栈或本地方法栈不同,程序计数器不会出现
OutOfMemoryError
或StackOverflowError
,因为它只是用于记录指令地址,不涉及复杂的数据结构。
注意:
当线程执行本地方法(即通过
native
关键字声明的方法)时,JVM并不会为本地方法生成字节码,因此此时的程序计数器并没有意义。因此,程序计数器在执行本地方法时通常是未定义的(Undefined)。
4. 方法区(线程共享)
在《Java虚拟机规范中》把此区域称之为"方法区",而在 HotSpot 虚拟机的实现中,在 JDK7 时此区域叫做永久代(PermGen),JDK8中叫做元空间(Metaspace)
方法区是一个运行时数据区,在JVM启动时创建,并且与堆一样,属于所有线程共享的内存区域。它的主要作用是存储:
- 类结构信息:包括类的全限定名、访问修饰符、父类、接口、字段、方法等。
- 常量池:用于存放编译时生成的常量,包括字符串常量、方法和字段的符号引用等。
- 静态变量:所有类的静态变量数据在方法区中存储,这些变量对于类的所有实例是共享的。
- 即时编译后的代码:JVM将一些热点代码编译成机器码,存储在方法区以提高执行效率。
由于方法区是所有线程共享的,JVM中加载的类信息、常量、静态变量等内容对所有线程可见,因此方法区对于整个程序的类加载和运行时元数据管理起着非常重要的作用。
4.1 JDK1.8元空间变化
1.对于 HotSpot 来说,JDK8元空间的内存属于本地内存,这样元空间的大小就不在受JM 最大内存的参数影响了,而是与本地内存的大小有关。
2.JDK8 中将字符串常量池移动到了堆中。
4.2 运行时常量池
方法区中有一个重要的组成部分是运行时常量池(Runtime Constant Pool)。常量池用于存储在编译时生成的字面量(如字符串常量)和符号引用(如类、方法和字段的引用)。这些数据在类或接口被加载时进入方法区的常量池,JVM在运行时可以通过常量池来解析类、方法和字段。
5. 堆区(线程共享)
堆区(Heap)是Java虚拟机(JVM)中最大的一块内存区域,属于线程共享 的区域,所有的对象实例和数组都在堆中分配内存。堆区是垃圾回收器重点管理的区域,因此也常被称为GC堆。
5.1 堆区的作用
堆区主要用于存放对象实例 ,当我们使用new
关键字创建对象时,JVM会在堆中为该对象分配内存。堆是所有线程共享的,因此它是多线程环境下最常访问的内存区域。
堆区的主要功能包括:
- 存储对象的实例数据(成员变量和对象引用)
- 用于内存分配和垃圾回收
在JVM启动时,堆区会被创建,程序运行时,几乎所有对象实例都会在堆区中分配内存。
5.2 堆的内存结构
堆里面分为两个区域:新生代和老生代,新生代放新建的对象,当经过一定 GC次数之后还存活的对象会放入老生代。新生代还有3个区域:一个 Endn+两个Survivor(S0/S1)。
下图所示的 Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代,中间一层属于老年代,最下面一层属于永久代[1]。
JDK 8 版本之后 PermGen(永久代) 已被 Metaspace(元空间) 取代,元空间使用的是本地内存。
大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 S0 或者 S1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold
来设置。不过,设置的值应该在 0-15,否则会爆出以下错误:
bash
MaxTenuringThreshold of 20 is invalid; must be between 0 and 15
为什么年龄只能是 0-15?
因为记录年龄的区域在对象头中,这个区域的大小通常是 4 位。这 4 位可以表示的最大二进制数字是 1111,即十进制的 15。因此,对象的年龄被限制为 0 到 15。
6. 内存布局中的异常问题
引用:
[1] 著作权归JavaGuide(javaguide.cn)所有 基于MIT协议 原文链接:https://javaguide.cn/java/jvm/memory-area.html