堆细分结构/大小设置/OOM案例
堆的完整结构
JVM 的堆在逻辑上被分为三个区域:年轻代(Young Generation)、老年代(Old/Tenured Generation),以及 JDK 7 之前存在的永久代(Permanent Generation)。
JDK 8 之后,永久代被移除,用元空间(Metaspace)替代——但元空间不在堆中,而是使用本地内存。
JDK 7 及之前:
┌──────────────────────────────────────────────────┐
│ 堆(Heap) │
│ │
│ ┌────────────────┐ ┌──────────────────┐ │
│ │ 年轻代 │ │ 老年代 │ │
│ │ Eden │ S0 │ S1 │ │ │ │
│ └────────────────┘ └──────────────────┘ │
│ │
│ ┌────────────────────────────────────────────┐ │
│ │ 永久代(PermGen) │ │
│ │ 类信息、常量、字符串常量池(JDK 7 及之前) │ │
│ └────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
JDK 8+:
┌──────────────────────────────────────────────────┐
│ 堆(Heap) │
│ │
│ ┌────────────────┐ ┌──────────────────┐ │
│ │ 年轻代 │ │ 老年代 │ │
│ │ Eden │ S0 │ S1 │ │ │ │
│ └────────────────┘ └──────────────────┘ │
│ │
└──────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────┐
│ 本地内存(Native Memory) │
│ │
│ ┌────────────────────────────────────────────┐ │
│ │ 元空间(Metaspace) │ │
│ │ 类信息、运行时常量池、JIT 编译代码缓存 │ │
│ └────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘各区域的详细说明
Eden 区:新对象的出生地
大多数新对象在 Eden 区分配。当 Eden 区满时,触发 Minor GC。
java
public class EdenAllocation {
public static void main(String[] args) {
// 这些对象都在 Eden 区分配
for (int i = 0; i < 100; i++) {
String s = "item" + i; // 字符串是对象
byte[] data = new byte[1024]; // 小数组
}
// Eden 区满时,Minor GC 把存活对象移到 Survivor 区
}
}Survivor 区:对象的「中转站」
Survivor 区分为 S0 和 S1 两块,大小相等。Minor GC 后,存活对象在两块 Survivor 区之间来回移动。
Minor GC 前:Eden 区满,S0 有一些对象
┌─────────────────────────────────────────────┐
│ Eden │ S0 (有对象) │ S1 (空) │
└─────────────────────────────────────────────┘
Minor GC 后:Eden 和 S0 中的存活对象复制到 S1
┌─────────────────────────────────────────────┐
│ Eden (已清空) │ S0 (已清空) │ S1 (存活对象) │
└─────────────────────────────────────────────┘
下次 Minor GC:角色互换
┌─────────────────────────────────────────────┐
│ Eden (已清空) │ S1 (存活对象) │ S0 (已清空) │
└─────────────────────────────────────────────┘Survivor 区中,对象每经历一次 Minor GC,年龄计数器就加 1。当年龄超过阈值(默认 15),对象晋升到老年代。
老年代:长期存活的对象
老年代存储生命周期较长的对象。当老年代满时,触发 Major GC 或 Full GC。
进入老年代的条件:
- 年龄达标:对象经历多次 Minor GC 后仍然存活(年龄超过
-XX:MaxTenuringThreshold,默认 15) - 大对象:超过
-XX:PretenureSizeThreshold阈值的对象直接在老年代分配 - Survivor 区空间不足:如果 Survivor 区中相同年龄的所有对象之和超过 Survivor 区的一半,年龄 >= 该年龄的对象直接进入老年代
堆大小设置
基本参数
bash
# 初始堆大小
java -Xms256m MyApp
# 最大堆大小
java -Xmx4g MyApp
# 设置初始和最大堆为相同值(避免运行时调整)
java -Xms4g -Xmx4g MyApp年轻代设置
bash
# 设置年轻代大小(JDK 8 及之前)
java -Xmn2g MyApp
# 设置 Eden 和 Survivor 的比例(JDK 8 及之前)
# 默认比例是 8:1:1(Eden : S0 : S1)
# -XX:SurvivorRatio=8 表示 Eden = Survivor * 8
java -XX:SurvivorRatio=4 MyApp # Eden 是 Survivor 的 4 倍
# JDK 17+,可以用比例设置年轻代
java -XX:NewRatio=2 MyApp # 年轻代 : 老年代 = 1 : 2内存设置原则
| 场景 | 建议配置 |
|---|---|
| 开发/测试环境 | -Xms512m -Xmx512m(固定大小,便于观察) |
| 高并发服务器 | -Xms4g -Xmx4g -Xmn2g(固定年轻代,减少 GC 频率) |
| 大内存机器 | -Xms8g -Xmx8g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 |
| 容器环境 | -XX:+UseContainerSupport -XX:MaxRAMPercentage=75 |
OOM 实战案例
案例一:堆内存耗尽
java
public class HeapOOMCase {
// 模拟不断增长的数据缓存
private static final List<byte[]> CACHE = new ArrayList<>();
public static void main(String[] args) {
int size = 0;
try {
while (true) {
byte[] buffer = new byte[1024 * 1024]; // 每次分配 1MB
CACHE.add(buffer);
size++;
System.out.println("Allocated " + size + " MB");
}
} catch (OutOfMemoryError e) {
System.out.println("OOM after " + size + " MB");
}
}
}java -Xmx256m -XX:+PrintGCDetails HeapOOMCase
Allocated 1 MB
Allocated 2 MB
...
Allocated 248 MB
Allocated 249 MB
OOM after 249 MB案例二:频繁 Full GC 但对象无法回收
java
public class MemoryLeak {
// 静态集合持有对象引用,导致无法回收
private static final Map<String, byte[]> LEAK_MAP = new HashMap<>();
public static void main(String[] args) {
int count = 0;
try {
while (true) {
// key 不重复,所以每次都插入新条目
LEAK_MAP.put(UUID.randomUUID().toString(), new byte[1024 * 1024]);
count++;
// 故意不清理,模拟内存泄漏
}
} catch (OutOfMemoryError e) {
System.out.println("OOM after " + count + " inserts");
}
}
}GC 日志会显示大量 Full GC,但每次 Full GC 后堆内存仍然接近最大值——这是典型的内存泄漏特征。
案例三:大对象直接进入老年代
java
public class LargeObjectCase {
public static void main(String[] args) {
// 超过 PretenureSizeThreshold 的大对象直接进入老年代
// 默认阈值是 0,表示不启用
// 设置阈值为 100KB
// java -XX:PretenureSizeThreshold=100000 -XX:+PrintGCDetails ...
// 这个 10MB 的数组会直接在老年代分配
byte[] hugeArray = new byte[10 * 1024 * 1024];
System.out.println("Large object allocated in old gen");
}
}排查工具
bash
# 1. 生成堆转储(OOM 时自动)
java -Xmx256m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/ MyApp
# 2. 使用 jmap 查看堆概况
jmap -heap <pid>
# 3. 使用 jstat 监控 GC
jstat -gcutil <pid> 1000本节小结
堆结构与配置的核心要点:
| 区域 | 作用 | 关键参数 |
|---|---|---|
| Eden | 新对象分配 | -XX:SurvivorRatio |
| Survivor | MinorGC 中转 | -XX:MaxTenuringThreshold |
| 老年代 | 长期存活对象 | -XX:NewRatio / -Xmn |
| Metaspace | 类元信息 | -XX:MaxMetaspaceSize |
OOM 的排查思路:转储 → 分析 → 定位泄漏点 → 修复引用链路。
下一节,我们来看 新生代/老年代参数设置。
