引言:为什么要理解JVM内存结构?
当你遇到以下问题时,是否感到困惑:
- StackOverflowError 和 OutOfMemoryError 有什么区别?
- 为什么有的对象存储在堆中,有的却在栈中?
- 静态变量存储在哪里?方法代码又存储在哪里?
- 为什么多线程之间可以共享对象,却不能共享局部变量?
这些问题的答案,都藏在 JVM内存结构 中。
理解JVM内存结构是深入学习JVM的基础:
- 性能调优:知道内存如何分配,才能优化内存参数
- 问题排查:90%的内存问题与内存区域的使用不当相关
- 代码优化:理解对象存储位置,才能写出高效代码
本文将带你建立JVM内存结构的全景认知,为后续深入学习每个区域打下基础。
JVM内存结构全景图
运行时数据区概览
当Java程序运行时,JVM会将内存划分为不同的数据区域,这些区域统称为 运行时数据区(Runtime Data Area)。
根据《Java虚拟机规范》,JVM运行时数据区包括以下5大区域:
┌─────────────────────────────────────────────────────────┐
│ JVM 运行时数据区 │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ 程序计数器 │ │ 程序计数器 │ 线程私有 │
│ │ (PC Register) │ │ (PC Register) │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ 虚拟机栈 │ │ 虚拟机栈 │ 线程私有 │
│ │ (VM Stack) │ │ (VM Stack) │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ 本地方法栈 │ │ 本地方法栈 │ 线程私有 │
│ │ (Native Stack) │ │ (Native Stack) │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────┐ │
│ │ 堆内存 │ 线程共享 │
│ │ (Heap) │ │
│ │ │ │
│ │ ┌─────────────────┐ │ │
│ │ │ 新生代 (Young) │ │ │
│ │ │ Eden + S0 + S1 │ │ │
│ │ └─────────────────┘ │ │
│ │ ┌─────────────────┐ │ │
│ │ │ 老年代 (Old) │ │ │
│ │ └─────────────────┘ │ │
│ └───────────────────────┘ │
│ │
│ ┌───────────────────────┐ │
│ │ 方法区 │ 线程共享 │
│ │ (Method Area) │ │
│ │ │ │
│ │ · 类信息 │ │
│ │ · 常量池 │ │
│ │ · 静态变量 │ │
│ │ · JIT编译后的代码 │ │
│ └───────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
┌───────────────────────┐
│ 直接内存 │ (堆外内存)
│ (Direct Memory) │
│ │
│ · NIO Buffer │
│ · Netty Zero-Copy │
└───────────────────────┘
关键特征:
- 线程私有:程序计数器、虚拟机栈、本地方法栈(每个线程独立一份)
- 线程共享:堆、方法区(所有线程共享)
- 堆外内存:直接内存(不属于JVM规范,但实际使用广泛)
5大内存区域详解
1️⃣ 程序计数器(Program Counter Register)
定义:记录当前线程正在执行的字节码指令地址。
核心特点:
- 线程私有(每个线程独立一个)
- 占用内存极小(通常几十字节)
- 唯一不会发生OutOfMemoryError的区域
- 如果执行Native方法,计数器值为空(Undefined)
作用:
- 线程切换后能恢复到正确的执行位置
- 多线程情况下,记录每个线程的执行进度
示例理解:
public void method() {
int a = 1; // 字节码指令地址:0
int b = 2; // 字节码指令地址:2
int c = a + b; // 字节码指令地址:4
}
程序计数器会依次存储 0 → 2 → 4,指示当前执行到哪一行字节码。
2️⃣ 虚拟机栈(Java Virtual Machine Stack)
定义:每个方法执行时创建一个 栈帧(Stack Frame),用于存储局部变量、操作数、方法出口等信息。
核心特点:
- 线程私有
- 生命周期与线程相同
- 栈深度受限(默认1MB,可通过
-Xss调整) - 后进先出(LIFO) 的数据结构
栈帧结构:
┌─────────────────────┐
│ 栈帧3 │ ← 当前方法(栈顶)
├─────────────────────┤
│ 栈帧2 │
├─────────────────────┤
│ 栈帧1 │ ← main方法
└─────────────────────┘
每个栈帧包含:
- 局部变量表:存储方法参数和局部变量
- 操作数栈:进行算术运算和方法调用
- 动态链接:指向运行时常量池的方法引用
- 方法返回地址:方法正常退出或异常退出的位置
可能的异常:
StackOverflowError:栈深度超过限制(如无限递归)OutOfMemoryError:无法分配新的栈内存(极少见)
3️⃣ 本地方法栈(Native Method Stack)
定义:为Native方法(使用JNI调用的C/C++方法)服务的栈。
核心特点:
- 线程私有
- 与虚拟机栈作用类似,但服务于Native方法
- HotSpot虚拟机中,虚拟机栈和本地方法栈合二为一
典型Native方法示例:
// Object类的hashCode方法是Native方法
public native int hashCode();
// System类的currentTimeMillis方法也是Native方法
public static native long currentTimeMillis();
可能的异常:
StackOverflowErrorOutOfMemoryError
4️⃣ 堆(Heap)
定义:JVM管理的最大内存区域,用于存储几乎所有的对象实例。
核心特点:
- 线程共享(所有线程访问同一个堆)
- JVM启动时创建,进程结束时销毁
- 垃圾收集器的主要工作区域
- 可通过
-Xms(初始大小)和-Xmx(最大大小)调整
分代设计(JDK 8及之前):
┌─────────────────────────────────────┐
│ 堆内存 (Heap) │
├─────────────────────────────────────┤
│ 新生代 (Young Generation) - 1/3 │
│ ┌──────────┬──────┬──────┐ │
│ │ Eden │ S0 │ S1 │ │
│ │ 8 │ 1 │ 1 │ │
│ └──────────┴──────┴──────┘ │
├─────────────────────────────────────┤
│ 老年代 (Old Generation) - 2/3 │
│ │
└─────────────────────────────────────┘
分代原因:
- 弱分代假说:大部分对象朝生夕死
- 强分代假说:熬过多次GC的对象难以消亡
- 通过分代,可以针对不同区域使用不同的GC策略
可能的异常:
OutOfMemoryError: Java heap space(堆内存溢出)
5️⃣ 方法区(Method Area)
定义:存储已被虚拟机加载的 类信息、常量、静态变量、JIT编译后的代码 等数据。
核心特点:
- 线程共享
- 也称为"非堆"(Non-Heap),但本质上仍属于堆外内存
- JDK 7及之前称为"永久代"(PermGen)
- JDK 8及之后改为"元空间"(Metaspace),使用本地内存
存储内容:
| 数据类型 | 说明 | 示例 |
|---|---|---|
| 类信息 | 类的版本、字段、方法、接口 | java.lang.String 类的元数据 |
| 运行时常量池 | 字面量、符号引用 | 字符串常量 "hello" |
| 静态变量 | 类变量 | public static int count = 0; |
| JIT编译代码 | 即时编译器编译的机器码 | 热点方法的本地代码 |
永久代 → 元空间的演变:
- JDK 7及之前:方法区使用永久代实现,容易发生
OutOfMemoryError: PermGen space - JDK 8及之后:移除永久代,改用元空间(使用本地内存),动态扩展,减少OOM风险
可能的异常:
OutOfMemoryError: Metaspace(元空间溢出,JDK 8+)OutOfMemoryError: PermGen space(永久代溢出,JDK 7及之前)
🔄 直接内存(Direct Memory)
定义:不属于JVM规范定义的内存区域,但在NIO、Netty等场景下广泛使用的 堆外内存。
核心特点:
- 不受JVM堆大小限制
- 不受GC管理(手动管理或通过Cleaner机制回收)
- 通过
-XX:MaxDirectMemorySize限制大小 - 读写性能更高(避免Java堆与本地内存之间的复制)
典型使用场景:
// NIO的DirectByteBuffer使用直接内存
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
// Netty的零拷贝技术也依赖直接内存
PooledByteBufAllocator.DEFAULT.directBuffer();
优势:
- 避免Java堆与本地内存之间的数据复制
- 提升IO性能(零拷贝)
可能的异常:
OutOfMemoryError: Direct buffer memory
线程私有 vs 线程共享
为什么要区分线程私有和共享?
线程私有区域(程序计数器、虚拟机栈、本地方法栈):
- 目的:保证线程安全,避免数据竞争
- 生命周期:与线程相同(线程启动时创建,线程结束时销毁)
- 典型存储:局部变量、方法调用链、字节码指令地址
线程共享区域(堆、方法区):
- 目的:实现线程间数据共享
- 生命周期:与JVM进程相同
- 典型存储:对象实例、类信息、静态变量
- 并发风险:需要考虑线程安全问题
示例:线程私有与共享的区别
public class MemoryDemo {
private static int sharedVar = 100; // 存储在方法区(线程共享)
public static void main(String[] args) {
Object obj = new Object(); // obj引用在栈中,对象实例在堆中(共享)
int localVar = 10; // 存储在栈中(线程私有)
new Thread(() -> {
// 每个线程有独立的栈,localVar不可见
// 但可以访问堆中的obj对象和方法区的sharedVar
System.out.println(obj);
System.out.println(sharedVar);
}).start();
}
}
内存分布:
线程1栈 线程2栈
┌──────────┐ ┌──────────┐
│ localVar │ │ (空) │
│ = 10 │ │ │
└──────────┘ └──────────┘
↓ ↓
┌────────────────────────────────────┐
│ 堆内存 (共享) │
│ new Object() 对象实例 │
└────────────────────────────────────┘
↓
┌────────────────────────────────────┐
│ 方法区 (共享) │
│ sharedVar = 100 │
└────────────────────────────────────┘
常见问题与误区
❌ 误区1:所有对象都存储在堆中
真相:绝大多数对象存储在堆中,但有例外:
- 逃逸分析优化:如果对象不会逃逸出方法,JIT编译器可能将其分配在 栈上(栈上分配)
- 标量替换:如果对象可以拆解为基本类型,可能直接在栈中存储
// 对象未逃逸,可能在栈上分配
public void method() {
Object obj = new Object(); // obj仅在方法内使用,不会逃逸
System.out.println(obj);
}
❌ 误区2:方法区不会发生GC
真相:方法区也会进行垃圾回收,但效率较低:
- 回收目标:废弃的常量、无用的类
- 条件苛刻:类的所有实例已回收、类加载器已回收、类对应的Class对象无引用
❌ 误区3:静态变量存储在方法区
真相:
- JDK 7及之前:静态变量存储在方法区(永久代)
- JDK 7及之后:静态变量随Class对象存储在 堆 中
总结
核心要点
JVM运行时数据区包括5大区域:
- 程序计数器:记录字节码指令地址(线程私有)
- 虚拟机栈:存储方法调用链和局部变量(线程私有)
- 本地方法栈:为Native方法服务(线程私有)
- 堆:存储对象实例(线程共享,GC主战场)
- 方法区:存储类信息、常量、静态变量(线程共享)
线程私有区域保证线程安全,线程共享区域实现数据共享
直接内存不属于JVM规范,但在高性能场景下广泛使用
理解内存结构是性能调优和问题排查的基础
与下篇文章的衔接
下一篇文章,我们将深入学习 程序计数器(PC Register),这个JVM中最小但至关重要的内存区域,理解它在多线程环境中的作用。
参考资料
- 《深入理解Java虚拟机(第3版)》- 周志明
- Java虚拟机规范(Java SE 8版)
- OpenJDK官方文档
下一篇预告:《程序计数器:最小的内存区域》 深入理解PC寄存器在多线程环境中的作用,以及为什么它是唯一不会OOM的区域。