跳到主要内容

JVM 内存结构

问题

JVM 的运行时数据区有哪些?堆和栈有什么区别?方法区和元空间是什么关系?

答案

运行时数据区概览

JVM 在执行 Java 程序时,会把内存划分为若干区域,每个区域有不同的用途和生命周期:

1. 程序计数器(Program Counter Register)

  • 线程私有,每个线程都有独立的程序计数器
  • 记录当前线程正在执行的字节码指令地址
  • 如果执行的是 Native 方法,计数器值为 undefined
  • 唯一不会发生 OutOfMemoryError 的区域

2. 虚拟机栈(VM Stack)

每个线程创建时会分配一个虚拟机栈,每个方法调用创建一个栈帧(Stack Frame):

栈帧包含:

组成部分说明
局部变量表存放方法参数和局部变量(基本类型、对象引用、returnAddress)
操作数栈方法执行过程中的操作数临时存储区,字节码指令从这里取数/压数
动态链接指向运行时常量池中该栈帧所属方法的引用,支持多态调用
方法返回地址方法正常返回或异常退出后的恢复点
StackExample.java
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
TLAB(Thread Local Allocation Buffer)

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 # 最大限制(建议设置,防止无限增长)
为什么用元空间替换永久代?
  1. 永久代大小难以确定:类的数量和大小在运行时才能确定,PermSize 设小了容易 OOM,设大了浪费内存
  2. 简化 GC:永久代的 GC 效率低,回收条件苛刻(类卸载条件极为严格)
  3. 与 JRockit 合并:Oracle 收购 BEA 后合并 HotSpot 和 JRockit,JRockit 没有永久代
  4. 字符串常量池移到堆:JDK 7 已将字符串常量池(StringTable)移到堆中,有利于 GC

6. 直接内存(Direct Memory)

  • 不属于 JVM 运行时数据区,但频繁使用
  • 通过 ByteBuffer.allocateDirect() 分配,NIO 中大量使用
  • 避免堆内存与本地内存之间的数据拷贝,提升 I/O 性能
  • 通过 -XX:MaxDirectMemorySize 设置上限
  • 不受 GC 直接管理,回收依赖 Cleaner 机制(基于虚引用)
DirectMemoryExample.java
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 结构)
异常OutOfMemoryErrorStackOverflowError

内存分配流程

一个 new 对象的分配过程:

逃逸分析与栈上分配

JIT 编译器的逃逸分析可以判断对象是否会逃出方法范围:

  • 栈上分配:未逃逸的对象直接在栈上分配,方法结束即销毁
  • 标量替换:将对象拆散为基本类型,直接在栈上分配
  • 锁消除:未逃逸对象的同步锁可被消除

通过 -XX:+DoEscapeAnalysis(JDK 8+ 默认开启)启用。


常见面试问题

Q1: JVM 运行时数据区有哪些?哪些是线程私有的?

答案

JVM 运行时数据区分为 5 个部分:

线程私有(随线程创建/销毁):

  • 程序计数器:记录当前线程执行的字节码行号
  • 虚拟机栈:存储栈帧(局部变量表、操作数栈等)
  • 本地方法栈:服务于 Native 方法

线程共享:

  • :存储对象实例和数组,是 GC 的主要区域
  • 方法区(JDK 8+ 实现为元空间):存储类的元信息、常量池、JIT 编译代码

另外直接内存不属于运行时数据区,但也是常用的内存区域。

Q2: 堆和栈有什么区别?

答案

核心区别:

  1. 存储内容:栈存局部变量和方法调用信息,堆存对象实例
  2. 线程可见性:栈是线程私有的,堆是线程共享的
  3. 生命周期:栈随方法结束自动释放,堆由 GC 管理
  4. 分配速度:栈分配只需移动栈顶指针,非常快;堆分配需要查找空闲空间
  5. 空间大小:栈一般几百 KB 到 1MB,堆可以达到 GB 级

Q3: 方法区和永久代、元空间是什么关系?

答案

  • 方法区是 JVM 规范中定义的逻辑概念
  • 永久代(PermGen)是 JDK 7 及以前 HotSpot 对方法区的具体实现,使用 JVM 堆内存
  • 元空间(Metaspace)是 JDK 8+ HotSpot 对方法区的新实现,使用本地内存

替换原因:永久代大小难以确定、GC 效率低、与 JRockit 合并需要统一实现。

Q4: 什么是栈帧?包含哪些内容?

答案

栈帧是虚拟机栈中的数据结构,每次方法调用创建一个,包含:

  1. 局部变量表:存放方法参数和 this(实例方法)及局部变量
  2. 操作数栈:字节码执行时的临时数据存储区
  3. 动态链接:指向运行时常量池,支持多态方法调用
  4. 方法返回地址:方法执行完毕后的恢复点

方法调用时入栈,方法返回时出栈,只有栈顶的栈帧是"活动"的。

Q5: 什么是 TLAB?为什么需要它?

答案

TLAB(Thread Local Allocation Buffer)是 JVM 为每个线程在 Eden 区预留的一小块私有内存。

为什么需要:堆是线程共享的,多线程同时分配对象需要同步(CAS),影响性能。TLAB 让每个线程在自己的缓冲区内分配对象,避免了竞争。

  • 默认开启(-XX:+UseTLAB
  • TLAB 用完后才需要同步申请新的 TLAB
  • TLAB 是 Eden 的一部分,不是额外的内存

Q6: 对象一定分配在堆上吗?

答案

不一定。JIT 编译器的逃逸分析(Escape Analysis)可以优化对象的分配位置:

  1. 栈上分配:如果对象不会逃逸出方法,可以直接在栈上分配
  2. 标量替换:将对象拆散为独立的基本类型变量,存储在栈上
  3. 实际上 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 exceededGC 占用 98% 时间但只回收 2% 内存
Metaspace元空间动态生成大量类(CGLIB、反射)
Direct buffer memory直接内存NIO 堆外内存不足
unable to create new native threadOS线程数超过系统限制
StackOverflowError递归太深、栈帧太大

详细排查方法可参考 OOM 排查

相关链接