Skip to content

03.4 - Java 堆

定位: 所有线程共享的最大内存区域——对象的生、老、病、死都在这里发生 面试高频度: ⭐⭐⭐⭐⭐ 考查方式: 分代设计动机、TLAB 原理、Eden:S0:S1 比例、晋升阈值为什么是 15、字符串常量池迁移

一、这是什么?为什么需要它?

Java 堆(Heap)是 JVM 管理的最大一块内存,是所有线程共享的区域。几乎所有的对象和数组都在这里分配。

为什么几乎所有对象都在堆上? 因为堆是 GC 的管理区域。对象生命周期不固定——一个对象可能在方法内创建,被返回到外部,再被其他线程引用。栈无法管理这种跨方法、跨线程的生命周期。堆让对象的"出生"和"死亡"完全由可达性决定,而不是语法作用域。

二、原理拆解

2.1 分代设计(Generational Collection)

为什么需要分代?

核心依据是弱分代假说(Weak Generational Hypothesis)

  1. 绝大多数对象早夭:比如循环中创建的局部对象、方法内的临时对象,超过 90% 在创建后很快就不再被引用
  2. 熬过多次 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 位。

java
// 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 也可能。

bash
# 查看当前阈值
-XX:+PrintTenuringDistribution
# 手动设置阈值 (1-15)
-XX:MaxTenuringThreshold=5

2.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。

bash
# 查看/设置 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: 默认堆空间分配

bash
# 查看 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,观察晋升行为:

bash
# JDK 8 不能设为 16
java -XX:MaxTenuringThreshold=16 -version
# 输出: MaxTenuringThreshold of 16 is invalid; must be between 0 and 15

验证 3: TLAB 分配与 CAS 分配的性能差异

java
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");
    }
}
bash
# 启用 TLAB (默认)
java -XX:+UseTLAB TLABBenchmark
# 创建 10,000,000 个对象耗时: ~180ms

# 禁用 TLAB
java -XX:-UseTLAB TLABBenchmark
# 创建 10,000,000 个对象耗时: ~420ms

TLAB 让对象分配快了约 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 如何判定堆中的对象是否存活
  • ← 返回 **运行时数据区索引**

Knowledge4J — Java 知识库