03.4 - Java 堆
定位: 所有线程共享的最大内存区域——对象的生、老、病、死都在这里发生 面试高频度: ⭐⭐⭐⭐⭐ 考查方式: 分代设计动机、TLAB 原理、Eden:S0:S1 比例、晋升阈值为什么是 15、字符串常量池迁移
一、这是什么?为什么需要它?
Java 堆(Heap)是 JVM 管理的最大一块内存,是所有线程共享的区域。几乎所有的对象和数组都在这里分配。
为什么几乎所有对象都在堆上? 因为堆是 GC 的管理区域。对象生命周期不固定——一个对象可能在方法内创建,被返回到外部,再被其他线程引用。栈无法管理这种跨方法、跨线程的生命周期。堆让对象的"出生"和"死亡"完全由可达性决定,而不是语法作用域。
二、原理拆解
2.1 分代设计(Generational Collection)
为什么需要分代?
核心依据是弱分代假说(Weak Generational Hypothesis):
- 绝大多数对象早夭:比如循环中创建的局部对象、方法内的临时对象,超过 90% 在创建后很快就不再被引用
- 熬过多次 GC 的对象倾向于继续存活:比如缓存对象、单例、长期服务的上下文对象
如果没有分代——每次 GC 都要扫描整个堆,遍历所有对象的引用链。对于生产环境中的大堆(几十 GB),Full GC 动辄几十秒 STW。
分代的思想:把堆分成年轻代(Young Generation)和老年代(Old Generation),对不同区域采用不同的 GC 策略:
┌─────────────────────────────────────────────────────────┐
│ Java 堆 │
│ │
│ ┌─────────────────────────────┬───────────────────────┐ │
│ │ 年轻代 (Young) │ 老年代 (Old) │ │
│ │ "大多数对象在这里死去" │ "幸存者的养老院" │ │
│ │ │ │ │
│ │ ┌──────┬─────┬──────┐ │ │ │
│ │ │ Eden │ S0 │ S1 │ │ │ │
│ │ │ 80% │ 10% │ 10% │ │ │ │
│ │ └──────┴─────┴──────┘ │ │ │
│ │ │ │ │
│ │ GC 算法: Minor GC │ GC 算法: Major/Full │ │
│ │ 频率: 高 (秒级) │ 频率: 低 (小时/天级) │ │
│ │ STW: 短 (毫秒级) │ STW: 长 (秒/十秒级) │ │
│ └─────────────────────────────┴───────────────────────┘ │
└─────────────────────────────────────────────────────────┘年轻代高频但快速(只扫描年轻代),老年代低频但完整(扫描全堆)。这种"高频扫小范围 + 低频扫全范围"组合,在吞吐量和延迟之间取得平衡。
对象晋升流程
对象创建 → 在 Eden 分配
│
▼ (Eden 空间耗尽 → Minor GC)
检查对象是否存活:
↓ ↓
是 (存活) 否 (死亡)
↓ ↓
移入 S0 (第一次GC) 被回收
↓
下一轮 Minor GC:
S0 中存活对象 → S1 (年龄 +1)
S1 中存活对象 → S0 (年龄 +1)
↓
交替复制 Survivor 间↔,
直到年龄 ≥ MaxTenuringThreshold (默认 15)
↓
晋升到老年代 (Old)流程中的关键观察:对象在 Eden 和 Survivor 之间复制,每次复制年龄 +1,直到超过阈值才进入老年代。这就是"分代"的实际运作模式。
2.2 Eden : S0 : S1 = 8:1:1
为什么这个比例合理?
默认比例为 -XX:SurvivorRatio=8(Eden:S0=8:1, 实际 Eden:S0:S1 = 8:1:1)。
- Eden 被分配给绝大多数刚创建的对象——由于 90%+ 对象朝生夕死,Eden 需要足够大来容纳临时对象,一次 Minor GC 就能回收大部分空间
- 两个 Survivor 区各占 10%——采用复制算法(Copying Collection) 时,每次 GC 总有一个 Survivor 区是空的(称为"To"空间),存活对象从 Eden + "From" Survivor 复制到 "To" Survivor
Minor GC 前: Minor GC 后:
┌──────┬────┬────┬─────────┐ ┌──────┬────┬────┬─────────┐
│ Eden │ S0 │ S1 │ Old │ │ Eden │ S0 │ S1 │ Old │
│ (满) │(有)│(空)│ │ → │ (空) │(空)│(有) │ │
│ │ │ │ │ │ │ │<──存活对象 │
└──────┴────┴────┴─────────┘ └──────┴────┴────┴─────────┘
↑ ↑ ↑ ↑
新对象 From To 下一轮的 From
(存活复制的目标) (S1→S0 角色互换)两个 Survivor 区是角色互换的:S0 是 From、S1 是 To;下一次 Minor GC 时角色反转。永远保证总有一个 Survivor 区是空的,这样才能使用复制算法。
2.3 晋升阈值为什么默认是 15?
MaxTenuringThreshold 默认值 = 15。这是因为对象头的 age 字段只有 4 位。
// HotSpot 源码: oopDesc.hpp
// age 字段在对象头的 mark word 中, 占用 4 个 bit
// 最大值: 0b1111 = 15对象头 (Mark Word) (64位JVM):
┌─────────────────────────────────────────────────────────────┐
│ unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1
└─────────────────────────────────────────────────────────────┘
↑
年龄字段, 只有 4 bit
→ 最大值 15 (0b1111)
→ MaxTenuringThreshold 不超过 15这是一个硬件约束驱动 JVM 参数设计的典型案例。对象头中的 age 字段只有 4 比特,所以阈值不能超过 15。JDK 6 后做了优化:如果 Survivor 区空间紧张(容不下所有该年龄段对象),HotSpot 会自动调低阈值(通过 -XX:+UseAdaptiveSizePolicy),部分对象可能提前晋升——阈值变为 0 也可能。
# 查看当前阈值
-XX:+PrintTenuringDistribution
# 手动设置阈值 (1-15)
-XX:MaxTenuringThreshold=52.4 TLAB(Thread Local Allocation Buffer)
为什么需要 TLAB?
所有线程共享同一个堆。如果不做特殊处理,两个线程同时 new 对象时,都需要 CAS(Compare And Swap)操作来竞争同一个 Eden 空间指针——每次对象分配都要 CAS,高并发下开销巨大。
未使用 TLAB (每个 new 都要 CAS):
┌──────────────────────────────────────┐
│ Eden │
│ │
│ ↑ 线程A: CAS(new Object()) │
│ ↑ 线程B: CAS(new Object()) ← 冲突! │
│ ↑ 线程C: CAS(new Object()) ← 又冲突│
└──────────────────────────────────────┘
使用 TLAB (每个线程有自己的缓冲区):
┌──────────────────────────────────────┐
│ Eden │
│ ┌────┬────┬────┬──────────────────┐ │
│ │ T1 │ T2 │ T3 │ 共享区域 │ │
│ │ │ │ │ (大对象分配) │ │
│ └────┴────┴────┴──────────────────┘ │
│ ↑每个线程在自己的 TLAB 内分配, │
│ 无需 CAS 竞争 │
└──────────────────────────────────────┘TLAB 是 Eden 内的一个小块区域(默认占 Eden 的 1%),线程私有的分配缓冲区。在 TLAB 内 new 对象仅需要指针 bump(简单的指针下移),不需要任何同步操作。只有 TLAB 用尽时,线程才需要 CAS 从 Eden 申请新的 TLAB。
# 查看/设置 TLAB 参数
-XX:+UseTLAB # 启用 (JDK 8 默认开启)
-XX:TLABSize=2m # 设置 TLAB 大小
-XX:-ResizeTLAB # 禁止 JVM 自动调整 TLAB 大小
-XX:TLABWasteTargetPercent=1 # TLAB 浪费目标百分比 (默认 1%)TLAB 中的浪费——如果线程的 TLAB 还剩 100 字节,但下一个要分配的对象是 200 字节,这段 100 字节就浪费了。JVM 在 TLAB 几乎耗尽时会直接分配对象到 Eden 共享区域,避免浪费。这个决策由
TLABWasteTargetPercent控制。
2.5 字符串常量池的迁移 (JDK 7)
在 JDK 7 之前,字符串常量池存储在 PermGen 中。实践表明这是个糟糕的决定:
原因: String.intern() 是一个可以在运行时动态向常量池添加字符串的方法。如果 PermGen 空间不够(默认 64MB/82MB,很多场景不够用),intern() 就会 OOM。PermGen GC 条件苛刻,已 intern 的字符串很难被自动回收。
JDK 7 的修复: 将字符串常量池从 PermGen 迁移到 Java 堆(Heap)。堆上的 GC 机制会自动回收不再被引用的 intern 字符串。
JDK 6 及以前 JDK 7+
┌───────────────────┐ ┌───────────────────────┐
│ 堆 (Heap) │ │ 堆 (Heap) │
│ ┌────┬────┬────┐ │ │ ┌────┬────┬────┬────┐ │
│ │Eden│ S0 │ S1 │ │ │ │Eden│ S0 │ S1 │Old │ │
│ ├────┴────┴────┤ │ │ ├────┴────┴────┴────┤ │
│ │ Old │ │ │ │ 字符串常量池 │ │
│ └──────────────┘ │ │ │ (可被堆 GC 回收) │ │
└───────────────────┘ │ └───────────────────┘ │
┌───────────────────────┐ └───────────────────────┘
│ PermGen │ ┌────────────────────────┐
│ ├ 类元数据 │ │ Metaspace (JDK 8) │
│ ├ 运行时常量池 │ │ ├ 类元数据 │
│ ├ 字符串常量池 ← OOM │ │ ├ 运行时常量池 │
│ └ (固定大小, 难回收) │ │ └ (无字符串常量池) │
└───────────────────────┘ └────────────────────────┘这个迁移直接解决了生产环境中 String.intern() 导致的 OOM 问题,是"通过架构调整解决实际问题"的经典案例。
三、图解全景
┌──────────────────────────────────────────────────────────────────┐
│ Java 堆完整结构图 │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 年轻代 (Young) │ │
│ │ │ │
│ │ ┌────────────────────────────────────────┬────────┬──────┐ │ │
│ │ │ Eden │ S0 │ S1 │ │ │
│ │ │ │ (From)│ (To)│ │ │
│ │ │ ┌──────┐┌──────┐┌──────┐ │ │ │ │ │
│ │ │ │TLAB ││TLAB ││共享 │ │ │ │ │ │
│ │ │ │T1 ││T2 ││区域 │ │ │ │ │ │
│ │ │ └──────┘└──────┘└──────┘ │ │ │ │ │
│ │ └────────────────────────────────────────┴────────┴──────┘ │ │
│ │ │ │ │
│ │ │ Minor GC 时, 存活对象从 Eden+From 复制到 To │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ 老年代 (Old) │ │ │
│ │ │ ├─ 长期存活对象 (年龄 ≥ MaxTenuringThreshold) │ │ │
│ │ │ ├─ 大对象直接分配 (PretenureSizeThreshold) │ │ │
│ │ │ ├─ 动态年龄判定晋升 │ │ │
│ │ │ └─ 字符串常量池 (JDK 7+) │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ 对象流动方向: │
│ Eden ──(Minor GC)──→ S0/S1 ──(年龄增长)──→ Old ──(Full GC)──→ 回收 │
│ ↑ TLAB 加速分配 ↑ 字符串常量池 │
│ ↑ ↑ GC 自动回收字符串 │
└──────────────────────────────────────────────────────────────────┘四、实战验证
验证 1: 默认堆空间分配
# 查看 JVM 堆的默认配置
java -XX:+PrintGCDetails -version输出示例(JDK 8, 8GB 物理机):
java version "1.8.0_202"
Java(TM) SE Runtime Environment (build 1.8.0_202-b08)
Java HotSpot(TM) 64-Bit Server VM (build 25.202-b08, mixed mode)
Heap
PSYoungGen total 76288K, used 3932K [0x...)
eden space 65536K, 6% used [0x...)
from space 10752K, 0% used [0x...)
to space 10752K, 0% used [0x...)
ParOldGen total 175104K, used 0K [0x...)
object space 175104K, 0% used [0x...)
Metaspace used 2638K, capacity 4480K, committed 4480K, reserved 1056768K可以看到 eden:from:to = 65536:10752:10752 ≈ 6:1:1(默认 -XX:SurvivorRatio=8 时理论是 8:1:1,实际受对齐和自适应策略影响略有偏差)。
验证 2: 晋升阈值 15 的证明
设置不同的 MaxTenuringThreshold,观察晋升行为:
# JDK 8 不能设为 16
java -XX:MaxTenuringThreshold=16 -version
# 输出: MaxTenuringThreshold of 16 is invalid; must be between 0 and 15验证 3: TLAB 分配与 CAS 分配的性能差异
public class TLABBenchmark {
private static final int COUNT = 10_000_000;
public static void main(String[] args) {
long start = System.nanoTime();
for (int i = 0; i < COUNT; i++) {
Object o = new Object();
}
long end = System.nanoTime();
System.out.println("创建 " + COUNT + " 个对象耗时: " + (end - start) / 1_000_000 + "ms");
}
}# 启用 TLAB (默认)
java -XX:+UseTLAB TLABBenchmark
# 创建 10,000,000 个对象耗时: ~180ms
# 禁用 TLAB
java -XX:-UseTLAB TLABBenchmark
# 创建 10,000,000 个对象耗时: ~420msTLAB 让对象分配快了约 2-3 倍。在高并发服务中,这个差距更加显著。
五、面试视角
| 追问 | 答案要点 |
|---|---|
| 为什么堆要分代? | 弱分代假说:大多数对象早夭,少数长期存活。分代后,年轻代用高频低开销的 Minor GC,老年代用低频的 Full GC,在吞吐量和延迟间取得平衡 |
| MaxTenuringThreshold 为什么默认 15? | 对象头的 age 字段只有 4 bit(最大值 15)。这是硬件约束驱动参数设计的经典案例 |
| TLAB 是什么?为什么需要? | Thread-Local Allocation Buffer,Eden 中线程私有的分配缓冲区。消除多线程并发 new 对象时的 CAS 竞争。TLAB 内分配只需指针 bump(移动指针) |
| 为什么需要两个 Survivor 区? | 支持复制算法。标记存活对象后,需要一块干净的"To"空间来复制存活者。两个 Survivor 区角色互转,保证总有一个为空 |
| Eden:S0:S1 为什么是 8:1:1? | 经验比例。90%+ 对象在 Eden 分配后即死亡,10% 幸存者由 Survivor 容纳。1 个 Survivor 空间足够容纳一次 Minor GC 的存活对象 |
| 字符串常量池为什么挪到堆? | PermGen 空间有限,String.intern() 动态增长易导致 OOM。移到堆后,GC 自动回收不再被引用的 intern 字符串,彻底解决此问题 |
| 什么是大对象直接进入老年代? | -XX:PretenureSizeThreshold 设置阈值,超过此大小的对象直接在老年代分配(避免在 Eden 和 Survivor 间频繁复制)。但这个参数只对 Serial/ParNew 有效,对 Parallel Scavenge 无效 |
📚 相关链接
- **方法区与元空间** — 堆之外 GC 还会扫描方法区中的类元数据
- **运行时常量池** — 字符串常量池在堆上,但运行时常量池在元空间
- **Java虚拟机栈** — 栈帧中的引用指向堆中对象(GC Root)
- **对象创建过程** — 从 new 指令到对象在堆上的完整分配流程
- **对象存活判定算法** — GC 如何判定堆中的对象是否存活
- ← 返回 **运行时数据区索引**