0
点赞
收藏
分享

微信扫一扫

对象住哪里?——深入剖析JVM内存结构与对象分配机制

对象住哪里?—— 深入剖析 JVM 内存结构与对象分配机制

在 Java 程序运行时,我们创建的每一个对象(如new User())都需要占用 JVM 内存,但这些对象究竟 “居住” 在哪个内存区域?为何有的对象很快被回收,有的却能长期存活?要解答这些问题,必须先理清 JVM 的内存结构划分,再深入对象从创建到销毁的全生命周期分配逻辑 —— 这不仅是面试高频考点,更是理解 JVM 性能优化、内存泄漏排查的核心基础。

一、前置认知:JVM 内存结构 —— 对象的 “居住地图”

在探讨 “对象住哪里” 之前,需先明确 JVM 的内存区域划分。根据《Java 虚拟机规范(Java SE 8)》,JVM 运行时数据区分为线程私有区域线程共享区域,不同区域的功能与对象存储特性完全不同。

1. 线程私有区域:每个线程独立拥有,随线程创建 / 销毁

线程私有区域的内存生命周期与线程一致,无需垃圾回收(GC),主要用于存储线程执行相关的数据:

内存区域

核心功能

是否存储对象

程序计数器

记录当前线程执行的字节码指令地址(如分支、循环、跳转的位置),确保线程切换后能恢复执行

否(仅存储地址值)

虚拟机栈

存储线程执行方法时的 “栈帧”(包含局部变量表、操作数栈、方法出口等),每个方法调用对应一个栈帧入栈

局部变量表中存储对象引用(而非对象本身)

本地方法栈

与虚拟机栈功能类似,仅服务于 Native 方法(如System.currentTimeMillis())

关键结论:线程私有区域仅存储 “对象引用”(类似指针),对象本身不会直接存储在此区域

2. 线程共享区域:所有线程共用,随 JVM 启动 / 关闭

线程共享区域是对象的 “主要居住地”,也是 GC 的核心作用区域,分为三个核心模块:

内存区域

核心功能

存储对象类型

关键特性

堆(Heap)

JVM 中最大的内存区域,专门用于存储对象实例(包括成员变量)

所有对象实例(如new User())

1. 线程共享,需 GC 回收;2. 可通过-Xms(初始堆大小)、-Xmx(最大堆大小)配置;3. 是对象分配的 “默认首选区域”

方法区

存储类信息(如类名、字段、方法)、常量、静态变量、JIT 编译后的代码

常量对象(如String s = "abc")、静态对象(如static User user = new User())

1. 线程共享,JDK 8 后由 “元空间(Metaspace)” 实现(替代永久代);2. 常量池中的字符串对象可能被缓存(如字符串常量池)

运行时常量池

方法区的一部分,存储编译期生成的常量(如final int a = 10)、符号引用等

编译期常量对象

1. 常量池中的对象一旦创建,通常不会被回收;2. JDK 7 后部分常量池(如字符串常量池)迁移至堆中

核心地图:绝大多数对象实例(99% 以上)居住在堆中,常量对象、静态对象则根据 JDK 版本不同,居住在方法区(元空间)或堆中;线程私有区域仅存储对象的 “引用地址”,而非对象本体。

二、核心流程:对象分配的 “常规路径”—— 从堆到栈

当我们执行User user = new User()时,对象的分配并非直接 “扔到堆里”,而是遵循 “优先栈上分配→TLAB 分配→堆分配” 的分层策略,JVM 通过这种方式优化内存使用效率与 GC 性能。

1. 第一步:栈上分配 ——“临时对象” 的最优选择

(1)什么是栈上分配?

对于生命周期极短、无逃逸(仅在当前方法内使用)

public void test() {    // User对象仅在test()方法内使用,无逃逸    User user = new User();     user.setName("临时用户");    // 方法执行结束后,栈帧出栈,user对象随栈帧销毁(无需GC)}

(2)为什么优先栈上分配?

  • 避免 GC 开销:栈帧随方法执行结束自动销毁,对象无需等待 GC 回收,减少 GC 压力;
  • 提升访问速度:栈内存的访问速度远快于堆(栈是连续内存,堆是离散内存,需寻址)。

(3)栈上分配的条件(JVM 优化技术:逃逸分析)

JVM 通过 “逃逸分析” 判断对象是否符合栈上分配条件:

  • 无逃逸:对象仅在当前方法内使用,未被返回、未被传递到其他方法 / 线程;
  • 标量可替换:对象可拆分为基本类型(如User类的name(String)、age(int)可拆分为局部变量)。

反例:若test()方法返回user对象(return user),则对象发生 “方法逃逸”,无法栈上分配,需进入堆中。

2. 第二步:TLAB 分配 —— 堆中 “线程私有” 的缓冲区域

若对象不符合栈上分配条件(如存在逃逸),JVM 会优先在堆的 “TLAB 区域” 分配对象,而非直接使用堆的共享区域。

(1)TLAB:Thread-Local Allocation Buffer(线程本地分配缓冲)

JVM 为每个线程在堆中预先分配一块 “私有小内存”(默认占堆大小的 1%),线程创建对象时,优先在自己的 TLAB 中分配,无需竞争共享堆资源。

(2)TLAB 分配的优势:解决线程安全与性能问题

  • 避免线程竞争:若所有线程直接在堆的共享区域分配对象,需通过锁保证线程安全(如 CAS 操作),会产生性能开销;TLAB 是线程私有,分配时无需加锁;
  • 提升分配效率:TLAB 是连续内存块,对象分配只需移动 “指针”(如 TLAB 初始指针为 0,分配一个 16 字节的对象后,指针移动到 16),类似栈的 “指针碰撞” 分配方式。

(3)TLAB 分配的流程

  1. 线程创建对象时,先检查自己的 TLAB 是否有足够空间;
  2. 若空间足够:直接在 TLAB 中分配对象,更新 TLAB 的指针位置;
  3. 若空间不足:
  • 检查 TLAB 的使用率(如是否超过 50%),若使用率低,直接扩容 TLAB 并分配;
  • 若使用率高,将 TLAB 中剩余空间归还给堆,重新申请新的 TLAB;
  • 若多次申请 TLAB 失败(如堆空间不足),则进入 “堆的共享区域” 分配。

3. 第三步:堆分配 ——“长期对象” 的最终归宿

当对象不符合栈上分配条件,且 TLAB 空间不足时,JVM 会将对象分配到堆的 “共享区域”。根据对象的生命周期,堆又分为 “新生代” 和 “老年代”,不同代的分配策略与 GC 机制不同。

(1)堆的代际划分:基于 “对象存活时间” 的优化

JVM 根据 “大多数对象存活时间短” 的特性(弱代假说),将堆分为新生代(Young Generation)和老年代(Old Generation),比例通常为 1:2(可通过-XX:NewRatio配置):

代际

占堆比例

存储对象类型

GC 机制

分配策略

新生代

1/3

新创建的对象(除大对象外)

Minor GC(轻量 GC)

1. 优先分配到 Eden 区;2. 存活对象进入 Survivor 区;3. 多次存活后进入老年代

老年代

2/3

1. 存活时间长的对象;2. 大对象;3. 新生代无法容纳的对象

Major GC(Full GC 的一部分)

直接分配(大对象)或从新生代晋升(长期存活对象)

(2)新生代的分配细节:Eden 区与 Survivor 区

新生代内部进一步分为 “Eden 区” 和两个大小相等的 “Survivor 区”(S0、S1),比例通常为 8:1:1(可通过-XX:SurvivorRatio配置):

  1. Eden 区(伊甸园):新对象的 “出生地”,90% 以上的新对象首先分配到 Eden 区;
  • 示例:执行new User(),若对象无逃逸且非大对象,先进入 Eden 区;
  1. Survivor 区(幸存者区):Eden 区 GC 后存活的对象会进入 S0 或 S1 区;
  • 流程:Eden 区满时触发 Minor GC,存活对象被复制到 S0 区(S1 区为空),并将对象的 “年龄计数器” 加 1;
  • 晋升:当对象在 Survivor 区存活次数达到阈值(默认 15 次,可通过-XX:MaxTenuringThreshold配置),会被 “晋升” 到老年代;
  1. Survivor 区的 “复制算法”:每次 Minor GC 仅复制存活对象(通常仅 5% 左右),避免内存碎片,效率极高。

(3)老年代的分配场景:“长期对象” 与 “特殊对象”

以下对象会直接或间接进入老年代:

  1. 长期存活对象:在 Survivor 区存活次数达到阈值的对象(如频繁被使用的缓存对象);
  2. 大对象:超过 “大对象阈值”(可通过-XX:PretenureSizeThreshold配置,默认无阈值,JDK 8 后由 JVM 动态判断)的对象,直接分配到老年代(避免在新生代频繁 GC 导致的复制开销);
  • 示例:创建一个 10MB 的数组(byte[] arr = new byte[1024*1024*10]),若超过阈值,直接进入老年代;
  1. 动态年龄判断:Survivor 区中某一年龄段的对象总大小超过 Survivor 区的 50%,则该年龄及以上的对象直接晋升老年代(避免 Survivor 区溢出);
  2. Minor GC 后存活对象无法放入 Survivor 区:若 Minor GC 后存活对象过多,Survivor 区无法容纳,这些对象会直接 “晋升” 到老年代(称为 “分配担保”)。

三、特殊场景:对象分配的 “例外情况”—— 方法区与常量池

除了堆和栈,部分特殊对象会 “居住” 在方法区(元空间)或运行时常量池中,这些场景容易被误解,需重点区分。

1. 常量对象:字符串常量池与运行时常量池

(1)字符串常量池的 “居住变迁”

字符串对象的分配因创建方式不同(new String() vs 字面量"abc"),居住区域也不同,且 JDK 版本对其影响极大:

创建方式

JDK 6 及之前的居住区域

JDK 7 及之后的居住区域

关键特性

String s1 = "abc"

方法区(永久代)的字符串常量池

堆中的字符串常量池

1. 优先检查常量池,若存在则直接返回引用;2. 不存在则创建字符串对象并放入常量池;3. 对象不会被 GC 回收(除非常量池清理)

String s2 = new String("abc")

1. 字符串常量池(若 “abc” 不存在则创建);2. 堆中创建新对象

1. 堆中的字符串常量池(若 “abc” 不存在则创建);2. 堆中创建新对象

1. 必然在堆中创建一个新对象;2. 常量池中的对象是 “原型”,堆中的对象是 “副本”;3. 堆中的对象可被 GC 回收,常量池中的对象通常不回收

经典面试题:s1 == s2的结果?

答案:false。因为s1指向常量池中的对象,s2指向堆中的新对象,两者引用地址不同。

(2)其他常量对象

编译期常量(如final String s = "abc"、final int a = 10)会存储在运行时常量池中,JDK 7 后运行时常量池虽在方法区(元空间),但常量对象本身仍存储在堆中,常量池仅存储对象的引用地址。

2. 静态对象:静态变量引用的对象

静态变量(static修饰)存储在方法区(元空间),但静态变量引用的对象实例仍存储在堆中,方法区仅存储对象的 “引用地址”:

public class Test {    // 静态变量user存储在方法区(元空间),但user引用的对象实例存储在堆中    public static User user = new User(); }

  • 居住区域:user变量(引用)在方法区,new User()对象实例在堆中;
  • 回收时机:静态变量的生命周期与类一致,只有当类被 “卸载”(如类加载器被回收)时,其引用的对象才可能被 GC 回收(通常很难触发,因此静态对象易导致内存泄漏)。

3. 类信息与 JIT 代码:方法区(元空间)的 “非对象” 存储

需特别注意:方法区(元空间)存储的是 “类信息”(如类结构、方法字节码),而非对象实例。例如:

  • User.class的类名、字段(name、age)、方法(setName())存储在方法区;
  • JIT(即时编译器)将热点代码(如频繁调用的方法)编译为本地机器码后,也存储在方法区;
  • 这些数据不属于 “对象”,无需 GC 回收(元空间的内存由操作系统管理,JVM 不主动 GC)。

四、实战分析:通过案例定位 “对象的居住地”

结合具体代码案例,分析不同对象的居住区域,加深理解:

案例 1:临时无逃逸对象

public class StackAllocationTest {    public static void main(String[] args) {        for (int i = 0; i < 100000; i++) {            createTempUser(); // 循环创建临时对象        }    }    private static void createTempUser() {        // User对象仅在createTempUser()内使用,无逃逸        User user = new User();         user.setAge(18);        user.setName("临时用户");        // 方法结束,栈帧出栈,user对象随栈帧销毁(栈上分配)    }}

  • 对象居住地:User对象通过栈上分配,居住在虚拟机栈的栈帧中;
  • 验证:运行时观察堆内存变化,堆大小基本不变(无大量对象创建),且无频繁 Minor GC。

案例 2:大对象分配

public class LargeObjectTest {    public static void main(String[] args) {        // 创建100MB的字节数组(大对象)        byte[] largeArr = new byte[1024 * 1024 * 100];     }}

  • 对象居住地:大数组超过 JVM 动态判断的 “大对象阈值”,直接分配到老年代;
  • 验证:通过 JVisualVM 观察堆结构,老年代内存占用增加 100MB 左右,新生代无明显变化。

案例 3:字符串常量与堆对象

public class StringAllocationTest {    public static void main(String[] args) {        String s1 = "hello"; // 常量池中的对象(堆中,JDK 7+)        String s2 = new String("hello"); // 堆中的新对象(副本)        String s3 = s2.intern(); // 返回常量池中的对象引用(与s1相同)                System.out.println(s1 == s2); // false(s1指向常量池,s2指向堆)        System.out.println(s1 == s3); // true(s3指向常量池)    }}

  • 对象居住地:s1指向堆中字符串常量池的对象,s2指向堆中的新对象,s3与s1指向同一常量池对象;
举报
0 条评论