JVM 内存模型

从这一章开始,我们进入 第九阶段:JVM 深度。前面八个阶段讲的都是”用 Java 写程序”——语法、API、并发、IO。这一阶段我们钻进 JVM 内部,看 Java 程序”跑起来”时到底在内存里发生了什么。这是 Java 工程师的”内功”——会写 Java 的人很多,懂 JVM 的少;懂 JVM 才能真正调性能、查问题、面试硬气。

我们从最基础的 JVM 内存模型 开始——它定义了 JVM 在运行 Java 程序时,内存被分成哪些区域,每个区域存什么。这是后面所有章节(GC、类加载、调优)的基础。

一、运行时数据区:JVM 规范的”五区”

Java 虚拟机规范(JVM Specification) 定义了 JVM 运行时的内存布局,叫运行时数据区(Runtime Data Areas)。规范定义了五个区域:

区域线程私有?存什么OOM 风险
程序计数器(PC Register)当前线程执行的字节码行号不会 OOM
虚拟机栈(VM Stack)方法调用的栈帧可能 StackOverflowError/OOM
本地方法栈(Native Method Stack)native 方法的栈帧可能 StackOverflowError/OOM
堆(Heap)对象实例、数组主要 OOM 来源
方法区(Method Area)类元数据、常量池、静态变量可能 OOM(元空间溢出)

外加一个不算”规范定义”但实际存在的——直接内存(Direct Memory),被 NIO 使用。

我们一个一个看。

二、程序计数器(PC Register)

2.1 是什么

程序计数器(Program Counter Register,PC) 是一块很小的内存区域,记录当前线程正在执行的字节码指令地址

类比:人读书用书签记录”读到哪一行”——PC 就是线程的”书签”。

2.2 特点

  • 线程私有——每个线程一个 PC,互不干扰。
  • 不会 OOM——太小,规范明确规定 PC 不会有 OOM。
  • 如果执行 native 方法,PC 是 undefined——native 方法不是字节码,没有”行号”概念。

2.3 为什么必须线程私有

多线程切换时,CPU 把当前线程挂起、跑别的线程,过会儿再切回来——切回来时怎么知道从哪继续?靠 PC。如果 PC 是共享的,线程 A 切回来会跑到线程 B 的代码——逻辑全乱了。线程私有 PC 是多线程”轮转”的基础。

三、虚拟机栈(VM Stack)与栈帧

3.1 虚拟机栈是什么

虚拟机栈(VM Stack)线程私有的,描述Java 方法调用的内存模型。每个方法被调用时,会创建一个栈帧(Stack Frame) 压入栈;方法返回时,栈帧弹出。

线程 A 的虚拟机栈:
┌─────────────────┐
│ main 的栈帧      │  ← 栈底
├─────────────────┤
│ methodA 的栈帧   │
├─────────────────┤
│ methodB 的栈帧   │  ← 栈顶 (当前执行)
└─────────────────┘

3.2 栈帧的结构

每个栈帧包含:

  • 局部变量表(Local Variable Table) —— 存方法参数和方法内局部变量。基本类型直接存值,引用类型存引用(指向堆)。
  • 操作数栈(Operand Stack) —— 字节码指令的”工作区”。比如 a + b 要把 a、b 压入操作数栈,再 iadd 弹出求和压回。
  • 动态链接(Dynamic Linking) —— 指向运行时常量池中该方法的引用,支持动态分派。
  • 方法返回地址 —— 方法结束后跳回调用者的 PC 位置。
  • 附加信息 —— 调试、注解等。

3.3 局部变量表

局部变量表是数组结构,每个”槽(slot)“32 位。long/double 占两个槽,其他类型占一个槽。

public int add(int a, int b) {
    int c = a + b;
    return c;
}
// 局部变量表:
// slot 0: this (实例方法隐含 this)
// slot 1: a
// slot 2: b
// slot 3: c

3.4 StackOverflowError 与 OOM

虚拟机栈有两种异常:

  • StackOverflowError —— 栈深度超过限制(递归调用太深)。默认栈大小 512KB-1MB,可以用 -Xss 调整。
  • OutOfMemoryError —— 栈扩展时内存不够。
public static void recurse(int n) {
    recurse(n + 1);   // 无限递归, 必然 StackOverflowError
}

3.5 调栈大小

java -Xss512k MyProgram   # 每个线程栈 512KB
java -Xss2m MyProgram     # 2MB

栈越大,能支持的递归越深,但能开的线程数越少(总内存固定)。栈越小,递归越浅,但能开更多线程——这是栈大小的权衡。

四、本地方法栈(Native Method Stack)

本地方法栈(Native Method Stack) 和虚拟机栈几乎一样,区别只在于——虚拟机栈服务 Java 方法,本地方法栈服务 native 方法(C/C++ 实现的方法,如 Object.hashCodeSystem.currentTimeMillis 等)。

有些 JVM(如 HotSpot)把虚拟机栈和本地方法栈合并成一个——规范允许这么做。异常也是 StackOverflowErrorOutOfMemoryError

五、堆(Heap):对象的家

5.1 堆是什么

堆(Heap) 是 JVM 最大的一块内存,所有线程共享几乎所有的对象实例和数组都在堆上分配(逃逸分析可能栈上分配,但绝大多数还是堆)。

堆是 GC(垃圾回收)的主战场——后面整章讲 GC,这里先看堆的结构。

5.2 堆的分代结构

现代 JVM 把堆分成新生代(Young Generation)老年代(Old Generation),因为”绝大多数对象朝生夕死”,分代让 GC 更高效。

堆 (Heap)
├── 新生代 (Young Gen) - 约 1/3 堆
│   ├── Eden (约 8/10 新生代)
│   ├── Survivor 0 (约 1/10)
│   └── Survivor 1 (约 1/10)
└── 老年代 (Old Gen) - 约 2/3 堆

Eden : Survivor0 : Survivor1 = 8 : 1 : 1,这是默认比例(-XX:SurvivorRatio=8)。

5.3 对象的生命周期

新对象先在 Eden 分配。Eden 满了触发 Minor GC(小型垃圾回收)

  1. Eden 中存活的对象复制到 S0(Survivor 0)。
  2. Eden 清空。
  3. 下次 Minor GC,Eden + S0 存活的对象复制到 S1。
  4. S0 清空。
  5. 下次 Minor GC,Eden + S1 存活的对象复制到 S0。
  6. 如此往复,S0、S1 来回复制。

每次 Minor GC 后还存活的对象,年龄 +1。年龄达到阈值(默认 15,-XX:MaxTenuringThreshold)就晋升到老年代

大对象(如大数组)直接进老年代——避免在 Survivor 间来回复制开销大(-XX:PretenureSizeThreshold 控制)。

5.4 老年代

老年代存放长期存活的对象——缓存、单例、长期持有的集合。老年代 GC 频率低,但单次耗时长。

老年代满了触发 Major GC / Full GC——比 Minor GC 慢得多,会 STW(Stop The World,暂停所有应用线程)。

5.5 堆的参数

-Xms2g           # 堆初始大小 2GB
-Xmx4g           # 堆最大大小 4GB
-Xmn1g           # 新生代大小 1GB
-XX:NewRatio=2   # 老年代:新生代 = 2:1
-XX:SurvivorRatio=8   # Eden:S = 8:1
-XX:MaxTenuringThreshold=15   # 晋升年龄

-Xms-Xmx 设一样大可以避免堆动态扩展的开销——生产环境的常见配置。

六、方法区(Method Area)与元空间(Metaspace)

6.1 方法区是什么

方法区(Method Area) 也是线程共享的,存储:

  • 类的元数据——类名、父类、接口、字段、方法、字节码。
  • 运行时常量池——类编译时的常量池加载后的运行时版本。
  • 静态变量——static 字段。
  • JIT 编译后的本地代码(部分实现)。

方法区是 JVM 规范定义的逻辑区域,不同 JVM 实现不同:

JDK 版本方法区实现位置
JDK 7 及以前永久代(PermGen)堆内
JDK 8+元空间(Metaspace)堆外(本地内存)

6.2 永久代为什么被废

JDK 8 之前用永久代(PermGen) 实现方法区,问题:

  • 大小固定——-XX:MaxPermSize 设小了容易 OOM,设大了浪费。
  • GC 效率低——永久代的 GC 和老年代耦合,性能差。
  • 难以预估——动态生成类(CGLIB、Groovy)多的话,永久代容易爆。

JDK 8 把永久代移除,改用元空间(Metaspace),存在本地内存(Native Memory)——大小受限于机器内存,不再受 JVM 堆大小限制。

-XX:MetaspaceSize=256m      # 元空间初始大小, 触发 GC 阈值
-XX:MaxMetaspaceSize=512m   # 元空间最大大小

6.3 字符串常量池的位置变化

字符串常量池(String Pool)的位置也有变化:

  • JDK 6:在永久代。
  • JDK 7+:移到堆中——因为永久代太小,String.intern() 大量使用会 OOM。
String s1 = "hello";              // 字符串常量池
String s2 = new String("hello");  // 堆中新建对象, 但引用常量池的 "hello"
System.out.println(s1 == s2);     // false
System.out.println(s1 == s2.intern());   // true

七、运行时常量池

运行时常量池(Runtime Constant Pool) 是方法区的一部分。每个类编译后会生成常量池表(在 class 文件里),类加载后这个表被加载到运行时常量池。

常量池里存:

  • 字面量——字符串、final 常量的值。
  • 符号引用——类名、方法名、字段名的符号(还没解析成直接引用)。
  • 方法句柄、方法类型——invokedynamic 用的。

运行时常量池是动态的——String.intern() 可以在运行时往里加字符串。

八、直接内存(Direct Memory)

8.1 不是 JVM 规范定义的

直接内存(Direct Memory) 不是 JVM 规范定义的运行时数据区,但被广泛使用——主要是 NIO 的 ByteBuffer.allocateDirect

直接内存是堆外内存——不在 Java 堆里,通过 unsafe.allocateMemory 或 NIO 的 DirectByteBuffer 申请,由操作系统管理。

8.2 为什么用直接内存

  • 零拷贝——NIO 的 Channel + DirectByteBuffer 可以直接和 OS 缓冲区交换数据,省去”内核缓冲区 → Java 堆”的拷贝。
  • 大对象——避免大对象在 Java 堆的 GC 开销。
  • JNI 交互——native 代码可以直接访问这块内存。
ByteBuffer buf = ByteBuffer.allocateDirect(1024 * 1024);   // 1MB 直接内存

8.3 风险

  • 不归 GC 管——DirectByteBuffer 的释放靠 Cleaner(虚引用机制),不及时可能内存泄漏。
  • OOM 风险——OutOfMemoryError: Direct buffer memory
-XX:MaxDirectMemorySize=1g   # 直接内存最大 1GB

Netty 大量使用直接内存做网络缓冲区——是高吞吐网络框架的标配。

九、对象的内存布局

了解了内存区域,再看对象在堆里长什么样。一个 Java 对象在堆里由三部分组成:

对象头 (Object Header)  ────  12 字节 (64 位 JVM, 压缩指针)
├── Mark Word (8 字节)        ── hashCode/分代年龄/锁状态/bias
└── 类型指针 (4 字节, 压缩)   ── 指向 Class 元数据
实例数据 (Instance Data)  ──  字段值 (按类型对齐)
对齐填充 (Padding)        ──  让对象大小是 8 字节的倍数
  • 对象头:12 字节(开启了压缩指针 -XX:+UseCompressedOops,默认开),不开压缩是 16 字节。
  • 实例数据:字段值,按字段类型占位(long/double 8 字节,int 4 字节,引用 4 字节压缩 / 8 字节不压缩)。
  • 对齐填充:8 字节对齐,不够补齐。

所以一个 new Object() 实际占 16 字节——12 字节头 + 0 字段 + 4 字节填充。

class Point {
    int x;   // 4 字节
    int y;   // 4 字节
}
// Point 对象大小 = 12 (头) + 4 (x) + 4 (y) = 20 字节, 对齐到 24 字节

对象头里的 Mark Word 是后面讲锁升级(无锁→偏向锁→轻量级锁→重量级锁)的关键——记录锁状态、hashCode、分代年龄。

十、实战:观察 JVM 内存

下面的例子演示用 Java 代码观察各内存区域的使用情况。

Java · 在线运行

观察重点

  • MemoryPoolMXBean 列出了所有内存池——能看到 Eden、Survivor、Old、Metaspace 等具体区域。
  • 元空间不在堆里——Runtime.freeMemory() 看不到元空间使用。
  • 直接内存不在堆里——ByteBuffer.allocateDirect(10MB) 不影响 Runtime.freeMemory()
  • 递归深度受栈大小限制——默认能到几千层(取决于栈帧大小)。
  • -Xss 调栈大小,-Xms/-Xmx 调堆,-XX:MaxMetaspaceSize 调元空间——三组不同的参数。

十一、对象创建过程:从 new 到可用

了解了内存区域,最后看一个对象从 new 到可用的全过程——把所有区域串起来:

  1. 类加载检查——new 指令时,JVM 检查类是否已加载、解析、初始化。没加载先走类加载(下一阶段第 53 章详讲)。
  2. 分配内存——在堆的 Eden 区分一块内存。两种方式:
    • 指针碰撞(Bump the Pointer):堆规整时(Serial/ParNew 带 Compact),移动指针即可。
    • 空闲列表(Free List):堆不规整时(CMS),维护一个空闲块列表,从中找合适的块。
  3. 内存初始化零值——分配的内存清零(不含对象头),所以字段默认值是 0/null。
  4. 设置对象头——填充 Mark Word(hash、分代年龄、锁状态)、类型指针。
  5. 执行 <init>——调用构造器,设置字段初始值。
Person p = new Person("Alice", 30);
// 1. JVM 检查 Person 是否已加载
// 2. 在 Eden 分配内存 (假设指针碰撞)
// 3. 内存清零 (name=null, age=0)
// 4. 设置对象头 (类型指针 → Person.class)
// 5. 调用 <init>("Alice", 30) 设置字段
// 6. p 引用指向这块内存

十二、本章小结

区域线程私有存什么调优参数异常
程序计数器字节码地址不 OOM
虚拟机栈方法栈帧-XssSOF / OOM
本地方法栈native 栈帧-XssSOF / OOM
对象/数组-Xms/-Xmx/-XmnOOM
方法区(元空间)类元数据/常量池-XX:MetaspaceSize/-XX:MaxMetaspaceSizeOOM
直接内存NIO 堆外内存-XX:MaxDirectMemorySizeOOM

堆的分代

区域比例存什么GC
Eden8/10 新生代新对象Minor GC
Survivor 0/11/10 各复制来的存活对象Minor GC
Old2/3 堆长期存活对象Major GC / Full GC

记忆口诀

  • PC 是书签——线程私有的”读到哪一行”。
  • 栈是方法调用栈——一帧一方法,先调后出。
  • 堆是对象的家——分代居住,朝生夕死的在新生代,老不死的在老年代。
  • 元空间是类的档案柜——JDK 8+ 在堆外,CGLIB 动态类多了就爆。
  • 直接内存是后花园——NIO 用,GC 管不着,要小心泄漏。
  • Eden : S0 : S1 = 8:1:1——新生代”复制收集”的标配。
  • 对象头 12 字节,Mark Word 是锁的命根子——后面讲锁靠它。

结语:内存模型是 JVM 的”地图”

这一章是 JVM 深度的”地图”——后续所有章节都在这张地图上展开:

  • GC(第 52 章) 在堆上回收对象,分代决定 GC 策略。
  • 类加载(第 53 章) 把类元数据塞进元空间。
  • 性能调优(第 54 章) 调堆大小、栈大小、元空间大小。
  • 字节码(第 55 章) 在操作数栈和局部变量表上跑指令。

理解了内存模型,你才能看懂 GC 日志、看懂 heap dump、看懂 jstack 输出——它是 JVM 工程师的”地图”。下一章我们讲 垃圾回收(GC)——堆上的对象怎么被自动回收,为什么 Java 不用像 C/C++ 那样手动 free。我们下一章见。