JVM对象创建与内存分配机制

在 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 过程​:

  1. Eden 区满 → 触发 Minor GC
  2. 活对象从 Eden 区移到 Survivor 区
  3. 下次 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

四、老年代空间分配担保机制

分配担保机制​:

  1. Minor GC 前,JVM 计算老年代剩余空间
  2. 如果剩余空间 < 年轻代所有对象大小之和
    • 检查 XX:-HandlePromotionFailure 是否设置
    • 如果设置,则检查老年代可用内存是否 > 之前 Minor GC 后进入老年代的平均大小
    • 如果不满足,触发 Full GC

Full GC 触发条件​:

  1. 老年代空间不足
  2. Minor GC 后存活对象无法放入老年代
  3. 无法进行分配担保

五、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 不再是问题,而是应用设计的自然结果。"

相关推荐
chilavert3182 小时前
技术演进中的开发沉思-327 JVM:内存区域与溢出异常(下)
java·jvm
wb043072013 小时前
一次jvm配置问题导致的数据库连接异常
服务器·jvm·数据库·后端
不穿格子的程序员4 小时前
JVM篇5:编译和解释的区分 + 区分堆栈的好处 + 垃圾回收期的选择
jvm·gc选择
被星1砸昏头16 小时前
掌握Python魔法方法(Magic Methods)
jvm·数据库·python
偷星星的贼1117 小时前
数据分析与科学计算
jvm·数据库·python
Dylan的码园18 小时前
功能包介绍 : calendar
java·jvm·eclipse
康小庄20 小时前
浅谈Java中的volatile关键字
java·开发语言·jvm·spring boot·spring·jetty
chilavert3181 天前
技术演进中的开发沉思-325 JVM:java体系技术全貌(下)
java·开发语言·jvm