JVM 内存结构
问题
JVM 的运行时数据区有哪些?堆和栈有什么区别?方法区和元空间是什么关系?
答案
运行时数据区概览
JVM 在执行 Java 程序时,会把内存划分为若干区域,每个区域有不同的用途和生命周期:
1. 程序计数器(Program Counter Register)
- 线程私有,每个线程都有独立的程序计数器
- 记录当前线程正在执行的字节码指令地址
- 如果执行的是 Native 方法,计数器值为 undefined
- 唯一不会发生 OutOfMemoryError 的区域
2. 虚拟机栈(VM Stack)
每个线程创建时会分配一个虚拟机栈,每个方法调用创建一个栈帧(Stack Frame):
栈帧包含:
| 组成部分 | 说明 |
|---|---|
| 局部变量表 | 存放方法参数和局部变量(基本类型、对象引用、returnAddress) |
| 操作数栈 | 方法执行过程中的操作数临时存储区,字节码指令从这里取数/压数 |
| 动态链接 | 指向运行时常量池中该栈帧所属方法的引用,支持多态调用 |
| 方法返回地址 | 方法正常返回或异常退出后的恢复点 |
public class StackExample {
// 调用 main → methodA → methodB,形成 3 个栈帧
public static void main(String[] args) {
int result = methodA(10);
}
static int methodA(int x) {
int y = 20;
return methodB(x + y);
}
static int methodB(int sum) {
return sum * 2;
}
}
- StackOverflowError:栈深度超过限制(如无限递归)
- OutOfMemoryError:无法申请到足够内存创建新线程的栈
- 通过
-Xss参数设置每个线程的栈大小(默认一般为 512KB ~ 1MB)
3. 本地方法栈(Native Method Stack)
- 与虚拟机栈类似,但服务于 Native 方法(如用 C/C++ 编写的 JNI 方法)
- HotSpot 虚拟机将本地方法栈和虚拟机栈合二为一
- 同样可能抛出 StackOverflowError 和 OutOfMemoryError
4. 堆(Heap)
堆是 JVM 内存中最大的一块,被所有线程共享,几乎所有对象和数组都在堆上分配。
分代设计(经典分代,G1 之前):
| 区域 | 比例 | 说明 |
|---|---|---|
| Eden | 约 80% 新生代 | 新对象首先分配在 Eden |
| Survivor 0/1 | 各约 10% 新生代 | Minor GC 后存活对象复制到此,两个 Survivor 交替使用 |
| 老年代 | 堆的 2/3(默认) | 长期存活的对象(Age 达到阈值)、大对象 |
关键 JVM 参数:
-Xms512m # 堆初始大小
-Xmx1024m # 堆最大大小
-Xmn256m # 新生代大小
-XX:SurvivorRatio=8 # Eden:Survivor = 8:1:1
-XX:NewRatio=2 # 老年代:新生代 = 2:1
JVM 为每个线程在 Eden 区划分一小块私有区域(TLAB),对象优先在 TLAB 中分配,避免多线程竞争堆内存的同步开销,提升分配性能。通过 -XX:+UseTLAB(默认开启)控制。
5. 方法区(Method Area)/ 元空间(Metaspace)
方法区是 JVM 规范中定义的概念,存储类的元信息:
| 存储内容 | 说明 |
|---|---|
| 类信息 | 类名、父类、接口、访问修饰符 |
| 字段信息 | 字段名、类型、修饰符 |
| 方法信息 | 方法名、参数、字节码 |
| 运行时常量池 | 字面量、符号引用 |
| JIT 编译代码 | 即时编译器编译后的本地代码 |
永久代 → 元空间的演变:
| 版本 | 实现 | 内存位置 | 大小限制 |
|---|---|---|---|
| JDK 7 及以前 | 永久代(PermGen) | JVM 堆内存的一部分 | -XX:MaxPermSize 限制,容易 OOM |
| JDK 8 及以后 | 元空间(Metaspace) | 本地内存(Native Memory) | 默认不限,受物理内存限制 |
# JDK 7: 永久代参数
-XX:PermSize=128m
-XX:MaxPermSize=256m
# JDK 8+: 元空间参数
-XX:MetaspaceSize=128m # 初始阈值,达到后触发 Full GC
-XX:MaxMetaspaceSize=256m # 最大限制(建议设置,防止无限增长)
- 永久代大小难以确定:类的数量和大小在运行时才能确定,PermSize 设小了容易 OOM,设大了浪费内存
- 简化 GC:永久代的 GC 效率低,回收条件苛刻(类卸载条件极为严格)
- 与 JRockit 合并:Oracle 收购 BEA 后合并 HotSpot 和 JRockit,JRockit 没有永久代
- 字符串常量池移到堆:JDK 7 已将字符串常量池(StringTable)移到堆中,有利于 GC
6. 直接内存(Direct Memory)
- 不属于 JVM 运行时数据区,但频繁使用
- 通过
ByteBuffer.allocateDirect()分配,NIO 中大量使用 - 避免堆内存与本地内存之间的数据拷贝,提升 I/O 性能
- 通过
-XX:MaxDirectMemorySize设置上限 - 不受 GC 直接管理,回收依赖
Cleaner机制(基于虚引用)
import java.nio.ByteBuffer;
public class DirectMemoryExample {
public static void main(String[] args) {
// 在堆外分配 1MB 直接内存
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024 * 1024);
// 写入数据
directBuffer.put((byte) 1);
// 堆内存分配(对比)
ByteBuffer heapBuffer = ByteBuffer.allocate(1024 * 1024);
}
}
堆 vs 栈 对比
| 特性 | 堆(Heap) | 栈(Stack) |
|---|---|---|
| 线程共享 | 共享 | 私有 |
| 存储内容 | 对象实例、数组 | 局部变量、方法调用信息 |
| 生命周期 | GC 回收时释放 | 方法结束自动释放 |
| 分配速度 | 慢(需要 GC 管理) | 快(指针移动) |
| 空间大小 | 大(GB 级) | 小(默认几百 KB) |
| 碎片问题 | 有(需要整理) | 无(LIFO 结构) |
| 异常 | OutOfMemoryError | StackOverflowError |
内存分配流程
一个 new 对象的分配过程:
JIT 编译器的逃逸分析可以判断对象是否会逃出方法范围:
- 栈上分配:未逃逸的对象直接在栈上分配,方法结束即销毁
- 标量替换:将对象拆散为基本类型,直接在栈上分配
- 锁消除:未逃逸对象的同步锁可被消除
通过 -XX:+DoEscapeAnalysis(JDK 8+ 默认开启)启用。
常见面试问题
Q1: JVM 运行时数据区有哪些?哪些是线程私有的?
答案:
JVM 运行时数据区分为 5 个部分:
线程私有(随线程创建/销毁):
- 程序计数器:记录当前线程执行的字节码行号
- 虚拟机栈:存储栈帧(局部变量表、操作数栈等)
- 本地方法栈:服务于 Native 方法
线程共享:
- 堆:存储对象实例和数组,是 GC 的主要区域
- 方法区(JDK 8+ 实现为元空间):存储类的元信息、常量池、JIT 编译代码
另外直接内存不属于运行时数据区,但也是常用的内存区域。
Q2: 堆和栈有什么区别?
答案:
核心区别:
- 存储内容:栈存局部变量和方法调用信息,堆存对象实例
- 线程可见性:栈是线程私有的,堆是线程共享的
- 生命周期:栈随方法结束自动释放,堆由 GC 管理
- 分配速度:栈分配只需移动栈顶指针,非常快;堆分配需要查找空闲空间
- 空间大小:栈一般几百 KB 到 1MB,堆可以达到 GB 级
Q3: 方法区和永久代、元空间是什么关系?
答案:
- 方法区是 JVM 规范中定义的逻辑概念
- 永久代(PermGen)是 JDK 7 及以前 HotSpot 对方法区的具体实现,使用 JVM 堆内存
- 元空间(Metaspace)是 JDK 8+ HotSpot 对方法区的新实现,使用本地内存
替换原因:永久代大小难以确定、GC 效率低、与 JRockit 合并需要统一实现。
Q4: 什么是栈帧?包含哪些内容?
答案:
栈帧是虚拟机栈中的数据结构,每次方法调用创建一个,包含:
- 局部变量表:存放方法参数和
this(实例方法)及局部变量 - 操作数栈:字节码执行时的临时数据存储区
- 动态链接:指向运行时常量池,支持多态方法调用
- 方法返回地址:方法执行完毕后的恢复点
方法调用时入栈,方法返回时出栈,只有栈顶的栈帧是"活动"的。
Q5: 什么是 TLAB?为什么需要它?
答案:
TLAB(Thread Local Allocation Buffer)是 JVM 为每个线程在 Eden 区预留的一小块私有内存。
为什么需要:堆是线程共享的,多线程同时分配对象需要同步(CAS),影响性能。TLAB 让每个线程在自己的缓冲区内分配对象,避免了竞争。
- 默认开启(
-XX:+UseTLAB) - TLAB 用完后才需要同步申请新的 TLAB
- TLAB 是 Eden 的一部分,不是额外的内存
Q6: 对象一定分配在堆上吗?
答案:
不一定。JIT 编译器的逃逸分析(Escape Analysis)可以优化对象的分配位置:
- 栈上分配:如果对象不会逃逸出方法,可以直接在栈上分配
- 标量替换:将对象拆散为独立的基本类型变量,存储在栈上
- 实际上 HotSpot 主要通过标量替换实现,而非真正的栈上分配
// 对象 point 不会逃逸,可以被标量替换
public int calculate() {
Point point = new Point(1, 2);
return point.x + point.y;
// 优化后等价于:
// int x = 1; int y = 2; return x + y;
}
Q7: 直接内存(堆外内存)是什么?什么时候使用?
答案:
直接内存不属于 JVM 运行时数据区,而是通过 NIO 的 ByteBuffer.allocateDirect() 在本地内存中分配。
使用场景:
- NIO 操作:避免堆内存和本地内存之间的数据拷贝
- 大块内存缓存:如网络框架(Netty)的 ByteBuf
- 内存映射文件:MappedByteBuffer
优点:I/O 性能好,不受 GC 影响
缺点:分配和释放比堆内存慢,不受 GC 直接管理
Q8: String 常量池在哪里?不同 JDK 版本有什么变化?
答案:
| JDK 版本 | 字符串常量池位置 |
|---|---|
| JDK 6 | 永久代(PermGen) |
| JDK 7+ | 堆(Heap) |
JDK 7 将字符串常量池从永久代移到堆中,主要原因是永久代 GC 频率低,大量使用 String.intern() 容易导致永久代 OOM。放在堆中可以享受更频繁的 GC。
Q9: 如何设置 JVM 各区域的内存大小?
答案:
# 堆内存
-Xms512m # 初始堆大小
-Xmx1024m # 最大堆大小(建议与 -Xms 相同,避免动态扩缩)
# 新生代
-Xmn256m # 新生代大小
-XX:NewRatio=2 # 老年代:新生代 = 2:1
-XX:SurvivorRatio=8 # Eden:S0:S1 = 8:1:1
# 虚拟机栈
-Xss256k # 每个线程的栈大小
# 元空间
-XX:MetaspaceSize=128m
-XX:MaxMetaspaceSize=256m
# 直接内存
-XX:MaxDirectMemorySize=256m
Q10: JVM 内存相关的 OOM 有哪些?
答案:
| OOM 类型 | 发生区域 | 常见原因 |
|---|---|---|
Java heap space | 堆 | 对象过多、内存泄漏 |
GC overhead limit exceeded | 堆 | GC 占用 98% 时间但只回收 2% 内存 |
Metaspace | 元空间 | 动态生成大量类(CGLIB、反射) |
Direct buffer memory | 直接内存 | NIO 堆外内存不足 |
unable to create new native thread | OS | 线程数超过系统限制 |
StackOverflowError | 栈 | 递归太深、栈帧太大 |
详细排查方法可参考 OOM 排查。