JVM内存模型(运行时数据区)
目录
编者想说
1、内存模型图
2、栈
3、程序计数器
3、堆
4、方法区(元空间)
5、本地方法栈(Native Method Stack)
编者想说
通过上一篇文章的对JVM的体系结构以及它的演化,我们对JVM有了一个比较清晰的认识,接下来我们将会深入其中的运行时数据区(也就是我们常说的JVM内存模型)进行更加深入的一个分析。
1、内存模型图
2、栈
首先,我们以下面这个程序为例来介绍一下栈这个内存区:
import java.util.Scanner;public class Test {public static void main(String[] args) {int a,b;Scanner sc = new Scanner(System.in);a = sc.nextInt();b = sc.nextInt();System.out.println("a+b="+add(a,b));}private static int add(int a, int b) {return a+b;}
}
在这个栈里面存储的叫栈帧(Stack Frame),每个方法调用对应一个栈帧,包含:
局部变量表:方法内的局部变量(基本类型、对象引用),比如上面程序中的a,b以及sc。
操作数栈:方法执行时的临时操作数(如算术运算的中间结果a+b)。
动态链接:指向方法区中该方法的类信息。
方法返回地址:方法执行完毕后的返回位置。
下面是我对这个程序执行之后在栈内存中的一些动作做的一个图示,方便大家理解(如果有错误,请各位大佬严肃批评指正)。
此处,我要对上图做一些解释:
1、在创建a,b时,这两个临时变量是没有被赋值的,系统自动初始化值为0
2、sc是一个引用数据类型,因为sc是通过new关键字创建的,会指向运行时数据区中的堆内存,而此时栈内存中的sc其实
指向堆内存中Scanner
实例的地址(因为对象本身在堆中)。
3、a = sc.nextInt()
和 b = sc.nextInt()这两个方法是通过sc读取我们输入的值并给a,b做一个赋值,
调用nextInt()
时,会临时压入nextInt
方法的栈帧(执行完毕弹出),接着将值传给a,b,覆盖初始值0。
4、add(a,b)调用add()方法并传入a,b的值,在main栈帧的操作数栈中压入a,b的值然后创建add()方法的栈帧,里面包含局部变量表(分配两个int槽位,存储传入的a、b的拷贝值),操作数栈(计算a+b的结果,并暂存于此)。在add方法结束之后,弹出add方法的栈帧,返回值压入main方法的操作数栈。
栈内存变量状态快照
代码执行位置 | 栈帧 | 局部变量表槽位(索引) | 存储内容 |
---|---|---|---|
int a, b; | main | 0 (args ) | 方法参数(未使用) |
1 (a ) | int 初始值0 | ||
2 (b ) | int 初始值0 | ||
Scanner sc = ... | main | 3 (sc ) | 指向堆中Scanner 的引用 |
a = sc.nextInt() | main | 1 (a ) | 键盘输入值(如5 ) |
b = sc.nextInt() | main | 2 (b ) | 键盘输入值(如3 ) |
add(a, b) 内部 | add | 0 (a ) | main 中a 的值拷贝 |
1 (b ) | main 中b 的值拷贝 |
在弄清楚栈之后,我们要开始介绍程序计数器了
3、程序计数器
程序计数器(Program Counter Register)是JVM运行时数据区中一个非常核心但容易被忽视的组件。它的作用可以用一句话概括:
记录当前线程正在执行的字节码指令的地址(行号),相当于代码执行的“书签”,确保线程切换或方法调用后能准确恢复到执行位置。
程序计数器的作用
1. 线程执行的“导航仪”
- 每个线程独立拥有一个程序计数器,互不干扰。
- 存储的是下一条待执行指令的地址(字节码的行号偏移量)。
- 例如:当前执行到
main
方法的第5行字节码,PC寄存器就保存5
。
- 例如:当前执行到
2. 方法调用时的“存档点”
- 当线程调用新方法(如
add(a, b)
)时:- PC寄存器会暂存当前方法的执行位置(如
main
方法的第10行)。 - 切换到新方法的起始指令地址(如
add
方法的第0行)。 - 方法返回时,根据PC寄存器保存的地址恢复执行。
- PC寄存器会暂存当前方法的执行位置(如
3. Native方法的特殊处理
- 如果线程执行的是
native
方法(如JNI调用),PC寄存器的值为undefined
。- 因为
native
方法的执行由本地代码(如C++)控制,不在JVM字节码范围内。
- 因为
4.为什么需要程序计数器?
- 线程切换恢复:CPU时间片轮转时,线程可能被挂起,PC寄存器确保恢复后继续执行正确位置。假设线程A执行
compute()
到PC=6
时被挂起,线程B开始执行。当线程A恢复时,程序计数器会准确恢复到PC=6
,继续执行iadd
指令。 - 方法调用/返回:嵌套调用方法时(如
A→B→C
),PC寄存器保存调用链的返回路径。 - 避免指令混乱:无PC寄存器时,多线程执行可能导致代码位置错乱。
5.与操作数栈的区别
程序计数器 | 操作数栈 |
---|---|
只存指令地址(数字),不存数据 | 存方法执行的临时数据(如a+b 的结果) |
线程切换依赖它 | 线程切换无关 |
永远不溢出 | 可能栈溢出(StackOverflowError ) |
以我们上面的那个程序为例:
public static void main(String[] args) {int a = sc.nextInt(); // 假设字节码行号10int b = sc.nextInt(); // 行号15int result = add(a, b); // 行号20
}
- 线程执行到
int a = sc.nextInt()
时,PC寄存器保存10
。 - 调用
nextInt()
方法时,PC寄存器暂存15
(下一条指令地址),跳转到nextInt
的字节码起始位置。 nextInt
执行完毕返回后,PC恢复为15
,继续执行。
唯一无OOM的区域:程序计数器是JVM规范中唯一不会发生OutOfMemoryError
的区域。
线程私有:每个线程独立存储,生命周期与线程相同。
性能关键:JIT编译器会优化PC寄存器的使用,减少指令定位开销。
程序计数器虽然不直接存储业务数据,但它是JVM多线程执行和指令顺序控制的基础,类似于CPU中的指令指针(EIP/RIP)。
3、堆
JVM的堆内存(Heap Memory)是Java程序运行时数据区域中最重要的部分之一,主要用于存储对象实例和数组。堆内存是所有线程共享的内存区域,在JVM启动时创建。
以下是堆的内存结构图
堆内存的主要特点
- 对象存储区域:几乎所有通过
new
关键字创建的对象实例都存储在堆中 - 垃圾回收的主要区域:GC(垃圾收集器)主要管理堆内存
- 线程共享:所有线程共享堆内存
- 动态分配:堆的大小可以在JVM启动时指定,也可以动态扩展
堆内存的分代结构
现代JVM通常将堆内存划分为几个不同的代(Generation),以便更高效地进行垃圾回收:
1. 年轻代(Young Generation)
- Eden区:新创建的对象首先分配在Eden区
- Survivor区:分为From Survivor和To Survivor两个区域,用于存放从Eden区经过GC后存活的对象
- 年轻代使用复制算法进行垃圾回收(Minor GC)
2. 老年代(Old Generation/Tenured Generation)
- 存放长期存活的对象
- 当对象在年轻代经历一定次数的GC后仍然存活,会被晋升到老年代
- 老年代使用标记-清除或标记-整理算法进行垃圾回收(Major GC/Full GC)
3. 永久代/元空间(PermGen/Metaspace)
- Java 8之前称为永久代(PermGen),Java 8及以后改为元空间(Metaspace)
- 存储类元数据、方法区信息等
- 元空间使用本地内存(Native Memory)而非堆内存
堆内存相关参数
-Xms
:初始堆大小-Xmx
:最大堆大小-Xmn
:年轻代大小-XX:NewRatio
:老年代与年轻代的比例-XX:SurvivorRatio
:Eden区与Survivor区的比例
堆内存溢出
当堆内存不足时,会抛出OutOfMemoryError
错误,常见原因包括:
- 内存泄漏
- 堆大小设置不合理
- 对象生命周期过长
4、方法区(元空间)
方法区(Method Area)是JVM规范定义的一个逻辑内存区域,在Java 8之前被称为永久代(PermGen),从Java 8开始被元空间(Metaspace)取代。
核心概念
-
存储内容:
- 类元数据(Class metadata):类的结构信息(字段、方法、构造器等)
- 运行时常量池(Runtime Constant Pool)
- 静态变量(Static variables)
- 即时编译器(JIT)编译后的代码
- 方法字节码
-
与堆的关系:
- 在Java 7及之前:方法区是堆的逻辑部分,物理上位于堆内存中
- 从Java 8开始:元空间使用本地内存(Native Memory),不再属于堆内存
永久代 vs 元空间
特性 | 永久代(PermGen) | 元空间(Metaspace) |
---|---|---|
位置 | JVM堆内存的一部分 | 本地内存(Native Memory) |
大小限制 | 受-XX:MaxPermSize 限制 | 默认只受系统可用内存限制 |
垃圾回收 | Full GC时回收 | 可单独触发元空间GC |
内存溢出 | java.lang.OutOfMemoryError: PermGen space | java.lang.OutOfMemoryError: Metaspace |
调优参数 | -XX:PermSize , -XX:MaxPermSize | -XX:MetaspaceSize , -XX:MaxMetaspaceSize |
元空间关键特性
-
动态扩展:
- 默认情况下,元空间会根据应用需求动态调整大小
- 避免了永久代固定大小导致的
OutOfMemoryError
-
类元数据生命周期:
- 与类加载器(ClassLoader)生命周期绑定
- 当类加载器被回收时,其加载的类元数据也会被回收
-
内存管理:
- 使用
mmap
而非malloc
来分配内存 - 采用块(Chunk)分配策略,提高内存利用率
- 使用
相关JVM参数
# 初始元空间大小(并非初始就分配,而是首次GC的阈值)
-XX:MetaspaceSize=64M# 最大元空间大小(默认基本无限制)
-XX:MaxMetaspaceSize=256M# 元空间扩容时增加的幅度(默认约20%)
-XX:MinMetaspaceFreeRatio=40
-XX:MaxMetaspaceFreeRatio=70# 启用类元数据的并行卸载(JDK 12+)
-XX:+ClassUnloadingWithConcurrentMark
常见问题
-
元空间内存泄漏:
- 通常由未关闭的类加载器引起
- 常见于动态生成类(如使用ASM、CGLIB)的应用
-
调优建议:
- 监控元空间使用情况(JVisualVM、JConsole等工具)
- 对于大量使用动态代理的应用,适当增加
MaxMetaspaceSize
- 避免创建过多类加载器
-
性能影响:
- 元空间GC会导致应用暂停(但比永久代Full GC影响小)
- 过大的元空间可能影响Native Memory其他组件的使用
5、本地方法栈(Native Method Stack)
本地方法栈是JVM内存结构中一个专门为本地方法(Native Method)服务的内存区域,与Java虚拟机栈类似但服务于不同的目的。
核心概念
-
基本定义:
- 为JVM运行本地方法(Native Method)服务的内存区域
- 每个线程在调用本地方法时会创建独立的本地方法栈
- 存储本地方法的调用状态、参数、局部变量等
举例:
//定义一个线程对象并开启
Thread t = new Thread();
t.start();
那么在这个start()方法的底层就有一个本地方法:
-
与虚拟机栈的区别:
特性 Java虚拟机栈 本地方法栈 服务对象 Java方法 本地方法(用native修饰的方法) 实现规范 JVM规范明确要求 由JVM实现者自行决定 语言类型 Java语言实现 通常用C/C++实现 异常类型 StackOverflowError/OutOfMemoryError 由操作系统决定
关键特性
-
内存分配:
- 线程私有,生命周期与线程相同
- 大小可以通过
-Xss
参数设置(与Java虚拟机栈共享同一参数) - 在HotSpot JVM实现中,本地方法栈和虚拟机栈是合二为一的
-
运行机制:
- 当线程调用native方法时:
- 在本地方法栈中创建栈帧
- 动态链接到本地方法接口(JNI)
- 执行本地方法实现(通常位于
.dll
或.so
文件中) - 返回结果后栈帧出栈
- 当线程调用native方法时:
-
重要限制:
- 栈深度限制(可能抛出StackOverflowError)
- 内存分配失败(可能抛出OutOfMemoryError)
- 本地方法执行错误可能导致JVM崩溃(因为超出了JVM控制范围)
常见问题与调优
-
StackOverflowError:
- 本地方法递归调用层次过深
- 解决方案:增大栈大小(
-Xss
参数),或优化本地方法实现
-
内存泄漏:
- 本地方法中分配的内存未正确释放
- 特别注意事项:通过JNI New创建的Java对象需要特别管理
-
性能调优:
# 设置线程栈大小(包括本地方法栈) -Xss1m# 对于大量使用本地方法的应用,可能需要增大该值 -Xss2m
-
安全风险:
- 本地方法栈可能成为攻击入口(缓冲区溢出等)
- 建议:对关键本地方法进行严格的安全审计
注意事项
-
HotSpot JVM的实现特点:
- 在主流JVM实现(如HotSpot)中,本地方法栈和Java虚拟机栈是合并的
- 通过
-Xoss
参数设置本地方法栈大小的功能在HotSpot中无效
-
平台差异性:
- 不同操作系统对本地方法栈的支持可能有差异
- 32位/64位系统的默认栈大小不同
-
调试建议:
- 使用
-XX:+PrintFlagsFinal
查看实际栈大小 - 对于本地方法问题,需要结合系统级调试工具(如gdb)
- 使用