【JVM | 第一篇】—— JVM内存区域详解

一、总览

JVM 内存结构主要分为以下几个区域:

|--------|--------|--------------------------------------------|--------------------------|
| 区域 | 区域 | 存储内容 | 异常 |
| 程序计数器 | 私有 | 当前线程执行的字节码指令地址 | 无 |
| 虚拟机栈 | 私有 | 栈帧(局部变量表、操作数栈、动态链接、返回地址) | StackOverflowError / OOM |
| 本地方法栈 | 私有 | Native 方法的栈帧 | StackOverflowError / OOM |
| 堆 | 共享 | 对象实例、数组,类的class对象,静态变量(jdk8+)字符串常量池(jdk6+) | OOM |
| 方法区 | 共享 | 类元信息、运行时常量池、静态变量(jdk7-) | OOM |
| 直接内存 | | NIO Buffer 等 | OOM |

二、程序计数器(Program Counter Register)

2.1 定义

程序计数器是一块较小的内存空间,里面存放了当前线程所要执行的字节码指令的地址。

2.2 核心特性

  • 线程私有:每个线程都有一个独立的程序计数器,互不干扰
  • 不会 OOM:是 JVM 规范中唯一没有规定任何 OutOfMemoryError 的区域
  • Native 方法时为 undefined:当线程执行的是 Native 方法(C/C++ 实现)时,程序计数器的值为 undefined

2.3 两大作用

① 字节码指令的控制流导航

对于单个线程而言,通过程序计数器可以知道下一个该执哪那条字节码指令。

  • 顺序执行:执行完一条指令后,计数器自动 +1,指向下一条指令
  • 分支/ 循环 :遇到 if、for、switch 等指令时,计数器直接跳转到目标指令的地址
  • 异常处理:抛出异常时,通过计数器定位异常发生的位置,再跳转到对应的异常处理器

② 线程切换后的执行状态恢复

CPU 采用时间片轮转机制执行线程。当一个线程的时间片用完被挂起时,JVM 会保存该线程的程序计数器值;当线程重新获得 CPU 时间片时,会从程序计数器中读取之前的执行位置,精确恢复到挂起前的指令继续执行。
这就是程序计数器必须线程私有的根本原因:每个线程的执行状态独立,互不干扰。

三、虚拟机栈(JVM Stack)

3.1 定义

虚拟机栈是 Java 方法执行的内存模型 ,和程序计数器一样属于线程私有的内存区域。每个线程启动时,JVM 会为其创建一个独立的虚拟机栈。所有 Java 方法的调用、执行、返回都依赖它来完成。

3.2 栈帧(Stack Frame)

每个方法从调用到执行完成,对应一个栈帧在虚拟机栈中的入栈出栈过程。栈顶的栈帧永远是当前正在执行的方法(称为"当前栈帧")。

每个栈帧包含以下四部分:

|-----------|-----------------------------------------------------------|
| 组成部分 | 说明 |
| 局部变量表 | 存放方法参数和方法内定义的局部变量,包括基本数据类型、对象引用、returnAddress 类型。编译期确定大小。 |
| 操作数栈 | 用于存放字节码指令执行过程中的操作数和中间结果,是一个后进先出的栈结构。 |
| 动态链接 | 指向运行时常量池中该栈帧所属方法的引用,用于支持方法调用过程中的动态连接(多态) |
| 返回地址 | 方法正常退出(return 指令)或异常退出时,恢复到调用者的位置继续执行。 |

3.3 可能抛出的异常

  • StackOverflowError:栈深度超过 JVM 允许的最大深度(典型场景:无限递归)
  • OutOfMemoryError:栈内存不足,无法创建新的栈帧或新的线程栈

补充:一个线程的栈内存默认约 1M。栈内存调大(-Xss)会导致单个线程占用更多内存,在总内存不变的情况下可创建的线程数会减少,反而容易出现 OOM。

3.4 方法内局部变量的线程安全性

  • 线程安全:局部变量没有逃离方法的作用范围(即只在方法内部使用,没有 return 出去,没有传给其他线程)
  • 线程不安全:局部变量引用了外部共享对象,或者局部变量本身逃离了方法作用范围(作为返回值或作为参数传入其他线程)

四、本地方法栈(Native Method Stack)

4.1 定义

本地方法栈与虚拟机栈的作用非常相似,区别在于:

  • 虚拟机栈 :为 JVM 执行 Java 方法(字节码)服务
  • 本地方法栈 :为 JVM 执行 Native 方法(C/C++ 实现)服务

4.2 核心特性

  • 线程私有
  • 也会抛出 StackOverflowError 和 OutOfMemoryError
  • 在 HotSpot 虚拟机中,本地方法栈和虚拟机栈直接合二为一,不做区分
  • 本地方法栈中也可以使用 C 语言的结构体来模拟栈帧

五、堆(Heap)

5.1 定义

堆是 JVM 所管理的内存中最大的一块 ,是所有线程共享 的区域。堆的唯一目的就是存放对象实例和数组 。当堆中没有足够空间为新对象分配内存,且堆也无法再扩展时,会抛出 OutOfMemoryError

5.2 分代结构(分代收集理论)

堆内存分为新生代老年代 ,默认比例为 1 : 2

5.3 新生代(Young Generation)

新生代又分为 Eden 和两个 Survivor 区(S0、S1),默认比例为 8 : 1 : 1

  • Eden :大多数新创建的对象首先分配到这里
  • Survivor 区(S0/S1 :两个 Survivor 区轮流充当中转站,每次 Minor GC 后,存活对象从 Eden 和当前使用的 Survivor 复制到另一个空的 Survivor 中

Minor GC 流程(复制算法):

  1. 当 Eden 区满时触发 Minor GC

  2. 标记 Eden 和当前使用的 Survivor 中的存活对象

  3. 将存活对象复制到另一个空闲的 Survivor 中

  4. 清空 Eden 和刚才的 Survivor 区

  5. 两个 Survivor 角色互换(谁是空的谁是下一次的复制目的地)

5.4 为什么需要两个 Survivor 区?

核心原因:复制算法要求始终有一块完全空闲的空间作为复制目的地。

假设只有 Eden + 1 个 Survivor:

|--------------|--------------------------|------------------------------------------|
| GC 次数 | 场景 | 问题 |
| 第一次 Minor GC | Eden 存活对象 → 复制到 Survivor | Survivor 被占用,无空闲空间 |
| 第二次 Minor GC | Eden 又满了需要回收 | Eden 存活对象无处可放(唯一的 Survivor 已被占用) |

结果只能:

  • 要么往 Survivor 里"硬塞",产生大量内存碎片
  • 要么提前晋升到老年代,导致老年代快速填满,频繁Full GC

结论:一个 Survivor 无法满足复制算法" 永远有一块空地" 的要求。

5.5 老年代(Old Generation)

  • 用途 :存放生命周期长、经过多次 Minor GC(默认 15 次,-XX:MaxTenuringThreshold)仍然存活的对象
  • 特点:空间比新生代大、对象存活率高、GC 频率低但单次 GC 耗时更长
  • GC 算法 :通常使用标记 - 清除标记 - 整理算法

5.6 对象晋升到老年代的条件

  1. 年龄阈值:对象每熬过一次 Minor GC 年龄 +1,达到 15 岁(默认)晋升

  2. 动态年龄判断:Survivor 中相同年龄的对象大小总和超过 Survivor 空间的一半,该年龄及以上对象直接晋升

  3. 大对象 :超过 -XX:PretenureSizeThreshold 设置的大对象直接在老年代分配

  4. 空间担保失败:Minor GC 时 Survivor 放不下的对象直接进入老年代

六、方法区(Method Area)

6.1 定义

方法区是 JVM 规范中定义的一块逻辑上独立的内存区域 ,用于存储已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

6.2 核心特性

  1. 线程共享:所有线程都能访问方法区数据,因此类加载和静态变量访问需要保证线程安全

  2. 逻辑连续,物理可分散:和堆一样,逻辑上是一块连续空间,但物理上可以由多个不连续的内存块组成

  3. 大小可配置:通过 JVM 参数指定初始大小和最大上限

  4. 有垃圾回收,但条件极其苛刻:主要回收废弃常量和无用的类(该类所有实例已被回收、类加载器已被回收、Class 对象没有被引用),回收效率远低于堆

  5. 会抛出 OutOfMemoryError:当无法分配足够内存存储新的类元数据时触发

6.3 方法区存储的核心内容

① 类元信息(Class Metadata)

是方法区最核心、占比最大的内容,每个被加载的类都会在这里存储一份完整的"类模板":

  • 类的基本信息:完整类名、访问修饰符、父类、实现的接口列表
  • 字段信息:所有字段的名称、类型、访问修饰符、注解
  • 方法信息:所有方法的名称、返回值、参数列表、字节码指令、异常表、注解
  • 类加载器引用:指向加载该类的 ClassLoader 对象
  • Class 对象引用 :指向堆中该类对应的 java.lang.Class 实例
  • 方法表:用于快速动态分派的虚方法表(vtable)和接口方法表(itable)

② 运行时常量池(Runtime Constant Pool)

  • 是 Class 文件中"静态常量池"的运行时版本,每个类独有一份
  • 字面量:字符串、整数、浮点数、布尔值等常量
  • 符号引用:类、方法、字段的字符串形式引用(编译期无法确定内存地址)
  • 核心特点:动态性 ,运行时可以向池中添加新常量(最典型的是 String.intern() 方法)

⚠️ 关键变化 :JDK7 及之后,字符串常量池从运行时常量池中分离,移到了堆内存中

③ 静态变量(Static Variables)
  • 类级别的变量,属于类本身而非实例
  • JDK7 之前存储在方法区(永久代)中
  • JDK7 及之后,静态变量和 Class 对象一起移到堆中,但逻辑上仍属于方法区

6.4 方法区的演进:永久代 → 元空间

|----------|------------------------------------|-------------------------|
| 对比维度 | 永久代(JDK7-) | 元空间(JDK8+) |
| 存储位置 | JVM 堆内存中 | 操作系统本地内存(Native Memory) |
| 大小限制 | 固定(-XX:PermSize / -XX:MaxPermSize) | 默认无上限,受限于物理内存 |
| OOM 风险 | 极易 OOM(大小固定,动态类生成直接爆) | 几乎不会(只要系统有内存) |
| GC 效率 | 低(和老年代共用 GC,只有 Full GC 才回收) | 高(有专门回收器,回收粒度更细) |
| 与堆的关系 | 和堆绑定,调优互相影响 | 和堆完全分离,调优互不影响 |
| 动态类支持 | 差(不适合反射、动态代理、CGLIB) | 好(动态扩展,不需要预估类数量) |

为什么要替换永久代?

  1. 永久代大小固定,启动时必须预先设定,无法动态扩展------动态生成大量类(Spring AOP、动态代理、CGLIB)时直接 PermGen space OOM

  2. 永久代 GC 和老年代绑定,只有 Full GC 才能回收,效率极低

  3. 永久代和堆耦合,调优困难

相关推荐
huohaiyu2 小时前
深入解析JVM核心原理与运行机制
运维·服务器·jvm
思麟呀4 小时前
在C++基础上理解CSharp-4
开发语言·jvm·c++·c#
颖火虫盟主4 小时前
Conan C++ 包管理工具深度解析
java·jvm·c++
tongluowan00715 小时前
jvm垃圾回收器 - CMS-已弃用的垃圾回收器
jvm·cms·垃圾回收器
light blue bird1 天前
主子表单控件图表带拖拽式控件工序管理组件
开发语言·jvm·信息可视化·桌面端winform
QWQ___qwq1 天前
Text2SQL 完整流程模拟详细笔记
jvm·笔记·oracle
light blue bird1 天前
可更新组装工序资源图表功能组件
开发语言·前端·jvm·.net·状态模式
light blue bird1 天前
组装工序资源功能工序路径菜单组件
jvm·数据库·winform·gdi+界面·多节点端
菜鸟是大神1 天前
JDK21-Windows安装
java·jvm·windows