文章目录
- 前言
- JVM的内存结构
-
- [1.程序计数器(PC Register)](#1.程序计数器(PC Register))
- [2.虚拟机栈(JVM Stack)](#2.虚拟机栈(JVM Stack))
- [3.本地方法栈(Native Method Stack)](#3.本地方法栈(Native Method Stack))
- [4.堆(Heap):GC 的主战场](#4.堆(Heap):GC 的主战场)
- [5. 方法区与元空间](#5. 方法区与元空间)
-
- (1)方法区、永久代、元空间三者关系
- (2)为什么用元空间替代永久代?
- [(4)方法区的 GC](#(4)方法区的 GC)
- [直接内存(Direct Memory)](#直接内存(Direct Memory))
- 常见面试题
前言
JVM 内存模型(准确说应该叫 JVM 运行时数据区)是 JVM 高效稳定运行的基石------它规定了 Java 程序在运行过程中内存申请、分配、管理的策略。
JVM 内存结构是 JVM 规范定义的物理内存布局,而 JMM 是并发编程中的抽象内存模型,描述线程与主内存之间的变量访问规则。
JVM的内存结构
按照线程是否共享来分类,JVM 运行时数据区可以分为两大部分:
- 线程私有(绿色部分):程序计数器(PC Register)、虚拟机栈(JVM Stack)、本地方法栈(Native Method Stack)
- 线程共享(蓝色部分):堆(Heap)、方法区/元空间(Method Area / Metaspace)

图片来源
1.程序计数器(PC Register)
程序计数器是一块极小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。在虚拟机概念模型中,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、跳转、循环、异常处理、线程恢复等基础操作都依赖它来完成。
- 线程私有:每个线程都有独立的程序计数器,用于线程切换后恢复到正确的执行位置。
- 唯一无 OOM:此区域是 JVM 规范中唯一没有规定任何 OutOfMemoryError 情况的区域。
- Native 方法特殊处理:如果线程正在执行一个 Java 方法,计数器记录的是字节码指令地址;如果是 Native 方法,计数器值则为空(Undefined)。
2.虚拟机栈(JVM Stack)
虚拟机栈是线程私有的内存区域,生命周期与线程相同。它描述的是 Java 方法执行的内存模型:每个方法执行的同时都会创建一个栈帧(Stack Frame),方法从调用到执行完成,对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
虚拟机栈可能抛出两种异常:
- StackOverflowError:线程请求的栈深度大于虚拟机允许的深度(如无限递归)。
- OutOfMemoryError:虚拟机栈动态扩展时无法申请到足够的内存。
一个方法被调用执行时,JVM 会在当前线程的虚拟机栈中为它创建一个栈帧(Stack Frame),方法执行完毕时栈帧出栈销毁。方法嵌套调用形成栈帧的压栈链,递归调用则会产生同一方法的多个栈帧实例。
3.本地方法栈(Native Method Stack)
本地方法栈与虚拟机栈的作用非常相似,区别在于:
- 虚拟机栈 为 Java 方法(字节码)服务。
- 本地方法栈 为 Native 方法服务。
在 HotSpot 虚拟机中,虚拟机栈和本地方法栈直接合二为一,不进行区分。与虚拟机栈一样,本地方法栈也会抛出 StackOverflowError 和 OutOfMemoryError。
HotSpot 虚拟机是 Java 生态中最主流、使用最广泛的 Java 虚拟机实现
4.堆(Heap):GC 的主战场
堆是 JVM 中最大的一块内存区域,被所有线程共享,用于存储几乎所有的对象实例和数组。Java 堆是垃圾收集器管理的主要区域,因此也被称为 "GC 堆" 。
(1)堆的分代结构
从内存回收的角度来看,现代垃圾收集器大多采用分代收集算法,因此 Java 堆可以细分为:
- 新生代(Young Generation):又细分为 Eden 空间 和两个 Survivor 空间(S0/From 和 S1/To)。新创建的对象优先在 Eden 区分配。
- 老年代(Old Generation):用于存放长期存活的对象(经过多次 Minor GC 后依然存活的对象会被晋升到老年代)。
分代的目的:将对象按照存活概率进行分类,可以针对不同区域采用不同的回收算法,减少扫描范围和 GC 频率,从而提高回收效率。
(2)默认空间比例配置
默认情况下:
- 新生代与老年代的比例为 1:2(-XX:NewRatio=2,表示新生代占堆的 1/3)。
- 在新生代内部,Eden 与 Survivor 的比例为 8:1:1(-XX:SurvivorRatio=8)。
可以通过 java -XX:+PrintFlagsFinal -version 查看当前 JVM 所有默认参数。
(3)对象分配与晋升规则
- 对象优先在 Eden 区分配。当 Eden 区没有足够空间时,触发 Minor GC(Young GC)。
- Minor GC 后,存活的对象会被转移到 Survivor 区(From 区),并且对象的 "年龄" 计数器加 1。
- 每次 Minor GC,存活对象在 Survivor 的 From 区和 To 区之间来回复制,总有一个 Survivor 区保持为空。
- 当对象的年龄达到 15(可通过 -XX:MaxTenuringThreshold 设置)时,会被晋升到老年代。
- 动态年龄判断:当一批存活对象的总大小超过 Survivor 区内存大小的 50% 时,会直接将年龄较大的对象提前转移到老年代。
- 大对象直接进入老年代:需要大量连续内存空间的对象(如大数组),为了避免在 Eden 和 Survivor 之间频繁拷贝,会直接分配到老年代。
- 老年代空间不足时触发 Full GC(Major GC),如果 Full GC 后仍不足,则抛出 OutOfMemoryError: Java heap space。
(4)堆内存参数调优
| 参数 | 含义 | 建议 |
|---|---|---|
| -Xms | 堆内存初始大小 | 生产环境建议与 -Xmx 设为相同,避免 GC 后动态调整带来的额外压力 |
| -Xmx | 堆内存最大大小 | 同上 |
| -XX:NewRatio | 老年代与新生代比例 | 默认为 2(老年代:新生代 = 2:1) |
| -XX:SurvivorRatio | Eden 与单个 Survivor 比例 | 默认为 8(Eden:S0:S1 = 8:1:1) |
| -XX:MaxTenuringThreshold | 对象晋升老年代的年龄阈值 | 默认 15 |
5. 方法区与元空间
(1)方法区、永久代、元空间三者关系
| 概念 | 定位 | 说明 |
|---|---|---|
| 方法区(Method Area) | JVM 规范中的逻辑概念 | 用于存储类信息、常量、静态变量、JIT 编译后代码等 |
| 永久代(PermGen) | HotSpot 对方法区的实现(JDK 7 及之前) | 位于堆内存中,有固定大小上限,易内存溢出错误(OOM) |
| 元空间(Metaspace) | HotSpot 对方法区的实现(JDK 8 及之后) | 使用本地内存(操作系统内存),默认仅受系统内存限制 |
方法区是规范,永久代/元空间是实现。两者本质上不等价------在其他虚拟机(如 JRockit、J9)中,从来就没有永久代这个概念。
永久代 ------> 元空间:字符串常量池和静态变量移至堆中,只存储类的元信息(类名、方法、字段、注解等)、运行时常量池(字面量、符号引用)、JIT 编译后的代码缓存。
(2)为什么用元空间替代永久代?
永久代存在以下问题:
- 永久代有固定的大小上限(-XX:MaxPermSize),容易发生 OutOfMemoryError: PermGen space。
- 为永久代调优非常困难------不同应用的类加载行为差异巨大,很难预估合适的大小。
JDK 8 改用元空间后:
- 元空间使用本地内存(Native Memory),不再与堆连续。
- 默认情况下,元空间的大小仅受本地内存限制,大幅降低了 OOM 的风险。
- 元空间的大小可通过 -XX:MetaspaceSize(初始大小)和 -XX:MaxMetaspaceSize(最大大小)进行设置。
JDK 8 将原本存放在永久代中的字符串常量池和静态变量移到了堆内存中,元空间只存储类的元信息(类结构、方法、字段等)。
(4)方法区的 GC
方法区的内存回收主要针对常量池的回收和类型的卸载。但类型的卸载条件相当苛刻,需要满足:
- 该类的所有实例已被回收。
- 加载该类的 ClassLoader 已被回收。
- 该类的 Class 对象在任何地方没有被引用。
因此,方法区的 GC "成绩"通常比较难令人满意,但确实有必要进行。
直接内存(Direct Memory)
直接内存并非 JVM 运行时数据区的一部分,也不是Java 虚拟机规范中定义的内存区域,但它被频繁使用(如 NIO 中的 ByteBuffer.allocateDirect())
- 直接内存是在 Java 堆外的、直接向操作系统申请的内存。
- 避免了在 Java 堆和 Native 堆之间来回复制数据,显著提升 I/O 性能。
- 直接内存的大小不受 -Xmx 限制,但受操作系统总内存限制,默认与 -Xmx 相同。如果分配过多,也可能导致 OOM。
- 可通过 -XX:MaxDirectMemorySize 设置最大直接内存大小。
常见面试题
1.堆和栈的区别是什么?
| 对比维度 | 堆(Heap) | 栈(Stack) |
|---|---|---|
| 线程共享性 | 线程共享 | 线程私有 |
| 存储内容 | 对象实例和数组 | 局部变量、操作数栈、方法返回地址等 |
| 内存大小 | 较大,可动态调整 | 较小,一般固定(-Xss) |
| 生命周期 | 由 GC 管理 | 随方法调用结束而释放 |
| 异常 | OutOfMemoryError | StackOverflowError / OutOfMemoryError |
2.方法区、永久代、元空间是什么关系?
- 方法区 是 JVM 规范中定义的一个逻辑区域。
- 永久代 是 HotSpot 在 JDK 7 及之前对方法区的实现,位于堆中。
- 元空间 是 HotSpot 在 JDK 8 及之后对方法区的实现,使用本地内存。
3.为什么用元空间替代永久代?
- 永久代有固定大小上限,容易 OOM,且难以调优。
- 元空间使用本地内存,默认仅受系统内存限制,降低 OOM 风险。
- 字符串常量池和静态变量移入堆中,方便 GC 管理。
4.什么情况下会出现栈内存溢出?
- 递归调用过深(没有正确的终止条件)。
- 方法内定义了过多局部变量,导致栈帧过大,可分配的栈深度减少。
- 线程数量过多,每个线程都有自己的栈空间,耗尽系统内存。
5.栈中存的到底是指针还是对象?
java
Person p = new Person("张三");
对于上面的代码 JVM 内部做了两件事:
- 在堆中:划出一块内存,创建真正的 Person 对象(包含 name 等实例数据)。
- 在栈中:在当前栈帧的局部变量表里,分配一个槽位(Slot),存储一个指向堆中该对象的地址(即引用/指针)。