主题
JVM 总览与运行时数据区
本文是 JVM & GC 系统学习系列的 L1 入门篇。前置:无。 相关文章:JVM 内存区域
JVM 解决什么问题
C 语言编译出来的 exe 只能跑在单一平台,换系统得重新编译。Java 的解法是在源码和机器码之间加一层中间格式:javac 把 .java 编译成平台无关的字节码,各平台的 JVM 负责把它翻译成本机机器指令。“一次编写,到处运行”的关键就在这层中间格式。
三层关系要分清:
- JDK = JRE + 开发工具链(javac、jdb、jstack、javap)
- JRE = JVM + 核心类库(java.lang、java.util 这些)
- JVM = 只负责执行字节码的虚拟机实例
排查线上问题用的 jstack、jmap 都来自 JDK,所以生产环境实际部署的也基本是 JDK。
运行时数据区全景
JVM 跑起来之后,它管理的内存按"线程私有还是共享"分成两派:
mermaid
graph TB
JVM["JVM 进程内存"]
JVM --> PRIV["线程私有(每线程一份,随线程生灭)"]
JVM --> SHARED["线程共享(整个 JVM 一份,GC 主战场)"]
PRIV --> PC["程序计数器"]
PRIV --> VSTACK["虚拟机栈\n(Java 方法)"]
PRIV --> NSTACK["本地方法栈\n(native 方法,HotSpot 与虚拟机栈合一)"]
SHARED --> HEAP["堆\n(对象实例、数组)"]
SHARED --> METASPACE["方法区 / 元空间\n(类元数据、运行时常量池)"]程序计数器、两个栈随线程生灭,不需要 GC 介入;堆和方法区全局共享,对象和类元数据的回收全靠垃圾收集器,也是后面十几篇的主角。
堆与方法区:对象和类住哪
堆是对象实例和数组分配的主战场,也是 GC 工作最密集的区域,后面的分代收集、G1 的 Region 切分都在堆上做文章。例外是 JIT 逃逸分析能让未逃逸对象栈上分配,留到 JIT 篇展开。
方法区存类的元数据:类名、字段描述、方法字节码、运行时常量池。它的实现方式有一段演进史,踩过这个坑的人不少:
- JDK 7 及以前:用"永久代"(PermGen)实现,物理上位于堆的一部分,用
-XX:PermSize/-XX:MaxPermSize控制 - JDK 8 起:改成元空间(Metaspace),使用本地内存,默认不设上限,需要的话用
-XX:MaxMetaspaceSize限制
废弃永久代的原因很实际:它的大小要人为预估,动态生成类多的应用(CGLib、大量 JSP)经常把它撑爆抛 PermGen space OOM。元空间用本地内存,能用到多大占多大,这类问题少了很多。
程序计数器和两兄弟栈
程序计数器记录当前线程执行到的字节码行号。线程被切出再切回时,靠它记住刚才执行到哪条指令,所以每个线程一份。它是唯一不会抛 OOM 的区域。
虚拟机栈是 Java 方法执行的载体:每个方法被调用就压入一个栈帧,返回就弹出。它有两种死法:
StackOverflowError:栈帧数量超过栈深度,典型是递归没写终止条件OutOfMemoryError:栈扩展时申请不到内存(少见,通常 -Xss 设得极小或系统内存耗尽)
本地方法栈给 native 方法用,作用和虚拟机栈一样,HotSpot 直接把两者合二为一。
栈帧里面长什么样:javap 看一眼
栈帧有四块:局部变量表、操作数栈、动态链接、方法返回地址。空讲没感觉,写个方法反编译一下:
java
public class FrameDemo {
public int add(int a, int b) {
int c = a + b;
return c;
}
}bash
javac FrameDemo.java && javap -c FrameDemo输出:
text
public int add(int, int);
Code:
0: iload_1 // 局部变量表 slot 1(参数 a)压入操作数栈
1: iload_2 // slot 2(参数 b)压入操作数栈
2: iadd // 弹出栈顶两个 int 相加,结果压回栈
3: istore_3 // 栈顶结果存入 slot 3(局部变量 c)
4: iload_3 // c 压栈
5: ireturn // 返回栈顶 intslot 0 被 this 占着,参数 a、b 落在 slot 1、2。字节码执行模型就是一台基于操作数栈的计算机:数据压栈、指令消费栈顶、结果回栈。
动态链接是指栈帧里持有指向运行时常量池中该方法所属类型的引用。它的价值在多态:invokevirtual 指令执行时根据对象的实际类型确定方法版本,而不是编译期写死的那个。
直接内存:不在五区内,但常背锅
直接内存不属于 JVM 规范定义的运行时数据区,但 NIO 的 DirectByteBuffer 会大量使用它。好处是省掉一次堆内存到 socket 缓冲区的复制。坑在于它不受 -Xmx 管辖(另有 -XX:MaxDirectMemorySize),泄漏症状是堆很健康、进程 RSS 却一路涨,得靠 NMT 定位。具体的排查手法在直接内存排查篇有完整演示。
动手实操
实验一:制造三种内存异常,认脸。
java
import java.util.ArrayList;
import java.util.List;
public class OOMLab {
// 1. 堆 OOM:java -Xmx16m OOMLab heap
static void heapOOM() {
List<byte[]> list = new ArrayList<>();
while (true) {
list.add(new byte[1024 * 1024]); // 每次挂 1MB,不放手
}
}
// 2. 栈溢出:java -Xss160k OOMLab soe
static void recursion(int depth) {
recursion(depth + 1); // 没有出口,栈帧一路压到崩
}
public static void main(String[] args) {
if ("heap".equals(args[0])) heapOOM();
if ("soe".equals(args[0])) recursion(0);
}
}堆 OOM 报 Java heap space,栈溢出报 StackOverflowError。第三种元空间 OOM 要靠 CGLib 之类不停生成新类才能触发,业务代码碰不到,知道 -XX:MaxMetaspaceSize 能限制它即可。
实验二:把栈帧字节数和实际对上。
把 add 的参数加到六个以上再 javap -c,会发现 slot 编号 3 之后的加载指令从 iload_n 变成带操作数的 iload n--只有 slot 0-3 有单字节快捷指令。这能帮你建立“局部变量表就是数组下标寻址”的直觉。
常见误区与小结
- "StackOverflowError 调大 -Xss 就好了"--治标不治本,无限递归给多大栈都会炸,先检查递归终止条件
- "方法区就是永久代"--JDK 8 起是元空间,在本地内存,PermSize 参数已失效,别在简历里写 JDK 8 的 PermGen 调优
- "所有对象都在堆上分配"--默认路径是堆,但 JIT 逃逸分析 + 标量替换可以让未逃逸对象栈上分配
- "进程内存涨 = 堆泄漏"--元空间、直接内存、线程栈、JIT 代码缓存都在堆外,RSS 涨先看 NMT 再下结论
- "程序计数器没用"--线程切换后恢复执行位置全靠它,没有它多线程就是玄学
小结:这篇把 JVM 内存布局认了个全--线程私有的计数器和两个栈、共享的堆和方法区、编外的直接内存。后面的 GC 篇讲堆里的生死判定,类加载篇讲方法区里的类从哪来,JIT 篇讲栈上的优化。下一篇进入类加载子系统,看一个 .class 文件从磁盘到方法区要过哪几道关卡。
参考
- 《Java 虚拟机规范(Java SE)》第 2 章:Run-Time Data Areas
- 周志明《深入理解 Java 虚拟机(第 3 版)》第 2 章