一、总览
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 流程(复制算法):
-
当 Eden 区满时触发 Minor GC
-
标记 Eden 和当前使用的 Survivor 中的存活对象
-
将存活对象复制到另一个空闲的 Survivor 中
-
清空 Eden 和刚才的 Survivor 区
-
两个 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 对象晋升到老年代的条件
-
年龄阈值:对象每熬过一次 Minor GC 年龄 +1,达到 15 岁(默认)晋升
-
动态年龄判断:Survivor 中相同年龄的对象大小总和超过 Survivor 空间的一半,该年龄及以上对象直接晋升
-
大对象 :超过 -XX:PretenureSizeThreshold 设置的大对象直接在老年代分配
-
空间担保失败:Minor GC 时 Survivor 放不下的对象直接进入老年代
六、方法区(Method Area)
6.1 定义
方法区是 JVM 规范中定义的一块逻辑上独立的内存区域 ,用于存储已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
6.2 核心特性
-
线程共享:所有线程都能访问方法区数据,因此类加载和静态变量访问需要保证线程安全
-
逻辑连续,物理可分散:和堆一样,逻辑上是一块连续空间,但物理上可以由多个不连续的内存块组成
-
大小可配置:通过 JVM 参数指定初始大小和最大上限
-
有垃圾回收,但条件极其苛刻:主要回收废弃常量和无用的类(该类所有实例已被回收、类加载器已被回收、Class 对象没有被引用),回收效率远低于堆
-
会抛出 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) | 好(动态扩展,不需要预估类数量) |
为什么要替换永久代?
-
永久代大小固定,启动时必须预先设定,无法动态扩展------动态生成大量类(Spring AOP、动态代理、CGLIB)时直接 PermGen space OOM
-
永久代 GC 和老年代绑定,只有 Full GC 才能回收,效率极低
-
永久代和堆耦合,调优困难
