探秘Java:一个对象的生成(上)

993 阅读13分钟

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情

人生苦短,不如养狗

作者:Brucebat.Sun

公众号:Brucebat的伪技术鱼塘

一、前言

  作为一门面向对象编程的语言,Java中所有的概念和行为都建立在对象之上。这也就意味着,Java程序的编写实际上就是定义和操作各种不同类型的对象。而为了更好的定义和操作这些对象,我们就需要全面的了解一下对象生成的过程。

  在上一篇关于Spring的两三事:万物之始—BeanDefinition文章中,我们学习了如何在应用程序层面动态地生成对象,但是在这篇文章中并没有过于关注JVM虚拟机层面关于对象创建的处理逻辑。为了补全对象生成流程的所有拼图,今天我们就来具体研究一下JVM是如何完成一个对象的生成

注意:以下讲解内容主要基于jdk1.8版本,如低版本和高版本有不同处理方式请按照对应版本的处理方式理解。

二、基本概念

  为了更好的理解JVM生成对象中的一些设计思路,我们需要先了解一些对象生成相关的基础知识。

1. 对象的生成方式

  抛开通过copy方法和序列化-反序列化手段生成对象的方式,真正意义上能够”无中生有“的对象生成方式一共有三种:

  • 通过new关键字生成对象;
  • 通过Class类的newInstance方法生成对象;
  • 通过Constructor类的newInstance方法生成对象;

  第一种方式是Java当中最为正统、使用最频繁的生成对象的方式,而后两者生成方式则是基于Java反射机制实现的。这里我们简单看一下后两者的代码示例:

  在上面的代码示例中,分别展示了两种生成方式最简单的使用,而进一步探究会发现这两种对象生成方式本质上都是使用类的构造函数来生成。也就是说在使用Class类的newInstance方法生成对象实际上使用的是类的无参构造函数来创建对象,这就意味着需要保证当前类具有无参构造函数

2. 对象的生成过程

  在前面的介绍中我们了解到了三种对象生成的方式,但这三种生成方式中只有使用new关键字的生成方式是真正执行完成一个对象生成过程中全部环节的。这么说的原因是在于后两种生成方法在使用前必须要保证JVM当中已经完成了对应类型的加载,也即上面代码中Class类型对象的实例必须存在于JVM虚拟机中。而这一步在使用new关键字来生成对象时则会包含在指令执行的过程中,无须开发人员做显式的处理。下面我们就来具体看一下new关键字是如何创建对象的。

2.1 类加载检查

  对于JVM来说,实际执行的程序并不是开发人员编写的Java源码,而是经过编译器编译之后的二进制字节码(.class文件)。在编译new关键字时,javac编译器会将new关键字编译成如下字节码指令:

  当JVM开始执行new指令时会首先检查是否能够依据当前指令的参数(即上图中的instances/Person)在常量池中寻找到目标类的一个符号引用,并且会检查这个符号引用所代表的类是否已经被加载、解析和初始化,如果没有完成以上这些操作则需要进行相应的类加载操作,待完成类加载操作之后才能继续进行后面的流程。

2.2 内存分配

  在完成类加载检查之后,JVM会依据类型信息为待创建的对象分配内存。对于分配内存的过程我们需要弄明白以下三个问题:

  • 如何确定需要分配的内存大小?
  • 在什么地方进行内存分配?
  • 如何进行内存分配?

  第一个问题:如何确定需要分配的内存大小? 要回答这个问题首先我们需要知道对象的存储布局,即分配给对象的内存到底存储了哪些数据。在JVM当中,对象的内存布局主要分为三个部分:对象头(Header)实例数据(Instance Data) 以及对齐填充(Padding) 。每部分存储内容如下:

  这里不难发现,对象头存储的数据结构对于所有的类型都是通用的,所以这部分数据的大小比较好确定的。当然有部分同学可能会有这样的疑问:对象头中不是会存储运行时数据吗,那这部分数据的大小不应该是动态变化的吗?这里JVM为了空间效率,将存储运行时数据的部分(即Mark Word)设计成一个动态定义的数据结构(这部分结构就不展开描述了,大家可以自行检索),即整体存储空间固定,但是存储数据的不同二进制位的含义会随着程序运行的阶段而发生变化。第二个比较好确定的是对齐填充部分,这部分的大小只需要根据已经使用的内存和8字节的整数倍进行补差即可。

  最难确定的实际上是实例数据部分的内存大小,而难以确定的主要原因在于分配给成员变量的内存空间存储的到底是实际数据的引用(即存储空间地址) 还是实际的数据本身。如果存储的是引用,那么存储空间就是固定的。但如果直接存储的是实际数据本身,其使用的内存大小就需要根据实际数据的类型和数据结构进行计算。那么如何判断应该存储引用还是存储实际数据呢?实际上这里是根据成员变量的数据类型来进行判断的,如果是基本类型则直接保存对应的数据,如果是引用类型则保存的是指向实际数据的引用。这里我们可以借助下图中的示例来直观地感受一下:

  对于基本类型而言,每种类型JVM虚拟机都为其限定好了存储数据能够使用的空间大小。而对于引用类型,由于其存储的是实际数据的地址,其大小也做了限定,为4字节。

  第二个问题:在什么地方进行内存分配? 借助上面图中的示例我们可以发现对象所需的空间都是从中分配的。这里我们需要特别关注一下在上面的示例中出现的一种特殊的数据类型——String类型,之所以说String类型特殊是因为String类型数据的存储位置会根据创建方式编译情况而发生改变。具体有以下三种情况:

  • 直接将字符串常量复制给一个String类型变量。

    String text = "hello, world";
    

    如上代码所示,此时代码中的hellow,world字符串在编译期就可以被确定,所以该字符串会被存储在字符串常量池中,而text变量中存储的是该字符串在常量池中的空间地址。此时存储情况就和上面图中示例展示的一样。

  • 使用new关键字创建一个String类型对象。

    String text = new String("hello, world");
    

    如上代码所示,此时hello, world字符串依然会存储在字符串常量池中,但是text变量并不会直接存储该字符串在常量池中的空间地址,此时text变量存储的是通过new关键字创建出来的String类型对象的内存地址,而这个对象内存储的才是实际字符串常量在常量池中的内存地址。具体如下图所示:

  • 使用动态方式获取到String类型对象。由于在实际工作中会有许多方法能够动态获取String类型的对象,这里我们就不一一举例了。简单理解,类似通过ORM框架、JSON工具等序列化出来的String对象或者调用toString()方法获取的String对象都是通过动态方式获取的,这类String类型的对象实际数据都会直接从堆中分配,而不会从常量池中分配。

  这里教给大家一个实用小技巧来确定实际代码中的String类型对象是否存储在常量中。JDK本身提供了一个非常好用的反汇编工具javap,通过javap工具我们可以根据自己的需要读取已经编译完毕的.class文件内的数据,具体指令为:

 javap -verbose  {targetClass}.class

  通过这样一个指令我们可以得到如下内容:

  在上图中展示的结果中会打印出当前类中所有存储在常量池中的信息,其中就包含了编译期被认为是常量的String类型对象。如果当前String类型对象是通过动态方式生成的,那么在常量池列表是无法查询到的。

  第三个问题:如何进行内存分配? 在Java中内存分配方式主要有两种:指针碰撞空闲列表

  指针碰撞方法使用的前提是可用内存空间是连续规整的,基于这一前提,JVM通过移动作为分界点的指针来完成内存空间的分配。具体分配过程可以参考下图:

  空闲列表方法则不要求可用内存空间是连续的,但是使用空闲列表法需要维护一张用于记录空闲内存块的列表,每次分配数据都需要查询并修改该空闲列表。具体分配过程可以参考下图:

  需要注意的是,上述的两种方法都会存在线程安全问题,由于篇幅和侧重点问题,笔者就不过多阐述,有兴趣的同学可以阅读周志明的《深入理解Java虚拟机》来了解一下JVM是如何解决在内存分配时的线程安全问题。

2.3 对象实例初始化

  在完成对象内存分配之后,JVM会对分配完成的内存空间进行零值初始化,需要注意的是这里的初始化是不包含对象头的。进行这一步操作的原因是为了保证对象当中的成员变量在没有赋值的情况是可以直接使用的,当然这里的零值初始化对于基本类型来说就是赋予缺省值,而对于引用类型来说就是赋予null值。

  在完成对象成员变量的初始化之后,JVM还会对对象进行一些必要的类元信息的设置,比如对象从属于的类型、如何找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息,这些信息实际上都会存储在对象头中。

2.4 执行init方法

  在上面展示的new关键字字节码的图中可以看到这样一条指令INVOKESPECIAL instances/Person.<init> ()V,这条指令实际上是去调用Class文件中的()方法,也即调用Java源码中的构造函数。通过构造函数的方式,开发人员可以按照自己的意愿对对象中的成员变量进行初始化。这里我们可以获得两个有用的信息,第一个是我们可以通过编译后的字节码指令来判断当前生成对象的方式是否调用了构造函数,第二个就是构造函数的调用时机会在JVM初始化零值之后。

3. 对象的访问定位

  让我们再来看一看下面的代码:

Person person = new Person();

  其实上面所有的内容都只是分析了等号右边new关键字的执行流程,并没有讲述如何将等号两边的内容关联起来的操作,也即person变量应该如何定位和访问通过new关键字创建的对象实例。

补充:仔细观察我们日常的代码可以发现,上面的这行代码只会出现在方法体中,而在方法体中的变量都会存在于虚拟机栈的本地变量表中,而使用new关键字创建对象实例则会存储在当中。

  JVM提供了两种方式来定位和访问堆当中的对象数据,分别是句柄直接指针。出现这两种方式的原因是在于对象数据中存在类型数据实例数据两种不同类型的数据,其中类型数据是通过类加载过程生成的一个代表这个类的java.lang.Class对象,而这个对象存储在方法区中。基于对象数据移动访问效率的考虑,诞生了上述两种定位访问方式。下面我们来具体了解一下这两种方式是如何执行的。

  • 使用句柄方法访问对象数据 :JVM会在堆中划分出一块内存作为句柄池,在句柄池中会分配一块存储了对象实例数据指针(即对象实例数据存储空间的地址)和对象类型数据指针(即对象类型数据存储空间的地址)的数据空间,而person变量中存储的实际上就是这个数据空间的地址。使用这种方式,在面对对象数据移动时只需要变动对象实例数据指针的值即可,person变量和对象类型数据指针均无须任何发生变化,但是在访问效率上则会显得相对低下。
  • 使用直接指针访问对象数据 :对象内存布局中本身就包含了对象类型数据指针,此时person变量存储的实际上就是对象的实际地址。使用这种方式在面对对象数据移动的情况下需要变更person变量,但由于节省了一次指针定位的时间开销,在访问效率上会显得相对高效。

  其实从上文的分析中我们不难发现,我们日常使用的JVM中都是使用直接指针来访问对象的,因为相比于对象移动时数据变动效率的提升,对象访问效率的提升才是开发人员真正需要的,毕竟在Java中对象的访问操作是一件非常频繁的事情。

三、总结

  本篇文章主要讲解了对象生成的流程以及流程中的一些基本概念,而对于对象生成的基石——类型信息并没有做过多的讲解分析(废话有点多,及时刹车,哈哈哈~~)。在下一篇文章中,笔者会对类型信息、类加载过程以及相关的一些问题进行分析。

  最后,疫情还在继续,希望大家出行注意防护,身体健康,每天保持好心情~~