JVM对象创建与内存分配
一、对象创建流程
对象创建需依次执行以下 5 个核心步骤,确保对象从 “指令触发” 到 “可用状态” 的完整生命周期:
-
类加载检查
- 触发条件:虚拟机遇到new 指令(对应语言层面的
new
关键词、对象克隆、对象序列化)。 - 检查内容:
- 定位常量池中该类的符号引用。
- 验证该符号引用对应的类是否已完成加载、解析、初始化。
- 处理逻辑:若类未加载,则先执行完整的类加载流程。
- 触发条件:虚拟机遇到new 指令(对应语言层面的
-
分配内存
-
前提:类加载检查通过后,对象所需内存大小已确定(类加载完成后可计算)。
-
核心问题与解决方案:
问题类型 解决方案 关键细节 内存划分方式 1. 指针碰撞:内存规整时,移动分界指针(默认方式);2. 空闲列表:内存交错时,维护可用内存块列表 指针碰撞依赖堆内存的 “规整性”(如 Serial、ParNew 收集器);空闲列表适用于 CMS 等收集器 并发安全问题 1. CAS + 失败重试:保证内存分配操作的原子性;2. TLAB:线程预分配独立内存块 TLAB 参数:-XX:+UseTLAB(默认开启)、-XX:TLABSize(指定大小)
-
-
初始化零值
- 操作:将分配的内存空间(除对象头外)全部初始化为零值(如 int=0、String=null)。
- 优势:保证对象的实例字段在 Java 代码中不赋初始值也可直接使用,程序能访问到字段的默认零值。
- 特殊情况:若使用 TLAB,零值初始化可提前至 TLAB 分配时执行。
-
设置对象头
-
HotSpot 虚拟机中,对象内存布局分为 3 部分:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。
-
核心组成:
-
对象头(Header):存储对象运行时关键信息,分为三部分:
-
Mark Word:占 32 位(4 字节)或 64 位(8 字节),存储哈希码、GC 分代年龄、锁状态标志等,不同锁状态下结构不同:
锁状态 32 位 Mark Word 结构(示例) 2bit 锁标志位 关键说明 无锁态 25bit 哈希码 + 4bit 分代年龄 + 1bit 无偏向 01 初始状态,无锁保护 偏向锁 23bit 线程 ID + 2bit Epoch + 4bit 分代年龄 + 1bit 有偏向 01 优化单线程下的锁竞争 轻量级锁 30bit 指向栈中锁记录的指针 + 2bit 标志 00 多线程轻度竞争,无阻塞 重量级锁 30bit 指向互斥量的指针 + 2bit 标志 10 重度竞争,依赖操作系统互斥量 GC 标记 30bit 无意义 + 2bit 标志 11 GC 回收标记阶段 -
类型指针:指向类元数据的指针,虚拟机通过该指针确定对象所属类;默认开启指针压缩时占 4 字节,禁用时占 8 字节。
-
数组长度(仅数组对象才有):占 4 字节,存储数组的元素个数。
-
-
实例数据(Instance Data):存储对象的实例字段(如
int id
、String name
),字段顺序受 JVM 优化影响。 -
对齐填充(Padding):无实际意义,仅用于保证对象大小为8 字节的整数倍(HotSpot 虚拟机要求),根据前两部分大小动态调整。
-
-
-
执行
方法 - 作用:将对象从 “零值状态” 转换为 “程序员定义的初始化状态”。
- 具体操作:
- 执行类的构造方法(
<init>()
)。 - 为实例字段赋值(区别于 “初始化零值”,此处为程序员显式赋值)。
- 执行类的构造方法(
二、对象大小与指针压缩
1. 对象大小查看工具:jol-core
-
依赖引入:
<dependency><groupId>org.openjdk.jol</groupId><artifactId>jol-core</artifactId><version>0.9</version> </dependency>
-
使用示例:通过
ClassLayout.parseInstance(Object obj).toPrintable()
打印对象内存布局,包含偏移量(OFFSET)、大小(SIZE)、字段描述等。
2. 指针压缩(64 位 JVM 核心优化)
-
核心概念:通过编码 / 解码方式,让 64 位 JVM 使用 32 位指针访问内存,平衡内存消耗与寻址范围。
-
关键参数:
参数 作用 默认值 -XX:+UseCompressedOops 开启指针压缩(OOP=Ordinary Object Pointer) 开启(JDK7+) -XX:-UseCompressedOops 禁用指针压缩 不推荐 -
压缩逻辑:
- 32 位指针默认支持 4G 内存(2³²),压缩后通过 “按 8 字节对齐” 优化,可支持≤32G 堆内存(4G×8)。
- 堆内存 > 32G 时,压缩失效,强制使用 64 位指针(8 字节),导致内存消耗增加 1.5 倍左右,GC 压力增大(所以有时候不是机器的内存越大越好,视情况而定)。
-
示例效果:
- 普通对象(如
new Object()
):开启压缩时对象大小为 16 字节(8 字节 Mark Word + 4 字节类型指针 + 4 字节对齐填充);禁用压缩时为 24 字节(8+8+8)。
- 普通对象(如
三、对象内存分配机制
JVM 根据对象特性(生命周期、大小),将对象分配到不同内存区域,优化 GC 效率:
1. 栈上分配(减少堆 GC 压力)
- 核心目的:对 “不会逃逸” 的临时对象,在栈帧中分配内存,随栈帧出栈自动销毁,无需 GC 回收。
- 依赖机制:
- 逃逸分析(Escape Analysis):分析对象动态作用域,判断是否被外部方法引用(是则“逃逸”,否则 “不逃逸”)。
- 开启参数:-XX:+DoEscapeAnalysis(JDK7 + 默认开启);禁用:-XX:-DoEscapeAnalysis。
- 标量替换(Scalar Replacement):将 “不逃逸” 的聚合量(对象)分解为标量(如
int
、reference
),在栈帧 / 寄存器分配空间。- 开启参数:-XX:+EliminateAllocations(JDK7 + 默认开启);禁用:-XX:-EliminateAllocations。
- 逃逸分析(Escape Analysis):分析对象动态作用域,判断是否被外部方法引用(是则“逃逸”,否则 “不逃逸”)。
- 示例验证:调用 1 亿次
alloc()
创建User
对象,开启逃逸分析与标量替换时(-Xmx15m)无 GC,禁用则触发大量 GC。
事实上,真实的业务中逃逸的对象占比很少,调优时可以不用考虑
2. Eden 区分配(新生代默认分配)
- 分配规则:大多数对象优先在新生代Eden 区分配,Eden 与 Survivor 区(From/To)默认比例为8:1:1。
- GC 触发:当 Eden 区无足够空间时,触发Minor GC(仅回收新生代垃圾,频繁、速度快)。
- 关键参数:
- -XX:-UseAdaptiveSizePolicy:禁用自适应大小策略,固定 Eden:Survivor=8:1:1;默认开启时 JVM 会动态调整比例。
- 示例效果:分配 60MB 对象时 Eden 区占满(100% 使用),再分配 8MB 对象触发 Minor GC,若 Eden 存活对象无法放入 Survivor,则提前移入老年代。
3. 老年代分配(长期存活 / 大对象)
- 1. 大对象直接进老年代:
- 定义:需大量连续内存的对象(如大数组、长字符串)。
- 触发参数:-XX:PretenureSizeThreshold(单位:字节),对象大小超过该值则直接进入老年代。
- 限制:仅对Serial和ParNew收集器有效。
- 目的:避免大对象在新生代频繁复制(Minor GC 时复制成本高)。
- 2. 长期存活对象进老年代:
- 年龄计数器:对象在 Eden 出生,经 1 次 Minor GC 存活并进入 Survivor 后,年龄设为 1;每熬过 1 次 Minor GC,年龄 + 1。
- 晋升阈值:年龄达到默认 15 岁(可通过 - XX:MaxTenuringThreshold 调整)时,晋升到老年代。
- 3. 动态年龄判断:
- 触发条件:Survivor 区中某批对象的总大小超过该区域内存的50%(比例由 - XX:TargetSurvivorRatio 指定)。
- 处理逻辑:年龄≥该批对象年龄最大值的所有对象,直接进入老年代(避免 Survivor 区溢出)。
4. 老年代空间分配担保机制
- 核心逻辑:Minor GC 前,JVM 判断老年代是否有足够空间容纳新生代存活对象,避免 Minor GC 后对象无法存放:
- 计算老年代剩余可用空间。
- 若可用空间 < 新生代所有对象大小之和:
- 检查 - XX:-HandlePromotionFailure(JDK1.8 默认开启):
- 若开启:判断可用空间是否 > 历史 Minor GC 后进入老年代的对象平均大小→是则执行 Minor GC;否则触发 Full GC。
- 若禁用:直接触发 Full GC。
- 检查 - XX:-HandlePromotionFailure(JDK1.8 默认开启):
- Minor GC 后,若存活对象大小 > 老年代可用空间→触发 Full GC;Full GC 后仍不足→抛出 OOM。
四、对象内存回收机制
1. 对象死亡判断(核心:可达性分析算法)
- 1. 引用计数法(淘汰方案):
- 逻辑:为对象添加引用计数器,引用新增 + 1,引用失效 - 1,计数器 = 0 则标记为垃圾。
- 缺陷:无法解决对象循环引用问题(如
objA.instance=objB
且objB.instance=objA
,计数器均不为 0,无法回收)。
- 2. 可达性分析算法(主流方案):
- 逻辑:以GC Roots为起点,向下搜索引用链,未被搜索到的对象标记为 “可回收垃圾”。
- GC Roots 包含:
- 线程栈中的本地变量(如方法参数、局部变量)。
- 方法区中的静态变量。
- 本地方法栈中的变量(Native 方法引用的对象)。
2. 四种引用类型(影响对象回收时机)
引用类型 | 核心特点 | 使用场景 | 示例代码 |
---|---|---|---|
强引用 | 普通变量引用,GC 永不回收(OOM 也不回收) | 常规对象引用(如User user = new User() ) |
public static User user = new User(); |
软引用 | GC 后内存不足时回收,用于内存敏感缓存 | 浏览器后退按钮缓存、临时数据缓存 | SoftReference<User> user = new SoftReference<>(new User()); |
弱引用 | GC 触发时直接回收,生命周期极短 | 临时关联数据(如 WeakHashMap 键) | WeakReference<User> user = new WeakReference<>(new User()); |
虚引用 | 最弱引用,仅用于跟踪 GC,无法通过引用获取对象 | 堆外内存回收通知(如 DirectByteBuffer) | PhantomReference<User> user = new PhantomReference<>(new User(), referenceQueue); |
3. finalize () 方法(对象最后的 “自救机会”)
- 两次标记流程:
- 第一次标记:可达性分析中无引用链的对象,筛选是否需执行 finalize ()(未覆盖则直接回收;已覆盖则放入 “F-Queue” 队列)。
- 第二次标记:虚拟机执行 F-Queue 中对象的 finalize (),若对象在方法中重新建立引用链(如赋值给类变量),则移除 “可回收” 集合;否则标记为 “最终可回收”。
- 限制:一个对象的 finalize () 仅执行 1 次,第二次被标记时无法再自救。
4. 无用类判断(方法区回收条件)
类需同时满足以下 3 个条件,才被判定为 “无用的类”,可在方法区中回收:
- 该类所有实例已被回收(Java 堆中无该类的任何对象)。
- 加载该类的ClassLoader 已被回收。
- 该类的
java.lang.Class
对象无任何引用(无法通过反射访问该类的方法)。
关键问题
问题 1:JVM 在为对象分配内存时,面临 “并发下多个线程同时使用同一指针分配内存” 的安全问题,其解决方案有哪些?各方案的核心逻辑与参数是什么?
答案:JVM 通过两种核心方案解决内存分配的并发安全问题:
- CAS + 失败重试机制:
- 核心逻辑:利用 CAS(Compare and Swap)操作的原子性,每次分配内存时对比 “预期指针位置” 与 “实际指针位置”,若一致则修改指针(分配成功);若不一致则重试,直到成功。
- 适用场景:未使用 TLAB 或 TLAB 空间不足时。
- TLAB(Thread Local Allocation Buffer,本地线程分配缓冲):
- 核心逻辑:为每个线程在 Java 堆中预先分配一小块独立内存,线程分配对象时优先使用自身 TLAB,避免与其他线程竞争全局指针,仅当 TLAB 不足时才使用 CAS 补充 TLAB 或直接分配全局内存。
- 关键参数:
- -XX:+UseTLAB:开启 TLAB(JVM 默认开启);-XX:-UseTLAB:禁用 TLAB。
- -XX:TLABSize:指定 TLAB 的初始大小(默认由 JVM 动态计算)。
问题 2:什么是 “对象的栈上分配”?其能减少 GC 压力的核心原因是什么?依赖哪些 JVM 机制才能实现?
答案:
- 定义:栈上分配是 JVM 对 “不会逃逸的临时对象” 的优化手段 —— 将对象内存分配在线程栈帧中,而非 Java 堆,对象随栈帧出栈(方法执行结束)自动销毁,无需 GC 回收。
- 减少 GC 压力的原因:堆中对象需依赖 GC 回收,而栈上对象随栈帧销毁自动释放,避免了 “临时对象大量进入堆导致 GC 频繁触发” 的问题。
- 依赖的核心机制:
- ① 逃逸分析(Escape Analysis):JVM 通过分析对象动态作用域,判断对象是否 “逃逸”(如被外部方法引用、作为返回值),仅 “不逃逸” 的对象才具备栈上分配资格。
- 控制参数:-XX:+DoEscapeAnalysis(JDK7 + 默认开启)、-XX:-DoEscapeAnalysis(禁用,对象强制分配到堆)。
- ② 标量替换(Scalar Replacement):JVM 将 “不逃逸的聚合量(对象)” 分解为多个标量(如
int id
、String name
),在栈帧或寄存器中分配标量空间,避免因 “对象需连续内存” 导致的分配限制。- 控制参数:-XX:+EliminateAllocations(JDK7 + 默认开启)、-XX:-EliminateAllocations(禁用,无法分解对象,栈上分配失效)。
- ① 逃逸分析(Escape Analysis):JVM 通过分析对象动态作用域,判断对象是否 “逃逸”(如被外部方法引用、作为返回值),仅 “不逃逸” 的对象才具备栈上分配资格。
问题 3:方法区的回收主要针对 “无用的类”,需满足哪些严格条件才能判定一个类为 “无用的类”?为什么这些条件必须同时满足?
答案:
- 判定 “无用的类” 的 3 个必要条件(需同时满足):
- 条件 1:该类的所有实例已被回收(Java 堆中不存在任何该类的对象)。
- 条件 2:加载该类的ClassLoader 已被回收(确保无其他类依赖该 ClassLoader 加载的类)。
- 条件 3:该类对应的
java.lang.Class
对象无任何引用(无法通过反射访问该类的方法、字段,确保类无 “潜在使用场景”)。
- 条件需同时满足的原因:
- 若仅满足 “无实例”(条件 1),但 ClassLoader 未回收(条件 2),ClassLoader 仍可能重新加载该类生成新实例,类不可回收。
- 若仅满足前两个条件,但
Class
对象有引用(条件 3),程序可通过反射(如Class.forName()
)重新使用该类,类仍处于 “可用状态”,不可回收。 - 三者缺一不可,确保类完全无 “使用可能”,才允许方法区回收其元数据,避免误回收导致程序异常。