深入剖析 JVM 中 Java 对象的创建
作为一名 Java 开发者,
new Keyword()这行代码我们每天都会写下无数次。它就像一句神奇的咒语,JVM 这位“魔法师”听到后便会为我们凭空变出一个对象。但你是否曾停下手中的键盘,思考过这背后的魔法仪式?对象究竟是如何在 JVM 这片神秘大陆上被创造出来的?今天,就让我们拨开 Java 语法的迷雾,潜入 JVM 的底层世界,来一场对象创建的深度探险!
不仅仅是一句 new:对象的完整创建流程
很多人认为 new 关键字就是对象创建的全部,实则不然。在 JVM 内部,一个对象的诞生需要经历多个严谨的步骤,如下图所示:
让我们沿着这条主路,一步步揭开每个环节的神秘面纱。
第一步:类加载检查(Check)
当 JVM 遇到一条 new 指令时,它首先不会急着去分配内存,而是先检查这个指令的参数是否能在运行时常量池(Runtime Constant Pool) 中定位到一个类的符号引用。
- 如果没找到? 抱歉,著名的
ClassNotFoundException就是你将要收到的“礼物”。 - 如果找到了? JVM 还会进一步检查这个类是否已经被加载、验证、准备过。如果没有,那就立刻触发类加载(Class Loading) 过程。这就像工厂生产产品前,必须先有该产品的设计图纸。类加载就是 JVM 读取“.class”设计图纸的过程。
只有确保了“图纸”齐全且有效,JVM 才会进入下一步。
第二步:分配内存(Allocate)
拿到了“设计图纸”,JVM 接下来就要为新生对象在 Java 堆(Heap) 上划分一块确定大小的内存了。这个大小在类加载完成后就已经完全确定(稍后对象头会讲到)。
那么,具体如何划分呢?主要有两种方式,这取决于 Java 堆的内存是否规整,而堆是否规整又由所采用的垃圾收集器(Garbage Collector, GC) 是否带有压缩(Compacting) 功能决定。
-
指针碰撞(Bump the Pointer)
- 适用条件:堆内存是规整的(即所有用过的内存放在一边,空闲的内存放在另一边)。
- 原理:假设堆内存从一个起始地址开始,完全连续分配。JVM 会维护一个指针,指向下一个可分配内存的起始地址。当需要分配内存时,只需将指针向空闲空间方向移动一段与对象大小相等的距离即可。这个移动动作叫做“碰撞”。
- 特点:简单高效。
-
空闲列表(Free List)
- 适用条件:堆内存是不规整的(即已使用和空闲的内存相互交错)。
- 原理:JVM 会维护一个列表,用来记录哪些内存块是可用的。在分配时,它需要遍历这个列表,找到一块足够大的空间划分给对象实例,并更新列表上的记录。
- 特点:相对复杂,分配效率较低。
并发安全的挑战与解决方案:TLAB
内存分配是一个非常频繁的操作。在并发场景下,哪怕只是修改一个指针的位置(指针碰撞),也可能是“线程不安全”的。你刚读取到指针位置,还没修改,另一个线程可能也读取了同样的位置,这会导致两个对象分配到同一块内存区域。
为了解决这个问题,JVM 引入了 TLAB(Thread-Local Allocation Buffer),即线程本地分配缓冲区。
- 是什么? TLAB 是 JVM 为每个线程在 Eden 区(堆中新生代的一部分)预先分配的一小块私有内存。
- 如何工作? 当线程需要创建对象时,会优先在自己的 TLAB 中进行分配。由于这块区域是线程私有的,所以不需要任何锁机制,分配动作变得非常快速。
- 分配失败? 如果 TLAB 剩余空间不足以分配新对象,JVM 会尝试在 Eden 区中重新申请一块新的 TLAB 并进行分配(这个过程需要同步锁定)。
TLAB 是提升 JVM 性能的一个关键优化点,它极大地减少了线程间的竞争,让对象分配更加高效。
第三步:初始化零值(Zeroing)
内存分配完成后,JVM 需要将分配到的内存空间(不包括对象头)都初始化为零值。这也就是为什么我们不对成员变量进行初始化,它们也会有默认值(如 int 是 0,boolean 是 false,引用类型是 null)。
这一步操作确保了对象的实例字段在不赋初值的情况下也能直接使用,是基于 Java 语言规范的一项安全措施。
第四步:设置对象头(Header)
对象现在已经有了一块“地皮”(内存)和“地基”(零值),但还缺少一个“门牌号”和“房产信息”。这就是对象头(Object Header)。
对象头是 JVM 用于存储对象元数据的复杂数据结构,通常包括两部分(以 HotSpot VM 为例):
-
Mark Word: 这是一系列标记位的集合,用于存储对象自身的运行时数据。它的内容会随着对象状态的变化而变化,非常“善变”。
- 包括:哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
- 在 32 位和 64 位的虚拟机中,Mark Word 分别是 32bit 和 64bit。下图简要展示了其结构:
(以 32 位 JVM 为例,在不同状态下的结构变化)
|-----------------------------------------------------------------| | Object Header (64 bits) | |------------------------------------|----------------------------| | Mark Word (32 bits) | Klass Word (32 bits) | |------------------------------------|----------------------------|Mark Word 详细结构:
|-------------------------------------------------------|--------------------| | Mark Word (32 bits) | State | |-------------------------------------------------------|--------------------| | identity_hashcode:25 | age:4 | biased_lock:1 | lock:2 | Normal | |-------------------------------------------------------|--------------------| | thread:23 | epoch:2 | age:4 | biased_lock:1 | lock:2 | Biased | |-------------------------------------------------------|--------------------| | ptr_to_lock_record:30 | lock:2 | Lightweight Locked | |-------------------------------------------------------|--------------------| | ptr_to_heavyweight_monitor:30 | lock:2 | Heavyweight Locked | |-------------------------------------------------------|--------------------| | | lock:2 | Marked for GC | |-------------------------------------------------------|--------------------| -
Klass Pointer(类型指针): 对象指向它的类元数据的指针。JVM 通过这个指针来确定这个对象是哪个类的实例。为了节省空间,64 位 JVM 通常会开启指针压缩(-XX:+UseCompressedOops),将指针从 64 位压缩至 32 位。
此外,如果对象是 Java 数组,对象头还必须有一块用于记录数组长度的数据。
第五步:执行 <init> 方法(Init)
从 JVM 的视角看,到上一步为止,一个对象已经创建成功了。但从我们程序员的视角看,对象还处于“半成品”状态。
因为 new 关键字之后通常会跟着调用构造方法(Constructor),进行我们自定义的初始化工作,比如给字段赋予我们设定的初值。JVM 会在这一步之后执行 <init> 方法。
<init> 方法是由编译器将实例变量初始化语句和构造方法中的代码合并而成的方法。只有执行完这个方法,对象才会按照我们编写的 Java 代码的意图,被真正地“构造”完成,成为一个可用的业务对象。
总结
回过头看,JVM 为我们执行 new Keyword() 这条简单语句所做的事情,远比我们想象的要多:
- 检查与加载:确保类已就位。
- 分配内存:在堆上划地盘,并用 TLAB 高效解决并发问题。
- 初始化零值:赋予字段安全的默认值。
- 设置对象头:给对象贴上完整的“身份标签”。
- 执行初始化:调用
<init>方法,完成程序员定义的构造过程。
下一次,当你再次写下 new 时,希望你的脑海中能浮现出这场精密而优雅的底层魔法仪式。理解这些细节,不仅能满足我们的好奇心,更能帮助我们理解 JVM 的性能特性、内存布局以及线程安全等高级话题,从而写出更高效、更健壮的 Java 程序。
Happy Coding!
更多推荐

所有评论(0)