在 Java 应用中,对象的创建和内存分配是基础且关键的环节。理解 JVM 对象创建的完整流程和内存分配机制,对于性能调优、内存泄漏排查和系统稳定性提升至关重要。本文将深入剖析 JVM 对象创建与内存分配机制,助你掌握这一核心技能。

一、对象创建的完整流程
JVM 创建对象的流程可概括为以下五个步骤:
1. 类加载检查
当虚拟机遇到 new 指令时,首先检查常量池中是否能定位到类的符号引用,并确认该类是否已被加载、解析和初始化。如果未加载,则执行类加载过程。
2. 分配内存
在类加载检查通过后,JVM 为新生对象分配内存。对象所需内存大小在类加载完成后即可确定。
分配内存的两种方法:
- 指针碰撞(默认):Java 堆内存规整,已用内存和空闲内存分隔,指针向空闲区域移动
- 空闲列表:Java 堆内存不规整,已用和空闲内存交错,需维护列表记录可用内存
并发分配解决方案:
- CAS:通过 CAS(Compare and Swap)保证内存分配的原子性
- TLAB(Thread Local Allocation Buffer):每个线程在 Java 堆中预先分配一小块内存
bash
# TLAB配置
-XX:+UseTLAB # 默认开启
-XX:TLABSize=16K # 设置TLAB大小
3. 初始化内存
分配内存后,JVM 将分配到的内存空间初始化为零值(不包括对象头)。如果使用 TLAB,这一步可提前至 TLAB 分配时进行。
4. 设置对象头
JVM 对对象进行必要设置,包括:
- 对象是哪个类的实例
- 类的元数据信息
- 对象的哈希码
- GC 分代年龄等
对象头结构(HotSpot):
- Mark Word:存储对象运行时数据(哈希码、GC 年龄、锁状态等)
- Klass Pointer:指向类元数据的指针
5. 执行方法
执行对象的初始化方法,即按照程序员意愿进行初始化,为属性赋值和执行构造方法。
二、内存分配机制深度剖析
1. 对象头结构
HotSpot 虚拟机的对象头包含两部分:
java
// 对象头结构(64位)
| 25 bits | 4 bits | 1 bit | 2 bits |
|---------|--------|-------|--------|
| hash | age | biased| lock |
Mark Word 存储内容:
- 哈希码(HashCode)
- GC 分代年龄
- 锁状态标志
- 线程持有的锁
- 偏向线程 ID
- 偏向时间戳
Klass Pointer:对象指向类元数据的指针,用于确定对象所属类。
2. 指针压缩
为什么需要指针压缩:
- 64 位平台使用 32 位指针,内存使用多出 1.5 倍
- 减少主内存和缓存间数据移动带宽
- 减轻 GC 压力
JVM 参数:
bash
# 默认开启
-XX:+UseCompressedOops # 压缩对象指针
-XX:+UseCompressedClassPointers # 压缩类指针
# 禁用指针压缩
-XX:-UseCompressedOops
-XX:-UseCompressedClassPointers
指针压缩原理:
- 堆内存小于 4G 时,JVM 直接去除高 32 位
- 堆内存大于 32G 时,压缩指针失效,使用 64 位指针
3. 对象大小计算
使用 JOL-Core 工具查看对象大小:
java
// 示例代码
public class JOLSample {
public static void main(String[] args) {
ClassLayout layout = ClassLayout.parseInstance(new Object());
System.out.println(layout.toPrintable());
}
}
对象大小计算示例:
java
public class A {
int id; // 4B
String name; // 4B(指针压缩后)
byte b; // 1B
Object o; // 4B(指针压缩后)
}
结果:
com.tuling.jvm.JOLSample$A object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (1)
4 4 (object header) 00 00 00 00 (0)
8 4 (object header) 61 cc 00 f8 (-134165407)
12 4 int A.id 0
16 1 byte A.b 0
17 3 (alignment/padding gap)
20 4 java.lang.String A.name null
24 4 java.lang.Object A.o null
28 4 (loss due to the next object alignment)
Instance size: 32 bytes
三、对象内存分配策略
1. 栈上分配
原理:通过逃逸分析确定对象不会被外部访问,可将对象分配在栈上,随栈帧出栈而销毁。
逃逸分析:
java
// 逃逸对象(返回外部)
public User test1() {
User user = new User();
user.setId(1);
user.setName("zhuge");
return user; // 逃逸
}
// 未逃逸对象(方法内部使用)
public void test2() {
User user = new User();
user.setId(1);
user.setName("zhuge");
// 无外部引用
}
JVM 参数:
bash
# 默认开启(JDK7+)
-XX:+DoEscapeAnalysis # 逃逸分析
-XX:+EliminateAllocations # 标量替换
栈上分配效果:
bash
# 运行参数
-Xmx15m -Xms15m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
- 1 亿次对象分配,不触发 GC
- 与不开启栈上分配(-XX:-DoEscapeAnalysis)对比,GC 频繁
2. Eden 区分配
默认内存比例:Eden:S0:S1 = 8:1:1
Minor GC 过程:
- Eden 区满 → 触发 Minor GC
- 活对象从 Eden 区移到 Survivor 区
- 下次 Eden 区满 → 触发 Minor GC,将 Eden 区和 Survivor 区活对象移到另一 Survivor 区
实战案例:
java
public class GCTest {
public static void main(String[] args) throws InterruptedException {
byte[] allocation1 = new byte[60000*1024]; // 57.6MB
byte[] allocation2 = new byte[8000*1024]; // 7.8MB
}
}
GC 日志分析:
[GC (Allocation Failure) [PSYoungGen: 65253K->936K(76288K)] 65253K->60944K(251392K), 0.0279083 secs]
- Eden 区几乎被占满(65536K)
- Minor GC 后,allocation1 被转移到老年代
3. 大对象直接进入老年代
大对象定义:需要大量连续内存空间的对象(如字符串、数组)
JVM 参数:
bash
# 大对象阈值(字节)
-XX:PretenureSizeThreshold=1000000
原理:避免为大对象分配内存时的复制操作,降低效率。
4. 长期存活对象进入老年代
对象年龄:对象在 Survivor 中每熬过一次 Minor GC,年龄 +1
晋升老年代条件:
- 年龄达到阈值(默认 15 岁,CMS 默认 6 岁)
- Survivor 空间中对象总大小 >Survivor 空间 50%(动态年龄判断)
JVM 参数:
bash
# 设置对象晋升年龄阈值
-XX:MaxTenuringThreshold=15
四、老年代空间分配担保机制
分配担保机制:
- Minor GC 前,JVM 计算老年代剩余空间
- 如果剩余空间 < 年轻代所有对象大小之和
- 检查
XX:-HandlePromotionFailure是否设置 - 如果设置,则检查老年代可用内存是否 > 之前 Minor GC 后进入老年代的平均大小
- 如果不满足,触发 Full GC
- 检查
Full GC 触发条件:
- 老年代空间不足
- Minor GC 后存活对象无法放入老年代
- 无法进行分配担保
五、JVM 内存分配优化实践
1. 内存分配优化策略
- 合理设置 TLAB:开启 TLAB,减小内存分配竞争
- 调整新生代比例:根据应用对象生命周期调整 Eden 和 Survivor 比例
- 避免大对象:避免创建过大的对象,减少直接进入老年代
- 合理设置晋升阈值 :根据应用特点调整
XX:MaxTenuringThreshold
2. 优化案例:电商订单系统
问题:订单创建时频繁进入老年代,导致 Full GC 频繁
分析:
- GC 日志显示大量对象在 Minor GC 后进入老年代
- 对象生命周期过长,频繁触发 Full GC
解决方案:
bash
# 优化新生代比例
-XX:NewRatio=3 # 新生代:老年代=1:3
# 优化Survivor比例
-XX:SurvivorRatio=8 # Eden:Survivor=8:1
# 优化晋升阈值
-XX:MaxTenuringThreshold=10
效果:
- Full GC 频率从每 5 分钟 1 次 → 每小时 1 次
- 系统响应时间从 500ms → 200ms
3. 逃逸分析优化案例
问题:频繁创建临时对象,导致 GC 压力大
解决方案:
java
// 开启逃逸分析和标量替换
-XX:+DoEscapeAnalysis
-XX:+EliminateAllocations
// 代码示例
private static void alloc() {
User user = new User();
user.setId(1);
user.setName("zhuge");
}
效果:
- 1 亿次对象分配,不触发 GC
- 内存使用从 1GB+ → 仅需 15MB
六、总结与建议
1. JVM 内存分配核心原则
- 尽可能让对象在新生代分配和回收:避免对象过早进入老年代
- 合理设置 TLAB:减少内存分配竞争
- 优化对象生命周期:避免创建不必要的对象
- 利用逃逸分析:将对象分配在栈上,减少 GC 压力
2. 重要提醒
- 指针压缩默认开启:JDK6 update14 开始支持,无需额外设置
- TLAB 默认开启:-XX:+UseTLAB
- 逃逸分析默认开启(JDK7+):-XX:+DoEscapeAnalysis
- 标量替换默认开启(JDK7+):-XX:+EliminateAllocations
"对象创建不是问题,而是内存分配策略的镜子------它暴露了应用设计的缺陷。"
实战建议清单
| 问题类型 | 诊断方法 | 解决方案 |
|---|---|---|
| 频繁 Full GC | GC 日志分析 | 优化新生代比例,减少对象过早进入老年代 |
| 内存压力大 | 内存使用率监控 | 开启逃逸分析,使用栈上分配 |
| 大对象频繁进入老年代 | 对象大小分析 | 避免创建大对象,调整 PretenureSizeThreshold |
| 对象生命周期过长 | 对象年龄分析 | 调整 MaxTenuringThreshold |
最后提醒:在实施内存优化前,务必在测试环境验证效果。一个错误的 JVM 参数可能导致生产环境严重问题,而正确的优化能带来 10 倍性能提升。
"当你在代码中考虑对象的生命周期,JVM 就能优雅地管理内存,让 GC 不再是问题,而是应用设计的自然结果。"