运行时数据区总览
运行时数据区是 JVM 执行 Java 程序时使用的内存模型。理解这些区域,是分析 OOM、StackOverflow、GC、线程问题的基础。
# 1. 运行时数据区分类
JVM 运行时数据区可以分为线程私有和线程共享。
线程私有:
- 程序计数器。
- Java 虚拟机栈。
- 本地方法栈。
线程共享:
- Java 堆。
- 方法区。
- 运行时常量池。
此外,JVM 还会使用直接内存等堆外内存。
# 2. 程序计数器
程序计数器记录当前线程正在执行的字节码指令地址。
特点:
- 线程私有。
- 占用内存很小。
- Java 方法执行时记录字节码位置。
- native 方法执行时值通常未定义。
程序计数器是 JVM 规范中唯一没有规定 OutOfMemoryError 的区域。
# 3. Java 虚拟机栈
每个线程都有自己的 Java 虚拟机栈。每次方法调用都会创建一个栈帧,方法执行结束后栈帧出栈。
栈帧包含:
- 局部变量表。
- 操作数栈。
- 动态链接。
- 方法返回地址。
常见异常:
StackOverflowError:栈深度超过限制。OutOfMemoryError:无法为新线程创建栈或扩展栈。
# 4. 本地方法栈
本地方法栈为 native 方法服务。它和 Java 虚拟机栈类似,只是执行的是本地方法。
例如一些底层 IO、压缩、加密、系统调用可能最终进入 native 代码。
# 5. Java 堆
Java 堆是对象实例和数组的主要分配区域,也是垃圾回收管理的重点。
特点:
- 线程共享。
- 可以被分代或分区管理。
- 大多数对象在堆上分配。
- 常见异常是
OutOfMemoryError: Java heap space。
# 6. 方法区
方法区存放类相关元数据,例如:
- 类名。
- 字段信息。
- 方法信息。
- 常量。
- 静态变量相关数据。
- JIT 编译后的代码缓存不等同于方法区,但也是 JVM 运行时重要内存。
HotSpot 在 JDK 8 后使用元空间实现方法区,元空间使用本地内存。
# 7. 直接内存
直接内存不是 JVM 规范定义的运行时数据区,但在 NIO、Netty、大文件处理等场景中非常重要。
例如:
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
直接内存不在 Java 堆中,但仍可能导致:
OutOfMemoryError: Direct buffer memory
# 8. 阶段小结
JVM 内存不是只有堆。堆负责对象,栈负责方法调用,方法区负责类元数据,程序计数器负责线程执行位置,直接内存负责部分堆外数据。排查 JVM 问题时,必须先判断问题发生在哪个区域。
# 9. 内存区域全景图
JVM 进程内存
├─ 线程共享
│ ├─ Java 堆
│ │ ├─ 新生代 / Region
│ │ └─ 老年代 / Old Region
│ ├─ 方法区(HotSpot JDK8+:元空间)
│ └─ 运行时常量池
│
├─ 线程私有
│ ├─ 程序计数器
│ ├─ Java 虚拟机栈
│ │ └─ 栈帧
│ │ ├─ 局部变量表
│ │ ├─ 操作数栈
│ │ ├─ 动态链接
│ │ └─ 返回地址
│ └─ 本地方法栈
│
└─ 堆外/本地内存
├─ 直接内存
├─ Code Cache
├─ GC 内部结构
├─ native 库
└─ mmap 映射
这个图对排障非常有用:不同错误对应不同区域。
| 错误或现象 | 优先怀疑区域 |
|---|---|
Java heap space | Java 堆 |
Metaspace | 元空间 |
StackOverflowError | 虚拟机栈 |
unable to create native thread | 线程栈、系统线程限制、本地内存 |
Direct buffer memory | 直接内存 |
| 容器 OOMKilled | 进程总内存 |
# 10. 方法执行时各区域如何配合
执行下面代码:
public void service() {
User user = new User();
user.setName("Alice");
}
大致过程:
1. service 方法被调用
-> 当前线程虚拟机栈创建 service 栈帧
2. 执行 new User()
-> 类元数据在元空间中
-> User 对象分配在堆上
3. 局部变量 user
-> 引用保存在栈帧局部变量表
-> 指向堆中的 User 对象
4. 字符串 "Alice"
-> 字符串对象在堆上
-> 字面量相关信息来自常量池
5. 方法结束
-> service 栈帧出栈
-> 如果没有其他引用,User 对象变成可回收
这也解释了一个常见问题:局部变量本身在栈中,但它指向的对象通常在堆中。
# 11. 开发中常见问题
# 11.1 堆很空,为什么容器还 OOMKilled
因为容器限制的是进程总内存。即使堆不满,元空间、直接内存、线程栈、Code Cache、native 内存也可能把进程 RSS 推高。
# 11.2 为什么线程太多会 OOM
每个线程都有自己的栈。线程数越多,线程栈总占用越高,还会受到操作系统线程数限制。
# 11.3 为什么元空间会泄漏
元空间保存类元数据。如果类加载器无法回收,它加载过的类元数据也无法释放。热部署、动态代理、脚本编译常见。
# 12. Tips 快问快答
Q:对象一定在堆上吗?
A:语义上对象在堆上,但 JIT 可能通过逃逸分析和标量替换消除实际对象分配。
Q:局部变量在堆上还是栈上?
A:基本类型局部变量值在栈帧局部变量表中;引用类型局部变量的引用在栈上,实际对象通常在堆上。
Q:方法区是不是永久代?
A:方法区是 JVM 规范概念,永久代是 HotSpot JDK 8 之前的实现,JDK 8 之后主要由元空间实现。
Q:程序计数器为什么线程私有?
A:线程切换后需要恢复到原来的字节码执行位置,因此每个线程都要有自己的程序计数器。