编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

    • README
    • 入门教程

    • 综合案例

    • 专栏博客

      • README
      • JVM内存模型与对象
        • 1. 案例引入
          • 1.1 一次线上事故
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构概览
          • 2.1 三大子系统
          • 2.2 私有与共享
        • 3. 运行时数据区
          • 3.1 程序计数器
          • 3.2 虚拟机栈
          • 3.3 栈帧结构
          • 3.4 本地方法栈
          • 3.5 堆内存
          • 3.6 逃逸分析
          • 3.7 方法区演进
          • 3.8 运行时常量池
          • 3.9 直接内存
        • 4. 对象创建过程
          • 4.1 类加载检查
          • 4.2 内存分配策略
          • 4.3 TLAB分配缓冲
          • 4.4 零值与对象头
          • 4.5 执行init方法
        • 5. 对象内存布局
          • 5.1 对象头详解
          • 5.2 实例数据填充
          • 5.3 对象大小计算
          • 5.4 指针压缩原理
        • 6. 对象访问定位
          • 6.1 句柄访问
          • 6.2 直接指针访问
        • 7. 对象生命周期
          • 7.1 从创建到可达
          • 7.2 可达性分析
          • 7.3 四种引用类型
          • 7.4 对象死亡过程
          • 7.5 方法区的回收
        • 8. 堆内存分代
          • 8.1 为什么要分代
          • 8.2 新生与老年代
          • 8.3 对象晋升策略
          • 8.4 空间分配担保
        • 9. OOM排查实战
          • 9.1 各区域OOM
          • 9.2 堆溢出排查
          • 9.3 内存泄漏诱因
          • 9.4 常用监控工具
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 一个User的一生
          • 10.3 设计哲学回扣
          • 10.4 内存区域速查
      • 类加载与双亲委派
      • 垃圾回收与GC调优
      • 异常体系与JVM机制
      • 字节码指令集javap实战
      • JIT编译与去优化机制
      • JVM性能诊断工具链
      • OOM八大现场全景剖析
      • JVM参数调优全景图
      • GraalVM与AOT编译原理
      • HashMap底层哈希设计
      • String不可变与常量池
      • ArrayList与LinkedList源码
      • ConcurrentHashMap并发
      • TreeMap与红黑树原理
      • LinkedHashMap与LRU实现
      • Java数字类型原理
      • Object通用方法的契约
      • 泛型擦除与类型系统
      • 枚举原理与最佳实践
      • 注解原理与编译期处理
      • Lambda与引用底层原理
      • Stream原理与流水线设计
      • Optional设计原理
      • Record密封类与模式
      • 反射机制与动态代理
      • MethodHandle与VarHandle
      • 三大字节码框架对比
      • JavaAgent与Instrumentation机制
      • AOP三种实现路线对比
      • synchronized与锁升级
      • volatile与JMM内存模型
      • 线程池核心源码设计
      • Thread线程生命周期
      • AQS同步框架源码
      • 并发锁三剑客
      • CAS和Atomic深入分析
      • 五大同步器对比
      • CompletableFuture异步
      • IO模型演进BIO到AIO
      • ByteBuffer与堆外内存
      • 序列化原理与替代方案
      • 文件IO与NIO.2
      • 面向对象的真意
      • JDK设计模式上
      • JDK设计模式下
      • SPI与模块化设计
  • Go入门到精通

  • JavaScript入门

  • CodeX
  • Java入门精通
  • 专栏博客
杨充
2026-06-02
目录

JVM内存模型与对象

# 01.JVM内存模型与对象

# 目录介绍

  • 1. 案例引入
    • 1.1 一次线上事故
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 三大子系统
    • 2.2 私有与共享
  • 3. 运行时数据区
    • 3.1 程序计数器
    • 3.2 虚拟机栈
    • 3.3 栈帧结构
    • 3.4 本地方法栈
    • 3.5 堆内存
    • 3.6 逃逸分析
    • 3.7 方法区演进
    • 3.8 运行时常量池
    • 3.9 直接内存
  • 4. 对象创建过程
    • 4.1 类加载检查
    • 4.2 内存分配策略
    • 4.3 TLAB分配缓冲
    • 4.4 零值与对象头
    • 4.5 执行init方法
  • 5. 对象内存布局
    • 5.1 对象头详解
    • 5.2 实例数据填充
    • 5.3 对象大小计算
    • 5.4 指针压缩原理
  • 6. 对象访问定位
    • 6.1 句柄访问
    • 6.2 直接指针访问
  • 7. 对象生命周期
    • 7.1 从创建到可达
    • 7.2 可达性分析
    • 7.3 四种引用类型
    • 7.4 对象死亡过程
    • 7.5 方法区的回收
  • 8. 堆内存分代
    • 8.1 为什么要分代
    • 8.2 新生与老年代
    • 8.3 对象晋升策略
    • 8.4 空间分配担保
  • 9. OOM排查实战
    • 9.1 各区域OOM
    • 9.2 堆溢出排查
    • 9.3 内存泄漏诱因
    • 9.4 常用监控工具
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 一个User的一生
    • 10.3 设计哲学回扣
    • 10.4 内存区域速查

# 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+ 字段
}
1
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)
1
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章「排查实战」
1
2
3
4
5
6

# 1.3 我们要回答什么

这个事故就是本篇的主线案例。我们带着上面 6 个问号往下走,每讲完一段原理,就能解开一两个问号。最后在第 10 章,我们会把整条链路串起来——回头看完会发现:不是 JVM 不够聪明,是我们没把 JVM 当回事。

本篇路线:

架构概览 (第2章)
   ↓
运行时数据区 (第3章) ─→ 解开"对象放在哪"
   ↓
对象创建/布局/访问 (第4-6章) ─→ 解开"对象长什么样"
   ↓
生命周期 + 分代 (第7-8章) ─→ 解开"对象什么时候死"
   ↓
OOM 排查 (第9章) ─→ 武器库
   ↓
综合案例 (第10章) ─→ 把案例彻底剖开
1
2
3
4
5
6
7
8
9
10
11

# 2. 架构概览

# 2.1 三大子系统

JVM(Java Virtual Machine)是 Java 程序的运行引擎。它的核心架构可以分为三大部分:

┌─────────────────────────────────────────────┐
│                  Java 源代码                   │
│                    ↓ javac 编译                │
│               .class 字节码文件                 │
└──────────────────┬──────────────────────────┘
                   ↓
┌─────────────────────────────────────────────┐
│              类加载子系统                       │
│   (加载 → 链接 → 初始化)                       │
└──────────────────┬──────────────────────────┘
                   ↓
┌─────────────────────────────────────────────┐
│             运行时数据区                        │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐     │
│  │ 程序计数器 │ │ 虚拟机栈  │ │本地方法栈 │     │
│  │ (线程私有) │ │ (线程私有) │ │(线程私有) │     │
│  └──────────┘ └──────────┘ └──────────┘     │
│  ┌──────────────────┐ ┌──────────────────┐  │
│  │     堆 (Heap)      │ │  方法区/元空间    │  │
│  │   (线程共享)       │ │  (线程共享)      │  │
│  └──────────────────┘ └──────────────────┘  │
│  ┌──────────────────┐                       │
│  │  直接内存(堆外)    │                       │
│  └──────────────────┘                       │
└──────────────────┬──────────────────────────┘
                   ↓
┌─────────────────────────────────────────────┐
│              执行引擎                          │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐    │
│  │  解释器   │ │JIT编译器  │ │    GC    │    │
│  └──────────┘ └──────────┘ └──────────┘    │
└─────────────────────────────────────────────┘
1
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 值,恢复时从保存的位置继续
1
2
3
4
5
6
7
8
9
10
11

# 3.2 虚拟机栈

虚拟机栈(VM Stack)描述的是 Java 方法执行的内存模型。每个方法被调用时会创建一个栈帧(Stack Frame),方法执行结束栈帧出栈。

┌────────────────────────┐
│     虚拟机栈 (线程私有)    │
│  ┌──────────────────┐  │
│  │ 栈帧:method_C()   │  │  ← 栈顶(当前方法)
│  ├──────────────────┤  │
│  │ 栈帧:method_B()   │  │
│  ├──────────────────┤  │
│  │ 栈帧:method_A()   │  │
│  ├──────────────────┤  │
│  │ 栈帧:main()      │  │  ← 栈底
│  └──────────────────┘  │
└────────────────────────┘
1
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
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 调整栈大小
-Xss256k    # 设置每个线程栈大小为 256KB(减小可以创建更多线程)
-Xss2m      # 设置为 2MB(增大可支持更深的递归)
1
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) │  │
│  │  方法正常/异常退出后的返回点   │  │
│  └──────────────────────────────┘  │
└────────────────────────────────────┘
1
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)
}
1
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)     栈:[]
1
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 实现
}
1
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
1
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;
}
1
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 对象!
}
1
2
3
4
5
6
7
8
9
10
11
12
13
# 相关 JVM 参数
-XX:+DoEscapeAnalysis       # 开启逃逸分析(默认开启)
-XX:+EliminateAllocations   # 开启标量替换(默认开启)
1
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

疑惑:为什么要从永久代改为元空间?

论证:

  1. 永久代有固定大小上限,容易出现 OutOfMemoryError: PermGen space。大量使用反射、动态代理、CGLib 等框架时尤其明显
  2. 字符串常量池放在永久代中导致 GC 效率低,永久代只在 Full GC 时才被回收
  3. 元空间使用本地内存,理论上只受物理内存限制,大大减少了 OOM 概率
  4. 统一 HotSpot 和 JRockit:JRockit 没有永久代的概念,合并后使用元空间更合理
# 元空间参数
-XX:MetaspaceSize=128m       # 初始元空间大小(触发 Full GC 的阈值)
-XX:MaxMetaspaceSize=256m    # 最大元空间大小(默认无上限)
1
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
1
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 中避免了堆内存和内核缓冲区之间的拷贝
1
2
3
4
5
6
7
-XX:MaxDirectMemorySize=256m  # 限制直接内存大小
# 默认与 -Xmx 相同
1
2

注意:直接内存不受 GC 直接管理,需要通过 Cleaner 机制回收。不当使用可能导致 OutOfMemoryError: Direct buffer memory。

# 4. 对象创建过程

当 JVM 遇到一条 new 指令时,会经历以下完整流程:

# 4.1 类加载检查

JVM 首先检查这个 new 指令对应的类是否已经被加载、解析和初始化过。如果没有,会先触发类加载机制(详见第2篇)。

new 指令
  → 在常量池中定位类的符号引用
    → 该类已加载?
      → 是:继续内存分配
      → 否:触发类加载(加载→验证→准备→解析→初始化)
1
2
3
4
5

# 4.2 内存分配策略

类加载检查通过后,JVM 为新对象分配内存。分配方式取决于堆内存是否规整:

指针碰撞(Bump the Pointer):

堆内存规整时(使用压缩整理型收集器,如 Serial、ParNew):

已使用      ↓ 分配指针     空闲
████████████│░░░░░░░░░░░░░░░░

分配 N 字节后:
████████████████│░░░░░░░░░░░░░
              ↑ 指针向空闲方向移动 N 字节
1
2
3
4
5
6
7
8

空闲列表(Free List):

堆内存不规整时(使用标记清除型收集器,如 CMS):

██░░██████░░░░██░░████░░░░░░
  ^        ^      ^
  空       空     空

JVM 维护一个空闲块列表,分配时找到一块足够大的空间
1
2
3
4
5
6
7

# 4.3 TLAB分配缓冲

并发安全问题:多个线程同时 new 对象怎么办?

方案一:CAS + 失败重试
  → 对分配指针做原子性更新
  → 竞争激烈时性能下降

方案二:TLAB(Thread Local Allocation Buffer)
  → 每个线程预先分配一小块堆内存(Eden 区中)
  → 对象优先在自己的 TLAB 中分配(无锁,速度极快)
  → TLAB 用完了再申请新的 TLAB(此时才需要同步)
  → 默认开启:-XX:+UseTLAB
1
2
3
4
5
6
7
8
9
Eden 区
┌──────────┬──────────┬──────────┬─────────────┐
│ Thread1  │ Thread2  │ Thread3  │   空闲       │
│ 的 TLAB  │ 的 TLAB  │ 的 TLAB  │             │
│ ████░░░  │ ██████░  │ █░░░░░░  │             │
└──────────┴──────────┴──────────┴─────────────┘
1
2
3
4
5
6
-XX:+UseTLAB                  # 开启 TLAB(默认开启)
-XX:TLABSize=512k             # 设置 TLAB 初始大小
-XX:+ResizeTLAB               # 自适应调整 TLAB 大小
1
2
3

# 4.4 零值与对象头

内存分配完成后:

  1. 初始化零值:将分配到的内存空间初始化为零值

    • int → 0, long → 0L, boolean → false, 引用 → null
    • 这就是为什么 Java 中字段有默认值
  2. 设置对象头:

    • 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             // 存入局部变量表
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字节倍数 │
└──────────────────────────────────────┘
1
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());
1
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 字节
1
2
3
4
5
6
7

# 5.4 指针压缩原理

64 位 JVM 中,对象引用占 8 字节。HotSpot 通过压缩指针(Compressed Oops)将其压缩为 4 字节:

-XX:+UseCompressedOops        # 默认开启(堆 < 32GB 时)
-XX:+UseCompressedClassPointers  # 压缩 Klass Pointer
1
2

原理:对象地址都是 8 字节对齐的,低 3 位永远是 0。JVM 只存储高 32 位(右移 3 位),使用时左移 3 位还原。这样 4 字节可以寻址 4GB × 8 = 32GB 的堆空间。

堆 > 32GB 时指针压缩失效:所有引用变为 8 字节,对象整体变大,可能反而比 32GB 堆更慢。因此生产环境建议堆大小不超过 32GB,或者直接设为 31GB 以确保压缩生效。

# 6. 对象访问定位

Java 通过栈中的 reference(引用)访问堆中的对象。JVM 规范没有规定如何通过引用定位对象,主流有两种方式:

# 6.1 句柄访问

栈                  句柄池(堆中)             堆
┌───────┐        ┌──────────────┐      ┌───────────┐
│ ref ──┼───→    │ 对象实例指针 ──┼──→   │ 实例数据    │
│       │        │ 类型数据指针 ──┼──┐   └───────────┘
└───────┘        └──────────────┘  │   
                                    └→  ┌───────────┐
                                        │ 类元数据    │ 方法区
                                        └───────────┘
1
2
3
4
5
6
7
8

优点:GC 移动对象时只需修改句柄中的指针,reference 不需要改变 缺点:多一次间接寻址

# 6.2 直接指针访问

栈                       堆
┌───────┐          ┌───────────────────┐
│ ref ──┼──────→   │ 对象头(含类型指针) │
│       │          │ 实例数据            │
└───────┘          └────────┬──────────┘
                            │ Klass Pointer
                            ↓
                     ┌───────────┐
                     │ 类元数据    │ 方法区
                     └───────────┘
1
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();
    }
}
1
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 内存
1
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() 中重新建立了引用链 → 对象"复活"
     │
     └─ 仍然不可达 → 正式回收
1
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 ? "对象存活" : "对象死亡"); // 对象死亡
    }
}
1
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 方法区的回收

方法区的回收主要是废弃常量和不再使用的类。

类卸载的条件(三个条件必须同时满足):

  1. 该类所有的实例都已被 GC 回收
  2. 加载该类的 ClassLoader 已被 GC 回收
  3. 该类对应的 Class 对象没有在任何地方被引用
# 查看类卸载信息
-XX:+TraceClassUnloading
1
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││ │                │  │
│  │  └─────┘ └───┘ └───┘│ │                │  │
│  └─────────────────────┘ └────────────────┘  │
└──────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10

新生代默认按 8:1:1 划分为 Eden 区和两个 Survivor 区(S0、S1)。新对象优先在 Eden 区分配。

Minor GC 过程:

  1. 将 Eden 区和正在使用的 Survivor 区(如 S0)中存活对象复制到另一个 Survivor 区(S1)
  2. 存活对象的 GC 年龄加 1
  3. 清空 Eden 和 S0
  4. S0 和 S1 角色互换
-XX:SurvivorRatio=8          # Eden:S0:S1 = 8:1:1
-XX:NewRatio=2               # 老年代:新生代 = 2:1
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)
1
2
3
4
5
6
7
8

# 8.4 空间分配担保

Minor GC 前,JVM 会检查老年代最大可用连续空间是否大于新生代所有对象总空间:

Minor GC 前检查
  │
  ↓ 老年代可用空间 > 新生代所有对象?
  │
  ├─ 是 → Minor GC 安全,直接执行
  │
  └─ 否 → 检查 HandlePromotionFailure 是否允许担保失败
     │
     ├─ 允许 → 检查老年代可用空间 > 历次晋升的平均大小?
     │   ├─ 是 → 冒险进行 Minor GC
     │   └─ 否 → 进行 Full GC
     │
     └─ 不允许 → 进行 Full GC
1
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:按类统计对象数量和大小
1
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 引用
    }
}
1
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);
}
1
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 按字节计权
1
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 的元数据才从元空间释放
1
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 内存模型升华为四条设计哲学:

  1. 隔离即安全:线程私有区域(PC、栈)保证了线程安全的天然边界,共享区域(堆、方法区)才需要 GC 和锁——这是"用空间换时间、用边界换简单"。
  2. 分而治之:堆按生命周期分代(新生代 / 老年代),按使用方式分区(Eden / Survivor / TLAB)。背后是对统计规律的尊重——98% 的对象朝生夕死,没必要用同一种回收策略对待所有对象。
  3. 延迟绑定与按需付费:TLAB 自适应调整大小、晋升阈值动态决策、指针在堆 < 32GB 时才压缩——JVM 几乎所有的优化策略都不是"一刀切",而是"先观察、再决策"。
  4. 机制与策略分离:可达性分析是机制(怎么判定垃圾),各种 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 篇:类加载与双亲委派。

上次更新: 2026/06/10, 11:13:41
README
类加载与双亲委派

← README 类加载与双亲委派→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式