likes
comments
collection
share

Java对象的创建过程以及init和clinit方法详解

作者站长头像
站长
· 阅读数 6

详细介绍了Java中对象的创建过程,包括对象的创建方式、分配内存、空间初始化、构造方法的执行等。

1 创建对象的方法有哪些?

  1. 运用New 关键字创建实例,这是最常用的创建对象方法。
  2. 运用反射,调用Java.lang.Class类当中newInstance方法。只能调用公共的无参构造函数。
  3. 运用反射,调用java.lang.reflect.Constructor类中的newInstance方法提供无参或有参实例。除了无参构造器,还可以调用有参数的/私有的/受保护的构造函数。事实上Class的newInstance方法内部调用Constructor的newInstance方法。这也是众多框架Spring、Hibernate、Struts等使用后者的原因。
  4. 调用对象的clone方法。必须先实现java.lang.Cloneable接口。
  5. 使用序列化和反序列化。必须先实现Serializable接口。
  6. 使用unsafe.allocateInstance(class)创建对象。Gson中使用到,关于unsafe详见此博客:JUC中的Unsafe类详解与使用案例

方法1、2、3本质都会调用构造函数,都是常规的Java创建对象的new机制;而方法4、5、6不会调用构造函数。本次主要讲解调用构造函数的方式,即使用了new关键字。

2 创建过程概述

2.1 检查类的加载

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

2.2 分配内存

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

2.2.1 内存分配方式

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

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

2.2.2 内存分配时的安全问题

对象创建在虚拟机中是非常频繁地行为,仅仅是修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。

解决这个问题有两种方案:

  1. 一种是对分配内存空间的动作进行同步处理-实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。
  2. 另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。哪个线程要分配内存就在那个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。

JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配。

2.3 空间初始化

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头), 如果使用TLAB方式分配内存 ,这一工作过程也可以提前至TLAB分配时进行。

这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

下面是各种数据类型的初始值:

类型 默认值
byte (byte)0
short (short)0
int 0
long 0L
float 0.0f
double 0.0d
boolean false
char '/uoooo'(null)
reference(引用类型) null

2.4 其他必要的设置

接下来 ,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息,这些信息存放在对象的对象头(Object Header ) 之中。 根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。这部分会在对象内存布局方面有讲解。

2.5 执行< init >方法(new关键字专属)

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从Java程序的视角来看,对象创建才刚刚开始——< init >方法(实例初始化方法,类初始化方法是< clinit > )还没有执行,所有的字段都还为零值。 所以,一般来说(由字节码中是否跟随invokespecial指令所决定,不走构造器的初始化方式没有这条指令),执行new指令之后会接着 执行< init >方法(子类的< init >方法中会首先对父类< init >方法的调用),把对象按照程序员的意愿进行初始化。然后将内存地址赋给栈内存中的变量,这样一个真正可用的对象才算完全产生出来。

特别注意,对象初始化和指向栈空间内存这两步,先后发生顺序是随机的。这对单例模式中的双重检查锁具有重要影响!可能直接导致单例模式的失效!(使用JDK1.5之后volatile关键字可以避免,但是之前的当本则无法保证!)

3 特殊方法

3.1 < init >和< clinit >

在Java虚拟机层面上,Java编程语言中的构造器是以一个名为< init >的特殊实例初始化方法的形式出现的。

< init >这个方法名称是由编译器命名的, 因为它并非一个合法的Java方法名字, 不可能通过程序编码的方式实现。实例初始化方法只能在实例的初始化期间, 通过Java虚拟机的invokespecial指令来调用, 而且只能在尚未初始化的实例上调用该指令。构造器的访问权限,也会约束由该构造器所衍生出来的实例初始化方法。

Java对象的创建过程以及init和clinit方法详解

一个类或者接口最多可以包含不超过一个类或接口的初始化方法, 类或者接口就是通过这个方法完成初始化的这个方法是一个不包含参数的、返回类型为void的方法,名为< clinit >

< clinit > 这个名字也是由编译器命名的, 因为它并非一个合法的Java方法名字, 不可能通过Java程序编码的方式直接实现。类或接口的初始化方法由Java虚拟机自身隐式调用, 没有任何虚拟机字节码指令可以调用这个方法, 它只会在类的初始化阶段中由虚拟机自身调用。

< clinit>方法对于类或接口来说并不是必须的,如果一个类/接口中没有静态语句块,也没有对类变量的赋值操作,或者该类声明了类变量,但没有明确使用类变量初始化语句或静态初始化语句初始化或者该类仅包含static final 变量的类变量初始化语句,并且类变量初始化语句是编译时常量表达式,那么编译器可以不为这个类生成< clinit>()方法。

3.2 区别

< init >是对象构造器方法,也就是说在程序执行 new 一个对象调用该对象类的 构造方法时才会执行< init >方法,而< clinit >是类构造器方法,也就是在jvm进行类加载—验证—解析—初始化中的初始化阶段jvm会调用< clinit >方法。

< init >将语句块、变量初始化、调用父类的构造器等操作放到该方法中,顺序为:

  1. 父类变量初始化块/父类语句块(按代码编写顺序)。
  2. 父类构造函数。
  3. 子类变量初始化块/子类语句块(按代码编写顺序)。
  4. 子类构造函数。

< clinit >将静态语句块、静态变量初始化等操作放到该方法中,顺序为:

  1. 父类静态变量初始化/父类静态语句块(按代码编写顺序)。
  2. 子类静态变量初始化/子类静态语句块(按代码编写顺序)。

更多< clinit >的使用和规则在类加载文章部分有详解。

相关文章:

  1. 《深入理解Java虚拟机》
  2. 《Java虚拟机规范》

如有需要交流,或者文章有误,请直接留言。另外希望点赞、收藏、关注,我将不间断更新各种Java学习博客!