对象分配过程(一般/特殊情况)
分配即分配,JVM 怎么做
对象分配是 Java 程序中最频繁的操作之一。JVM 需要在堆中为每个 new 对象找到一块空闲空间,并初始化它。
一般分配过程:TLAB + 指针碰撞
指针碰撞(Bump the Pointer)
当堆内存规整时(即已分配的对象和空闲区域严格分开,中间没有碎片),JVM 只需要维护一个指针,指向空闲区域的起始位置。分配时,把指针向后移动对象大小的距离即可:
┌─────────────────────────┬──────────────────┐
│ 已分配区域 │ 空闲区域 │
│ [Obj1][Obj2][Obj3] │ Bump Pointer│
└─────────────────────────┴──────────────────┘
↑
分配新对象时
指针向前移动指针碰撞非常高效,但前提是堆必须规整——这由 GC 决定。
本地线程分配缓冲(TLAB)
这是 JVM 中最重要的分配优化之一。
多个线程同时在堆上分配对象时,如果都用指针碰撞,需要同步机制(CAS)来保证线程安全——这会严重降低并发分配的性能。
TLAB 的解决思路:每个线程在 Eden 区预先分配一块专属区域(Thread-Local Allocation Buffer),线程在自己的 TLAB 内分配对象,无需任何同步。
线程 A 的 TLAB 线程 B 的 TLAB 线程 C 的 TLAB
┌────────────┐ ┌────────────┐ ┌────────────┐
│ ObjA1 │ │ ObjB1 │ │ ObjC1 │
│ ObjA2 │ │ ObjB2 │ │ ObjC2 │
│ (继续分配) │ │ (继续分配) │ │ (继续分配) │
└────────────┘ └────────────┘ └────────────┘
↑ Eden 区剩余空间(非 TLAB 区域) ↑
更多线程的 TLABpublic class TLABDemo {
public static void main(String[] args) {
// 在 TLAB 中分配了大量小对象
for (int i = 0; i < 1000000; i++) {
// 每个线程在自己的 TLAB 中分配,互不干扰
// 分配速度接近栈分配
byte[] data = new byte[64];
}
}
}完整分配流程
new Object()
│
▼
检查:类是否已加载?
│ 否 → 类加载
│
▼
计算对象大小(在编译时确定)
│
▼
检查:能否在 TLAB 中分配?
│
├── 能:TLAB 内指针碰撞分配
│
└── 不能:尝试在 Eden 区分配
│
├── 成功:指针碰撞分配
└── 失败:触发 Minor GC
│
▼
回收后重试分配
│
├── 成功:分配
└── 失败:对象太大 → 直接在老年代分配
│
├── 成功:分配
└── 失败:Full GC → OOM特殊情况一:大对象直接进老年代
超过阈值的对象不在年轻代分配,直接在老年代分配。
// 设置阈值为 1MB
// java -XX:PretenureSizeThreshold=1048576
public class LargeObject {
public static void main(String[] args) {
// 超过阈值的大数组直接在老年代分配
// 避免频繁的大对象 Minor GC
byte[] hugeData = new byte[2 * 1024 * 1024]; // 2MB
}
}大对象直接进老年代的原因:如果一个大对象在年轻代分配,Minor GC 后它肯定还会存活(因为太大无法进入 Survivor 区),直接晋升老年代。与其让它每次 Minor GC 都经历一次复制,不如直接放在老年代。
特殊情况二:长期存活对象晋升
对象每经历一次 Minor GC 且存活,年龄计数器就加 1。当年龄达到 MaxTenuringThreshold 时,对象晋升到老年代。
public class AgingToOld {
static class CacheNode {
byte[] data = new byte[10 * 1024]; // 模拟缓存节点
}
public static void main(String[] args) {
// 这个缓存节点存活时间很长
CacheNode cache = new CacheNode();
// 使用 16 次(超过默认阈值 15)
for (int i = 0; i < 16; i++) {
// 第 16 次 Minor GC 后,cache 晋升老年代
useCache(cache);
}
}
static void useCache(CacheNode c) { /* 模拟使用缓存 */ }
}特殊情况三:动态年龄判定
即使没达到 MaxTenuringThreshold,如果 Survivor 区内相同年龄的所有对象大小之和超过 Survivor 区的一半,年龄 >= 该年龄的所有对象直接晋升老年代。
public class DynamicPromotion {
public static void main(String[] args) {
// 场景:大量对象同时在 Survivor 区
// 如果这批对象的年龄相同且总和超过 Survivor 一半
// 即使年龄小于 MaxTenuringThreshold,也会晋升
for (int i = 0; i < 1000; i++) {
byte[] obj = new byte[100]; // 累积约 100KB
}
// 假设 Survivor 只有 200KB
// 1000 个 100 字节对象 = 100KB,占 Survivor 50%
// 下次 Minor GC 时,这批对象全部晋升老年代
}
}特殊情况四:空间分配担保
Minor GC 之前,JVM 检查老年代最大可用连续空间是否大于年轻代所有对象的总大小。
public class AllocationGuarantee {
public static void main(String[] args) {
// 假设年轻代所有对象总大小 = 900MB
// 老年代最大可用连续空间 = 500MB
// 500MB < 900MB → 担保失败,触发 Full GC(而非 Minor GC)
// Full GC 会整理老年代,释放空间后再 Minor GC
}
}JDK 8 Update 24 之后,担保失败的逻辑被简化——无论是否担保失败,都会先尝试 Minor GC,如果失败再 Full GC。
对象分配总结流程图
new 关键字
│
▼
┌─────────────────────────────┐
│ 检查类是否已加载 │
│ 否 → 先类加载 │
└─────────────┬───────────────┘
▼
┌─────────────────────────────┐
│ 计算对象所需空间 │
└─────────────┬───────────────┘
▼
┌─────────────────────────────┐
│ TLAB 分配? │
├──── 是 ──→ TLAB 内碰撞分配 │
│ 否 │
│ ▼ │
│ Eden 区指针碰撞? │
│ 否(eden 满) │
│ ▼ │
│ Minor GC → Survivor 区复制 │
│ 晋升判断 → 老年代分配 │
└─────────────┬───────────────┘
▼
初始化 + 返回引用本节小结
对象分配看似简单,背后是一套完整的判断流程:
| 步骤 | 判断条件 | 结果 |
|---|---|---|
| TLAB 分配 | 线程自己的 TLAB 有空间 | 快速分配,无需同步 |
| Eden 指针碰撞 | Eden 有足够空间 | 正常分配 |
| Survivor 转移 | Minor GC 后 Survivor 有空间 | 存活对象进入 Survivor |
| 老年代担保 | 老年代空间足够 | Minor GC 后晋升 |
| 老年代直接分配 | 对象很大 | 直接在老年代分配 |
| OOM | 全部失败 | OutOfMemoryError |
理解分配过程,是理解 GC 行为的入口。下一节,我们来看 TLAB 与内存分配策略。
