深入JVM了解Java对象实例化过程

一、对象创建方式

  • new:最常见的方式、Xxx的静态方法,XxxBuilder/XxxFactory的静态方法
  • Class的newInstance方法:反射的方式,只能调用空参的构造器,权限必须是public
  • Constructor的newInstance(XXX):反射的方式,可以调用空参、带参的构造器,权限没有要求
  • 使用clone():不调用任何的构造器,要求当前的类需要实现Cloneable接口,实现clone()
  • 使用序列化:从文件中、从网络中获取一个对象的二进制流
  • 第三方库 Objenesis

二、对象产生步骤

1、判断对象是否已经加载、链接、初始化

当我们在程序中写下new指令的时候,首先改指令的参数是否在常量池中定位到一个符号引用(Symbolic Reference),并检查这个符号引用代表的类是否已经加载、解析和初始化。其实就是验证是否是第一个使用该类。如果是第一次使用该类,就会执行类的加载过程。

注:符号引用是指,一个类中引入了其他的类,可是 JVM 并不知道引入其他类在什么位置,所以就用唯一的符号来代替,等到类加载器去解析时,就会使用符号引用找到引用类的具体地址,这个地址就是直接引用

类的加载过程在双亲委派模式下,使用当前类加载器按照ClassLoader + 包名 + 类名key进行查找对应的.class文件。

如果找到了,直接进行加载,生成Class对象,如果没有找到。抛出ClassNotFoundException的异常 。

在类加载完成后,JVM 就可以完全确定new出来的对象的内存大小了,接下来,JVM 会执行为该对象分配内存的工作

2、为对象分配内存空间

为对象分配空间的任务等同于把一块确定大小的内存从 JVM 堆中划分出来,目前常用的有两种方式(根据使用的垃圾收集器的不同而使用不同的分配机制):

  1. Bump the Pointer(指针碰撞)
  2. Free List(空闲列表)

指针碰撞
意思是所有用过的内存在一边,空闲的内存放另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针指向空闲那边挪动一段与对象大小相等的距离罢了。如果垃圾收集器选择的是SerialParNew这种基于压缩算法的,虚拟机采用这种分配方式。一般使用带Compact(整理)过程的收集器时,使用指针碰撞。
在这里插入图片描述
空闲列表
如果 JVM 堆内存并不是规整的,即:已用内存空间与空闲内存相互交错,JVM 会维护一个空闲列表,记录那些内存块是可用的,在为该对象分配空间时,JVM 会从空闲列表中找到一块足够大的空间划分给对象使用
在这里插入图片描述

3、处理并发问题

  • 采用CAS失败重试、区域加锁保证更新的原子性
  • 每个线程预先分配一块TLAB:通过设置 -XX:+UseTLAB参数来设定

对象的内存分配过程中,主要是对象的引用指向这个内存区域,然后进行初始化操作
但是,因为堆是全局共享的,因此在同一时间,可能有多个线程在堆上申请空间,在并发场景中,就会存在两个线程先后把对象引用指向了同一个内存区域。
在这里插入图片描述
为了解决这个并发问题,对象的内存分配过程就必须进行同步控制。但是无论是使用哪种同步方案(实际上虚拟机使用的可能CAS),都会影响内存的分配效率。所以就有了一个HotSpot虚拟机的解决方案,这 种方案被称之为TLAB分配,即Thread Local Allocation Buffer。这部分Buffer是从堆中划分出来的,但是是本地线程独享的。TLAB只是HotSpot虚拟机的一个优化方案,不代表所有的虚拟机都有这个特性。

每个线程在Java堆中预先分配一小块内存,然后再给对象分配内存的时候,直接在自己这块”私有”内存中分配,当这部分区域用完之后,再分配新的”私有”内存。

3.1 TLAB

TLAB是虚拟机在堆内存的eden划分出来的一块专用空间,是线程专属的。在虚拟机的TLAB功能启动的情况下,在线程初始化时,虚拟机会为每个线程分配一块TLAB空间,只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。

所以说,因为有了TLAB技术,堆内存是线程共享的这个命题是不准确的,其eden区域中还是有一部分空间是分配给线程独享的。
TLAB分配对象逻辑

4、初始化零值

JVM 会为所有实例数据赋零值 (默认值),即:将方法区内对实例变量的定义拷贝一份到堆区,然后赋默认值,例如整型的默认值为 0,引用类型的默认值为null等等。保证对象实例字段在不赋值时可以直接使用。

5、完善对象内存布局的信息

在我们为对象分配好内存空间后,JVM 会设置对象的内存布局的一些信息。

对象在内存中存储的布局(以HotSpot虚拟机为例)分为:对象头,实例数据以及对齐填充

  • 对象头
    对象头包含两个部分:
    • Mark Word:存储对象自身的运行数据,如:Hash Code,GC 分代年龄,锁状态标志等等
    • 类型指针:对象指向它的类的元数据的指针
  • 实例数据
    实例数据是真正存放对象实例的地方
  • 对齐填充
    这部分不一定存在,也没有什么特别含义,仅仅是占位符。因为 HotSpot 要求对象起始地址都是 8 字节的整数倍,如果不是就对齐

并且,JVM 会为对象头进行必要的设置,例如这个对象是哪个类的实例,如何才能找到类的元数据信息,对象的 Hash Code, 对象的 GC 分带年龄等等,这些信息都存放在对象的对象头中

6、调用对象的实例化方法 <init>

在Java程序的视角看来,初始化才正式开始。初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。

因此一般来说(由字节码中跟随invokespecial指令所决定),new指令之后会接着就是执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完成创建出来。

在 JVM 完善好对象内存布局的信息后,会调用对象的 <init> 方法,根据传入的属性值为对象的变量赋值。

我们在上文介绍了类加载的过程(加载 -> 连接 -> 初始化),在初始化这一步骤,JVM 为类的静态变量显示赋值,并且执行了静态代码块。实际上这一步骤是由 JVM 生成的<clinit>方法完成的。

<clinit> 的执行的顺序为:

  • 父类静态变量初始化
  • 父类静态代码块
  • 子类静态变量初始化
  • 子类静态代码块

而我们在创建实例 new 一个对象时,会调用该对象类构造器进行初始化,这里面就会执行<init>方法。

<init> 的执行顺序为:

  • 父类变量初始化
  • 父类普通代码块
  • 父类构造函数
  • 子类变量初始化
  • 子类普通代码块
  • 子类构造函数

关于 <init> 方法:

有多少个构造器就会有多少个 <init> 方法。<init> 具体执行的内容包括非静态变量的赋值操作,非静态代码块的执行,与构造器的代码非静态代码赋值操作与非静态代码块的执行是从上至下顺序执行,构造器在最后执行

关于<clinit><init> 方法的差异:

<clinit> 方法在类加载的初始化步骤执行,<init> 在进行实例初始化时执行<clinit> 执行静态变量的赋值与执行静态代码块,而 <init> 执行非静态变量的赋值与执行非静态代码块以及构造器
<init>构造器和<cinit>以及构造方法的关系

7、总结

对象创建的几个过程:
在这里插入图片描述

  1. 加载类元信息
  2. 为对象分配内存
  3. 处理并发问题
  4. 属性的默认初始化(零值初始化)
  5. 设置对象头信息
  6. 属性的显示初始化、代码块中初始化、构造器中初始化

三、对象的内存布局

这点其实是上面第五点的展开说明。一个对象的内存布局包括三个部分:
1 对象头 2. 实例数据 3. 填充数据
在这里插入图片描述

1、对象头

对象头包含了两部分,分别是运行时元数据(Mark Word)和类型指针。如果是数组,还需要记录数组的长度。

1.1 运行时元数据(Mark Word)

32位的hotspot对象头
在这里插入图片描述
64位:
在这里插入图片描述
对上图64位的进行具象表示如下图所示:
在这里插入图片描述
下面对各个标志位进行解读:

锁标志lock—— 区分锁的状态,参数占用两个字节,可以表示四种状态。但是上面锁的状态有五种,可以看出无锁态和偏向锁都用01表示。那么如何区分无锁态和偏向锁?这时就需要引入偏向锁参数。0表示普通对象,1表示偏向锁。

是否偏向锁(biased_lock)——是否偏向锁,这个参数占用1bit,0表示不是偏向锁,1表示的是偏向锁。

分代年龄——表示Java对象被GC的次数,每次GC的时候,如果对象在Survivor区复制一下,年龄增加1。当对象达到设定的阈值时,就会晋升为老年代。这个参数占4bit,也就是最大是2^4 - 1 = 15次。这是JVM参数XX:MaxTenuringThreshold选项最大为15的原因。默认情况下并行GC的年龄阈值为15,并发GC的年龄阈值为6。
hashcode——对象的hashcode,使用方法System.identityHashCode()进行计算,如果采用延迟计算,计算后会把结果写到该对象头中。当对象被锁定时,该值会移动到Monitor中。
线程ID——在偏向模式中,当某个线程持有该对象,则该对象头的线程ID位置存储的就是这个线程ID。这样在后面的操作中就不需要在进行获取锁的动作。
epoch——偏向锁的时间戳,用于在CAS锁操作过程中,偏向性表示,表示更偏向那个锁。
ptr_to_lock_record——在轻量级锁的状态下,指向栈中纪录的指针。当锁获取是无竞争时,JVM使用原子操作而不是OS互斥。这种技术称为轻量级锁定。在轻量级锁定的情况下,JVM通过CAS操作在对象头中设置指向锁纪录的指针。
ptr_to_heavyweight_monitor——在重量级锁的状态下,指向管程Monitor的指针。如果两个不同的线程同在一个对象上竞争,则必须将轻量级锁定升级到Monitor新管理等待的线程。在重量级锁定的情况下,JVM设置ptr_to_heavyweight_monitor指向Monitor

基本上是以下几种:

  • 哈希值(HashCode)
  • GC分代年龄
  • 锁状态标志
  • 线程持有的锁
  • 偏向线程ID
  • 翩向时间戳

1.2 类型指针(Klass Word)

指向类元数据InstanceKlass,确定该对象所属的类型。
推荐阅读: Class对象存储在堆中

2、 实例数据(Instance Data)

是对象真正存储的有效信息,包括程序代码中定义的各种类型的字段(包括从父类继承下来的和本身拥有的字段)

  • 相同宽度的字段总是被分配在一起
  • 父类中定义的变量会出现在子类之前
  • 如果CompactFields参数为true(默认为true):子类的窄变量可能插入到父类变量的空隙

3、 填充(padding)

这部分不一定存在,也没有什么特别含义,仅仅是占位符。因为 HotSpot 要求对象起始地址都是 8 字节的整数倍,如果不是就对齐。

例子说明:

public class Customer{
    int id = 1001;
    String name;
    Account acct;

    {
        name = "匿名客户";
    }

    public Customer() {
        acct = new Account();
    }
}

public class CustomerTest{
    public static void main(string[] args){
        Customer cust=new Customer();
    }
}

上述在内存中的关系:
在这里插入图片描述

三、对象的访问定位

在描述完创建一个对象的过程之后,我们再来简单看一下如何去访问这个对象。

JVM 规范中只规定了reference类型是一个指向对象的引用,但没有规定这个引用具体如何去定位,访问堆中对象,因此对象的访问取决于 JVM 的具体实现,目前主流的访问对象的方式有两种:句柄间接访问直接指针访问

1、 句柄间接访问

JVM 堆中会划分一块内存来作为句柄池,reference 中存储句柄的地址,句柄中则存储对象的实例数据何类的元数据的地址:
在这里插入图片描述

2、直接指针访问

直接指针是局部变量表中的引用,直接指向堆中的实例,在对象实例中有类型指针,指向的是方法区中的对象类型数据。Hotspot是采用这种方式的。
在这里插入图片描述

四、对象的生命周期

在 JVM 运行空间中,对象的整个生命周期大致可以分为七个阶段:在JVM运行空间中,对象的整个生命周期大致可以分为7个阶段:创建阶段(Creation)、应用阶段(Using)、不可视阶段(Invisible)、不可到达阶段(Unreachable)、可收集阶段(Collected)、终结阶段(Finalized)与释放阶段(Free)。上面的这7个阶段,构成了 JVM中对象的完整的生命周期。下面分别介绍对象在处于这7个阶段时的不同情形。

1、Creation

一个对象想要进入创建阶段,前提是它的类文件必须已经加载到内存中,并且已经创建了 Class 对象,这样才能根据类信息进行创建
在对象的创建阶段,系统通过以下步骤完成对象的创建过程:

  • 为对象在堆内存中分配空间
  • 构造对象。从最顶层的父类开始对局部变量进行赋值
  • 从最顶层的父类开始往下调用构造方法

2、Using

当对象创建阶段结束之后,通常就会进入到对象的应用阶段。这个阶段是对象得以表现自身能力的阶段。也就是说对象的应用阶段是对象整个生命周期中证明自身 “存在价值” 的时期。在对象的应用阶段,对象具备下列特征:

  • 系统至少维护着对象的一个强引用(Strong Reference

  • 所有对该对象的引用全部是强引用(除非我们显式地使用了:软引用(Soft Reference)弱引用(Weak Reference)虚引用(Phantom Reference)

3、Invisible

不可视阶段中,对象存在且被引用,但是这个引用在接下来的代码中并没有被使用到,这就造成了内存的冗余。

public void process() {
    try {
        MyObject obj = new MyObject();
        obj.doSomething();
    }catch (Exception e) {
        e.printStackTrace();
    }

    while (true) {
        // 该代码块对 obj 对象来说已经是不可视的
        // 因此下面代码在编译时会引发错误
        obj.doSomething();
    }
}

如果一个对象已经使用完毕,并且在可视区域内不再使用,那么应该主动将其设置为 null。这样做的意义是,可以帮助JVM及时地发现这个垃圾对象,并且可以及时地回收该对象所占用的系统资源。

4、Unreachable

当一个对象没有再被强引用时,就会进入不可达阶段,在这个阶段中,对象随时会被回收,这由 JVM 中的垃圾回收器(GC)来决定。

5、 Collected、Finalized、Free

对象生命周期的最后一个阶段是可收集阶段、终结阶段与释放阶段。当对象处于这个阶段的时候,可能处于下面三种情况:

  • 垃圾回收器发现该对象已经不可到达

  • finalize 方法已经被执行

  • 对象空间已被重用

当对象处于上面三种情况时,该对象就处于可收集阶段、终结阶段与释放阶段了。虚拟机就可以直接将该对象回收了。

五、对象初始化顺序总结

在没有继承的条件下,实例化一个对象初始化的顺序为:

  • 静态成员的初始化

  • 静态初始化块

  • 成员的初始化

  • 初始化块

  • 构造器

这里面需要注意的是【静态部分只在类加载时初始化一次】

如果有继承关系,那么实例化子类对象的初始化顺序为:

  • 父类静态成员的初始化

  • 父类静态代码块初始化

  • 子类静态成员的初始化

  • 子类静态代码块初始化

  • 父类成员的初始化

  • 父类初始化块

  • 父类构造器

  • 子类成员的初始化

  • 子类初始化块

  • 子类构造器


  • 2
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值