堆内存:对象的老巢
如果把 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(存活者)区分为两块:From 和 To,也称为 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)存储的是生命周期较长的对象。
进入老年代的条件:
- 对象年龄达到阈值(默认 15,可通过
-XX:MaxTenuringThreshold设置) - 大对象(超过
-XX:PretenureSizeThreshold)直接进入老年代 - Survivor 区相同年龄的所有对象大小总和超过 Survivor 空间的 50%
老年代的垃圾回收(Major GC / Full GC)比新生代慢得多,因为老年代通常比新生代大得多。
四、TLAB:线程本地分配缓冲
这是 JVM 的一个性能优化。
多线程同时在堆上分配对象时,如果都往同一个区域写,需要加锁来保证线程安全。这会成为性能瓶颈。
TLAB(Thread Local Allocation Buffer)解决这个问题:每个线程在 Eden 区预留一小块空间,分配对象时先在本地缓冲区操作,只有缓冲区满了才需要加锁。
# TLAB 相关参数
-XX:+UseTLAB # 开启 TLAB(默认开启)
-XX:TLABSize=512k # TLAB 大小
-XX:+UseAdaptiveSizePolicy # 自适应调整 TLAB 大小┌─────────────────────────────────────────────────────────┐
│ Eden 区 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ TLAB 1 │ │ TLAB 2 │ │ TLAB 3 │ ... │
│ │ 线程1 │ │ 线程2 │ │ 线程3 │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ ↑ │
│ 各线程独立操作,无竞争 │
└─────────────────────────────────────────────────────────┘五、常用配置参数
# 堆大小配置
-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 的前世今生。
