☕【JVM 技术之旅】你真正掌握了 Java 对象创建的流程吗?
每日一句
拥有梦想只是一种智力,实现梦想才是真正的能力
前提概要
我们都知道类的装载过程中,分为 加载、链接(校验、准备、解析)、初始化(类的初始化),此处初始化主要是代表着类的初始化操作,之后会进入装载阶段之外的操作【类的实例化】
类初始化
类的创建的触发操作
在 Java 代码中,有很多行为可以引起对象的创建,最为直观的一种就是使用 new 关键字来调用一个类的构造函数显式地创建对象,这种方式在 Java 规范中被称为 :由执行类实例创建表达式而引起的对象创建。除此之外,我们还可以使用反射机制(Class 类的 newInstance 方法、使用 Constructor 类的 newInstance 方法)、使用 Clone 方法、使用反序列化等方式创建对象。
使用 new 关键字创建对象
这是我们最常见的也是最简单的创建对象的方式,通过这种方式我们可以调用任意的构造函数(无参的和有参的)去创建对象。比如:
使用 Class 类的 newInstance 方法(反射机制)
我们也可以通过 Java 的反射机制使用 Class 类的 newInstance 方法来创建对象,事实上,这个 newInstance 方法调用无参的构造器创建对象,比如:
使用 Constructor 类的 newInstance 方法(反射机制)
java.lang.relect.Constructor 类里也有一个 newInstance 方法可以创建对象,该方法和 Class 类中的 newInstance 方法很像,但是相比之下,Constructor 类的 newInstance 方法更加强大些,我们可以通过这个 newInstance 方法调用有参数的和私有的构造函数,比如:
使用 newInstance 方法的这两种方式创建对象使用的就是 Java 的反射机制,事实上 Class 的 newInstance 方法内部调用的也是 Constructor 的 newInstance 方法。
使用 Clone 方法创建对象
无论何时我们调用一个对象的 clone 方法,JVM 都会帮我们创建一个新的、一样的对象,特别需要说明的是,用 clone 方法创建对象的过程中并不会调用任何构造函数。简单而言,要想使用 clone 方法,我们就必须先实现 Cloneable 接口并实现其定义的 clone 方法,这也是原型模式的应用。比如:
使用(反)序列化机制创建对象
当我们反序列化一个对象时,JVM 会给我们创建一个单独的对象,在此过程中,JVM 并不会调用任何构造函数。为了反序列化一个对象,我们需要让我们的类实现 Serializable 接口,比如:
使用 Unsafe 类创建对象
Unsafe 类使 Java 拥有了像 C 语言的指针一样操作内存空间的能力,同时也带来了指针的问题。过度的使用 Unsafe 类会使得出错的几率变大,因此 Java 官方并不建议使用的,官方文档也几乎没有。Oracle 正在计划从 Java 9 中去掉 Unsafe 类,如果真是如此影响就太大了。
我们无法直接创建 Unsafe 对象。这里我们使用反射方法得到
拿到这个对象后,调用其中的 native 方法 allocateInstance 创建一个对象实例
从 Java 虚拟机层面看,除了使用 new 关键字创建对象(<init>(invokespecial))的方式外,其他方式全部都是通过转变为 invokevirtual 指令直接创建对象的。
类的初始化与实例化
概念介绍
Java 对象的创建过程往往包括类初始化和类实例化两个阶段。类的初始化在前、类的实例化在后。
注意:这与 spring 的 bean 正好相反,spring 的 bean 的生命周期,主要是先进行实例化 java 对象,然后在进行操作属性、最后进行初始化,这里初始化并不是 java 对象的初始化,而是 spring 的参数的初始化(initMethod、afterPropertiesSet)等。(@PostConstruct 是前置拦截初始化方法)。
类的初始化
主要职责:
类的构造器调用(<clinit>),初始化相关静态代码块以及静态变量的赋值
对象在可以被使用之前必须要被正确地初始化,这一点是 Java 规范规定的。在实例化一个对象时,JVM 首先会检查相关类型是否已经加载并初始化,如果没有,则 JVM 立即进行加载并调用类构造器完成类的初始化。
注意:可以看到类的初始化主要到类构造器变量结束执行的时间点。
类的实例化
主要职责:实例的构造器调用(<init>)、分配内存、属性值得定制化赋值机制。
类的实例化本身意义就是对象的概念,其实就是实例化对应的对象的过程。
实例对象内存的分配、实例对象参数的默认初始化+实例对象参数的实例化(就是按开发要求的实现调用,例如调用构造器<init>等)
此时一般处于在装载阶段的初始化完成之后,使用之前的阶段,接下来就要进行类的实例化操作
类初始化过程中或初始化完毕后,根据具体情况才会去对类进行实例化,首先会有一下几个步骤:
java 虚拟机就会为其分配内存来存放自己及其从父类继承过来的实例变量
为这些实例变量分配内存的同时,这些实例变量先会被赋予默认值(零值)【这个零值与加载阶段中的准备很相似,就是先赋值语义级别的默认值,而并非参数真正的初始化】
在内存分配完成之后调用<init>方法,Java 虚拟机执行构造代码块、构造方法等,方法参数执行等。才会对新创建的对象赋予我们程序给定的值
小结:创建一个对象包含下面两个过程:
类构造器完成类初始化(赋予静态变量默认值)
类实例化(分配内存、赋予默认值、执行定制化赋值操作)
类实例化过程
检测类是否被加载
Java 虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过**。如果没有,那必须先执行相应的类加载过程**。
为新生对象分配内存
在类加载并完成类初始化之后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载及初始化完成后便可完全确定。
确定对象内存大小
对象的大小在类加载完成后就已经确定,对象在内存中可以分为三块。
对象头
大小确定 与类无关 与操作系统有关,包括标记字段和类型指针
实例数据
即使父类的实例字段被子类覆盖或者被 private 修饰,都照样为其分配内存,相同宽度的字段会分配在一起,其次,父类的字段在子类之前赋值和初始化。
对齐填充
满足虚拟机对 8 的倍数的要求
对象分配内存的方式。
假设 Java 堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那么分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”。
如果 Java 堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”。
选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
对象创建在虚拟机执行的过程中是非常频繁的行为,仅仅修改一个指针所指向的位置,在并发情况下不是线程安全的。因此也有两种解决方案:
使用 CAS 并配上失败重试的方式保证更新操作的原子性。
(TLAB)给每一个线程在 Java 堆中预先分配线程私有分配缓冲区,哪个线程需要分配内存,只要在线程私有分配缓冲区中分配即可以。
初始化零值
将分配到的内存空间初始化零值,这保证了实例字段不赋值可以直接使用。如果使用了 TLAB,这一步可以提前到 TLAB 分配的时候进行。
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
进行必要的对象头设置
虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象的对象头之中,主要负责的 5 个点。
对象是哪个类的实例;
如何找到类的元数据信息;
对象的哈希码;
对象的 GC 分代年龄信息;
锁的标识等;
执行完以上步骤,从虚拟机角度,一个对象已经产生了,但是对于 java 程序而言,构造函数还没有开始执行。接下来按照构造函数的要求,对对象进行初始化即可。
对象头主要包含两类信息。第一类是用于存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。对象头的另外一部分是类型指针,即对象指向它的类型元数据的指针。
类型数据部分是对象真正存储的有效信息,即程序代码中定义的各种类型的字段内容。
对齐填充:任何对象的大小都必须是 8 字节的整数倍。
对象的访问定位
使用句柄访问的话,Java 堆中将可能会划分出来一块内存来作为句柄池。Reference 变量中存放的是句柄池的地址,句柄池中存放有到对象实例数据的指针以及到对象类型数据的指针。
使用直接访问的话,reference 变量中存放的是对象的实例数据、对象的实例数据中包含有到对象类型数据的指针。
执行 init 方法
在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init>方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行<init>方法,把对象按照程序的进行初始化,这样一个真正可用的对象才算完全产生出来。
总结创建一个对象的过程
检测类是否被加载没有加载的先加载→为新生对象分配内存→将分配到的内存空间都初始化为零值→**对对象进行必要的设置(对象头)**→执行<init>方法把对象进行初始化
扩展延伸
需要类初始化的五种情况(有且仅有这五种) 。
遇到 new getstatic putstatic invokestatic 这四个指令时,如果类没有初始化,则会触发类的初始化。
四个指令对应的最常见的情况为使用 new 关键字实例化对象,读取静态变量,设置静态变量(编译期结果放入常量池的除外,会触发前几个阶段) ,调用类的静态方法。
使用 java.lang.reflect 包的方法对类进行反射调用
初始化一个类时 如果父类未初始化 则初始化父类
常见的不会触发初始化的引用方式
通过子类引用父类的静态变量 只会初始化父类 不会初始化子类
创建类的数组
引用常量池内的变量
总体流程
加载
通过名字获取类的二进制流并在内存中生成 class 对象。
验证
验证二进制流的正确性和安全性 包括文件格式验证,元数据验证,字节码验证以及符号引用验证四个步骤。
准备
给类变量(static)分配空间并完成初始化 注意:如果不是 final 变量,值均为零值。
解析
将符号引用解析为直接引用,包括类或接口的解析,字段解析,类方法解析,接口方法解析。
初始化
执行类的<cinit>()方法,这个方法由所有对静态变量的赋值操作和所有静态代码块组成,虚拟机会保证父类的<cinit>()方法在子类的方法开始之前结束,并且提供线程安全的保证(类似于 double check,多个线程同时初始化时只有一个线程进入方法,其他线程阻塞,执行完成后其他线程不会再进入方法)
类加载器-双亲委派模型
Java 推荐的类加载器的实现模型,除了启动类加载器(bootstrap classLoader)以外的所有类加载器都应该拥有父加载器,这个关系不是通过继承来实现,而是通过组合的方式。类加载器收到加载请求时,首先请求父加载器进行加载,如果父加载器不能加载则调用自己的加载方法。
遵从双亲委派模型:自定义类加载器时如果我们希望则重写 findClass()方法。
不想遵循双亲委托型:方案即重写 loadClass()方法
类加载器-分类
遵循双亲委派从上到下可以分为
启动类加载器 (Bootstrap classLoader) 加载<JAVA_HOME>\lib 下的指定文件名的类
扩展类加载器 (Extension classLoader) 加载<JAVA_HOME>\lib\ext 下的类
系统类加载器(应用类加载器) 加载 Classpath 内的类
自定义类加载器
不遵循双亲委派的常见类加载器:
SPI (Service Provider Interface)加载器 - 线程上下文加载器 实现父加载器向子加载器请求加载
OSGi 模块化加载器 每个模块拥有一个自定义类加载器 网状结构的加载过程
分配内存
内存分配与内存回收紧密相关,根据不同的回收策略也有不同的分配策略。
如果采用的是具有压缩过程的垃圾回收策略,如 Serial,ParNew,则 Java 堆中的内存是规整的,我们只需要将内存指针向后移内存大小的位置即可,这种方式称为指针碰撞(Bump the Pointer)。如果采用的回收策略没有压缩过程,如 CMS,那虚拟机就需要维护一个列表,记录哪些内存是可用的,这种方式称为空闲列表(Free List)
其次,对象创建也需要考虑线程安全的问题,一种方案是采用 CAS+失败重试的方法来保证线程安全,另一种方法则是为每一个线程提前分配一块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer , TLAB),线程创建对象时优先在自己的 TLAB 上分配。
对象头
对象头包括
MarkWord 32bit/64bit 取决于操作系统
类型指针 指向类元数据的指针
数组长度 如果是数组的话
我们主要介绍 MarkWord
根据锁状态的不同,markword 会复用自己的空间,分别记录一些不同的信息。
我们注意到 轻量级锁和重量级锁状态时,会将分代年龄覆盖掉,那当锁状态解除时,要怎么恢复呢?
答案是上锁时,锁的数据中会保存一份原 markword 的备份
版权声明: 本文为 InfoQ 作者【李浩宇/Alex】的原创文章。
原文链接:【http://xie.infoq.cn/article/16bc53b969de0b7cda78687d8】。文章转载请联系作者。
评论 (2 条评论)