HotSpot虚拟机对象探秘:为对象分配内存

84 阅读7分钟



哈喽大家好,我是小米,一个爱喝咖啡也爱debug的31岁程序员!

今天我们不聊业务、不聊面试题,咱们一起坐上时间穿梭机,潜入JVM内部——去探一探当我们 new 一个对象时,它在HotSpot虚拟机里究竟经历了什么!对象的内存是怎么分配的?分在哪?怎么找到?有没有可能出差错?

别急,我慢慢讲给你听——这是一段对象的“出生之旅”。

新世界的起点:对象是怎么诞生的?

上周项目组在优化线上接口响应时间,我盯着一段高频调用代码,突然脑子里闪过一个问题:

“我们每次 new 一个对象,它到底在哪儿被创建出来?JVM 里是怎么安排的?”

这个问题,可能很多人都跟我一样——听说过堆内存、栈内存、对象头、TLAB,但模模糊糊的。今天我就带你从 JVM 的角度,捋清楚对象创建背后的底层流程。

我们从一个熟悉的 Java 语句开始:

这看似简单的代码背后,其实发生了很多魔法——HotSpot JVM 会负责完成对象的内存分配、初始化、关联变量引用等一系列工作。

我们先说第一步:内存分配

为对象分配空间:是在堆上!

在 HotSpot 虚拟机中,对象默认会分配在 Java 堆(Heap)中——它是JVM最大的一块内存区域,专门用于存放对象实例。

但问题来了:

"这么大的堆空间,怎么知道哪个地方是空的?怎么分配?又如何保证线程安全?"

别急,JVM其实早想好了两种策略:

1. 指针碰撞(Bump the Pointer)

如果 Java 堆中的内存是规整的、空闲内存区域全都挨着,就像一个排好队的队列。那么分配对象就非常简单:

  • JVM 维护一个指针 alloc_ptr,指向当前空闲内存的起始位置;
  • 每次分配对象时,只需要把 alloc_ptr 向后移动一段大小(对象所需内存),就完成了分配!

是不是很像我们订酒店房间?一间接一间,依次入住。

这个分配方式非常高效,但它有前提条件:堆内存是规整的,没有碎片。这通常是使用Serial或ParNew GC的“新生代”才具备的条件,因为它们会采用“复制算法”进行垃圾回收,保证空间整洁。

2. 空闲列表(Free List)

如果内存不是规整的,堆里东一块、西一块有空闲区域,那怎么办?

这时候 JVM 会使用“空闲列表”的策略。它维护一个链表,记录哪些内存块是空闲的,每次创建对象时,从列表中挑出一块“刚刚好”或“足够大”的内存区域进行分配。

这种方式的成本稍高一些,因为可能需要遍历链表、甚至碎片整理。但在使用CMS或G1 GC的老年代中,这种方式更合适。

线程安全吗?TLAB 来帮忙!

你可能会想:“多线程环境下同时分配对象,不会冲突吗?”

是的,JVM在设计的时候也考虑到了这个问题。

于是引入了一个高效的机制:TLAB(Thread Local Allocation Buffer)线程本地分配缓冲区

它的思想很简单:

“既然大家都想分配对象,那干脆每个线程给你一小块私有堆内存,别抢了!”

每个线程在创建时,JVM都会从堆中划出一小块内存,作为它的TLAB。这个TLAB由线程独占,分配对象时就只需要修改自己的指针,不涉及锁操作,效率非常高。

当一个线程的TLAB不够用了怎么办?可以申请新的 TLAB,或者退而求其次,进入全局堆中分配(这个时候就会涉及锁或者原子操作了)。

TLAB 不是必须的,可以通过 -XX:-UseTLAB 关闭。但在高并发场景下,它几乎是性能提升的刚需。

对象头里藏了什么秘密?

对象分配好内存之后,还有一个重要的步骤:初始化对象头

每一个 Java 对象在 HotSpot JVM 中都有一个“对象头”,它是对象存储结构的一部分,包含了很多元数据。

通常来说,对象头分为两个部分:

1、Mark Word(标记字段)

这里存储了对象的哈希码、GC年龄、锁状态(轻量锁、偏向锁、重量锁)等信息。

是的,synchronized 的锁信息就藏在这!

2、类型指针

指向对象的 Class 元数据,用于确定这个对象属于哪个类。

构造函数并不负责“分配”!

这里要特别提醒一下:

很多朋友会误解,以为 new 的时候调用构造函数就“创建对象”了。但实际上:

  • 构造函数并不负责分配内存;
  • 它只是初始化对象的字段值(尤其是用户自定义的属性);
  • 真正的内存分配是在构造函数之前就完成的!

所以,构造函数不是创建者,而是“整理者”。

那些意想不到的坑

在一次线上事故中,我们排查内存泄漏时发现一个诡异的现象:某个线程竟然疯狂 new 对象,最终 OOM!

但奇怪的是:这个线程一直在做“短命对象”的操作啊?

排查半天,原来是:

  • TLAB太小,频繁退回全局堆中分配;
  • 全局堆因为碎片太多,导致分配失败频繁触发 GC;
  • GC 又无法及时清理,导致“堆内存耗尽”。

这让我对对象分配机制产生了新的敬畏——分配策略虽然底层,但对性能和稳定性的影响不可忽视!

总结归纳:对象出生的全流程

我们来快速总结一下 HotSpot 对象分配的全流程:

  • 类加载完成,准备好元数据;
  • 内存分配
    • 否则在堆中通过指针碰撞或空闲列表分配;
    • 如果开启TLAB,先在本地线程缓存中分配;
  • 对象头初始化
  • 执行构造函数,初始化字段值;
  • 引用变量赋值,让 user 指向对象。

对象从“nothing”变成“活蹦乱跳的Java实例”,只用了短短几毫秒,却穿越了 JVM 的千山万水!

小彩蛋:如何观测对象分配?

如果你也像我一样好奇,可以加上这些JVM参数,观察对象的分配细节:

  • -XX:+PrintGCDetails
  • -XX:+PrintTLAB
  • -XX:+UseTLAB

尤其是 -XX:+PrintTLAB,能清楚看到线程是否使用了 TLAB、是否退回到了堆中。

推荐再配合一个神器:JOL (Java Object Layout),它可以帮你精确查看每个对象在内存中的布局结构,包括对象头、字段偏移等,非常直观!

尾声:JVM世界的浪漫

小时候玩乐高,总觉得搭建世界很有趣。长大后写 Java,看着一行 new 背后也能搭建出一个对象的“生命”,这种感觉也挺浪漫的。

JVM 就像一个“对象托儿所”,每天处理成千上万的新生儿。而我们程序员,就像它的故事讲述者。

今天我们只是揭开了对象出生的一角。接下来,还可以聊聊:

  • 对象逃逸分析是什么?
  • 为什么有的对象能“栈上分配”?
  • 对象什么时候进入老年代?

如果你也感兴趣,留言告诉我,下期我们继续聊!

我是小米,我们下次对象“探秘”再见!

END

如果你觉得本文对你有帮助,欢迎点赞、分享、在看,让更多人一起感受 JVM 的魅力!

我是小米,一个喜欢分享技术的31岁程序员。如果你喜欢我的文章,欢迎关注我的微信公众号“软件求生”,获取更多技术干货!