往往越熟悉的其实越陌生
我们刚开始学 Java 的时候,就开始用这个 new 命令。一天八百遍的 new 却不知道它默默的为我们付出了什么。
所以我们就从 JVM 的角度去看 Java 对象是如何创建出来的。
首先说明一下流程,方便下面的理解
NO.1 - new
当程序计数器 PC 遇到这个 new 指令的时候,首先要做的是去 方法区的常量池 中定位到这个类的符号引用,并且检查这个类是否已被加载,链接和初始化过。如果没有,那么就需要把 先执行这个类的加载过程了。
当然使用反射机制(class的new Instance、constructor的new Instance),使用clone等,同样会触发对象的创建。
NO.2 - 加载
加载指的是 查找字节流,并据此创建类的过程。这就用到加载器了 。涉及知识点类加载器双亲委任模式。
- 启动类加载器 boot class loader (爷爷)
- 扩展类加载器 extension class loader (爸爸)
- 应用类加载器 application class loader (儿子)
这是一个孝道为尊的世界,有事情先请教长辈,长辈不想处理的给自己处理。
加载类的时候需要问问自己的老爸这个您处理吗?老爸问他的老爸处理吗?依次类推,长辈不处理的小辈才可以处理,不能坏了规矩。比如家族里的事情 需要族长来处理,家里的事情老爸处理,我们自己的事情自己处理。但是无论任何事情都要过问一下长辈,长辈不做,我们才可以做。为啥这样呢?因为这样可以避免重复处理事情,大家都可以处理那不就乱套了吗?(避免类的重复加载)其中还有安全问题,比如家族的庆典 必须族长来处理的,一个骗子想要来主持家族庆典,通过这套机制,没人处理才会轮到这个骗子,像庆典这种事轮不上骗子的,所以这样可以防止系统核心API被篡改。
NO.3 - 在堆中分配内存
当类加载完毕之后,JVM 就会在堆中给对象分配对象。就像盖房子,类加载是图纸,那么堆分配就是毛坯房。这个内存大小在类加载的时候就已经确定了,因为图纸都画了,就按照这么尺寸这个高度,这么这么干。
当然分配内存有两种方式这其实也涉及到了GC的问题,知识串起来才是自己的,串不起来,白费劲。
1、指针碰撞
如果我们的 java 堆是规整的,用过的在这儿边,没用过得在那边,分配内存的时候直接移动指针就好了。
2、空闲列表
如果 java 堆是不完整的,那么 JVM 会维护一个列表,记录那一块用来,哪一块没用,找到一块足够大的空间为对象实例并更新这个列表记录。
所以内存的申请方式是根据 java 堆是否规整来选择 指针碰撞 或者 空闲列表。
Java 堆是否完整收 JVM 是否采用压缩整理方式。
多说一句:(重点)
在多线程中,Java 分配堆内存需要考虑同步问题。
解决办法:
- 对分配堆的动作进行同步处理
- 运用TLAB (Thread Local Allocation Buffer)把内存分配放到不同的空间中进行,每个线程在堆中拥有预分配的一小块内存,成为本地线程分配缓冲。
多说两句:(重重点)
静态内部类的单例模式是如何保证单例的?是不是和这个有点关系呢?这是一个问题,又牵扯到另一个问题。另一个问题又炸出无数个问题,哈哈哈哈哈~~
NO.4 - 将空间初始值置零
对象的初始化,把内存区域初始化为零值,保证了对象的实例字段可以不赋值就可以使用。(不包括对象头)
这就是所谓 全局变量不需要初始化也能用,局部变量必须初始化的原因。(我了个擦 牵扯出这么多知识)
NO.5 - 设置对象头的信息
我们在讲 JMM 的时候就涉及对象头了,对象头包括 Mark word 和 类型指针。
Mark word 存储了 哈希码 、 GC 分代、锁标志,偏向线程 id 等等。 Mark word里放置的信息也叫运行时数据。
涉及知识点:
对象的内部布局:
对象头,实例数据,对齐填充
对象头: Mark word 和 类型指针
类型指针:对象指向它的类元数据的指针,虚拟机通过这个指针来确定对象是哪个类的实例。
实例数据:对象真正存储的有效信息。也是程序代码中定义的各种类型的字段类型,无论是自己的还是继承来的都要记录。
对其填充:就是起到了占位符的作用,以内 VM 自动内存管理系统要求对象必须是 8 字节 的整数倍,对象头正好是 8 字节的 整数倍,所以实例部分在没有对齐的时候,需要通过对齐填充来补全。
NO.6 - 初始化对象信息
执行<init>方法。
<init>就是指收集类中的所有实例变量的赋值动作、实例代码块和构造函数合并产生的。
懵逼中~~啥意思啊?就是当我们对实例对象进行直接赋值或者使用实例代码块赋值,那么编译器会将其中的代码放到类的构造方法中去,并且这些代码会放在超类构造函数的调用语句之后,构造函数本身代码之前。
我了个擦擦,原来 JVM 是这么干的,
父类的类构造器() -> 子类的类构造器() -> 父类成员变量的赋值和实例代码块 -> 父类的构造函数 -> 子类成员变量的赋值和实例代码块 -> 子类的构造函数
最后我们的对象就创造出来了。
知识延伸:
对象的访问定位
疑问:怎么样把门牌号对的上房子呢?
如何把 Java 栈里的引用 和 Java 堆里的地址对应上呢?目前有两种主流的方法。
- 句柄
- 直接指针
我们通过画图开直观的看一下
在 Java 堆中开辟一个句柄池,reference 持有的地址是句柄地址,句柄存放对象实例数据的指针和对象类型数据的指针。
这样做的好处是当对象移动的时候只需要改变句柄的地址,reference 本身不需要改变。
比如:多个变量引用同一个对象的时候,对象位置改变不会引起多个引用同时改动。
句柄方式访问对象
refeerence 直接保存的就是对象的地址,对象的空间里存储着访问类型数据的相关信息。
这样的好处是节省一次指针的开销。定位效率高。
直接指针访问对象
最后总结
对象创造的过程:
1、通过 new 关键字、反射机制、clone 等方式触发类的创建。
2、查询方法区的常量池,定位符号引用,如果这个类没有被加载,那么会先去加载,如果已经加载了,那么就直接进行第三步。
3、虚拟机给在堆中给对象分配内存,根据垃圾回收方法不同,导致内存块是否规整也不同。根据是否规整采用指针碰撞还是空闲列表。
4、在内存分配的时候,注意并发线程的安全,加同步快或者Tlab本地线程分配缓冲。
5、内存分配完毕后,会进行默认初始化。这就是对象的实例对象不需要显示初始化就可以使用的原因。
6、从JVM的角度来说,对象已经创建完毕,但从程序的角度来说,对象的创建才刚开始,它还没有运行<init>(实例初始化方法),所有的字段都还为默认值。只有运行了<init>之后,一个真正可用的对象才算产生出来。