引言:对象的家园

当你写下这行代码时:

User user = new User();

这个User对象存储在哪里?答案是:堆(Heap)

堆是JVM管理的 最大的内存区域,也是 垃圾收集器的主战场。理解堆的结构和工作原理,是掌握Java内存管理和性能调优的基础。

为什么堆如此重要?

  • 90%以上的对象实例存储在堆中
  • 堆内存不足是最常见的OOM原因
  • 垃圾回收主要发生在堆中
  • 堆的大小直接影响应用性能

本文将深入理解堆的分代设计、分代假说、以及对象的分配策略。


什么是堆?

核心定义

堆(Heap) 是JVM管理的最大内存区域,用于存储 几乎所有的对象实例和数组

关键特点

  • 线程共享:所有线程共享同一个堆
  • 动态分配:对象的创建和销毁是动态的
  • GC管理:堆是垃圾收集器的主要工作区域
  • 可调整大小:通过JVM参数调整堆的大小

堆的基本参数

通过JVM参数控制堆的大小:

# -Xms: 初始堆大小(起始大小)
# -Xmx: 最大堆大小
# 建议: 生产环境中将两者设置为相同值,避免堆动态扩展的开销

# 示例1:设置初始堆512MB,最大堆2GB
java -Xms512m -Xmx2g MyApp

# 示例2:设置固定堆大小为1GB
java -Xms1g -Xmx1g MyApp

# 示例3:查看堆信息
java -XX:+PrintFlagsFinal -version | grep HeapSize

常见配置

应用类型推荐堆大小说明
小型应用512MB - 1GB适合个人项目、小型Web应用
中型应用2GB - 4GB适合中等规模的企业应用
大型应用8GB - 16GB适合高并发、大数据应用
超大型应用32GB+适合超大规模、内存密集型应用

堆的分代设计

为什么要分代?

在深入分代结构之前,先理解 为什么要分代

分代假说(Generational Hypothesis)

  1. 弱分代假说(Weak Generational Hypothesis)

    • 绝大多数对象都是朝生夕死的
    • 超过98%的对象在创建后很快就会变成垃圾
  2. 强分代假说(Strong Generational Hypothesis)

    • 熬过多次GC的对象难以消亡
    • 对象存活时间越长,越不容易被回收

实际数据支持

  • 研究表明,98%的对象生命周期小于1秒
  • 只有2%的对象存活超过1秒

分代的优势

  • 针对性GC:新生代使用快速GC,老年代使用全面GC
  • 减少GC开销:不需要每次扫描整个堆
  • 提升GC效率:大部分对象在新生代就被回收

堆的分代结构

根据分代假说,堆被划分为 新生代(Young Generation)老年代(Old Generation)

┌─────────────────────────────────────────────────────┐
│               JVM 堆内存 (Heap)                      │
├─────────────────────────────────────────────────────┤
│                                                     │
│  ┌──────────────────────────────────────────────┐  │
│  │       新生代 (Young Generation)  - 1/3       │  │
│  ├──────────────────────────────────────────────┤  │
│  │  ┌─────────┬──────────┬──────────┐          │  │
│  │  │  Eden   │ Survivor │ Survivor │          │  │
│  │  │  Space  │    0     │    1     │          │  │
│  │  │   8     │    1     │    1     │  (比例)  │  │
│  │  └─────────┴──────────┴──────────┘          │  │
│  └──────────────────────────────────────────────┘  │
│                                                     │
│  ┌──────────────────────────────────────────────┐  │
│  │       老年代 (Old Generation)  - 2/3         │  │
│  │                                              │  │
│  │  · 长期存活的对象                             │  │
│  │  · 大对象直接进入                             │  │
│  │  · 从新生代晋升的对象                         │  │
│  └──────────────────────────────────────────────┘  │
│                                                     │
└─────────────────────────────────────────────────────┘

注:新生代与老年代的默认比例为 1:2(可通过-XX:NewRatio调整)

比例关系

  • 新生代 : 老年代 = 1 : 2(默认)
  • Eden : Survivor0 : Survivor1 = 8 : 1 : 1(默认)
  • 可用的新生代空间 = Eden + 1个Survivor = 90%

新生代(Young Generation)详解

新生代的结构

新生代分为3个区域:

  1. Eden区(伊甸园区):大部分对象首次分配在这里
  2. Survivor 0区(S0,From区):存储第一次GC后存活的对象
  3. Survivor 1区(S1,To区):存储第二次GC后存活的对象

关键特点

  • Eden区占新生代的80%
  • 两个Survivor区各占10%
  • 任何时刻,两个Survivor区中只有一个在使用

对象分配与Minor GC流程

完整流程

阶段1:对象分配到Eden区

User user1 = new User();  // 分配到Eden区
User user2 = new User();  // 分配到Eden区
User user3 = new User();  // 分配到Eden区

内存状态

Eden区: [user1] [user2] [user3] ...
S0区: []
S1区: []

阶段2:Eden区满,触发Minor GC

当Eden区满时,触发 Minor GC(年轻代GC)

  1. 扫描Eden区和From Survivor区(S0)
  2. 标记存活对象
  3. 将存活对象复制到To Survivor区(S1)
  4. 清空Eden区和From Survivor区

假设user1和user2存活,user3已死亡

GC前:
Eden区: [user1] [user2] [user3]
S0区: []
S1区: []

GC后:
Eden区: []
S0区: []
S1区: [user1] [user2]  ← 存活对象移到这里

阶段3:再次分配对象

User user4 = new User();
User user5 = new User();

内存状态

Eden区: [user4] [user5]
S0区: []
S1区: [user1] [user2]  ← 上次GC的幸存者

阶段4:再次触发Minor GC

Eden区再次满,触发第二次Minor GC:

  1. 扫描Eden区和当前的From Survivor区(S1)
  2. 将存活对象复制到To Survivor区(S0)
  3. From和To角色互换(S0变To,S1变From)

假设user1、user2、user4存活,user5已死亡

GC前:
Eden区: [user4] [user5]
S0区: []
S1区: [user1] [user2]

GC后:
Eden区: []
S0区: [user1] [user2] [user4]  ← 所有存活对象
S1区: []  ← 清空

阶段5:对象晋升到老年代

对象在Survivor区中每经历一次Minor GC,年龄(Age) 就增加1。

晋升条件

  • 年龄达到阈值(默认15,可通过 -XX:MaxTenuringThreshold 调整)
  • Survivor区空间不足

示例

假设user1经历了15次Minor GC:

GC前:
Eden区: [...]
S0区: [user1(age=15)] [其他对象]
S1区: []

GC后:
Eden区: []
S0区: [其他对象]
S1区: []
Old区: [user1]  ← user1晋升到老年代

Minor GC的触发条件

触发时机

  • Eden区空间不足
  • Survivor区无法容纳存活对象时触发

特点

  • 速度快(通常几毫秒到几十毫秒)
  • 频率高(可能每秒数次)
  • 使用 复制算法(效率高)

老年代(Old Generation)详解

老年代的特点

定义:存储长期存活的对象。

进入老年代的条件

  1. 对象年龄达到阈值(默认15)
  2. 大对象直接进入(超过 -XX:PretenureSizeThreshold
  3. Survivor区放不下时直接晋升
  4. 动态年龄判定:Survivor区中相同年龄对象大小总和超过Survivor空间一半,则大于等于该年龄的对象直接进入老年代

特点

  • 存活对象多,存活率高
  • GC频率低(可能几分钟甚至几小时一次)
  • GC时间长(可能几百毫秒到几秒)

Full GC(老年代GC)

触发条件

  1. 老年代空间不足
  2. 调用 System.gc()(不推荐)
  3. 元空间(方法区)不足
  4. 分配担保失败

Full GC的影响

  • Stop The World(STW):所有应用线程暂停
  • GC时间长,影响用户体验
  • 应尽量避免频繁Full GC

对象分配策略

策略1:对象优先在Eden区分配

规则:绝大多数对象首先在Eden区分配。

public class AllocateDemo {
    public static void main(String[] args) {
        byte[] allocation1 = new byte[2 * 1024 * 1024];  // 2MB,分配到Eden
        byte[] allocation2 = new byte[2 * 1024 * 1024];  // 2MB,分配到Eden
        byte[] allocation3 = new byte[2 * 1024 * 1024];  // 2MB,分配到Eden
    }
}

策略2:大对象直接进入老年代

规则:大对象(需要连续大量内存的对象)直接分配到老年代,避免在Eden和Survivor之间频繁复制。

设置大对象阈值

# 设置大对象阈值为1MB(超过1MB直接进入老年代)
java -XX:PretenureSizeThreshold=1048576 MyApp

示例

// 分配4MB的大数组,直接进入老年代
byte[] bigArray = new byte[4 * 1024 * 1024];

为什么要这样设计?

  • 避免大对象在新生代触发频繁GC
  • 避免Survivor区空间不足导致的对象提前晋升

策略3:长期存活的对象进入老年代

规则:对象在Survivor区中每经历一次Minor GC,年龄+1,达到阈值后晋升到老年代。

设置年龄阈值

# 设置对象晋升年龄为10(默认15)
java -XX:MaxTenuringThreshold=10 MyApp

策略4:动态对象年龄判定

规则:如果Survivor区中相同年龄对象的总大小 超过Survivor空间的一半,则年龄 大于或等于 该年龄的所有对象直接进入老年代。

示例

假设Survivor区大小为10MB:

当前Survivor区对象分布:
· age=1的对象: 2MB
· age=2的对象: 3MB
· age=3的对象: 2MB
· age=4的对象: 1MB

动态判定:
· age=1 + age=2 = 5MB > 10MB / 2
· 因此,age >= 2的所有对象(3MB + 2MB + 1MB = 6MB)直接晋升到老年代

实战示例:观察对象分配

示例代码

public class HeapDemo {
    private static final int _1MB = 1024 * 1024;

    /**
     * VM参数:-Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
     * -Xms20M: 初始堆大小20MB
     * -Xmx20M: 最大堆大小20MB
     * -Xmn10M: 新生代大小10MB(老年代自动为10MB)
     * -XX:SurvivorRatio=8: Eden与Survivor比例为8:1
     * -XX:+PrintGCDetails: 打印GC详情
     */
    public static void main(String[] args) {
        byte[] allocation1, allocation2, allocation3, allocation4;

        allocation1 = new byte[2 * _1MB];  // 2MB
        allocation2 = new byte[2 * _1MB];  // 2MB
        allocation3 = new byte[2 * _1MB];  // 2MB

        // Eden区:2 + 2 + 2 = 6MB(Eden区大小约8MB)

        allocation4 = new byte[4 * _1MB];  // 4MB,Eden区不足,触发Minor GC
    }
}

GC日志分析

[GC (Allocation Failure) [PSYoungGen: 7291K->808K(9216K)] 7291K->6952K(19456K), 0.0031993 secs]
[Times: user=0.01 sys=0.00, real=0.00 secs]

解读:
· PSYoungGen: 新生代GC
· 7291K->808K: 新生代从7MB减少到808KB
· (9216K): 新生代总大小9MB
· 7291K->6952K: 整个堆从7MB减少到6.8MB
· (19456K): 堆总大小19MB
· allocation1/2/3晋升到老年代(6MB),allocation4在Eden区(4MB)

常见问题与误区

❌ 误区1:所有对象都在堆中

真相

  • 绝大多数对象在堆中
  • 逃逸分析优化可能将对象分配在栈上
  • 标量替换可能将对象拆解为基本类型

❌ 误区2:Survivor区越大越好

真相

  • Survivor区过大会浪费内存
  • Survivor区过小会导致对象提前晋升
  • 保持Eden:S0:S1 = 8:1:1的比例通常是合理的

❌ 误区3:堆越大越好

真相

  • 堆过大会导致Full GC时间过长
  • 合理设置堆大小,避免频繁GC和过长的GC暂停

总结

核心要点

  1. 堆是JVM最大的内存区域,存储几乎所有对象实例

  2. 分代设计基于分代假说:大部分对象朝生夕死,少部分对象长期存活

  3. 新生代分为Eden和两个Survivor区,比例为8:1:1

  4. 对象分配策略

    • 优先在Eden区分配
    • 大对象直接进入老年代
    • 长期存活对象晋升到老年代
    • 动态年龄判定机制
  5. Minor GC频繁但快,Full GC罕见但慢

与下篇文章的衔接

下一篇文章,我们将学习 方法区(Method Area),理解类元数据、常量池、静态变量的存储,以及永久代到元空间的演变。


参考资料


下一篇预告:《方法区:类元数据的存储演变》 深入理解方法区的作用,以及从永久代到元空间的演变历程。