Skip to content

堆内存:对象的老巢

如果把 JVM 内存比作一座城市,堆就是最大的那个区——几乎所有"居民"(对象)都住在这里。

程序中 new 出来的对象,99% 都住在堆里。理解堆的结构,是理解 Java 内存管理的关键。


一、堆的核心地位

堆(Heap)是 JVM 中最大的一块内存区域,被所有线程共享。

它的主要职责是存储:

  • 对象实例:所有通过 new 创建的对象
  • 数组:所有数组对象
  • 字符串常量池(JDK 7 及之前在方法区,JDK 7 及之后移入堆)
  • Class 对象:反射时生成的 Class 对象
┌─────────────────────────────────────────────────────────────────┐
│                         JVM 堆内存                               │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │                      老年代 (Old Generation)             │   │
│  │                                                          │   │
│  │    ┌────────────┐  ┌────────────┐  ┌────────────┐     │   │
│  │    │  对象实例   │  │  对象实例   │  │  大对象     │     │   │
│  │    └────────────┘  └────────────┘  └────────────┘     │   │
│  │                                                          │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
│  ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─  │
│                      新生代 (Young Generation)                    │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │ Eden 区          │    Survivor 0     │    Survivor 1   │   │
│  │                  │   (From)           │   (To)          │   │
│  │  [新对象分配]     │   [存活对象]        │   [空闲]        │   │
│  │                  │                    │                 │   │
│  │  [  TLAB  ]      │                    │                 │   │
│  │  [  TLAB  ]      │                    │                 │   │
│  │  [  TLAB  ]      │                    │                 │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

二、新生代的内部结构

新生代(Young Generation)分为三部分:

2.1 Eden 区

新对象出生的第一站。当我们 new 一个对象时,它首先被分配到 Eden 区。

2.2 Survivor 区

Survivor(存活者)区分为两块:FromTo,也称为 S0 和 S1。

它们的作用是:存放经过 Minor GC 后仍然存活的对象

为什么需要两个 Survivor 区?因为复制算法需要。Minor GC 后,存活的对象会被复制到另一个 Survivor 区,然后清空当前 Survivor 区。两块 Survivor 区轮流扮演"目的地"的角色。

Minor GC 执行前:
┌─────────────────────────────────────────┐
│ Eden │  Survivor From  │  Survivor To   │
│      │    (有数据)      │    (空)        │
└─────────────────────────────────────────┘

Minor GC 执行后:
┌─────────────────────────────────────────┐
│ Eden │  Survivor From  │  Survivor To   │
│ (空)  │    (空)          │  (存活对象)     │
└─────────────────────────────────────────┘

角色交换后:
┌─────────────────────────────────────────┐
│ Eden │  Survivor From  │  Survivor To   │
│      │    (空)          │   (有数据)      │
└─────────────────────────────────────────┘

2.3 对象分配流程

新对象创建

    ├─→ 是否为大对象? ──→ 是 ──→ 直接进入老年代

    └─→ 否

        分配到 Eden 区

        Eden 区满,触发 Minor GC

        ┌─→ 对象是否存活? ──→ 否 ──→ 回收

        └─→ 是

            ┌─→ 对象年龄 < 15? ──→ 是 ──→ 复制到 Survivor 区
            │                          (年龄+1)

            └─→ 否 ──→ 进入老年代

对象年龄超过 15 后,会进入老年代。这个 15 是因为对象头中用来记录年龄的空间只有 4 位(最大表示 15)。


三、老年代

老年代(Old Generation)存储的是生命周期较长的对象

进入老年代的条件:

  1. 对象年龄达到阈值(默认 15,可通过 -XX:MaxTenuringThreshold 设置)
  2. 大对象(超过 -XX:PretenureSizeThreshold)直接进入老年代
  3. Survivor 区相同年龄的所有对象大小总和超过 Survivor 空间的 50%

老年代的垃圾回收(Major GC / Full GC)比新生代慢得多,因为老年代通常比新生代大得多。


四、TLAB:线程本地分配缓冲

这是 JVM 的一个性能优化

多线程同时在堆上分配对象时,如果都往同一个区域写,需要加锁来保证线程安全。这会成为性能瓶颈。

TLAB(Thread Local Allocation Buffer)解决这个问题:每个线程在 Eden 区预留一小块空间,分配对象时先在本地缓冲区操作,只有缓冲区满了才需要加锁。

bash
# TLAB 相关参数
-XX:+UseTLAB          # 开启 TLAB(默认开启)
-XX:TLABSize=512k     # TLAB 大小
-XX:+UseAdaptiveSizePolicy  # 自适应调整 TLAB 大小
┌─────────────────────────────────────────────────────────┐
│                         Eden 区                          │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐             │
│  │  TLAB 1  │  │  TLAB 2  │  │  TLAB 3  │  ...        │
│  │  线程1   │  │  线程2   │  │  线程3   │             │
│  └──────────┘  └──────────┘  └──────────┘             │
│                         ↑                              │
│                  各线程独立操作,无竞争                    │
└─────────────────────────────────────────────────────────┘

五、常用配置参数

bash
# 堆大小配置
-Xms256m             # 初始堆大小
-Xmx512m             # 最大堆大小
-Xmn128m             # 新生代大小

# 新生代配置
-XX:NewRatio=2        # 老年代:新生代 = 2:1(默认)
                     # 如果 -Xmx=512m,则新生代=170m,老年代=341m

-XX:SurvivorRatio=8  # Eden:Survivor = 8:1(默认)
                      # 如果新生代=128m,则 Eden=102m,每个 Survivor=12.8m

# 对象年龄配置
-XX:MaxTenuringThreshold=15      # 最大年龄阈值(JDK 11+ 最大值是 15)
-XX:TargetSurvivorRatio=50      # Survivor 区使用率达到 50%时,提升对象年龄

# 大对象配置
-XX:PretenureSizeThreshold=10m   # 超过 10MB 的大对象直接进入老年代

# TLAB 配置
-XX:+UseTLAB                       # 开启 TLAB(默认)
-XX:TLABSize=512k                  # TLAB 大小
-XX:-UseAdaptiveSizePolicy         # 关闭自适应策略,手动指定

六、内存分配计算示例

假设设置:-Xmx=256m -Xms=256m -Xmn=64m -XX:NewRatio=1 -XX:SurvivorRatio=8

总堆 = 256MB

新生代 = 64MB
  - Eden = 64 * 8/(8+1+1) = 56.9MB ≈ 56MB
  - 每个 Survivor = 64 * 1/(8+1+1) = 7.1MB ≈ 7MB

老年代 = 256 - 64 = 192MB

七、面试高频问题

问题 1:对象一定在 Eden 区分配吗?

不一定。有几种情况会直接进入老年代:

  • 大对象(超过 -XX:PretenureSizeThreshold
  • 长期存活的对象(年龄达到阈值)
  • Survivor 区相同年龄的所有对象大小总和超过 Survivor 空间的 50%

问题 2:TLAB 的作用是什么?

TLAB 解决的是多线程并发分配对象时的竞争问题。每个线程有自己的本地缓冲区,分配对象时先在本地操作,避免加锁,提升分配性能。

问题 3:为什么需要两个 Survivor 区?

为了支持复制算法。Minor GC 时,存活对象需要复制到另一个 Survivor 区。如果只有一个 Survivor 区,无法进行这个操作——要么需要额外的空间来中转,要么会覆盖正在使用的对象。

问题 4:对象年龄为什么最大是 15?

因为对象头的 Mark Word 中,用来存储分代年龄的空间只有 4 位。4 位二进制最大能表示的值是 15(1111)。


留给你的问题

我们讲了堆的结构:Eden、Survivor、Old,以及 TLAB 优化。

你有没有想过:在实际项目中,如何判断新生代和老年代的比例是否合理?

如果 Minor GC 频繁发生,说明新生代太小;如果 Full GC 频繁发生,说明老年代太小。但更根本的问题是:你的应用创建的对象,是"朝生夕灭"还是"长寿"?

如果 90% 的对象都是用完就扔(弱分代假说),那么增大 Eden 区、减少 Survivor 可以提升性能。

反之,如果对象生命周期较长,过小的 Survivor 会导致对象过早进入老年代,增加 Full GC 频率。

下一节,我们来聊聊方法区——JDK 7 的 PermGen 和 JDK 8 的 Metaspace 的前世今生。

基于 VitePress 构建