JVM对象创建与内存分配详解

 

JVM对象创建与内存分配详解

 
 

1、核心概述

一个普通的 Java 对象(不包括数组和 Class 对象等)从无到有,到被回收,其生命周期可以概括为以下几个阶段:

  1. 创建:当 JVM 遇到一条 new 指令时。

  2. 分配:为这个新对象在堆内存中划分一块空间。

  3. 初始化:对分配的内存空间进行设置,包括实例数据、对象头等。

  4. 使用:对象在程序中被引用和操作。

  5. 回收:对象不再被引用时,被垃圾回收器回收。

 

2、对象的创建过程

1. 类加载检查

JVM 首先检查 new 指令的参数是否能在常量池中定位到一个类的符号引用。

  • 检查内容:检查这个符号引用代表的类是否已被加载、解析和初始化。

  • 如果未加载:则必须先执行相应的类加载过程。

2. 分配内存

JVM 中的对象主要(但不仅限于)在以下位置分配:

  1.  - 绝大多数对象的分配位置。

  2. 栈 - 通过逃逸分析(-XX:+DoEscapeAnalysis标量替换(-XX:+EliminateAllocations)实现的栈上分配。

    • -XX:-EliminateAllocations (使用 - 关闭)

  3. TLAB - 线程本地分配缓冲(仍在堆内,但属于特殊优化)。大小可通过 -XX:TLABSize 调整。

逃逸分析

在真正分配内存之前,JVM 会先进行逃逸分析,判断对象的作用域和生命周期:

  • 方法逃逸:对象在方法中被定义后,可能被外部方法引用(如作为参数传递或赋值给类变量)。

  • 线程逃逸:对象可能被其他线程访问到。

  • 不逃逸:对象仅在当前方法中使用,不会逃逸到方法或线程外部。

基于逃逸分析的结果,JVM 会选择不同的分配策略。JVM 会选择以下三种分配方式之一:

A. 栈上分配

  • 条件:对象被分析为不逃逸,且可以进行标量替换

  • 过程:JVM 会将对象的成员变量分解为若干个基本数据类型(标量),直接在栈帧的局部变量表中分配,而不在堆上创建完整对象。

  • 优势:对象随栈帧出栈而自动销毁,无需垃圾回收,极大减少GC压力。

 

 B. 堆分配(主要路径)
如果对象逃逸出方法,则进入堆分配流程。堆分配又有两种方式

a. TLAB分配

  • 位置:在 Eden 区内为每个线程单独划分一小块私有内存区域。

  • 目的:解决多线程环境下在堆中分配内存时的线程安全问题,避免频繁的锁竞争。

  • 过程:线程优先在自家的 TLAB 中分配对象,速度极快。当 TLAB 用完时,才需要同步地在 Eden 区分配。

b.Eden区直接分配

  • 当 TLAB 不足或不适合时,直接在 Eden 区的公共区域分配,可能需要同步操作。虚拟机采用CAS配上失败重试的方式保证更新操作的原子性来对分配内存空间的动作进行同步处理。

对象所需内存大小在类加载完成后便可完全确定。分配方式取决于 Java 堆是否规整,而堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定分配方式:

  • 指针碰撞:如果堆内存是绝对规整的(使用 Serial, ParNew 等带压缩整理的收集器),所有用过的内存在一边,空闲的内存在另一边,中间放着一个指针作为分界点的指示器。分配内存仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。
  • 空闲列表:如果堆内存不是规整的(使用 CMS 这种基于标记-清除算法的收集器),已使用的内存和空闲的内存相互交错,虚拟机就必须维护一个列表,记录哪些内存块是可用的。在分配时从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。

3、内存空间初始化

内存分配完成后,JVM 需要将分配到的内存空间(不包括对象头)都初始化为零值

  • 目的:这步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值(如 int 是 0,boolean 是 false,引用类型是 null)。

4、设置对象头

对象在内存中存储的布局可以分为3块区域:对象头(Header)、 实例数据(Instance Data) 和对齐填充(Padding)JVM 要对对象进行必要的设置,这些信息存放在 对象头 中。

对象头内容:

  • 运行时数据:如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。
  • 类型指针:即对象指向它的类元数据的指针,JVM 通过这个指针来确定这个对象是哪个类的实例。
  • 如果对象是数组,对象头中还必须有一块用于记录数组长度的数据。

5、执行 <init> 方法

从 JVM 的视角看,一个新的对象已经产生了。但从 Java 程序的视角看,对象创建才刚刚开始——构造函数(<init> 方法)还没有执行。

  • JVM 会按照程序员的意愿,调用对象的构造函数,进行初始化。

  • 这一步才会将对象赋上代码中设定的初始值(例如 int a = 1;)。

 

3、内存分配详解

对象的内存分配,主要就是在 Java 堆 上进行。为了提高分配效率和优化 GC 性能,JVM 采用了分代收集思想,将堆内存划分为不同的区域,对象会根据其情况被分配到不同的区域。

对象分配的内存区域与规则

1. 对象优先在 Eden 区分配

  • 规则:绝大多数新创建的对象都会被分配在 Eden 区。

  • 过程:当 Eden 区没有足够空间进行分配时,JVM 将发起一次 Minor GC。

2. 大对象直接进入老年代(这个参数只在 Serial 和ParNew两个收集器下 有效。)

  • 大对象:指需要大量连续内存空间的 Java 对象,最典型的就是很长的字符串或者元素数量很多的数组。

  • 规则:JVM 提供了一个 -XX:PretenureSizeThreshold 参数,令大于这个设置值的对象直接在老年代分配。

  • 目的:避免大对象在 Eden 区及两个 Survivor 区之间来回复制,同时避免在 Eden 区分配时因为空间不足导致频繁的 Minor GC。

3. 长期存活的对象将进入老年代

  • 对象年龄计数器:JVM 给每个对象定义了一个对象年龄计数器,存储在对象头中。

  • 规则:对象通常在 Eden 区出生,如果经过第一次 Minor GC 后仍然存活,并且能被 Survivor 区容纳,它会被移动到 Survivor 区,同时对象年龄设为 1。以后对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1 岁。当它的年龄增加到一定程度(默认为 15),就会被晋升到老年代。

  • 阈值参数:-XX:MaxTenuringThreshold 可以设置晋升年龄阈值。

4. 动态对象年龄判定

  • 为了能更好地适应不同程序的内存状况,JVM 并不总是要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代。

  • 规则:如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。

5. 空间分配担保

  • 在发生 Minor GC 之前,JVM 会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间

    • 如果条件成立,那么 Minor GC 可以确保是安全的。

    • 如果不成立,JVM 会查看 -XX:HandlePromotionFailure 设置是否允许担保失败。

      • 如果允许,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小

        • 如果大于,将尝试进行一次有风险的 Minor GC。

        • 如果小于,或者不允许担保失败,则改为进行一次 Full GC。

  • 目的:避免由于新生代对象全部存活,而老年代没有足够空间容纳,导致 GC 失败。

 当然,如果minor gc之后剩余存活的需要挪动到老年代的对象大小还是大于老年代可用空间,那么也会触发full gc,full  gc完之后如果还是没有空间放minor gc之后的存活对象,则也会发生“OOM”

 

posted @ 2025-11-26 14:03  邓维-java  阅读(7)  评论(0)    收藏  举报