【从零开始学习JVM | 第六篇】运行时数据区
前言:
JVM运行时数据区是程序运行时内存分配的核心区域,用于存储不同类型的数据和执行状态。堆用于存放对象实例,是垃圾回收主要区域;方法区存储类信息、常量等共享数据;虚拟机栈随方法调用创建栈帧,管理局部变量和方法执行;本地方法栈处理Native方法调用;程序计数器记录当前执行字节码位置。这些区域协同工作,保障程序的内存分配、指令执行和状态管理,是JVM实现跨平台运行的重要基础。
接下来让我们一起来探索运行时数据区。
运行时数据区五部分
程序计数器
程序计数器(Program Counter Register)也叫pc寄存器是 JVM 运行时数据区中最小的一块内存区域,它的作用类似于 “代码执行的游标”。每个线程都有独立的程序计数器,用于存储当前线程要执行的字节码指令地址:如果执行的是 Java 方法,计数器记录的是字节码指令的地址;如果执行的是 Native 方法,计数器值为空(Undefined)。
由于 JVM 的多线程是通过线程轮流切换并分配处理器时间片的方式实现的,因此在任何时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间的计数器互不影响,独立存储,这类内存区域被称为 “线程私有” 的内存。
程序计数器是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域(也就是不会出现内存泄漏),因为它的生命周期与线程相同,随线程的启动而创建,随线程的结束而销毁。
在代码执行过程中程序计数器会记录下一行字节码指令(核心)的地址。每次执行完当前指令,虚拟机都可以根据程序计数器执行下一次的指令。
java虚拟机栈
JVM 中的栈主要指虚拟机栈(Java Virtual Machine Stack),它是线程私有的内存区域,生命周期与线程一致。虚拟机栈用于存储栈帧,每个栈帧对应一个方法的调用过程,包含局部变量表、操作数栈、动态链接、方法返回地址等信息。
1. 栈帧(Stack Frame)
每个栈帧代表一次方法调用,随方法调用创建,随方法返回销毁,包含以下关键组件:
-
局部变量表(Local Variable Table)
- 存储方法参数和局部变量(如基本类型变量、对象引用)。
- 以数组形式实现,容量在编译期确定,通过索引访问变量。
-
操作数栈(Operand Stack)
- 用于字节码指令执行时的临时数据存储和计算,遵循 “后进先出(LIFO)” 原则。
- 例如执行
i = a + b
时,先将a
和b
压入栈,计算后将结果压回栈。
-
动态链接(Dynamic Linking)
- 存储方法的符号引用(如类名、方法名),在运行时解析为直接引用(内存地址)。
- 指向方法区中的类信息,用于方法调用时的动态绑定。
-
方法返回地址(Return Address)
- 记录方法执行完毕后返回的位置(方法的下一条指令地址)。
- 方法正常返回时,返回地址来自程序计数器;异常返回时,返回地址由异常处理机制决定。
栈的内存溢出
StackOverflowError(栈溢出)
- 触发场景:
- 方法递归调用深度超过栈的最大深度(如无限递归、递归深度过大)。
- 方法栈帧过大(如局部变量表占用内存过多),导致栈无法容纳新栈帧。
如果递归调用没有正确的结束条件,就会这样。比如:
public void recursiveMethod() { recursiveMethod(); // 无限递归,不断创建栈帧
}
栈的默认大小
JVM 虚拟机栈的默认大小会因 JVM 实现和操作系统的不同而有所差异。以常用的 HotSpot 虚拟机为例,在 64 位操作系统下,其默认栈大小通常在 1MB 左右(比如 1MB 或 1.5MB),而 32 位系统可能会有不同的默认值。不过,这个默认值并非绝对固定,它还可能受到 JDK 版本的影响,比如较新版本的 JDK 可能会根据硬件环境调整默认配置。另外,不同的 JVM 参数设置也能改变栈的大小,例如通过-Xss
参数可以显式指定每个线程的栈空间大小。需要注意的是,默认大小在多数常规应用场景下是足够的,但如果程序中存在深度递归或大量线程并发的情况,可能需要根据实际需求调整该值,以避免出现栈溢出异常。
修改栈大小语法:-Xss栈大小
以下是不同操作系统下 JVM 栈默认大小的相关情况:
- Linux:在 64 位 Linux 系统(x64 架构)中,JVM 栈的默认大小通常是 1024KB,即 1MB。在 32 位的 Linux 系统上,如 x86 平台,JVM 栈默认大小可能是 320KB。
- macOS:对于 64 位的 macOS 系统,JVM 栈的默认大小一般为 1024KB,也就是 1MB。
- Windows:Windows 系统中 JVM 栈的默认大小情况较为特殊,它依赖于虚拟内存,没有一个固定的明确值。在 32 位的 Windows JVM 中,原生栈大小通常是 320KB,在 64 位 JVM 中通常不会修改这个值,但这并不是严格固定的默认值。
- Oracle Solaris:在 64 位的 Oracle Solaris 系统(x64 架构)上,JVM 栈的默认大小为 1024KB(1MB)。在 32 位的 Sparc 平台上,Java SE 6 中默认栈大小是 512KB。
需要注意的是,这些默认值可能会因 JVM 的版本、JDK 的实现以及硬件环境等因素而有所不同。
堆内存
堆内存的核心作用
JVM 堆内存是 JVM 运行时数据区中最大的一块内存区域,由所有线程共享。其核心作用是:
- 存储对象实例:所有通过
new
创建的对象(如类实例、数组等)都存放在堆中。 - GC 管理的主要区域:堆内存由垃圾回收器(GC)自动管理,负责回收不再使用的对象,释放内存空间。
栈上的局部变量表,可以存放堆上对象的引用。静态变量也可以存放堆对象的引用,通过静态变量就可以实现对象在线程之间共享。
- 堆空间有三个需要关注的值,used,total,max。
- used指的是当前已使用的堆内存,total是指java虚拟机已经分配可以使用的内存,max指堆的最大容量。
当total内存不够是,Java虚拟机会继续分配内存给total
那是不是used=total=max的内存就溢出?答案:不是。
在 JVM 堆内存中,“堆内存溢出”(Out of Memory, OOM)的触发条件并非简单的 “已使用内存(used)等于总内存(total)等于最大内存(max)”,而是由内存分配请求失败触发的。
OOM 的真正触发条件
JVM 触发堆内存溢出的核心逻辑是:当尝试分配对象时,发现堆内存无法满足分配需求,且无法通过扩展或 GC 释放足够空间。具体流程如下:
- 分配对象时,JVM 检查当前堆剩余空间(
total - used
)是否足够。- 若不足,先尝试扩展堆(若
total < max
),扩展后再检查空间。- 若扩展后仍不足,或
total已等于max
,则触发 GC 回收内存。- 若 GC 后剩余空间仍无法满足分配需求,抛出
java.lang.OutOfMemoryError: Java heap space
异常。
设置堆大小
语法:-Xmx值 -Xms值
限制:Xmx必须大于2MB,Xms必须大于1MB
方法区
一个虚拟概念,方法区是 Java 虚拟机(JVM)内存结构中的一部分,主要用于存储已加载类的元数据、常量、静态变量等数据。在类的加载阶段完成
方法区的定位与作用
- 位置:方法区属于 JVM 的堆外内存(非堆内存),独立于 Java 堆存在。
- 核心作用:存储类的结构信息(如字段、方法、接口)、运行时常量池、静态变量、即时编译(JIT)后的代码等,是 JVM 加载类时的 “数据仓库”。
方法区的关键组成部分(jdk8之前)
-
类元数据(Class Metadata)
- 存储类的完整定义,包括类名、父类、接口、字段类型、方法字节码等,由 JVM 的类加载器加载后存入。
- 例如:
public class User { int id; void print() { ... } }
的类结构会在此处存储。
-
运行时常量池(Runtime Constant Pool)
- 是类文件中常量池的运行时版本,包含编译期确定的字面量(如字符串、基本类型值)和符号引用(如方法名、字段名)。
- 例如:
String str = "hello"
中的"hello"
字符串会存入常量池。
-
静态变量(Static Variables)
- 类中用
static
修饰的变量,其内存分配在方法区,生命周期与类相同。 - 例如:
static int count = 100;
的count
变量存储于此。
- 类中用
-
即时编译代码(JIT Compiled Code)
- JVM 对热点代码(频繁执行的方法)进行即时编译后生成的本地机器码,也存储在方法区。
在JDK8之前,静态变量存储在JVM的方法区中。方法区是用于存储已被虚拟机加载的类信息、常量、静态变量以及即时编译器编译后的代码等数据。这个区域被称为永久代(PermGen)。
然而,从JDK8开始,Java虚拟机内存模型发生了变化,永久代被元空间(Metaspace)所取代。与永久代不同,元空间并不在虚拟机内存中而是使用本地内存。因此,静态变量和常量池已从方法区迁移到了堆内存中。这意味着在JDK8及之后的版本中,静态变量实际上是存储在堆内存中的。
-
JDK 1.7 及之前:永久代(PermGen)
- 使用堆的一部分作为方法区,称为 “永久代”,大小由参数
-XX:PermSize
(初始)和-XX:MaxPermSize
(最大)控制。 - 常见问题:永久代大小固定,易因类加载过多导致 “PermGen OOM”(如 Web 容器部署多应用时)。
- 使用堆的一部分作为方法区,称为 “永久代”,大小由参数
-
JDK 1.8 及之后:元空间(Metaspace)
- 移除永久代,将方法区移至直接内存,称为 “元空间”。
- 关键变化:
- 大小不再受堆限制,由系统内存决定,可通过
-XX:MetaspaceSize
(初始阈值)和-XX:MaxMetaspaceSize
(最大限制,默认无上限)调整。 - 类元数据存储在元空间,字符串常量池移至 Java 堆(解决 PermGen 中字符串常量的内存管理问题)。
- 大小不再受堆限制,由系统内存决定,可通过
本地方法栈
本地方法栈的定位与作用
- 位置:与虚拟机栈(Java Stack)并列,属于 JVM 运行时数据区的一部分,独立于 Java 堆存在。
- 核心作用:为 Java 代码中通过
native
关键字声明的本地方法(非 Java 语言实现的方法,通常由 C/C++ 编写)提供调用栈空间,存储方法调用的局部变量、操作数栈、返回地址等信息。
本地方法栈的工作机制
-
本地方法的调用场景
- 当 Java 代码需要调用底层操作系统或硬件功能时(如文件操作、网络通信、图形界面等),会通过
native
方法调用本地代码。 - 例如,Java 的
java.lang.Thread
类中的部分方法(如start0()
)就是通过本地方法实现的。
- 当 Java 代码需要调用底层操作系统或硬件功能时(如文件操作、网络通信、图形界面等),会通过
-
与虚拟机栈的区别
- 虚拟机栈:管理 Java 方法的调用,存储 Java 方法的栈帧(局部变量表、操作数栈等)。
- 本地方法栈:管理本地方法的调用,其具体实现由 JVM 厂商决定(如 HotSpot 虚拟机将本地方法栈与虚拟机栈合并实现)。
本地方法栈的内存模型与参数配置
-
内存分配方式
- 本地方法栈的内存空间是线程私有的,每个 Java 线程在创建时会分配独立的本地方法栈。
- 栈的大小可通过 JVM 参数
-Xoss
(非标准参数,部分 JVM 支持)设置,例如:-Xoss 256k
表示将本地方法栈大小设为 256KB。
-
不同 JVM 实现的差异
- HotSpot 虚拟机:未明确区分本地方法栈和虚拟机栈,两者使用相同的栈空间,通过
-Xss
参数统一设置栈大小(如-Xss 1M
)。 - 其他 JVM(如 JRockit):可能单独实现本地方法栈,参数配置方式不同
- HotSpot 虚拟机:未明确区分本地方法栈和虚拟机栈,两者使用相同的栈空间,通过
总结
JVM 运行时数据区是 Java 虚拟机在执行程序时管理内存的核心结构,主要包括以下几个部分:
首先是堆内存,它是 JVM 中最大的一块内存区域,被所有线程共享,用于存储对象实例和数组,是垃圾回收的主要区域,分为新生代和老年代等不同区域以优化内存管理。
其次是方法区,同样被所有线程共享,用于存储类的元数据、常量、静态变量以及即时编译后的代码等,在 JDK 8 及以后版本中,方法区的实现改为元空间,使用本地内存。
虚拟机栈是线程私有的,每个线程都有一个虚拟机栈,用于存储方法调用时的栈帧,包括局部变量表、操作数栈、动态链接和方法返回地址等,当方法调用深度超过栈大小时会抛出栈溢出异常。
本地方法栈也是线程私有的,用于管理本地方法(通过 native 关键字声明的方法)的调用,不同 JVM 实现可能将其与虚拟机栈合并,如 HotSpot 虚拟机中通过参数统一设置栈大小。
程序计数器是线程私有的,是最小的一块内存区域,用于记录当前线程执行的字节码指令地址,以便线程切换后能恢复执行,Java 方法执行时记录字节码地址,本地方法执行时计数器值为 undefined。
此外,还有运行时常量池,是方法区的一部分,用于存放编译期生成的各种字面量和符号引用,运行时可能动态添加常量,如 String 的 intern () 方法。这些区域共同构成了 JVM 运行时的数据存储和管理体系,各自承担不同的内存职责,确保 Java 程序的正常执行。
感谢你的阅读,希望通过本篇文章你能对java虚拟机有一个更深刻的认识,感谢你的阅读。创作不易你的阅读点赞和转发是我最大的动力。