Fork me on GitHub
0%

Java 中对象和数组的创建

在上篇文章中,主要介绍了 JVM 的运行时数据区域分布情况,包括堆,程序计数器,栈,方法区,运行时常量池,直接内存等,接下来我们就先来看下和堆区相关的知识点,之前说过堆区是提供所有的类实例以及数组进行内存分配的地方,那实例和数组又是怎么创建的呢?我们一起来看下吧。

在 Java 程序员圈子中我们可能都听说过类似这样的段子:”程序员找不到对象没关系,我们可以直接 new 一个“,这句话虽说是程序员的自我调侃,但在 Java 语言层面我们确实是通过 new 这个关键字来创建对象的,而且在程序的运行过程中无时无刻都在创建。

在 Java 语言层面我们通过 new 关键字很快就创建出一个对象,比如说像下面这样:

1
Object object = new Object();

Java 语言层面看起来很简单,但这一行代码在 Java 虚拟机中却伴随着一系列的过程,首先 JVM 既然要创建 Object 类的对象,那它肯定需要先有 Object 类才能创建,于是就需要先将 Object 这个类加载到 JVM 中,也就是我们经常听到的类加载。类加载分为以下几个过程:加载,连接,初始化

加载

首先说明这里的加载并不是指上面提到的类加载过程,这里只是类加载过程中的一个阶段,拿上面的代码来说,也就是将 Object 这个类的字节流加载到 JVM 中,至于字节流从哪里来其实没有明确的规定,所以可以有很多种方式,可以从 jar 包读取,反射动态生成(动态生成代理类),网络读取,以及 Class 文件,其中我们最为常见的可能就是从 Class 文件中读取。在读取时则需要借助类加载器来完成这个过程,同时根据类加载器的功能特征可将类加载器分成三级,在 Java 9 之前从上到下分别是启动类加载器(bootstrap class loader) > 扩展类加载器(extension class loader) > 应用类加载器(application class loader)。

从名字也可以大概知道启动类加载器一般负责加载最基础以及最重要的类,包括其它下面的类加载器类,比如说扩展类加载器和应用类加载器都是 java.lang.ClassLoader 的子类,也就是这些类加载器类也是需要通过启动类加载器先进行加载才可使用。对于扩展类加载器主要负责 jre/lib/ext 目录下 jar 包中对应的类,而应用类加载器则负责加载应用程序路径下的类,通俗的来说就是我们指定的 classpath 路径下的类。从 Java 9 开始会有一些变化,比如说扩展类加载器改名成为平台类加载器(platform class loader),如果对具体细节感兴趣的话可以去查阅相关资料,这里就不再深究了,知道个大概就行。

除了 Java 提供的这三类核心加载器之外,我们也可以自定义我们自己的类加载器实现一些特殊的需求,比如说我们将一个 Class 文件内容先进行了加密操作,那么在加载时就需要进行解密,这时就需要通过我们自定义的类加载器来实现了。

对于类加载器还有一个比较重要的点就是我们也经常听到的双亲委派模型,什么意思呢?前面我们提到类加载器是分等级的,在类加载器尝试加载一个类时,它首先需要先将这个加载请求转发给它的父类加载器,只有在父类加载器无法加载的情况下它才会去加载这个类,这也就是类加载器的双亲委派模型,其实主要是避免重复加载,如果每个类加载器都去加载自己所需要的类,那么就可能造成多个类加载器都去加载同一个类的情况。

连接

连接里面又可细分为 验证,准备,解析 三个阶段,在类加载之后,JVM 首先需要对加载的字节流是符合 JVM 的规范进行验证,比如说加载的字节流当前版本的虚拟机是否能够处理,超出当前版本则无法处理,当然还有一些更为精细的校验。

其次是准备阶段,在这一阶段中正式为类变量在方法区中分配内存以及设置初始值,也就是为 static 修饰的变量分配内存,对于基本数据类型,比如说 int 类型初始值为 0,boolean 类型为 false,对于引用类型则为 null,而真正的赋值是在第三阶段初始化阶段来完成。这里有个特殊情况,当类变量同时被 final 修饰的话,则会直接赋予代码中所给定的值。

1
2
public static int a = 100; // 准备阶段 a 的初始值为 0
public static final int b = 200; // 准备阶段 b 直接赋实际值 200

某个类在加载到虚拟机前,它是不知道它所依赖的类,以及它里面的字段和方法对应的具体地址的,因此 Java 编译器在编译时会生成一些符号引用来指代它所引用的类,字段,方法,那么在解析阶段就是将这些符号引用解析成内存中具体的地址,如果解析过程中某个类还没有加载那么就会触发该类的加载,但不一定会触发连接和初始化。

初始化

在初始化阶段,将根据代码的实际赋值进行初始化,比如说上面的 a 正式赋值为 100,这一过程 JVM 是通过执行 <clinit> 方法实现的,在 <clinit> 方法中包括了所有的类变量的赋值操作以及所有的静态代码块, <clinit> 方法具有以下主要的特征:

  1. <clinit> 方法和实例构造器(<init>)不同,它不需要显式调用父类实例构造器,虚拟机会保证子类的 <clinit> 方法执行前,父类的 <clinit> 方法已经执行完成,由此可见,JVM 中第一个被执行的 <clinit> 方法的类是 Object 类。
  2. 对于接口,它和类有所不同,执行接口中的 <clinit> 方法不需要先执行父接口中的 <clinit> 方法,只有当父接口中的类变量需要赋值时才会执行,同时接口的实现类执行 <clinit> 方法也不会执行接口中的 <clinit> 方法。
  3. 从第一点中可以看出,父类的 <clinit> 方法先执行意味着父类的静态代码块在子类的静态代码块之前执行。
  4. <clinit> 方法不是必需的,如果类中没有类变量的初始化赋值操作,也没有静态代码块,那么可以不用生产 <clinit> 方法。
  5. 虚拟机会通过加锁的方式保证一个类的 <clinit> 方法在多线程环境中只会被执行一次,因此需要注意如果在 <clinit> 方法中有耗时操作,可能会造成阻塞。

哪些情况下会触发类的初始化呢?下面则是比较常见的需要对类进行初始化的情况,对于个别复杂的暂时不提了。

  1. 虚拟机启动时会先初始化用户指定的主类,比如 SpringBoot 里面的 Application 类
  2. 通过 new 创建对象的类需先初始化
  3. 通过类访问静态变量,对类中静态变量赋值,调用类的静态方法都会触发该类的初始化,这里需要注意,无论是静态变量的访问,赋值,以及静态方法的调用都是直接定义这些变量和方法的类才会初始化,比如说通过子类调用父类中的静态变量则只会触发父类初始化,而不会初始化子类
  4. 初始化一个类时如果它的父类还没初始化会先将父类初始化
  5. 对一个类进行反射调用也会触发该类的初始化
1
2
3
4
5
6
7
8
9
10
11
12
public class Singleton {

private Singleton(){}

private static class Holder {
private static Singleton INSTANCE = new Singleton();
}

public static Singleton getSingleton(){
return Holder.INSTANCE;
}
}

如果你研究过单例模式的实现的话应该看到过上面面这种延时加载并且线程安全的实现方式,这里其实就是利用了 JVM 的类加载机制,当通过 Singleton 访问 getSingleton 方法,然后调用 Holder 类的类变量 singleton,这时才会触发 Holder 类的初始化,然后新建 Singleton 的实例,前面提到过,初始化过程 JVM 是保证线程安全并且只会执行一次,所以这里也就做到了线程安全的单例。

创建对象

前面花了很大一部分介绍了对象在创建前的准备工作,也就是在创建 Object 的对象前,对 Object 类的一个加载的过程,那么在 Object 类加载完成之后,就可以开始创建 Object 的对象了。

首先为新创建的对象在堆中进行内存分配,至于分配多大的内存在类加载完成之后就已经确定了,内存分配完之后,会将分配的内存空间初始化零值,这也是为什么我们在代码中可以不用赋值就能直接使用的原因。紧接着设置对象的头信息,比如说在 HotSpot 虚拟机中,一个对象在内存中主要包含三部分数据: 对象头,实例数据,对齐填充,其中对象头中的信息比较重要,可以分为两部分,一部分是存储对象自身的运行时数据:哈希码,GC 分代年龄,锁状态标志,线程持有的锁,偏向线程 ID 等,而另一部分则是对象的类型指针,指向它的类元数据的指针,也就是该对象是属于哪个类的实例,但这部分数据并非所有的 JVM 都会保留,因为可通过其他的方式获取到该对象的类型数据。

在设置完对象头信息之后,在虚拟机中一个对象的创建算是完成了,但是在 Java 语言层面才算刚刚开始,因为这时候对象的字段值都为零值,然后通过实例构造器 <init> 方法进行赋值操作,在 <init> 方法执行完成之后,对象的创建也就正式完成了。

创建数组

对于数组类来说,它是由 JVM 直接创建的,而不会像非数组类创建对象时需要经历类加载器加载的过程,但是需要注意的是虽然数组类不会使用类加载器加载,但是数组类中的元素类型(非基本类型的情况下)还是需要经历类加载的过程的。还有一个值得注意的就是当我们定义一个数组而没有使用,这时只是将元素类型加载了,而不会触发元素类型类的初始化,就相当于只是开辟了一块空间出来,定义了用来保存对应元素类型的容器,而并没有开始真正使用到相应的元素类,只有在真正使用时才会初始化。

1
Object[] objects = new Object[5]; // 定义一个 Object 类型的数组

访问对象

对象创建好之后,我们可以通过引用中所存储的地址来找到堆中对象的具体位置,而引用中的地址可能有两种情况,一种是直接指向堆中对象的地址,另一种则是指向句柄池中句柄的地址,句柄中才真正存有对象在堆中的地址。通过下面的这幅图你应该就能明白两者的区别:

第一种方式可以直接定位到对象的内存数据,第二种则需要进行多一次的地址定位才能找到对象的内存数据,相比下来块一些,但是第二种方式的优势在于垃圾收集时,对象地址发生了移动,这时只需要修改句柄池中句柄的指向即可,而不用修改引用本身。

上面就是创建一个对象的整个过程了,可能就像前面提到的,在 Java 层面,创建一个对象直接 new 就好了,看起来非常简单,但如果我们对底层做了哪些事情有个初步的了解,这对我们在排查错误,理解代码为什么是按这样的顺序执行都是有很大帮助的。

 wechat
扫描上面图中二维码关注微信公众号