03-JVM对象创建与内存分配

type
status
date
slug
summary
tags
category
icon
password

1️⃣ 对象创建

1、类加载检查

虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
new指令对应到语言层面上讲是,new关键词、对象克隆、对象序列化等。
类加载检查是指验证类是否被正确加载到JVM,并确保其元信息可用。类加载过程包括 加载(Loading)验证(Verification)准备(Preparation)解析(Resolution)初始化(Initialization)
类加载过程
  • 加载(Loading)
    • 通过类加载器(ClassLoader)读取 .class 文件字节码,生成 java.lang.Class 对象,存储在方法区(JDK 8+ 为元空间)。
    • 类加载器:Bootstrap、Extension、Application 或自定义类加载器。
  • 验证(Verification)
    • 确保字节码符合JVM规范(文件格式、语义、字节码合法性、符号引用)。
    • 防止加载恶意或错误类。
  • 准备(Preparation)
    • 为静态变量分配内存,赋默认值(例如,int 为 0,引用为 null)。
  • 解析(Resolution)
    • 将常量池的符号引用解析为直接引用(内存地址)。
  • 初始化(Initialization)
    • 执行 <clinit> 方法(静态变量赋值、静态代码块)。
    • 触发条件:访问静态成员、创建实例、反射等。

2、分配内存

  • 位置:对象实例分配在堆的 新生代 Eden 区(大对象可能直接分配到老年代)。
  • 过程
    • JVM计算对象大小(包括实例字段、填充字节,不含静态字段)。
    • 在 Eden 区分配连续内存。
    • 内存分配方式
      • 指针碰撞(Bump the Pointer):适用于规整内存(串行或并行 GC),移动指针分配。
      • 空闲列表(Free List):适用于非规整内存(如 CMS GC),从空闲列表选择块。
    • 线程安全
      • 多线程分配内存可能冲突,JVM使用:
      • CAS(Compare and Swap):乐观锁分配内存。
      • TLAB(Thread Local Allocation Buffer):为每个线程预分配小块 Eden 区,提高效率。
  • 例如
    • 代码中创建对象(如 String fileName = item.getFilename().toLowerCase())在堆中分配 String 实例。
    • 频繁创建临时对象(如字符串操作)可能导致 Eden 区快速填满,触发 Minor GC。

划分内存的方法

1️⃣ “指针碰撞”(Bump the Pointer)(默认用指针碰撞)
如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。
2️⃣ “空闲列表”(Free List)
如果Java堆中的内存并不是规整的,已使用的内存和空 闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录

解决并发问题的方法

CAS(compare and swap) 虚拟机采用CAS配上失败重试的方式保证更新操作的原子性来对分配内存空间的动作进行同步处理。
 
本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)
把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存。通过 XX:+/UseTLAB参数来设定虚拟机是否使用TLAB(JVM会默认开启XX:+UseTLAB), XX:TLABSize 指定TLAB大小。

3、初始化

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头), 如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
  • 作用:将对象内存清零,设置字段默认值。
  • 过程
    • 分配的内存初始化为零值:
      • 基本类型int0booleanfalse 等。
      • 引用类型null
    • 确保对象在 <init> 执行前处于安全状态(字段不会是未定义值)。

4、设置对象头

初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头Object Header之中。 在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)实例数据(Instance Data)对齐填充(Padding)。HotSpot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时 间戳等。对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
  • 作用:设置对象的元数据,存储在对象头(Object Header)。
  • 对象头内容(HotSpot JVM):
    • Mark Word(标记字段):
      • 存储锁状态(轻量锁、重量锁)、GC 标记、哈希码等。
      • 32 位或 64 位(视JVM架构)。
    • Klass Pointer(类型指针):
      • 指向方法区(元空间)中的 Class 对象,标识对象所属类。
      • 可通过 -XX:+UseCompressedOops 压缩(32 位指针)。
    • 数组长度(仅数组对象):
      • 存储数组长度(4 字节)。

5、执行<init>方法

执行<init>方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意,这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法。
  • 作用:执行构造器,初始化对象字段,运行实例代码块。
  • 过程
    • JVM 调用对象的<init>方法(由编译器生成,包含构造器代码、实例字段赋值、实例代码块 {})。
    • <init>方法在栈帧中执行,修改堆中对象的字段值。
    • 父类构造器先执行(通过super()调用父类的<init>)。

2️⃣ 对象大小与指针压缩

对象大小的组成

  • 对象头:8 字节(Mark Word)+ 4 字节(Klass Pointer,指针压缩)= 12 字节。
  • 实例数据:int(4 字节)+ long(8 字节)+ Object 引用(4 字节)= 16 字节。
  • 总大小:12(对象头)+ 16(实例数据)= 28 字节。
  • 对齐填充:28 字节不是 8 的倍数,填充 4 字节,总大小为 32 字节

1. 对象头(Object Header)

  • 作用:存储对象的元数据。
  • 组成
    • Mark Word
      • 存储运行时信息,如哈希码、锁状态(无锁、轻量锁、重量锁)、GC 标记等。
      • 大小:
        • 32 位JVM:4 字节(32 位)。
        • 64 位JVM:8 字节(64 位)。
    • Klass Pointer(类型指针):
      • 指向方法区(元空间)中的 Class 对象,标识对象所属类。
      • 大小(未压缩):
        • 32 位JVM:4 字节。
        • 64 位JVM:8 字节。
      • 指针压缩(见下文)可将其压缩为 4 字节。
    • 数组长度(仅数组对象):
      • 存储数组长度,4 字节。
      • 非数组对象无此部分。
  • 对象头大小(不含数组长度):
    • 32 位JVM:8 字节(Mark Word 4 + Klass Pointer 4)。
    • 64 位JVM(未压缩):16 字节(Mark Word 8 + Klass Pointer 8)。
    • 64 位JVMM(指针压缩):12 字节(Mark Word 8 + Klass Pointer 4)。

2. 实例字段(Instance Data)

  • 作用:存储对象的非静态字段(包括父类字段)。
  • 大小
    • 基本类型:
      • byte:1 字节。
      • short:2 字节。
      • int, float:4 字节。
      • long, double:8 字节。
      • char:2 字节(UTF-16 编码)。
      • boolean:1 字节(实际可能因填充而变化)。
    • 引用类型:
      • 32 位JVM:4 字节。
      • 64 位JVM(未压缩):8 字节。
      • 64 位JVM(指针压缩):4 字节。
  • 注意:静态字段存储在方法区(JDK 7+ 移至堆的 Class 对象),不计入实例大小。

3. 对齐填充(Padding)

  • 作用JVM要求对象大小为 8 字节的倍数(64 位JVM),以优化内存访问。
  • 过程:如果对象头和字段总大小不是 8 字节的倍数,添加填充字节。
  • 示例
    • 对象头 12 字节 + int 字段 4 字节 = 16 字节(无需填充)。
    • 对象头 12 字节 + byte 字段 1 字节 = 13 字节,填充 3 字节到 16 字节。

4. 数组对象的额外开销

数组对象比普通对象多 4 字节(数组长度)。
数组元素按字段大小计算(例如,int[] 每个元素 4 字节)。
示例:new int[10]
  • 对象头:12 字节(64 位,指针压缩)。
  • 数组长度:4 字节。
  • 元素:10 × 4 = 40 字节。
  • 总大小:12 + 4 + 40 = 56 字节。

5. 示例对象大小计算

假设类 Picture
  • 64 位 JVM,指针压缩
    • 对象头:12 字节(Mark Word 8 + Klass Pointer 4)。
    • 字段:3 个 String 引用,每个 4 字节(指针压缩),共 12 字节。
    • 总大小:12 + 12 = 24 字节(8 字节对齐)。
  • 注意
    • String 引用指向堆中的 String 对象(单独计算)。
    • String 对象的内部结构(包含 char[] 或 byte[])会增加额外内存。

指针压缩(Compressed Oops)

指针压缩是 HotSpot JVM在 64 位环境下优化内存使用的技术,通过压缩对象引用(Ordinary Object Pointers, Oops)减少内存占用。
  • 背景
    • 64 位JVM的指针(引用)默认 8 字节,导致对象头和引用字段占用更多内存。
    • 指针压缩将 64 位指针压缩为 32 位(4 字节),减少内存开销。
  • 实现
    • JVM使用 偏移编码:将对象地址表示为相对于堆基址的偏移量,压缩为 32 位。
    • 堆大小限制:指针压缩要求堆大小小于 32GB(2^32 × 8 字节),以确保地址可编码为 32 位。
    • Klass Pointer 和对象引用都可压缩:
      • Klass Pointer:从 8 字节压缩为 4 字节。
      • 对象引用:从 8 字节压缩为 4 字节。
  • 效果
    • 对象头从 16 字节(未压缩)减为 12 字节。
    • 引用字段从 8 字节减为 4 字节。
    • 减少内存占用,降低 GC 压力,提升性能。

指针压缩的限制

堆大小:堆超过 32GB 时,指针压缩失效,需使用 64 位指针。
性能权衡
  • 压缩/解压缩指针增加少量 CPU 开销,但内存节省通常更显著。
  • 大堆场景(>32GB)禁用压缩可能更合适。

指针压缩的实现机制

  • 偏移编码
    • JVM将对象地址表示为相对于堆基址的偏移量。
    • 偏移量用 32 位表示,乘以 8 字节(对象对齐单位)覆盖 32GB 堆(2^32 × 8)。
  • 对象对齐
    • JVM要求对象地址按 8 字节对齐,低 3 位恒为 0,压缩时省略低 3 位,进一步节省空间。
  • 压缩范围
    • 引用类型字段(如 String 引用)。
    • 对象头的 Klass Pointer(指向元空间的 Class 对象)。
    • 数组引用和元素引用。
  • 限制
    • 堆 > 32GB 时,压缩失效,需使用 64 位指针(-XX:-UseCompressedOops)。
    • 部分JVM实现可能不支持或行为不同。

为什么要进行指针压缩

  • 在64位平台的HotSpot中使用32位指针,内存使用会多出1.5倍左右,使用较大指针在主内存和缓存之间移动数据,占用较大宽带,同时GC也会承受较大压力
  • 为了减少64位平台下内存的消耗,启用指针压缩功能
  • JVM中,32位地址最大支持4G内存(2的32次方),可以通过对对象指针的压缩编码、解码方式进行优化,使得JVM只用32位地址就可以支持更大的内存配置(小于等于32G)
  • 堆内存小于4G时,不需要启用指针压缩,JVM会直接去除高32位地址,即使用低虚拟地址空间
  • 堆内存大于32G时,压缩指针会失效,会强制使用64位(即8字节)来对java对象寻址,这就会出现1的问题,所以堆内存不要大于32G为好

3️⃣ 对象栈上分配

  • 定义:对象栈上分配是JVM的一种优化技术,通过 逃逸分析 确定某些对象不会逃逸出方法作用域(即不被外部引用),从而将这些对象分配在 栈(Java 方法栈) 而非 堆(Heap)
  • 原理
    • 栈上分配依赖 逃逸分析(Escape Analysis),由 JIT(Just-In-Time)编译器在运行时分析对象的生命周期。
    • 如果对象仅在方法内使用(无逃逸),JVM将其分配在栈的栈帧中,随方法结束自动销毁,无需 GC。
  • 与堆分配的区别
    • 堆分配
      • 对象分配在堆(Eden 区),由 GC 管理。
      • 适用于长生命周期或被多个方法/线程引用的对象。
      • 代码中,StringPicture 对象通常分配在堆中。
    • 栈上分配
      • 对象分配在栈帧,生命周期与方法绑定,方法结束时销毁。
      • 适用于短生命周期、方法内使用的对象。
      • 减少堆分配和 GC 开销。

1.逃逸分析的类型

  • 逃逸(No Escape)
    • 对象仅在方法内使用,不被外部引用。
    • 示例:
    • 适合栈上分配,temp 在方法结束时随栈帧销毁。
  • 方法逃逸(Method Escape)
    • 对象被方法返回或赋值给外部变量。
    • 示例:
    • 必须分配在堆中。
  • 线程逃逸(Thread Escape)
    • 对象被其他线程访问(如赋值给静态变量或实例字段)。
    • 示例:
    • 必须分配在堆中。

2.逃逸分析的优化

  • 栈上分配:无逃逸对象分配在栈,方法结束时自动回收。
  • 标量替换(Scalar Replacement)
    • 如果对象可以分解为基本类型(标量),JVM直接用局部变量替换对象。
    • 示例:
    • 标量替换后:
    •  
  • 同步消除(Lock Elision)
    • 如果对象无逃逸,同步锁(如synchronized)可被 JIT 优化移除。
    • 示例:
    • JIT 移除 synchronized,因为 sb 不被其他线程访问。

3.为什么要进行栈上分配?

减少堆分配
  • 堆分配涉及内存分配(Eden 区)、GC 管理,增加开销。
  • 栈分配直接在栈帧中分配,速度快,无需 GC。
  • 频繁创建临时 String(如 toLowerCase)或 Long 可能触发 Minor GC,栈上分配可减少堆压力。
快速内存回收
  • 栈上对象随方法结束自动销毁(栈帧弹出),无需 GC。
  • 堆上对象需等待 GC 回收,增加延迟。
降低 GC 频率
  • 减少 Eden 区分配,延迟 Minor GC,提升吞吐量。
  • PictureString 操作创建大量对象,栈上分配可降低 GC 负担。
提高缓存效率
  • 栈内存通常更靠近 CPU 缓存,访问速度快于堆。
  • 例如:字符串操作涉及频繁对象访问,栈上分配提升性能。

4.栈上分配的限制

逃逸分析依赖
  • 只有无逃逸对象才能分配在栈上,逃逸对象仍需堆分配。
  • picture.setFileName(fileName) 导致 fileName 逃逸,无法栈上分配。
栈空间限制
  • 栈大小由 -Xss 控制(默认 1MB),栈上分配过多对象可能导致 StackOverflowError
  • 若涉及深层调用,需谨慎调整 -Xss
JIT 编译依赖
  • 栈上分配依赖 JIT 编译,早期运行(解释模式)可能仍分配在堆。
  • 确保应用运行足够时间,触发 JIT 优化。
对象大小
  • 大对象(例如,大数组)不适合栈上分配,因栈帧空间有限。

5.对象在Eden区分配

新创建的对象通常在新生代Eden区分配。
使用TLAB(Thread Local Allocation Buffer)或CAS确保线程安全。
Eden满时触发Minor GC,存活对象复制到Survivor区(S0/S1),Eden清空。
优化:通过-Xmn调整新生代大小,-XX:SurvivorRatio设置Eden与Survivor比例。

6.大对象直接进入老年代

大对象(如大数组)直接分配到老年代,避免在Eden区频繁复制。
条件:对象大小超过-XX:PretenureSizeThreshold(需设置此参数)。
 
优点:减少Minor GC开销,适合内存占用大的场景。
注意:可能导致老年代提前填满,触发Full GC。

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

对象在Survivor区经历多次Minor GC(年龄+1),达到-XX:MaxTenuringThreshold(默认15)时晋升至老年代。
 
过程:Eden → Survivor(S0/S1) → 老年代。
优化:调整MaxTenuringThreshold控制晋升年龄,平衡新生代和老年代压力。

8.对象动态年龄判断

当Survivor区中相同年龄对象总大小超过Survivor空间一半时,年龄大于或等于该年龄的对象可直接晋升老年代。
 
例如:Survivor空间50M,年龄2的对象占30M,则年龄≥2的对象进入老年代。
优点:动态适应对象存活特征,提高内存利用率。
依赖GC算法(如Parallel Scavenge),无需手动配置。

9.老年代空间分配担保机制

Minor GC前,JVM检查老年代最大可用连续空间是否大于新生代所有对象的平均晋升大小。
若不足,查看-XX:-HandlePromotionFailure是否开启:
  • 开启:尝试Minor GC,若仍失败,触发Full GC。
  • 关闭(默认JDK 8+):直接触发Full GC。
担保目的:防止Minor GC后Survivor区对象无法放入老年代导致失败。
优化:通过-XX:MaxTenuringThreshold-Xmx调整老年代容量。

4️⃣ 对象内存回收

堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)。

引用计数法

给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1;当引用失效,计数器就减1;任何时候计数器为0的对象就是不可能再被使用的。 这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。 所谓对象之间的相互引用问题,如下面代码所示:除了对象objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为0,于是引用计数算法无法通知 GC 回收器回收他们。

可达性分析算法

将“GC Roots” 对象作为起点,从这些节点开始向下搜索引用的对象,找到的对象都标记为非垃圾对象,其余未标记的对象都是垃圾对象GC Roots根节点:线程栈的本地变量、静态变量、本地方法栈的变量等等

常见引用类型

强引用:普通的变量引用
软引用:将对象用SoftReference软引用类型的对象包裹,正常情况不会被回收,但是GC做完后发现释放不出空间存放新的对象,则会把这些软引用的对象回收掉。软引用可用来实现内存敏感的高速缓存。
弱引用:将对象用WeakReference软引用类型的对象包裹,弱引用跟没引用差不多,GC会直接回收掉,很少用
虚引用:虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系,几乎不用

finalize()方法最终判定对象是否存活

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历再次标记过程。
标记的前提是对象在进行可达性分析后发现没有与GC Roots相连接的引用链。
  1. 第一次标记并进行一次筛选。 筛选的条件是此对象是否有必要执行finalize()方法。 当对象没有覆盖finalize方法,对象将直接被回收。
  1. 第二次标记 如果这个对象覆盖了finalize方法,finalize方法是对象脱逃死亡命运的最后一次机会,如果对象要在finalize()中成功拯救自己,只要重新与引用链上的任何的一个对象建立关联即可,譬如把自己赋值给某个类变量或对象的成员变量,那在第二次标记时它将移除出“即将回收”的集合。如果对象这时候还没逃脱,那基本上它就真的被回收了。 注意:一个对象的finalize()方法只会被执行一次,也就是说通过调用finalize方法自我救命的机会就一次。

5️⃣ 如何判断一个类是无用的类

方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢? 类需要同时满足下面3个条件才能算是 “无用的类” :
  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
 
上一篇
02-JVM内存模型
下一篇
04-垃圾收集算法
Loading...