JVM内存模型与对象
# 01.JVM内存模型与对象
# 目录介绍
# 1. 案例引入
# 1.1 一次线上事故
先看一段在生产环境真实跑过的代码,看着平平无奇,却让一个支付服务在凌晨 3 点连续 OOM 三次:
public class OrderCache {
// 静态缓存——"放心,重启就清了"
private static final List<Order> RECENT = new ArrayList<>();
public static void onOrderCreated(Order order) {
RECENT.add(order); // 业务说"最近一万单"
if (RECENT.size() > 10_000) {
RECENT.remove(0); // 超过就丢最早的
}
}
}
class Order {
private String id;
private byte[] receipt; // 电子小票,PDF 截图,平均 200KB
private User buyer; // 买家信息
// ... 共 30+ 字段
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
现象:上线第 7 天凌晨告警,堆内存从 2GB 一路涨到 4GB,触顶后抛出:
java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3210)
at java.util.ArrayList.grow(ArrayList.java:265)
at com.xxx.OrderCache.onOrderCreated(OrderCache.java:11)
2
3
4
直觉怀疑:是不是缓存上限没生效?打开 dump 一看,RECENT 里确实只有 10000 个元素,那为什么还是炸了?
# 1.2 顺藤摸到根因
带着疑问继续推:
- 假设 1:是
Order太大?—— 看了下,单个 Order 平均 220KB,10000 个 ≈ 2.2GB。对得上,但代码作者一开始预估的是"10000 个 Order × 1KB = 10MB"。 - 假设 2:那 220KB 是从哪冒出来的?—— 翻字段才发现
byte[] receipt一个就 200KB。 - 假设 3:为什么之前测试环境没复现?—— 测试环境单元测试用的 Order 不带 receipt,receipt 字段为 null。
看似 "add 一万、删一个" 的逻辑没问题,但问题不在算法,在内存。
这里至少藏着 6 个 JVM 知识点:
① 这 10000 个 Order 实际放在堆的哪一块? → 第3、8章
② byte[] 这种大对象的分配路径和小对象一样吗? → 第4章
③ 为什么 RECENT 是 static 就活得这么久? → 第7章「GC Roots」
④ remove(0) 之后那个 Order 立刻被回收吗? → 第7章「可达性」
⑤ JVM 怎么判断该不该回收? → 第7章「可达性分析」
⑥ 为什么 -Xmx4g 之后还是会 OOM? → 第9章「排查实战」
2
3
4
5
6
# 1.3 我们要回答什么
这个事故就是本篇的主线案例。我们带着上面 6 个问号往下走,每讲完一段原理,就能解开一两个问号。最后在第 10 章,我们会把整条链路串起来——回头看完会发现:不是 JVM 不够聪明,是我们没把 JVM 当回事。
本篇路线:
架构概览 (第2章)
↓
运行时数据区 (第3章) ─→ 解开"对象放在哪"
↓
对象创建/布局/访问 (第4-6章) ─→ 解开"对象长什么样"
↓
生命周期 + 分代 (第7-8章) ─→ 解开"对象什么时候死"
↓
OOM 排查 (第9章) ─→ 武器库
↓
综合案例 (第10章) ─→ 把案例彻底剖开
2
3
4
5
6
7
8
9
10
11
# 2. 架构概览
# 2.1 三大子系统
JVM(Java Virtual Machine)是 Java 程序的运行引擎。它的核心架构可以分为三大部分:
┌─────────────────────────────────────────────┐
│ Java 源代码 │
│ ↓ javac 编译 │
│ .class 字节码文件 │
└──────────────────┬──────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ 类加载子系统 │
│ (加载 → 链接 → 初始化) │
└──────────────────┬──────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ 运行时数据区 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 程序计数器 │ │ 虚拟机栈 │ │本地方法栈 │ │
│ │ (线程私有) │ │ (线程私有) │ │(线程私有) │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ 堆 (Heap) │ │ 方法区/元空间 │ │
│ │ (线程共享) │ │ (线程共享) │ │
│ └──────────────────┘ └──────────────────┘ │
│ ┌──────────────────┐ │
│ │ 直接内存(堆外) │ │
│ └──────────────────┘ │
└──────────────────┬──────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ 执行引擎 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 解释器 │ │JIT编译器 │ │ GC │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
核心设计思想:JVM 通过在操作系统之上抽象出一层虚拟的运行环境,实现了"一次编译,到处运行"。运行时数据区的划分,本质上是对线程私有数据和共享数据的隔离设计——私有区域保证线程安全,共享区域通过 GC 和同步机制管理。
# 2.2 私有与共享
为什么要这么切? 我们做一个反向论证:假设所有数据都共享。
- 如果程序计数器共享:CPU 在线程 A 和线程 B 之间切来切去,PC 寄存器会被互相覆盖,线程切回来后根本不知道执行到哪了 ➜ 必须线程私有。
- 如果栈共享:方法 A 的局部变量可能被方法 B 覆盖,且每次方法调用都要加锁 ➜ 必须线程私有。
- 如果堆私有:两个线程 new 出来的对象互相看不见,
SharedQueue这种东西就写不出来 ➜ 必须共享。 - 如果类元数据每个线程一份:1000 个线程就要加载 1000 次
String.class,内存爆炸 ➜ 必须共享。
推到这里你就明白:JVM 的内存切分不是拍脑袋,是被需求逼出来的——线程切换需要现场保存 → 私有;对象需要被多线程协作 → 共享。后续每个区域的设计都遵循这条主线。
# 3. 运行时数据区
# 3.1 程序计数器
程序计数器(Program Counter Register)是一块很小的内存空间,记录当前线程正在执行的字节码指令地址。
核心特点:
- 线程私有:每个线程都有自己独立的程序计数器,互不影响
- 唯一不会OOM的区域:JVM 规范中没有规定任何
OutOfMemoryError情况 - 执行 native 方法时为空:因为 native 方法由操作系统执行,不需要 JVM 层面的指令计数
为什么需要它? CPU 时间片轮转进行线程切换时,需要知道每个线程执行到了哪里。程序计数器就是用来恢复线程执行位置的"书签"。
// 字节码示例
public int add(int a, int b) {
return a + b;
}
// 对应字节码及PC值
0: iload_1 // PC = 0
1: iload_2 // PC = 1
2: iadd // PC = 2
3: ireturn // PC = 3
// 线程切换时保存 PC 值,恢复时从保存的位置继续
2
3
4
5
6
7
8
9
10
11
# 3.2 虚拟机栈
虚拟机栈(VM Stack)描述的是 Java 方法执行的内存模型。每个方法被调用时会创建一个栈帧(Stack Frame),方法执行结束栈帧出栈。
┌────────────────────────┐
│ 虚拟机栈 (线程私有) │
│ ┌──────────────────┐ │
│ │ 栈帧:method_C() │ │ ← 栈顶(当前方法)
│ ├──────────────────┤ │
│ │ 栈帧:method_B() │ │
│ ├──────────────────┤ │
│ │ 栈帧:method_A() │ │
│ ├──────────────────┤ │
│ │ 栈帧:main() │ │ ← 栈底
│ └──────────────────┘ │
└────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
两种异常:
- StackOverflowError:栈深度超过限制(递归过深)
- OutOfMemoryError:如果虚拟机栈可以动态扩展,扩展时无法申请到足够内存
// 演示 StackOverflowError
public class StackOverflowDemo {
private static int depth = 0;
public static void recursion() {
depth++;
recursion(); // 无终止条件的递归
}
public static void main(String[] args) {
try {
recursion();
} catch (StackOverflowError e) {
System.out.println("栈溢出!递归深度: " + depth);
// 默认栈大小 512K~1M,输出类似:栈溢出!递归深度: 7261
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 调整栈大小
-Xss256k # 设置每个线程栈大小为 256KB(减小可以创建更多线程)
-Xss2m # 设置为 2MB(增大可支持更深的递归)
2
3
# 3.3 栈帧结构
每个栈帧由四部分组成:
┌────────────────────────────────────┐
│ 栈帧 (Stack Frame) │
│ ┌──────────────────────────────┐ │
│ │ 局部变量表 (Local Variables) │ │
│ │ slot[0]: this │ │
│ │ slot[1]: int a = 10 │ │
│ │ slot[2]: String s │ │
│ │ slot[3]: long l (占2个slot) │ │
│ ├──────────────────────────────┤ │
│ │ 操作数栈 (Operand Stack) │ │
│ │ 字节码指令的工作区 │ │
│ │ 类似CPU寄存器的功能 │ │
│ ├──────────────────────────────┤ │
│ │ 动态链接 (Dynamic Linking) │ │
│ │ → 运行时常量池中该方法的引用 │ │
│ ├──────────────────────────────┤ │
│ │ 方法返回地址 (Return Address) │ │
│ │ 方法正常/异常退出后的返回点 │ │
│ └──────────────────────────────┘ │
└────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
局部变量表详解:
- 存放方法参数和局部变量
- 基本类型(int, float, boolean 等)直接存值
- 引用类型(对象)存引用指针
- long 和 double 占 2 个 slot,其他占 1 个
- 实例方法的 slot[0] 固定是
this引用 - slot 可以复用——变量超出作用域后,其 slot 可被后续变量使用
public void demo(int a, long b) {
// 局部变量表:
// slot[0]: this
// slot[1]: int a
// slot[2-3]: long b(占2个slot)
String s = "hello"; // slot[4]: String s
{
int temp = 1; // slot[5]: int temp
}
// temp 超出作用域,slot[5] 可以被复用
int c = 2; // slot[5]: int c(复用了 temp 的 slot)
}
2
3
4
5
6
7
8
9
10
11
12
13
操作数栈示例:
// 计算 a + b 的字节码执行过程
int result = a + b;
// 字节码:
iload_1 // 将局部变量表 slot[1](a) 压入操作数栈 栈:[a]
iload_2 // 将局部变量表 slot[2](b) 压入操作数栈 栈:[a, b]
iadd // 弹出栈顶两个元素相加,结果压入栈 栈:[a+b]
istore_3 // 弹出栈顶元素存入 slot[3](result) 栈:[]
2
3
4
5
6
7
8
# 3.4 本地方法栈
本地方法栈(Native Method Stack)与虚拟机栈类似,区别在于它服务的是 native 方法(用 C/C++ 实现的方法)。
在 HotSpot 虚拟机中,本地方法栈与虚拟机栈合二为一,不做区分。
// native 方法示例
public class Object {
public native int hashCode();
// 实现在 JVM 的 C++ 代码中
}
public class Thread {
private native void start0();
// 调用操作系统的线程创建 API
}
public class System {
public static native void arraycopy(...);
// 高性能的内存拷贝,C 实现
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 3.5 堆内存
堆(Heap)是 JVM 中最大的一块内存区域,也是垃圾回收的主战场。
核心特点:
- 线程共享:所有线程创建的对象实例都存放在堆中
- GC 管理:堆是垃圾收集器管理的主要区域,又称"GC 堆"
- 可扩展:通过
-Xms(初始大小)和-Xmx(最大大小)参数控制
# 堆内存参数示例
-Xms256m # 初始堆大小 256MB
-Xmx1024m # 最大堆大小 1024MB
-Xmn128m # 新生代大小 128MB
# 生产环境建议:-Xms 和 -Xmx 设为相同值,避免动态调整的开销
-Xms4g -Xmx4g
2
3
4
5
6
7
设计思考:为什么对象要放在堆上而不是栈上?
栈是线程私有的、空间有限、生命周期与方法绑定。而对象的大小在编译期通常无法确定,生命周期也可能跨越多个方法调用。堆提供了更灵活的动态内存分配能力。
# 3.6 逃逸分析
JVM 也有逃逸分析优化:如果 JIT 编译器发现一个对象不会逃逸出方法作用域,就可能将其分配在栈上,避免堆内存分配和 GC 压力。
// 不逃逸——可以栈上分配
public int getSum() {
Point p = new Point(1, 2); // p 不逃逸出方法
return p.x + p.y;
}
// JIT 优化后可能:
// 直接在栈上分配 Point,甚至标量替换为两个 int 变量
// 方法结束栈帧弹出,无需 GC
// 逃逸——必须堆上分配
public Point createPoint() {
Point p = new Point(1, 2); // p 被返回,逃逸出方法
return p;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
标量替换:逃逸分析的进阶优化。将对象拆散为独立的标量(基本类型变量),完全避免对象创建:
// 原始代码
public int compute() {
Point p = new Point(x, y);
return p.x * p.y;
}
// 标量替换后(JIT 优化)
public int compute() {
int px = x; // 直接用基本类型替代
int py = y;
return px * py;
// 完全不创建 Point 对象!
}
2
3
4
5
6
7
8
9
10
11
12
13
# 相关 JVM 参数
-XX:+DoEscapeAnalysis # 开启逃逸分析(默认开启)
-XX:+EliminateAllocations # 开启标量替换(默认开启)
2
3
# 3.7 方法区演进
方法区(Method Area)存放已加载的类信息、常量、静态变量、JIT 编译后的代码等。
技术演变过程:
| JDK 版本 | 实现方式 | 存储位置 | 大小限制 |
|---|---|---|---|
| JDK 1.6 及之前 | 永久代(PermGen) | JVM 堆内存 | -XX:MaxPermSize |
| JDK 1.7 | 永久代(部分移除) | 字符串常量池移至堆 | -XX:MaxPermSize |
| JDK 1.8+ | 元空间(Metaspace) | 本地内存(Native) | -XX:MaxMetaspaceSize |
疑惑:为什么要从永久代改为元空间?
论证:
- 永久代有固定大小上限,容易出现
OutOfMemoryError: PermGen space。大量使用反射、动态代理、CGLib 等框架时尤其明显 - 字符串常量池放在永久代中导致 GC 效率低,永久代只在 Full GC 时才被回收
- 元空间使用本地内存,理论上只受物理内存限制,大大减少了 OOM 概率
- 统一 HotSpot 和 JRockit:JRockit 没有永久代的概念,合并后使用元空间更合理
# 元空间参数
-XX:MetaspaceSize=128m # 初始元空间大小(触发 Full GC 的阈值)
-XX:MaxMetaspaceSize=256m # 最大元空间大小(默认无上限)
2
3
元空间存储的内容:
- 类的元数据(方法、字段、注解信息等)
- 常量池(Class 文件中的符号引用)
- 方法的字节码
- JIT 编译后的代码
# 3.8 运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分,存放编译期生成的各种字面量和符号引用。
// Class 文件中的常量池示例
Constant pool:
#1 = Methodref #6.#20 // java/lang/Object."<init>":()V
#2 = Fieldref #21.#22 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #23 // Hello
#4 = Methodref #24.#25 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #26 // HelloWorld
#6 = Class #27 // java/lang/Object
2
3
4
5
6
7
8
动态性:运行时常量池可以在运行时添加新常量(如 String.intern()),这是与 Class 文件常量池的重要区别。
# 3.9 直接内存
直接内存(Direct Memory)不属于 JVM 运行时数据区,但被频繁使用。
// NIO 的 DirectByteBuffer 使用直接内存
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024); // 1MB 直接内存
// 直接内存的优势:
// 1. 不经过 JVM 堆,减少一次数据拷贝(零拷贝基础)
// 2. 不受 GC 管理,适合长期存在的缓冲区
// 3. 网络 IO 中避免了堆内存和内核缓冲区之间的拷贝
2
3
4
5
6
7
-XX:MaxDirectMemorySize=256m # 限制直接内存大小
# 默认与 -Xmx 相同
2
注意:直接内存不受 GC 直接管理,需要通过 Cleaner 机制回收。不当使用可能导致 OutOfMemoryError: Direct buffer memory。
# 4. 对象创建过程
当 JVM 遇到一条 new 指令时,会经历以下完整流程:
# 4.1 类加载检查
JVM 首先检查这个 new 指令对应的类是否已经被加载、解析和初始化过。如果没有,会先触发类加载机制(详见第2篇)。
new 指令
→ 在常量池中定位类的符号引用
→ 该类已加载?
→ 是:继续内存分配
→ 否:触发类加载(加载→验证→准备→解析→初始化)
2
3
4
5
# 4.2 内存分配策略
类加载检查通过后,JVM 为新对象分配内存。分配方式取决于堆内存是否规整:
指针碰撞(Bump the Pointer):
堆内存规整时(使用压缩整理型收集器,如 Serial、ParNew):
已使用 ↓ 分配指针 空闲
████████████│░░░░░░░░░░░░░░░░
分配 N 字节后:
████████████████│░░░░░░░░░░░░░
↑ 指针向空闲方向移动 N 字节
2
3
4
5
6
7
8
空闲列表(Free List):
堆内存不规整时(使用标记清除型收集器,如 CMS):
██░░██████░░░░██░░████░░░░░░
^ ^ ^
空 空 空
JVM 维护一个空闲块列表,分配时找到一块足够大的空间
2
3
4
5
6
7
# 4.3 TLAB分配缓冲
并发安全问题:多个线程同时 new 对象怎么办?
方案一:CAS + 失败重试
→ 对分配指针做原子性更新
→ 竞争激烈时性能下降
方案二:TLAB(Thread Local Allocation Buffer)
→ 每个线程预先分配一小块堆内存(Eden 区中)
→ 对象优先在自己的 TLAB 中分配(无锁,速度极快)
→ TLAB 用完了再申请新的 TLAB(此时才需要同步)
→ 默认开启:-XX:+UseTLAB
2
3
4
5
6
7
8
9
Eden 区
┌──────────┬──────────┬──────────┬─────────────┐
│ Thread1 │ Thread2 │ Thread3 │ 空闲 │
│ 的 TLAB │ 的 TLAB │ 的 TLAB │ │
│ ████░░░ │ ██████░ │ █░░░░░░ │ │
└──────────┴──────────┴──────────┴─────────────┘
2
3
4
5
6
-XX:+UseTLAB # 开启 TLAB(默认开启)
-XX:TLABSize=512k # 设置 TLAB 初始大小
-XX:+ResizeTLAB # 自适应调整 TLAB 大小
2
3
# 4.4 零值与对象头
内存分配完成后:
初始化零值:将分配到的内存空间初始化为零值
- int → 0, long → 0L, boolean → false, 引用 → null
- 这就是为什么 Java 中字段有默认值
设置对象头:
- Mark Word:哈希码、GC 分代年龄、锁状态等
- Klass Pointer:指向类的元数据(方法区中)
- 数组长度(如果是数组)
# 4.5 执行init方法
从 JVM 角度对象已创建完毕,但从 Java 角度,构造方法还未执行。new 指令之后会紧跟 invokespecial 指令执行 <init>() 方法(构造函数)。
// Java 代码
User user = new User("张三", 25);
// 字节码
new #2 // class User → 分配内存、初始化零值、设置对象头
dup // 复制引用(一个用于构造函数,一个用于赋值)
ldc #3 // String "张三"
bipush 25
invokespecial #4 // User."<init>":(Ljava/lang/String;I)V → 执行构造函数
astore_1 // 存入局部变量表
2
3
4
5
6
7
8
9
10
# 5. 对象内存布局
# 5.1 对象头详解
一个 Java 对象在内存中由三部分组成:
┌──────────────────────────────────────┐
│ 对象头 (Object Header) │
│ ┌──────────────────────────────┐ │
│ │ Mark Word (8字节/64位JVM) │ │
│ │ - 哈希码 (hashCode, 31bit) │ │
│ │ - GC 分代年龄 (4bit, 最大15) │ │
│ │ - 锁状态标志 (2bit) │ │
│ │ - 偏向锁标志 (1bit) │ │
│ │ - 偏向线程ID (54bit) │ │
│ ├──────────────────────────────┤ │
│ │ Klass Pointer (4字节/压缩) │ │
│ │ - 指向类的元数据信息 │ │
│ ├──────────────────────────────┤ │
│ │ 数组长度 (4字节,仅数组对象有) │ │
│ └──────────────────────────────┘ │
├──────────────────────────────────────┤
│ 实例数据 (Instance Data) │
│ - 对象真正存储的有效信息 │
│ - 包含父类和子类定义的所有字段 │
│ - 相同宽度的字段会被分配到一起 │
│ - 父类字段在子类字段之前 │
├──────────────────────────────────────┤
│ 对齐填充 (Padding) │
│ - HotSpot 要求对象大小必须是8字节倍数 │
└──────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Mark Word 在不同锁状态下的布局(64位):
| 锁状态 | 存储内容 | 标志位 |
|---|---|---|
| 无锁 | unused(25) | hashCode(31) | age(4) | bias(0) | 01 |
| 偏向锁 | threadId(54) | epoch(2) | age(4) | bias(1) | 01 |
| 轻量级锁 | Lock Record 指针(62) | 00 |
| 重量级锁 | Monitor 指针(62) | 10 |
| GC 标记 | 空 | 11 |
关键细节:Mark Word 中 GC 分代年龄用 4 bit 表示,最大值为 15。这就是为什么默认 -XX:MaxTenuringThreshold=15。
# 5.2 实例数据填充
实例数据的存储顺序:
- HotSpot 默认按照 long/double → int/float → short/char → byte/boolean → 引用 的顺序分配
- 相同宽度的字段总是被分配到一起
- 父类定义的字段在子类字段之前
- 可以通过
-XX:FieldsAllocationStyle参数改变分配策略
对齐填充:
- HotSpot 要求对象的起始地址是 8 字节的倍数
- 如果实例数据部分没有对齐到 8 的倍数,需要填充
- 这是为了提高 CPU 的内存访问效率(对齐访问比非对齐访问快)
# 5.3 对象大小计算
// 使用 JOL(Java Object Layout)工具精确计算
import org.openjdk.jol.info.ClassLayout;
class User {
private int age; // 4 字节
private String name; // 4 字节(压缩指针)
private boolean active; // 1 字节
}
System.out.println(ClassLayout.parseClass(User.class).toPrintable());
2
3
4
5
6
7
8
9
10
输出示例(64 位 JVM,开启指针压缩):
OFFSET SIZE TYPE DESCRIPTION
0 12 (object header) // 12 字节
12 4 int User.age // 4 字节
16 1 boolean User.active // 1 字节
17 3 (alignment gap) // 3 字节对齐
20 4 java.lang.String User.name // 4 字节
24 0 (object size) // 总计 24 字节
2
3
4
5
6
7
# 5.4 指针压缩原理
64 位 JVM 中,对象引用占 8 字节。HotSpot 通过压缩指针(Compressed Oops)将其压缩为 4 字节:
-XX:+UseCompressedOops # 默认开启(堆 < 32GB 时)
-XX:+UseCompressedClassPointers # 压缩 Klass Pointer
2
原理:对象地址都是 8 字节对齐的,低 3 位永远是 0。JVM 只存储高 32 位(右移 3 位),使用时左移 3 位还原。这样 4 字节可以寻址 4GB × 8 = 32GB 的堆空间。
堆 > 32GB 时指针压缩失效:所有引用变为 8 字节,对象整体变大,可能反而比 32GB 堆更慢。因此生产环境建议堆大小不超过 32GB,或者直接设为 31GB 以确保压缩生效。
# 6. 对象访问定位
Java 通过栈中的 reference(引用)访问堆中的对象。JVM 规范没有规定如何通过引用定位对象,主流有两种方式:
# 6.1 句柄访问
栈 句柄池(堆中) 堆
┌───────┐ ┌──────────────┐ ┌───────────┐
│ ref ──┼───→ │ 对象实例指针 ──┼──→ │ 实例数据 │
│ │ │ 类型数据指针 ──┼──┐ └───────────┘
└───────┘ └──────────────┘ │
└→ ┌───────────┐
│ 类元数据 │ 方法区
└───────────┘
2
3
4
5
6
7
8
优点:GC 移动对象时只需修改句柄中的指针,reference 不需要改变 缺点:多一次间接寻址
# 6.2 直接指针访问
栈 堆
┌───────┐ ┌───────────────────┐
│ ref ──┼──────→ │ 对象头(含类型指针) │
│ │ │ 实例数据 │
└───────┘ └────────┬──────────┘
│ Klass Pointer
↓
┌───────────┐
│ 类元数据 │ 方法区
└───────────┘
2
3
4
5
6
7
8
9
10
优点:速度更快,少一次指针定位 缺点:GC 移动对象时需要修改所有引用
HotSpot 使用直接指针,因为对象访问是极其频繁的操作,省去一次指针定位的开销积少成多。
# 7. 对象生命周期
# 7.1 从创建到可达
对象创建后进入"可达"状态。只要存在从 GC Roots 到该对象的引用链,对象就不会被回收。
GC Roots 包括:
- 虚拟机栈(栈帧中的局部变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中 JNI 引用的对象
- 被
synchronized持有的对象 - JVM 内部引用(如基本类型的 Class 对象、系统类加载器等)
- 跨代引用(如老年代对象引用新生代对象,通过记忆集 Remembered Set 维护)
# 7.2 可达性分析
JVM 使用可达性分析来判断对象是否存活,而不是引用计数法。
疑惑:为什么不用引用计数法?简单高效不好吗?
论证:引用计数法有一个致命缺陷——循环引用:
public class CircularReference {
public Object ref = null;
public static void main(String[] args) {
CircularReference a = new CircularReference();
CircularReference b = new CircularReference();
a.ref = b; // a 引用 b
b.ref = a; // b 引用 a
a = null;
b = null;
// 此时两个对象互相引用,引用计数不为0
// 引用计数法无法回收,但可达性分析可以
System.gc();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
可达性分析从 GC Roots 出发,遍历引用链。a = null; b = null; 之后,两个对象都不可从 GC Roots 到达,因此可以被回收。
# 7.3 四种引用类型
JDK 1.2 引入了四种引用类型,让开发者可以更细粒度地控制对象的生命周期:
| 引用类型 | 回收时机 | 用途 | 类 |
|---|---|---|---|
| 强引用 | 永不回收(只要可达) | 普通赋值 Object o = new Object() | - |
| 软引用 | 内存不足时回收 | 缓存 | SoftReference<T> |
| 弱引用 | 下次 GC 时回收 | WeakHashMap、ThreadLocal | WeakReference<T> |
| 虚引用 | 随时回收 | 跟踪对象回收,堆外内存清理 | PhantomReference<T> |
// 软引用做图片缓存
SoftReference<byte[]> imageCache = new SoftReference<>(loadImage("photo.jpg"));
byte[] image = imageCache.get();
if (image != null) {
// 缓存命中,直接使用
display(image);
} else {
// 内存不足被回收了,重新加载
image = loadImage("photo.jpg");
imageCache = new SoftReference<>(image);
display(image);
}
// 弱引用——ThreadLocal 防止内存泄漏
// ThreadLocalMap 的 Entry 继承了 WeakReference<ThreadLocal>
// 当 ThreadLocal 变量被回收后,Entry 的 key 变为 null
// ThreadLocalMap 在 get/set 时会清理这些 key=null 的 Entry
// 虚引用——跟踪 DirectByteBuffer 的回收
// JDK 内部使用 PhantomReference + ReferenceQueue
// 当 DirectByteBuffer 被 GC 回收时,通过虚引用感知,释放对应的 native 内存
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 7.4 对象死亡过程
对象从"不可达"到真正被回收,至少经历两次标记:
第一次标记: GC 发现对象不可达
│
↓ 检查是否有必要执行 finalize()
│
├─ 没有覆盖 finalize() 或已经执行过 → 直接标记为可回收
│
└─ 有必要执行 → 放入 F-Queue 队列
│
↓ Finalizer 线程执行 finalize()(低优先级,不保证执行完毕)
│
↓ 第二次标记: GC 再次检查
│
├─ finalize() 中重新建立了引用链 → 对象"复活"
│
└─ 仍然不可达 → 正式回收
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class FinalizeEscape {
public static FinalizeEscape SAVE_HOOK = null;
@Override
protected void finalize() throws Throwable {
super.finalize();
FinalizeEscape.SAVE_HOOK = this; // 在 finalize 中拯救自己
System.out.println("finalize() 执行,对象复活了!");
}
public static void main(String[] args) throws Exception {
SAVE_HOOK = new FinalizeEscape();
// 第一次拯救成功
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
System.out.println(SAVE_HOOK != null ? "对象存活" : "对象死亡"); // 对象存活
// 第二次——finalize 只会被调用一次
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
System.out.println(SAVE_HOOK != null ? "对象存活" : "对象死亡"); // 对象死亡
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
结论:finalize() 只会被 JVM 调用一次,且执行时机不确定,不推荐使用。JDK 9 已标记为 @Deprecated。应使用 try-with-resources 或 Cleaner 替代。
# 7.5 方法区的回收
方法区的回收主要是废弃常量和不再使用的类。
类卸载的条件(三个条件必须同时满足):
- 该类所有的实例都已被 GC 回收
- 加载该类的 ClassLoader 已被 GC 回收
- 该类对应的 Class 对象没有在任何地方被引用
# 查看类卸载信息
-XX:+TraceClassUnloading
2
在大量使用反射、动态代理、CGLIB、JSP 等场景下,方法区的回收能力对系统稳定性很重要。
# 8. 堆内存分代
# 8.1 为什么要分代
疑惑:为什么不对整个堆做统一的垃圾回收?
论证:IBM 研究表明,大部分对象"朝生夕死"——新创建的对象约 98% 在下一次 GC 时就会死亡。如果每次 GC 都扫描整个堆,效率极低。
分代的核心思想是用空间换时间:将堆分为新生代和老年代,对不同特征的对象采用不同的回收策略:
- 新生代:对象存活率低 → 采用复制算法,效率高
- 老年代:对象存活率高 → 采用标记-清除或标记-整理算法
# 8.2 新生与老年代
┌──────────────────────────────────────────────┐
│ 堆 (Heap) │
│ ┌─────────────────────┐ ┌────────────────┐ │
│ │ 新生代 (1/3 堆) │ │ 老年代 (2/3) │ │
│ │ ┌─────┐ ┌───┐ ┌───┐│ │ │ │
│ │ │Eden │ │S0 │ │S1 ││ │ │ │
│ │ │ 8/10│ │1/10│1/10││ │ │ │
│ │ └─────┘ └───┘ └───┘│ │ │ │
│ └─────────────────────┘ └────────────────┘ │
└──────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
新生代默认按 8:1:1 划分为 Eden 区和两个 Survivor 区(S0、S1)。新对象优先在 Eden 区分配。
Minor GC 过程:
- 将 Eden 区和正在使用的 Survivor 区(如 S0)中存活对象复制到另一个 Survivor 区(S1)
- 存活对象的 GC 年龄加 1
- 清空 Eden 和 S0
- S0 和 S1 角色互换
-XX:SurvivorRatio=8 # Eden:S0:S1 = 8:1:1
-XX:NewRatio=2 # 老年代:新生代 = 2:1
2
# 8.3 对象晋升策略
对象从新生代晋升到老年代有以下几种情况:
| 策略 | 条件 | 参数 |
|---|---|---|
| 年龄达到阈值 | GC 年龄 ≥ 15 | -XX:MaxTenuringThreshold=15 |
| 大对象直接进入 | 对象大小 ≥ 阈值 | -XX:PretenureSizeThreshold |
| 动态年龄判断 | 同龄对象大小总和 > Survivor 的一半 | JVM 自动计算 |
| Survivor 空间不足 | Minor GC 后存活对象放不下 | 空间分配担保 |
动态年龄判断详解:
假设 Survivor 区 10MB
年龄1对象: 2MB
年龄2对象: 3MB → 累计 5MB,超过 Survivor/2 = 5MB
年龄3对象: 1MB
此时年龄 ≥ 2 的对象全部晋升到老年代
(不需要等到 MaxTenuringThreshold)
2
3
4
5
6
7
8
# 8.4 空间分配担保
Minor GC 前,JVM 会检查老年代最大可用连续空间是否大于新生代所有对象总空间:
Minor GC 前检查
│
↓ 老年代可用空间 > 新生代所有对象?
│
├─ 是 → Minor GC 安全,直接执行
│
└─ 否 → 检查 HandlePromotionFailure 是否允许担保失败
│
├─ 允许 → 检查老年代可用空间 > 历次晋升的平均大小?
│ ├─ 是 → 冒险进行 Minor GC
│ └─ 否 → 进行 Full GC
│
└─ 不允许 → 进行 Full GC
2
3
4
5
6
7
8
9
10
11
12
13
# 9. OOM排查实战
# 9.1 各区域OOM
| 错误类型 | 溢出区域 | 常见原因 |
|---|---|---|
OutOfMemoryError: Java heap space | 堆 | 内存泄漏、对象过多 |
OutOfMemoryError: Metaspace | 元空间 | 加载类过多、动态代理过度 |
OutOfMemoryError: Direct buffer memory | 直接内存 | NIO 直接内存泄漏 |
OutOfMemoryError: unable to create native thread | 操作系统 | 线程数过多 |
OutOfMemoryError: GC overhead limit exceeded | 堆 | GC 回收极少内存但频繁执行 |
StackOverflowError | 栈 | 递归过深、方法调用链过长 |
# 9.2 堆溢出排查
# 第一步:发生 OOM 时自动 dump 堆快照
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/tmp/heapdump.hprof
# 第二步:开启 GC 日志
# JDK 8
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/tmp/gc.log
# JDK 9+
-Xlog:gc*:file=/tmp/gc.log:time,uptime,level,tags
# 第三步:实时监控
jmap -heap <pid> # 查看堆内存使用情况
jstat -gcutil <pid> 1000 # 每秒打印 GC 统计信息
jmap -histo <pid> | head -30 # 查看对象数量排名
# 第四步:分析 dump 文件
# 使用 MAT (Memory Analyzer Tool) 分析
# 重点关注:
# - Dominator Tree(支配树):找到占用内存最大的对象
# - Leak Suspects:自动检测可能的内存泄漏
# - Histogram:按类统计对象数量和大小
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 9.3 内存泄漏诱因
// 1. 静态集合持有大量对象
private static List<Object> cache = new ArrayList<>();
public void process(Object data) {
cache.add(data); // 只增不减,永远不会被 GC
}
// 2. 未关闭的资源
public void readFile() {
InputStream is = new FileInputStream("file");
// 忘记 close,如果发生异常更容易泄漏
// 正确做法:try-with-resources
}
// 3. ThreadLocal 未清理
ThreadLocal<byte[]> tl = new ThreadLocal<>();
tl.set(new byte[1024 * 1024]);
// 线程池中的线程长期存活,ThreadLocal 的 value 一直被持有
// 正确做法:finally 中 tl.remove()
// 4. 内部类持有外部类引用
public class Outer {
private byte[] data = new byte[10_000_000]; // 10MB
class Inner {
// 隐式持有 Outer.this 引用
// 如果 Inner 被长期持有,Outer 的 10MB 也无法回收
}
// 解决:使用 static 内部类
static class StaticInner {
// 不持有 Outer 引用
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# 9.4 常用监控工具
| 工具 | 用途 | 命令/说明 |
|---|---|---|
| jps | 列出 Java 进程 | jps -lv |
| jstat | GC 统计信息 | jstat -gcutil <pid> 1000 |
| jmap | 堆内存快照 | jmap -dump:format=b,file=heap.hprof <pid> |
| jstack | 线程快照 | jstack <pid> |
| jcmd | 综合诊断 | jcmd <pid> GC.heap_info |
| VisualVM | 图形化监控 | 可视化查看内存、CPU、线程 |
| MAT | 堆快照分析 | Eclipse Memory Analyzer |
| Arthas | 线上诊断 | 阿里开源的 Java 诊断工具 |
# 10. 综合案例串讲
读到这里,回头看第 1 章那段 OrderCache,前面散落的知识点就都有了着力点。这一章我们把案例彻底剖开,把整本第 1 篇的内容串成一条完整链路。
# 10.1 案例真相揭晓
回到开篇代码:
private static final List<Order> RECENT = new ArrayList<>();
public static void onOrderCreated(Order order) {
RECENT.add(order);
if (RECENT.size() > 10_000) RECENT.remove(0);
}
2
3
4
5
我们一开始有 6 个疑问,现在能逐条作答了:
疑问 ①:这 10000 个 Order 实际放在堆的哪一块?
→ Eden(第 3.5 节、第 8.2 节)。订单是高频创建的"朝生夕死"对象,默认走 Eden + TLAB 路径。但因为 RECENT 这个静态字段一直握着引用,几次 Minor GC 后它们会按"动态年龄判断"或"年龄达到 15"晋升到老年代(第 8.3 节)。这就解释了为什么压测时老年代会越来越满。
疑问 ②:byte[] receipt 200KB 的大对象,跟普通对象走一条路吗?
→ 不一定。如果 byte[] 大小超过 -XX:PretenureSizeThreshold(默认值随收集器不同),它会直接进入老年代(第 8.3 节),跳过新生代。这正是为什么这个服务的老年代涨得特别快——每个订单背后跟着一个 200KB 的大数组,每个都"出生即老去"。
疑问 ③:为什么 static 字段就活得这么久?
→ 因为它是 GC Roots 之一(第 7.1 节)。RECENT 是 OrderCache 类的静态字段,只要 OrderCache.class 不被卸载,RECENT 就一直可达,RECENT 引用的所有 Order 也都可达。
疑问 ④:RECENT.remove(0) 之后,那个 Order 立刻被回收吗?
→ 不。remove(0) 只是把这个引用从列表里移除。如果该 Order 没有被任何其他地方引用,它会变成"不可达",等下一次 GC 时才被回收(第 7.2 节)。期间它还会占着内存。
疑问 ⑤:JVM 怎么判断该不该回收?
→ 可达性分析(第 7.2 节)。从 GC Roots 出发遍历引用链,遍历不到的就是垃圾。而循环引用根本不是问题——这是它战胜引用计数法的关键。
疑问 ⑥:为什么 -Xmx4g 还是 OOM?
→ 简单算账:10000 单 × 220KB ≈ 2.2GB(仅 Order 主体)。再叠上正常业务的对象、Survivor 不能放下导致的频繁晋升、老年代碎片,老年代很快被填满,然后 Full GC 也救不回来——因为这些对象全部可达,根本不是垃圾,GC 再多也没用。这不是 GC 不行,是真的"内存不够装"。
根本症结:作者预估容量时算的是 10000 × sizeof(Order壳) ≈ 10MB,完全忽略了 Order 内部 byte[] 这条暗线。receipt 字段占据了对象总体 99% 的体积。
两层修复:
// 第一层:把大字段从缓存对象里剥出去,缓存只存元信息
class OrderSummary {
String id;
String receiptUrl; // 只存 OSS 链接,200KB 的图存对象存储里
long buyerId;
}
// 第二层:缓存上限按"内存预算"而不是"条数"算
// 10000 条 × 平均 1KB(去掉 byte[] 后) ≈ 10MB,符合最初设计
// 或者直接换成 Caffeine 的 maximumWeight 按字节计权
2
3
4
5
6
7
8
9
10
# 10.2 一个User的一生
把案例升华一下。User user = new User("张三", 25); 这一行代码,背后到底发生了什么?我们沿着前 9 章的内容,把它的一生顺一遍:
【出生】
[第4章] 遇到 new 指令
├─ 4.1 去运行时常量池查 User 的符号引用 → 未加载则触发加载
├─ 4.2 决定在堆还是栈上分配
│ ├─ 逃逸分析发现 user 被 return 出去 → 逃逸了 → 分堆上
│ └─ 如果不逃逸 → 可能被 JIT 标量替换掉,根本不创建对象(3.6)
├─ 4.3 优先在当前线程的 TLAB 里抢占一小块(无锁!)
├─ 4.4 字段全部初始化为零值 → 写 Mark Word + Klass Pointer
└─ 4.5 invokespecial 调用 <init>("张三", 25),name/age 被赋值
【青年】
[第5章] 在堆上占 24 字节(对象头 12 + age 4 + name引用 4 + 填充 4)
[第6章] 栈上的 reference 直接指向堆中该对象
[第7.1] 被 GC Roots(本例是 main 栈帧的局部变量 user)可达 → 安全
[第8.2] Eden 满了发生 Minor GC,被复制到 Survivor,年龄+1
[第8.3] 反复几次后年龄达到 15 或动态年龄阈值 → 晋升老年代
【中年】
被业务类、集合、缓存传来传去,栈上一个临时 reference 也能让它"可达"
[第7.3] 如果被装进 SoftReference,它只在内存不足时才会被回收
[第7.3] 如果被装进 WeakReference(如 ThreadLocal 的 key),下一轮 GC 就死
【衰老】
[第7.1] 业务结束,user 局部变量出作用域,堆中对象不再可达
[第7.4] 第一次标记:检查是否重写了 finalize
[第7.4] User 没重写 → 略过 F-Queue,直接打上"可回收"标记
【死亡】
[第8章] 下一轮 GC——老年代被标记-整理后,User 的 24 字节平静归还给堆
[第7.5] 当 User 类的加载器也被卸载 → User.class 的元数据才从元空间释放
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
如果你能够把上面这棵树逐节点说出对应章节、对应参数、对应字节码,那这一篇 JVM 内存模型就真的算吃透了。
# 10.3 设计哲学回扣
跳出代码,把整篇 JVM 内存模型升华为四条设计哲学:
- 隔离即安全:线程私有区域(PC、栈)保证了线程安全的天然边界,共享区域(堆、方法区)才需要 GC 和锁——这是"用空间换时间、用边界换简单"。
- 分而治之:堆按生命周期分代(新生代 / 老年代),按使用方式分区(Eden / Survivor / TLAB)。背后是对统计规律的尊重——98% 的对象朝生夕死,没必要用同一种回收策略对待所有对象。
- 延迟绑定与按需付费:TLAB 自适应调整大小、晋升阈值动态决策、指针在堆 < 32GB 时才压缩——JVM 几乎所有的优化策略都不是"一刀切",而是"先观察、再决策"。
- 机制与策略分离:可达性分析是机制(怎么判定垃圾),各种 GC 算法是策略(用哪种方式收);对象访问的句柄/直接指针是机制,HotSpot 选直接指针是策略。理解这种分层,才能读懂后续每一篇的设计取舍。
# 10.4 内存区域速查
最后一张表,建议截图保存——后续每一篇 GC、JMM、并发都会反复用到:
| 区域 | 线程 | 存储内容 | 异常 | 案例落点 |
|---|---|---|---|---|
| 程序计数器 | 私有 | 字节码指令地址 | 无 | 线程切换不丢上下文 |
| 虚拟机栈 | 私有 | 栈帧(局部变量表、操作数栈) | SOE / OOM | 案例中 user 引用的栖身处 |
| 本地方法栈 | 私有 | native 方法信息 | SOE / OOM | Object.hashCode() 走这里 |
| 堆 | 共享 | 对象实例、数组 | OOM | 案例中 10000 个 Order 的家 |
| 方法区/元空间 | 共享 | 类信息、常量、静态变量 | OOM | 案例中 RECENT 这个静态字段 |
| 直接内存 | 共享 | NIO 直接缓冲区 | OOM | NIO/Netty 的零拷贝基础 |
掌握 JVM 内存模型,是理解后续 GC 算法、性能调优、并发原理的基石。下一篇我们顺着"User 这个类是怎么从磁盘走到方法区"这条线,进入第 02 篇:类加载与双亲委派。