一个新生命诞生的过程(Java对象的创建)

本文深入探讨Java对象创建过程,从字节码指令到内存分配、数据初始化,涉及常量池、类加载、内存区域划分(指针碰撞与空闲列表)、多线程同步与TLAB,以及构造方法执行。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

一个新生命诞生的过程(Java对象的创建)

 

这个标题取得有点哲学意思哈。

Java中频繁的创建对象,是我们家常便饭,那么对于我们的家常便饭,他如何被创建出来的呢?他制作的过程中,分为哪些主要的步骤呢?制作的过程又涉及到哪些我们所了解的内存区域呢?Java虚拟机是怎么帮我们制作出这些对象的?

问题很多,有这么多的疑问,我们都想去学习其中的所以然。那么我们就带着这些疑问去了解一下整个对象的创建过程。

 

关于创建对象,我们都是 new Object(),方式,但是笔者也见过通过深拷贝、序列化等方式去构造一个新的对象。

此处需要先定义一下什么叫做新的对象:就是在堆内存中出现了一个新开辟内存空间的对象实例,那么这个实例就是一个新的对象。

关于上述深拷贝,就是将一个已存在的对象实例进行拷贝复制,从而产生一个新的对象实例地址作为指向地址。其中浅拷贝,就不是了,对象指向还是原有的对象实例地址,那自然不能称之为创建新对象了。

 

 

1.Java虚拟机通过识别出字节码指令new,就会去首先判断该对象是否存在“运行时常量池”,“运行时常量池”中是否存在这个类的“符号引用”的地址信息(类和对象,我们需要严格区分开来。类:定义了一个对象的信息;对象:是由同一个类实例化出来的。就好比我们做月饼,都是通过同一个模具制作出来的,其中类就好比模具,对象就好比一个个月饼。)

那么如果这个类在常量池中没有找到对应的类的符号引用,且符号引用对应的类是否被加载过了,解析过了,初始化过了?(加载解析初始化,这都是类加载子系统的动作)那么就说明当前类是首次被使用(这个模具第一次用于做月饼),那么就需要首次执行类加载的动作。(此处就可以说明,常量池中存放了不止常量对象等信息,还存放了类的信息)

 

2.执行完了类的加载等一系列的前置动作后,就开始真正创建新对象了。

 

3.首先就是新对象的内存分配问题。当类加载动作执行后,就可以确定当前对象所需要的初始内存的多少(举个例子:我们做个月饼,模具的大小确定了,那么这个月饼所需的原料大小是不是就也确定了,那么我们每次做一个新的月饼,都预估出这个月饼的原料,直接准备好就可以了);

关于新对象的内存,也就是月饼所需原料,我们到底如何分配呢?因为一堆原料,可能是已经被分配给了做蛋糕的,也有可能被分配给了做包酥饼的。我们要想从这堆原料中扣扣出来,整理出能够做月饼的原料,就有很多种情况了。

我们假设,现在在有一个桌子,桌子上是固定的大小的桌面区域,这个区域就好比堆内存。那么我们假设需要在这个桌面上划分出一块没有被占用的区域用于存放月饼;月饼就好比一个对象。

有两种情况:

(1)桌面上很工整,左边是很空旷的区域,用于放新的月饼;右边使用很充分,全部整整齐齐的放满了各种已经做好的月饼。

(2)桌面上杂乱无序,被各种东西占满了,但是我们可以从中找到一些没有被占用的空闲小区域,我们可以从这些地方来分别分配新的月饼放。

对于第一种情况,想必我们都很喜欢,那么我们怎么对这个桌面上分配出一块区域给新的月饼放呢?都知道,桌面左边是空闲的,右边是占满的,那就会有一个临界线(称为指针);当我们需要分配1个新的单位给新的月饼放的时候,就需要将该临界线往左边挪动1个单位即可,右边挪动所产生的新区域就被分配出去了,这块区域就是当前对象的内存区域。这种分配方式就是指针碰撞。

那么第二种情况,杂乱无序,我们怎么处理呢?我们可以观察其中的空闲区域?不不不,倘若桌面足够大,大到一定程度的时候,就不能很直观的观察出哪里已经被占用的,哪里还是空闲的?那么怎么解决呢?这个时候,我们可以使用一张纸,一份表格记录下来。都知道,当一个很大很大的库房,一批新进的货物,要想在这个库房中存放,首先得看看能不能存放,存放到哪里。这些问题,那么不可能由管理员挨个去逛,看看哪里可以放,哪里是没有东西的。管理员肯定是有一张管理记录表,用于记录所有货架有没有存放东西,且存放的是什么东西,然后采用坐标方式,可以快速定位到想要的货架了。是的,那么这个管理记录表可以抽象为空闲列表。当我们有新的月饼需要产生,需要3个单位的空闲区域,那么就需要在“空闲列表”上寻找空闲的区域,尽可能找到一个装得下,且连续的空闲区域(此处注意,在物理上很有可能不是连续存在的,因为我们内存片上本身就无法保证连续存储,但是在使用上必须保证连续获取)。找到后,就标注该区域被使用,那么就可以了。这种分配的解决方式称之为“空闲列表”。

两种情况,我们都有相应的解决方案,至于什么时候使用哪种,我们得看做月饼的管理师傅采用的什么整理方案了。如果师傅很勤奋,每次都会整理好桌面,将两个区域清晰的分开,那么就采用第一种方式解决更高效。那么如果师傅很聪明,不用频繁的整理,只需要记录空闲区域,那么就采用第二种方式解决更简单高效。

两种整理方式,就对应了java虚拟机的两种GC算法;当使用空间压缩整理方法,就是归整的。当使用标记清除算法,就是无序的。

 

 

4.说清楚了如何分配空闲的区域给新产生的月饼放位置。那么我们就还得考虑一个问题:对于新对象的创建,在jvm虚拟机中是无时无刻都在发生的事情,那么将会存在并发问题,也就是将会出现同一个区域,会被两个新对象同时抢占;并且我们上述描述的压缩整理算法,也会存在我们还未将旧的对象移走(本质就是修改内存指向,但也存在问题),新的对象就已经放在了相同的内存区域了。这种时候就会出现多线程下的内存管理安全问题。虽然堆是共享内存区域,但是我们也要保证多线程环境下的数据安全问题。

对于月饼存放也是同样一个道理,我们一个桌面,可能会有多个糕点师傅在同时使用,师傅A想创建一个新月饼,想使用1号区域;师傅B想创建一个新月饼,想使用1号区域;因为1号区域现在看着都是空闲的,谁都可以使用。那么这个时候如果不解决,就会出现两个新月饼占用了同一个空闲区域。

怎么解决呢?我们可以使用一个临时托盘?或者说,将这个桌面上先划分出一小块的区域给各位师傅使用,那是不是就解决了线程资源竞争问题了呢?是的。那么假设如果划分出的临时区域不够使用了怎么办呢?那么将采用自旋方式去申请新的空闲区域,也就是师傅不停的判断当前这块区域有没有人使用,如果没有人使用我就使用,如果有人也在申请,我们就也一直申请。当没有申请的时候,我就立马抢占式去申请,这种方式称之为自旋方式,自旋方式是能够保证操作的原子性的。那么如果失败了,我们就再试一遍,直到将我们需要的空闲区域申请下来。

上面说到的临时空闲区域称之为“本地线程分配缓冲区(Thread Local Allocation Buffer)”,简称 TLAB,这种方式,减少了多线程环境下的内存资源竞争问题,但如果各自的“本地线程分配缓冲区”消耗完,也是需要再次去竞争申请的,申请是一个同步锁定的过程。

 

 

5.上面很繁琐的讲了一下新对象的内存初始化分配问题,那么内存初始问题分配好了,就开始初始化内存空间了。

如果我们上面使用了“本地线程分配缓冲区”概念,那初始化动作就可以在缓冲区中进行了。

我们都写过代码, int i; i是有默认值的,即为0;Integer integer;也是有默认值的。我们在使用这些对象的时候就可以获取到其中的默认值,就好比我构建了一个对象实例,然后通过实例对象访问其中的属性,都会访问到默认值,因为我没有对其进行赋值。

 

6.当对象中的内存空间都被初始化之后,那么么就是对该对象实例进行数据的绑定设置了,比如将其与其对应的 类进行绑定,绑定完之后,我们就可以访问到类的一些元数据信息了(所谓元数据,就是当前类的结构数据,类的组成等基础信息);然后就是对象的实例化数据存放分代位置了(比如数据够大,直接进入老年代);对于对象实例,也会存在对象头位置(Object Header)。

怎么理解对象头,就比如我们常见的请求体,请求体数据就会有请求头,请求头中的数据,通常都是存放当前请求体的一些编码格式,请求体方式,以及加密信息等数据了。那么对于不同的虚拟机,就会有一些不同的请求头数据组成。

其中对于对象的hashCode值,将会采用一种懒加载方式,也就是说,当前对象的调用hashCode()方法的时候,才会计算出当前对象的hash值。

 

7.到此处了,一个对象本质来说还没完成创建,只不过在上述操作中,已经在jvm层面完成了。但是在Java字节码加载,还未完成。(还有初始化对象操作),我们的类的构造方法还未执行呢!

上述都是将所有对象的属性都给定了一个默认值,那么有实例化话对象,就会有构造方法的执行,否则怎么真正的构造一个新的对象。

举个图片中的例子:

在图片中所构造出的一个新对象,虽然是无参构造,但是我们重写了其对象的无参构造方法,对于无参构造中的方法,我们还需要将其执行。

那么对于的这个无参构造方法,在字节码指令中,将其统一归为<init>方法描述了,也就是说,该方法还未执行,那么这个对象就还没创建完成。

 

我们看一下对象的实例化中的一个反编译class文件的代码,可以观察执行这个new Object()方法,在字节码中被解析拆解成哪些指令了。

从下图中,我们可以看到main方法中有两个指令,第一个指令就是new,构造一个对象Obejct,然后再看到后面的 invokespecial指令,这个指令就是执行了NewObjcet对象中的<init>方法。当这个方法执行完成后,那么就开始获取静态对象System.out了。

接下来的执行指令,我们就不关心了,主要看到两个指令,所以说,当前构造一个新对象的时候,会默认执行invokespecial指令。

所以我们可以总结出:只要我们在Java程序中执行了new Object()代码,就会出现 new、invkespecial指令。因为这样才是构造出了一个新的对象实例。

 

 

8.对象实例化完成。

 

 

9.总结:

一个对象的实例化操作笔者认为主要可以分为两大操作:JVM层面的内存分配操作+JAVA层面的数据初始化操作。

那么JVM层面又细分了很多区域的数据初始化操作,从类的基本元数据的加载到对象实例的堆内存分配,涉及很多分配规则,和方案。有两个很好的方案:指针碰撞和空闲列表。其中标记清除算法不一定不能使用指针碰撞方式,如果有很大的一块空闲区域,我们也是可以实现的,因为到后期我们分块结构,那么就会将很多区域分治,分而治之了。

Java层面的对象实例化,主要就是一些我们构造方面的数据初始化,基本属性的复制等,关于这类初始化,我们更多关照对象间继承的初始化顺序问题。其中类成员变量,父类构造的初始化等等。

此处我们还需分别出静态变量是属于类初始化阶段的产物,并不属于哪个对象实例的。

 

最后附一张对象实例创建过程的图解顺序