Java对象的创建过程

Java对象的创建过程

  1. 当Java虚拟机遇到一条字节码new指令时,首先会去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程

  2. 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。

  3. 内存分配完成后,虚拟机必须将分配到的内存空间(不包含对象头)都初始化为零值(如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行TLAB为本地线程分配缓冲 详解可见下文)。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。

  4. 接下来,Java虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息保存在对象的对象头中。根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

  5. 至此,从虚拟机的角度来看,一个新的对象已经产生。然而从Java程序的角度来看,对象创建才刚刚开始--->构造函数,即Class文件中的<init>()方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。一般来说,new指令之后会接着执行<init>()方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。

类加载的执行过程

  1. 加载--主要是将.class文件中的二进制字节流读入到新JVM中

    1. 通过类的全限定名获取该类的二进制字节流。
    2. 将字节流所代表的静态存储结构转化为方法区的运行时数据结构。
    3. 在内存中生成一个该类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
  2. 连接

    1. 验证--确保加载进来的字节流符合JVM规范
      • 文件格式验证
      • 元数据验证,是否符合java语言规范
      • 字节码验证,确保程序语义合法,符合逻辑
      • 符号引用验证,确保下一步的解析能正常执行
    2. 准备--为静态变量在方法区分配内存,并设置默认初始值
    3. 解析--虚拟机将常量池内的符号引用替换为直接引用
      符号引用:符号引用与虚拟机实现的布局无关,引用的目标并不一定要已经加载到内存中。各种虚拟机实现的内存布局各不相同,但是它们能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
      直接引用:直接引用可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在内存中存在。
  3. 初始化--标记为常量值的字段赋值的过程,只对static修饰的变量或语句块进行初始化。
    初始化阶段是执行类构造器<client>方法的过程。<client>方法是由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成的。虚拟机会保证子<client>方法执行之前,父类的<client>方法已经执行完毕,如果一个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成<client>()方法。

注意以下几种情况不会执行类初始化:

  1. 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
  2. 定义对象数组,不会触发该类的初始化。
  3. 常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类。
  4. 通过类名获取 Class 对象,不会触发类的初始化。
  5. 通过 Class.forName 加载指定类时,如果指定参数 initialize 为 false 时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化。
  6. 通过 ClassLoader 默认的 loadClass 方法,也不会触发初始化动作。

内存的分配方式

内存的分配方式有以下两种:

  1. 指针碰撞
    假设堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离。
  2. 空闲列表
    如果堆中内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。

选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由采用的垃圾收集器是否带有空间压缩整理的能力决定。

因此,当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,即简单又高效。
而当使用CMS这种基于清除(Sweep)算法的收集器时,理论上就只能采用较为复杂高效的空闲列表来分配内存。

指针碰撞方式存在的问题:
对象创建在虚拟机中是非常频繁的行为,仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的。
可能会出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决这个问题有两种可选方案:

  1. 对分配内存空间的动作进行同步处理---实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性。
  2. 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓冲区时才需要同步锁定。

对象的内存布局

由于Java面向对象的思想,在JVM中需要大量存储对象,存储时为了实现一些额外的功能,需要在对象中添加一些标记字段用于增强对象功能,这些标记字段组成了对象头。
Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)
MarkWord:默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁的标志位的变化而变化。
Klass Point:对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的还是在子类中定义的字段都必须记录起来。

对其填充不是必然存在的,也没有特别的含义,它仅仅起占位符的作用。由于任何对象的大小都必须是8字节的整数倍,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

posted @ 2021-04-10 20:14  cos晓风残月  阅读(710)  评论(0编辑  收藏  举报
*