1. 概述
Java 对象分配是 JVM 运行时系统的核心功能之一,涉及内存管理、垃圾回收、性能优化等多个方面。深入理解对象分配过程,对于 Java 开发者编写高效代码、排查内存问题和 JVM 调优至关重要。
本文将从 JVM 内存结构入手,详细讲解 Java 对象的完整生命周期,包括内存分配策略、对象布局、优化技术等核心内容,并提供实际案例分析和最佳实践建议。
2. JVM 内存结构与对象分配位置
2.1 堆内存结构详解
Java 堆是对象分配的主要区域,通常分为以下几个部分:
-
年轻代(Young Generation)
- Eden 区:新创建对象的首选分配区域,通常占年轻代的 80%
- Survivor 区(S0/S1):经历一次垃圾回收后仍存活的对象存放区(两个大小相等、功能对称的区域,用于保存经历过 Minor GC 后仍存活的对象,各占年轻代的 10%)
-
老年代(Old Generation/Tenured Generation)
- 长期存活的对象存放区
-
元空间(Metaspace) (JDK 8+,替代永久代)
- 存储类元数据,不存储对象实例
+-----------------------------------------------+
| 堆内存 |
+----------------------------+------------------+
| 年轻代 (Young Gen) | 老年代 (Old Gen) |
+------------+---------------+------------------+
| Eden区 | Survivor区 | |
| +-------+-------+ |
| | S0 | S1 | |
+------------+-------+-------+------------------+
2.2 非堆内存中的对象
非堆内存是 JVM 管理的、不属于 Java 堆的内存区域,用于存储 JVM 运行时的元数据、代码和其他内部数据结构。
特点:
✅ 不受堆内存大小限制(-Xmx)
✅ 不参与 GC 的常规垃圾回收(但有自己的回收机制)
✅ 生命周期通常与类加载器相关
✅ 直接受操作系统内存限制
回顾下非堆内存有哪些:
JVM 内存
├── 堆内存 (Heap Memory) ---- 大部分对象在这里分配
│ ├── 新生代 (Young Generation)
│ │ ├── Eden Space
│ │ ├── Survivor Space 0
│ │ └── Survivor Space 1
│ └── 老年代 (Old Generation)
│
└── 非堆内存 (Non-Heap Memory)
├── 方法区 (Method Area) / 元空间 (Metaspace)
| |
| |-- 类元数据
| |-- 常量池
| |-- 静态变量
|
├── 代码缓存 (Code Cache)
| |
| |-- JIT 编译后的本地代码
|
├── 直接内存 (Direct Memory)
| |
| |-- DirectByteBuffer 等
|
├── 线程栈 (Thread Stack)
└── 本地方法栈 (Native Method Stack)
2.3 对象分配决策树
创建对象 new Object()
↓
JVM 分析对象特征
↓
├─→ 对象逃逸分析
│ │
│ ├─→ 【情况1】未逃逸 + 开启逃逸分析
│ │ └─→ ✅ 栈上分配(Stack Allocation)
│ │
│ ├─→ 【情况2】未逃逸 + 标量替换
│ │ └─→ ✅ 标量替换(Scalar Replacement)
│ │ → 对象拆解为局部变量,分配在栈上
│ │
│ └─→ 发生逃逸
│ └─→ 必须堆分配
│
├─→ 【情况3】大对象
│ └─→ 直接在老年代分配
│
├─→ 【情况4】特殊对象类型
│ ├─→ DirectByteBuffer → 直接内存
│ ├─→ 字符串字面量 → 字符串池(堆中特殊区域)
│ └─→ 类对象(Class<?>) → 元空间
│
└─→ 【情况5】普通对象(默认)
└─→ 堆分配(Eden 区 / TLAB)
2.4 内存分配的两种方式
2.4.1 指针碰撞(Bump the Pointer)
适用于堆内存规整的情况(使用 Serial、ParNew 等复制算法收集器):
- 堆中已使用内存和空闲内存之间有一个指针作为分界点
- 分配内存时,只需将该指针向空闲空间方向移动与对象大小相等的距离
+-------------------+------------------+
| 已使用内存区域 | 空闲内存区域 |
+-------------------+------------------+
^
|
分界指针
2.4.2 空闲列表(Free List)
适用于堆内存不规整的情况(使用 CMS 等标记-清除算法收集器):
- JVM 维护一个空闲内存块列表
- 分配内存时,从列表中找到足够大的空间划分给对象
- 更新空闲列表
+--------+---------+------+----------+--------+
| 空闲块 | 已用块 | 空闲块 | 已用块 | 空闲块 |
+--------+---------+------+----------+--------+
| |
+--------------------------+
空闲列表
2.5 分配详情
🚀 情况 1: 栈上分配(Stack Allocation)
原理:通过逃逸分析判断对象是否逃出方法作用域,如果不逃逸,则在栈上分配。
- 优势 :对象生命周期与方法栈帧相同,方法返回后自动回收,无需 GC
- 原理 :将对象的内存分配在调用栈上,而不是 Java 堆中
- 实现 :将对象实例字段直接分配在栈帧的局部变量表中
// 优化前:堆上分配对象
public void beforeOptimization() {
for (int i = 0; i < 1000; i++) {
Point p = new Point(i, i * 2); // 每次循环都在堆上创建对象
processPoint(p);
}
}
// JVM优化后(概念上):栈上分配
public void afterOptimization() {
// 概念上的等价实现,实际由JVM在编译期优化
for (int i = 0; i < 1000; i++) {
int x = i; // 直接在栈上分配字段
int y = i * 2;
processPointFields(x, y);
}
}
🔧 情况 2: 标量替换(Scalar Replacement)
原理:将对象拆解为基本类型的标量,直接在栈上分配。
- 适用条件 :对象不会被外部引用,且可以被完全分解
- 优化方式 :将对象的各个字段替换为局部变量
- 内存节省 :消除对象头、对齐等额外开销
标量 (Scalar): 不可再分的基本数据类型
- int, long, float, double, boolean, byte, char, short
- 可以直接在栈上存储
聚合量 (Aggregate): 可以继续分解的数据
- 对象、数组
- 通常需要在堆上分配
标量替换示例:
public class ScalarReplacementDemo {
// 原始代码
public int calculate() {
Point p = new Point(10, 20);
return p.x + p.y;
}
// JVM 标量替换后的等效代码
public int calculateOptimized() {
// Point 对象被"消除"
// 成员变量被替换为局部变量
int p_x = 10; // 栈上分配
int p_y = 20; // 栈上分配
return p_x + p_y;
// 优势:
// 1. 没有对象创建开销
// 2. 没有 GC 压力
// 3. 数据在栈上,访问更快(CPU 缓存友好)
}
// 复杂对象的标量替换
public void complexScalarReplacement() {
User user = new User("Alice", 25, new Address("Beijing", "China"));
String info = user.name + " from " + user.address.city;
System.out.println(info);
}
// JVM 优化后
public void complexScalarReplacementOptimized() {
// User 对象被拆解
String user_name = "Alice";
int user_age = 25;
// Address 对象也被拆解
String address_city = "Beijing";
String address_country = "China";
// 所有对象都被"消除",只剩下栈上的基本变量
String info = user_name + " from " + address_city;
System.out.println(info);
}
}
@Data
@AllArgsConstructor
class Address {
String city;
String country;
}
标量替换的条件
标量替换的前提条件:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✅ 必须满足:
1. 对象未逃逸
2. 对象的成员变量可以被拆解为标量
3. 对象不是数组(数组长度动态,难以拆解)
4. JVM 开启了标量替换优化
❌ 无法标量替换:
1. 对象逃逸到方法外
2. 对象包含对象引用(嵌套对象)但无法继续拆解
3. 对象大小过大
4. 对象被同步使用(synchronized)
🎯 情况 3: TLAB(Thread Local Allocation Buffer)
原理:虽然 TLAB 仍然在堆上,但它是一种线程私有的分配策略,减少了线程竞争。
传统堆分配(无 TLAB):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Thread 1 ─┐
Thread 2 ─┼─→ [竞争 Eden 区] ← 需要加锁同步
Thread 3 ─┘ ↓ 性能瓶颈
TLAB 机制(线程本地分配):
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
┌─────────────────────────────────────────────┐
│ Eden 区(堆内存) │
├─────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ Thread 1 的 TLAB │
│ │ TLAB 1 │ ← 线程私有,无锁分配 │
│ │ │ │
│ └──────────────┘ │
│ │
│ ┌──────────────┐ Thread 2 的 TLAB │
│ │ TLAB 2 │ ← 线程私有,无锁分配 │
│ │ │ │
│ └──────────────┘ │
│ │
│ ┌──────────────┐ Thread 3 的 TLAB │
│ │ TLAB 3 │ ← 线程私有,无锁分配 │
│ │ │ │
│ └──────────────┘ │
│ │
│ [共享区域] ← 大对象或 TLAB 满后才使用 │
│ │
└─────────────────────────────────────────────┘
TLAB 分配流程
// 伪代码:对象分配逻辑
public Object allocateObject(Class<?> clazz) {
int size = clazz.getSize();
// 1. 尝试在当前线程的 TLAB 中分配
if (size <= TLAB.remainingSpace()) {
Object obj = TLAB.allocate(size); // ✅ 快速分配,无锁
return obj;
}
// 2. TLAB 空间不足
if (size < TLAB.threshold) {
// 2.1 废弃当前 TLAB,申请新的 TLAB
TLAB.retire();
TLAB = Eden.allocateNewTLAB(); // ⚠️ 需要加锁
// 2.2 在新 TLAB 中分配
Object obj = TLAB.allocate(size);
return obj;
}
// 3. 对象太大,直接在 Eden 共享区分配
Object obj = Eden.allocateInSharedSpace(size); // ⚠️ 需要加锁
return obj;
}
💾 情况 4: 直接内存分配
📦 情况 5: 常量池和字符串池
public class StringPoolAllocation {
public static void main(String[] args) {
// 场景1: 字符串字面量
String s1 = "Hello"; // ✅ 在字符串池中(JDK 7+ 在堆中)
String s2 = "Hello"; // ✅ 复用 s1,不创建新对象
System.out.println(s1 == s2); // true
// 场景2: new String()
String s3 = new String("Hello"); // ❌ 在堆中创建新对象
System.out.println(s1 == s3); // false
// 场景3: intern()
String s4 = new String("Hello").intern(); // ✅ 返回池中的引用
System.out.println(s1 == s4); // true
// 场景4: 运行时字符串
String s5 = new String("Hel") + new String("lo");
System.out.println(s1 == s5); // false
String s6 = s5.intern();
System.out.println(s1 == s6); // true
// 内存布局(JDK 7+):
// ┌──────────────────────────────────────┐
// │ 堆内存 │
// ├──────────────────────────────────────┤
// │ │
// │ ┌─────────────────────────────────┐ │
// │ │ 字符串池(StringTable) │ │
// │ ├─────────────────────────────────┤ │
// │ │ "Hello" ← s1, s2, s4, s6 指向 │ │
// │ │ "World" │ │
// │ │ ... │ │
// │ └─────────────────────────────────┘ │
// │ │
// │ [String "Hello" 对象] ← s3 指向 │
// │ [String "Hello" 对象] ← s5 指向 │
// │ │
// └──────────────────────────────────────┘
}
}
🎓 小结
对象不在堆上分配的情况
✅ 栈上分配 - 对象未逃逸
✅ 标量替换 - 对象被拆解为基本类型
✅ 直接内存 - DirectByteBuffer 等
⚠️ TLAB - 仍在堆中,但线程私有
✅ 类元数据 - 存储在元空间
优化建议
✅ 开启逃逸分析(JDK 8+ 默认开启)
✅ 减少对象逃逸(方法内部使用)
✅ 使用小对象、简单对象
✅ 高频 I/O 使用直接内存
✅ 合理使用对象池
❌ 避免不必要的对象创建
3. 对象分配优化技术
3.1 大对象直接进入老年代
为避免在 Eden 区和 Survivor 区之间频繁复制大对象,JVM 提供了直接进入老年代的机制:
-XX:PretenureSizeThreshold=3145728 # 对象大小超过 3MB 直接进入老年代
3.2 对象年龄晋升
对象在 Survivor 区每经历一次 GC,年龄就增加 1,达到一定年龄后晋升到老年代:
-XX:MaxTenuringThreshold=15 # 对象年龄超过 15 岁进入老年代
3.3 动态年龄判断
如果 Survivor 区中相同年龄的所有对象大小总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 设置的年龄。
4 常见问题与调优建议
4.1 频繁 Minor GC
- 可能原因 :
- Eden 区太小
- 对象创建速率过高
- 对象存活时间短但数量大
- 调优建议 :
- 增大 Eden 区大小: -Xmn
- 调整 Survivor 比例: -XX:SurvivorRatio
- 检查是否有过多的临时对象创建
4.2 对象过早晋升
- 可能原因 :
- Survivor 区太小
- 对象年龄阈值设置过低
- 调优建议 :
- 调整 Survivor 比例: -XX:SurvivorRatio
- 增大 MaxTenuringThreshold: -XX:MaxTenuringThreshold
4.3 OOM 异常
- 可能原因 :
- 堆大小设置不合理
- 内存泄漏
- 大对象过多
- 调优建议 :
- 增加堆大小: -Xms 、 -Xmx
- 使用内存分析工具(如 MAT)查找泄漏点
- 检查大对象使用: -XX:+HeapDumpOnOutOfMemoryError
5. 对象分配优化的最佳实践
5.1 代码层面优化
-
减少临时对象创建
- 重用对象而非频繁创建
- 使用对象池管理频繁创建的对象
- 避免在循环中创建对象
-
合理设计对象大小
- 避免创建超大对象
- 按需加载数据,避免一次性加载过多数据
-
利用不可变对象
- 线程安全,无需同步
- 有助于 JVM 优化
5.2 JVM 参数调优
-
堆大小设置
- 初始堆大小 = 最大堆大小: -Xms512m -Xmx512m
- 根据应用特性设置合适比例
-
新生代与老年代比例
- 年轻代比例: -XX:NewRatio=2 (新生代:老年代 = 1:2)
- 直接设置新生代大小: -Xmn256m
-
TLAB 相关调优
- 启用 TLAB: -XX:+UseTLAB
- 设置 TLAB 大小: -XX:TLABSize=64k
-
逃逸分析相关
- 启用逃逸分析: -XX:+DoEscapeAnalysis
- 启用标量替换: -XX:+EliminateAllocations
- 启用同步消除: -XX:+EliminateLocks
[补充] 逃逸分析的判断标准
1 无逃逸(No Escape)
对象仅在方法内部创建和使用,不会被外部引用。
public void noEscape() {
// 对象仅在方法内部使用,无逃逸
StringBuilder sb = new StringBuilder();
sb.append("Hello");
System.out.println(sb.toString());
}
2 方法逃逸(Method Escape)
对象被传递到方法外部,但未跨线程。
public StringBuilder methodEscape() {
// 对象作为返回值,发生方法逃逸
StringBuilder sb = new StringBuilder();
sb.append("Hello");
return sb; // 对象逃逸到方法外部
}
3 线程逃逸(Thread Escape)
对象被发布到其他线程,可能被其他线程访问。
private static StringBuilder staticField;
public void threadEscape() {
StringBuilder sb = new StringBuilder();
staticField = sb; // 静态字段引用,可能被其他线程访问
// 或在线程中使用
new Thread(() -> {
// 使用sb,发生线程逃逸
}).start();
}
Java对象分配与JVM优化全解析

被折叠的 条评论
为什么被折叠?



