JVM 基础 4 - JVM 内存结构'
运行时数据区
Java虚拟机在运行Java程序过程中管理的内存区域,称之为运行时数据区。《Java虚拟机规范》中规定了每一部分的作用。
根据 Java 虚拟机规范的规定,运行时数据区可以分为以下几个部分:
- 程序计数器(Program Counter Register)
- Java 虚拟机栈(Java Virtual Machine Stacks)
- 本地方法栈(Native Method Stack)
- 堆(Heap)
- 方法区(Method Area)
程序计数器
定义|作用
程序计数器(Program Counter Register)也叫PC寄存器,用来存储指向下一条指令的地址,即将要执行的指令代码。由执行引擎读取下一条指令。
当我们的java程序被编译成二进制字节码文件后,如下图:
右面,是我们写的代码,左面是二进制字节码形式(.class)
它们将由我们的解释器来将他们转换为机械码,从而让机器运行。
细心的你会发现,每个二进制字节码的前面都有一个类似于索引的数字。他们的作用也跟索引差不多,为当前程序标一个序号,记上他们的地址。
即使有了地址,解释器也不知道他们的顺序是什么样的,他只负责运行。
于是,便有了程序计数器,程序计数器记下了字节码运行的顺序,每当一行字节码走完,他就会立即告诉解释器下一个该走哪里。
双双配合,最终实现全部代码。
这就是程序计数器的作用,不断为解释器寻找下一个要执行的程序。
特点
它是唯一一个在 JVM 规范中没有规定任何
OutOfMemoryError
情况的区域内存溢出(
OutOfMemoryError
)指的是程序在使用某一块内存区域时,存放的数据需要占用的内存大小超过了虚拟机能提供的内存上限。它是一块很小的内存空间,几乎可以忽略不计。也是运行速度最快的存储区域
在 JVM 规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期一致
任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。如果当前线程正在执行的是 Java 方法,程序计数器记录的是 JVM 字节码指令地址,如果是执行 native 方法,则是未指定值(undefined)
它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令
Java虚拟机栈
定义|作用
Java虚拟机栈(Java Virtual Machine Stack)采用栈的数据结构来管理方法调用中的基本数据,先进后出(First In Last Out),每一个方法的调用使用一个栈帧(Stack Frame)来保存。
Java虚拟机栈的栈帧(Frame)中主要包含以下内容:
- 局部变量表(Local Variables):局部变量表的作用是在运行过程中存放所有的局部变量
- 操作数栈(Operand Stack):操作数栈是栈帧中虚拟机在执行指令过程中用来存放临时数据的一块区域
- 帧数据:帧数据主要包含动态链接、方法出口、异常表的引用
- 动态链接(Dynamic Linking):指向运行时常量池的方法引用
- 方法返回地址(Return Address):方法正常退出或异常退出的地址
- 异常表
栈帧的内部结构
局部变量表
- 存储基本数据类型 + 对象引用 + returnAddress 类型(指向了一条字节码指令的地址,已被异常表取代)
- 以**变量槽(Slot)**为最小单位(32位,64位数据占2个Slot)
- 编译期确定大小,运行期不改变
举个栗子:
以下代码的局部变量表中会占用几个槽?
1 | public void test4(int k,int m){ |
分析:
为了节省空间,局部变量表中的槽是可以复用的,一旦某个局部变量不再生效,当前槽就可以再次被使用。
- 方法执行时,实例对象
this
、k
、m
会被放入局部变量表中,占用3个槽
- 将1的值放入局部变量表下标为3的位置上,相当于给a进行赋值。
- 将2放入局部变量表下标为4的位置,给b赋值为2。
- ab已经脱离了生效范围,所以下标为3和4的这两个位置可以复用。此时c的值1就可以放入下标为3的位置。
- 脱离c的生效范围之后,给i赋值就可以复用c的位置。
- 最后放入j,j是一个long类型,占用两个槽。但是可以复用b所在的位置,所以占用4和5这两个位置
所以,局部变量表数值的长度为6。这一点在编译期间就可以确定了,运行过程中只需要在栈帧中创建长度为6的数组即可。
操作数栈
- 方法执行的工作区(类似CPU寄存器)
- 存储计算过程的中间结果
举个栗子:
1 | public int calculate() { |
ps:操作数中的数据类型必须与字节码指令匹配,以上面的 iadd 指令为例,该指令只能用于整型数据的加法运算,它在执行的时候,栈顶的两个数据必须是 int 类型的,不能出现一个 long 型和一个 double 型的数据进行 iadd 命令相加的情况。
帧数据
帧数据主要包含动态链接、方法返回地址、异常表的引用。
动态链接(Dynamic Linking)
当前类的字节码指令引用了其他类的属性或者方法时,需要将符号引用(编号)转换成对应的运行时常量池中的内存地址。动态链接就保存了编号到运行时常量池的内存地址的映射关系。
- 指向运行时常量池的方法引用
- 支持多态特性(后期绑定)
方法返回地址(Return Address)
方法出口指的是方法在正确或者异常结束时,当前栈帧会被弹出,同时程序计数器应该指向上一个栈帧中的下一条指令的地址。所以在当前栈帧中,需要存储此方法出口的地址。
- 存储调用者的程序计数器值
- 包含正常返回和异常返回两种路径
异常表
异常表存放的是代码中异常的处理信息,包含了异常捕获的生效范围以及异常发生后跳转到的字节码指令位置。
栈内存异常
StackOverflowError
原因:栈深度超过虚拟机限制(通常由无限递归引起)
1
2
3
4// 典型示例:无限递归
public void infiniteRecursion() {
infiniteRecursion();
}调节栈大小
1
2
3
4-Xss256k
-XX:ThreadStackSize=1024
Windows(64位)下的JDK8测试最小值为180k,最大值为1024m。
OutOfMemoryError
- 原因:线程创建过多导致栈空间耗尽
- 场景:大量线程并发执行(通常需数千线程)
本地方法栈
Java虚拟机栈存储了Java方法调用时的栈帧,而本地方法栈存储的是native本地方法的栈帧。
在Hotspot虚拟机中,Java虚拟机栈和本地方法栈实现上使用了同一个栈空间。本地方法栈会在栈内存上生成一个栈帧,临时保存方法的参数同时方便出现异常时也把本地方法的栈信息打印出来。
堆
对于大多数应用,Java 堆是 Java 虚拟机管理的内存中最大的一块,被所有线程共享。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数据都在这里分配内存。
为了进行高效的垃圾回收,虚拟机把堆内存逻辑上划分成三块区域(分代的唯一理由就是优化 GC 性能):
- 新生带(年轻代):新对象和没达到一定年龄的对象都在新生代
- 老年代(养老区):被长时间使用的对象,老年代的内存空间应该要比年轻代更大
- 元空间(JDK1.8 之前叫永久代):像一些方法中的操作临时对象等,JDK1.8 之前是占用 JVM 内存,JDK1.8 之后直接使用物理内存
堆内存溢出
- **
java.lang.OutOfMemoryError: GC Overhead Limit Exceeded
**:当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。 java.lang.OutOfMemoryError: Java heap space
:假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误。和本机的物理内存无关,和我们配置的虚拟机内存大小有关!
设置堆的大小
要修改堆的大小,可以使用虚拟机参数 –Xmx(max最大值)和-Xms (初始的total)。
语法:-Xmx值 -Xms值
单位:字节(默认,必须是 1024 的倍数)、k或者K(KB)、m或者M(MB)、g或者G(GB)
限制:Xmx必须大于 2 MB,Xms必须大于1MB
堆内存诊断
- jps 工具
查看当前系统中有哪些 java 进程 - jmap 工具
查看堆内存占用情况 jmap - heap 进程id - jconsole 工具
图形界面的,多功能的监测工具,可以连续监测 - jvisualvm 工具
方法区
方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。在不同的 JDK 版本上有着不同的实现。在 JDK 7 的时候,方法区被称为永久代(PermGen),而在 JDK 8 的时候,永久代被彻底移除,取而代之的是元空间。
它的结构如下:
方法区内存溢出
JDK 1.8 之前永久代还没被彻底移除的时候通常通过下面这些参数来调节方法区大小。
-XX:PermSize=N
//方法区 (永久代) 初始大小-XX:MaxPermSize=N
//方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen
JDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是本地内存。
-XX:MetaspaceSize=N
//设置 Metaspace 的初始(和最小大小)-XX:MaxMetaspaceSize=N
//设置 Metaspace 的最大大小
运行时常量池
常量池
就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量信息。存在.class
文件中的 Constant_Pool
表。
举个栗子:
1 | public class Test { |
然后使用 javap -v Test.class 命令反编译查看结果。
运行时常量池
- 类加载时创建:JVM 加载类时,将
.class
文件的常量池转换后放入方法区 - 动态性:运行时可以添加新常量(如
String.intern()
) - 真实地址:将符号引用解析为直接引用(内存真实地址)
动态添加栗子:
1 | String s1 = new String("Hello"); // 堆中创建对象 |
常量池 vs 运行时常量池
特性 | 常量池 (Constant Pool) | 运行时常量池 (Runtime Constant Pool) |
---|---|---|
存在位置 | .class 文件中 |
JVM 方法区中(JDK8+ 的元空间) |
创建时机 | 编译期生成 | 类加载时创建 |
内容是否可变 | 静态不可变 | 动态可变(运行时添加新常量) |
存储内容 | 符号引用 + 字面量 | 类加载后的真实引用 + 动态常量 |
生命周期 | 文件存在即存在 | 类卸载时销毁 |
字符串常量池
字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
特点
- 常量池中的字符串仅是符号,只有在被用到时才会转化为对象
- 利用字符串常量池的机制,来避免重复创建字符串对象
- 字符串变量拼接的原理是StringBuilder
- 字符串常量拼接的原理是编译器优化
- 可以使用
intern
方法,主动将串池中还没有的字符串对象放入串池中
存放位置
JDK版本 | 字符串常量池位置 | 影响 |
---|---|---|
JDK ≤ 6 | 运行时常量池(永久代) | 容易引发 PermGen OOM |
JDK 7+ | 堆内存 中单独划分区域 | 减少 OOM 风险,支持更大字符串池 |
字符串创建流程:
graph TD
A["new String 'hello'"] --> B{"池中是否存在?"}
B -->|否| C["在堆创建新对象"]
B -->|是| D["返回池中引用"]
C --> E{"调用 intern?"}
E -->|是| F["将引用加入字符串池"]
E -->|否| G["直接使用堆对象"]
intern方法
JDK1.8
调用字符串对象的intern()方法,会将该字符串对象尝试放入到串池中。
- 如果串池中没有该字符串对象,则放入成功,返回引用的对象
- 如果有该字符串对象,则放入失败,返回字符串里有的该对象
无论放入是否成功,都会返回串池中的字符串对象。
注意:此时如果调用intern方法成功,堆内存与串池中的字符串对象是同一个对象;如果失败,则不是同一个对象
JDK1.6
调用字符串对象的intern方法,会将该字符串对象尝试放入到串池中
- 如果串池中没有该字符串对象,会将该字符串对象复制一份,再放入到串池中,返回的是复制的对象
- 如果有该字符串对象,则放入失败,返回串池原有的该字符串的对象
注意:此时无论调用intern方法成功与否,串池中的字符串对象和堆内存中的字符串对象都不是同一个对象
字符串常量池和运行时常量池有什么关系?
早期设计时,字符串常量池是属于运行时常量池的一部分,他们存储的位置也是一致的。后续做出了调整,将字符串常量池和运行时常量池做了拆分。
静态变量存储在哪里呢?
- JDK6及之前的版本中,静态变量是存放在方法区中的,也就是永久代。
- JDK7及之后的版本中,静态变量是存放在堆中的Class对象中,脱离了永久代。具体源码可参考虚拟机源码:BytecodeInterpreter针对putstatic指令的处理。
直接内存
直接内存指的就是Direct Memory,常见于Nio操作,区别于io,在读写操作时有着更高的效率。直接内存并不在《Java虚拟机规范》中存在,所以并不属于Java运行时的内存区域。
特点:
- 常见于 NIO 操作时,用于数据缓冲区
- 分配回收成本较高,但读写性能高
- 不受 JVM 内存回收管理
学习文献