0、前情
对于经常写代码的同学有没有思考这样一个问题:为什么成员变量有默认值?为什么局部变量必须手动赋值?
- 先不考虑变量类型,如果没有默认值会怎么样?变量存储的是内存地址对应的任意随机值,如果不对其赋值,那么程序读取该内存地址时拿到的值是随机的,运行会出现意外。
- 对于编译器(javac)来说,局部变量没赋值很好判断,可以直接报错。而成员变量可能是运行时赋值,无法判断,误报“没默认值”又会影响用户体验,所以采用自动赋默认值。
拓展:为什么说对编译器来说,局部变量没有赋值很好判断?而成员变量不好判断?
在Java中,局部变量(在方法、构造函数或块中声明的变量)和成员变量(在类中声明的变量)在编译器的处理上有一些不同之处。
对于局部变量来说,编译器可以在编译时检查到是否对其进行了赋值。这是因为局部变量的生命周期在方法、构造函数或块的执行期间,编译器可以跟踪每个变量在代码块中是否被初始化。如果编译器发现某个局部变量在使用之前没有被赋值,它就会报告编译错误,因为这样的情况很可能是代码中的错误。这种静态检查使得编译器可以在编译时就发现潜在的错误,而不必等到运行时。
相比之下,对于成员变量来说,情况则更复杂一些。成员变量的生命周期与对象的生命周期相关联,而对象的创建可能发生在运行时。因此,编译器无法确定对象在运行时是否会对成员变量进行初始化。在Java中,如果成员变量没有被明确初始化,它们会被赋予一个默认值(如o、false或null,具体取决于变量的类型)。这样做的目的是为了确保在对象创建时,成员变量都有一个合理的初始值,以防止潜在的运行时错误。
因此,尽管对于编译器来说,局部变量没有赋值更容易判断,但成员变量的初始化更为复杂,因为它们的初始化可能发生在运行时,编译器无法静态地确定。
以下我将阐述在 Java 语言中,对象的实例化过程,相信在阅读之后上述这些问题都能迎刃而解。
1、整体流程
从整体上来看对象的整个实例化过程如下图所示:
为了故事的顺利发展,这里我们定义一个Demo,并据此详细讨论一下dc对象是如何创建并实例化出来的。
public class Demo {public static void main(String[] args) {DemoClass dc = new DemoClass();}
}
class DemoClass {private static final int a = 1;private static int b = 2;private static int c;private int d = 4;private int e;static{c = 3;}public DemoClass() {e = 5;}}
2、类初始化检查
这里我们使用 new 关键字创建对象,Java 中创建对象的方式还有好多种,比如反射,克隆,序列化与反序列化等等。这些方式不一而同,但是经过编译器编译之后,对应到 Java 虚拟机中其实就是一条 new(这里的 new 指令与前面提到的 new 关键字不同,这是虚拟机级别的指令)指令。
当Java虚拟机碰到一条 new 指令时,会首先根据这条指令所对应的参数去常量池中查找是否有该类所对应的符号引用,并判断该类是否已经被加载、解析、初始化过,也就是到方法区中检查是否有该类的类型信息,如果没有,首先要进行类加载与初始化。如果类已经加载和初始化,那么继续后续的操作。
这里假设DemoClass类还没有被加载与初始化,也就是方法区中还没有DemoClass的类型信息,这时需要进行DemoClass类的加载与初始化。
3、类加载过程
类加载过程总的可分为7个步骤:
- 加载
- 验证
- 准备
- 解析
- 初始化
- 使用
- 卸载
这里我们看一下前六个阶段。
加载
加载阶段主要干了三件事:
- 根据类的全限定名获取类的二进制字节流。
- 将二进制字节流所代表的静态存储结构转化为方法区中运行时数据结构。
- 在内存中创建一个代表该类的Java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
具体到这里就是首先根据package.DemoClass全限定名定位DemoClass.class二进制文件,然后将该.class文件加载到内存进行解析,将解析之后的结果存储在方法区中,最后在堆内存中创建一个Java.lang.Class的对象,用来访问方法区中加载的这些类信息。
验证
验证阶段完成的任务主要是确保class文件中字节流中包含的信息符合Java虚拟机的规范,虽然说得很简单,但是Java虚拟机进行了很多复杂的验证工作,总的来说可分为四个方面:
- 文件格式验证
- 元数据验证
- 字节码验证
- 符号引用验证
具体到这里就是对于加载进内存的DemoClass.class中存储的信息进行虚拟机级别的校验,以确保DemoClass.class中存储的信息不会危害到Java虚拟机的运行。
准备
准备阶段完成的工作就是为类变量(也就是静态变量)分配内存并赋予初始值,通常情况下是变量所对应的数据类型的零值。但是在这个阶段,被final修饰的变量也就是常量会在这个阶段准确的被赋值。
具体到这里,在这个阶段DemoClass中的a会被赋值为1,b与c均被赋值为0。
解析
这个阶段主要的任务是将常量池中的符号引用替换为直接引用。
初始化
在之前的阶段中,除了加载阶段通过自定义的类加载器可以干预虚拟机的加载过程外,其他的阶段都是虚拟机完全主导,而在初始化阶段才开始根据程序员的意愿执行类的初始化,这个阶段主要完成的工作是执行类构造器方法(),同时虚拟机会保证执行该类的类构造器方法时,其父类的类构造器方法已经被正确的执行,同时,由于类的初始化只进行一次,当多个线程并发的进行初始化时,虚拟机可以确保多个线程只有一个可以完成类的初始化工作, 保证线程安全工作。
具体到DemoClass类,在这个阶段会将b赋值为2,c赋值为3。
4、内存分配
当类加载过程完成后,或者类本身之前已经被加载过,下一步就是虚拟机要为新生对象分配内存。对象所需要的内存空间在类加载过程完成后就可以完全确定下来,为对象分配内存空间就相当于从堆内存中划分出一块合适的内存来,分配内存的主要方式有两种:指针碰撞和空闲列表。
- 指针碰撞:这种方式将堆内存分为空闲空间与已分配空间,使用一个指针来作为二者之间的分界线,当要为新生对象分配内存空间的时候,相当于将指针向着空闲空间的方向移动一段与对象大小相等的距离,可见这种分配方式Java堆内存必须是规整的,所有空闲空间在一边,已分配空间在另外一边。
- 空闲列表:在虚拟机中维护一个列表,用来记录堆中哪一块内存是空闲可用的,在为新生对象分配内存时,从列表中寻找一块合适大小的可用内存块,分配完成后更新空闲列表,这种方式下堆内存的空闲空间与分配空间可以交错存在。
从上面来看,选择采用指针碰撞还是空闲列表法分配内存,主要由Java堆内存是否规整决定的,而Java堆内存是否规整又取决于所采用的垃圾收集算法,这就涉及到垃圾回收机制(可见知识都是相通的,程序员就是活到老学到死啊!),GC中压缩或者整理的动作等的能力等。
同时,由于创建对象的动作是十分频繁的,多线程可能存在多个线程同时申请为对象分配内存空间,这个时候如果不采取一定的同步机制,就有可能导致一个线程还未来得及修改指针,另一个线程就使用了原来的指针分配内存空间。
这里采用两种方式来处理并发情况下线程不安全的情况:
1)CAS配上失败重试(CAS配上重试)
对分配内存空间的动作进行同步处理——实际上虚拟机时采用 CAS 配上失败重试的方式保证跟新操作的原子性;
2)TLAB方式。
把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓存。
第一种方式很好理解,多个线程使用CAS的方式更新指针,多线程下只有一个线程可以更新完成,其他线程通过不断重试完成内存指针的重新移动。
第二种方式是每个线程提前分配一块内存空间,这个内存空间就是线程本地缓冲TLAB,这样线程每次要分配内存时,先去TLAB中获取,当TLAB中内存空间不足的时候才采用同步机制继续申请一块TLAB空间,这样就降低了同步锁的申请次数。
具体到这个阶段,是在堆内存中为DemoClass对象,也就是dc对象实例开辟了一块内存空间。
5、初始化零值
在为对象分配内存完成之后,虚拟机会将分配到的这块内存初始化为零值,这样也就使得Java中的对象的实例变量可以在不赋初值的情况下使用,因为代码所访问的就是虚拟机为这块内存分配的零值。
具体到这里,就是Java虚拟机将上面分配的内存空间初始化为零值,这一步使得现在DemoClass中的d与e均被赋值为0。
6、设置对象头
对象头就像我们人的身份证一样,存放了一些标识对象的数据,也就是对象的一些元数据,我们首先看一下对象的构成。
在初始化了零值之后,怎么知道对象是哪个类的实例,就需要设置指向方法区中类型信息的指针,对象Mark Word中相关信息的设置,就在这个阶段完成。
7、实例对象初始化
这一步虚拟机将调用实例构造器方法(), 根据我们程序员的意愿初始化对象,在这一步会调用构造函数,完成实例对象的初始化。
具体到这里就是DemoClass的d被赋值为4,e被赋值为5。
8、创建引用、入栈
执行到这一步,堆内存中已经存在被创建完成的对象,但是我们知道,在Java中使用对象是通过虚拟机栈中的引用来获取对象属性,调用对象的方法,因此这一步将创建对象的引用,并压入虚拟机栈中,最终返回引用供我们使用。
在这里就是将对象的引入入栈,并返回赋值给dc,至此,一个对象被创建完成。