【JVM学习笔记07】对象实例化与访问定位

七、对象实例化与访问定位

image-20201117102438783

7.1 创建对象的方式

  • new:最常见的方式、单例类中调用 getInstance 的静态类方法,XXXFactory 的静态方法
  • Class 的 newInstance 方法:反射的方式,在 JDK 9 里面被标记为过时的方法,因为只能调用空参构造器,权限必须是 public
  • Constructor 的 newInstance(XXX):反射的方式,可以调用空参的,或者带参的构造器,权限没有要求
  • 使用 clone():不调用任何的构造器,但是要求当前的类实现 Cloneable 接口中的 clone() 方法
  • 使用反序列化:序列化一般用于 Socket 的网络传输,从文件、网络中获取文件二进制流
  • 第三方库 Objenesis

7.2 对象创建的过程

image-20201117103320462

7.2.1 加载类元信息

虚拟机遇到一条 new 指令,首先去检查这个指令的参数能否在常量池【字节码文件中的常量池】中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化(即判断类元信息是否存在)。如果没有,那么在双亲委派模式下,使用当前类加载器以 ClassLoader + 包名 + 类名为 Key 进行查找对应的 .class 文件,如果没有找到文件,则抛出 ClassNotFoundException 异常;如果找到,则进行类加载,生成对应的 Class 对象。【加载阶段

7.2.2 分配内存

在类加载检查通过之后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完后就可以完全确定。【准备阶段是给静态变量分配内存和零值初始化】

(1)内存划分

为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。

  • 堆内存绝对规整——指针碰撞

    假设堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的放在另一边,中间放着一个指针作为分界点的指示器,那所要分配的内存就仅仅是把那个指针向空闲空间挪动一段与对象大小相等的距离,这种分配方式被称为“指针碰撞”。

    image-20201117110450830

  • 堆内存不规整——空闲列表

    如果堆内存不规整,已被使用的内存和空闲的内存交织在一起,那就不能简单地使用“指针碰撞”了。虚拟机此时就必须维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”。

选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整由所采用的的垃圾回收器是否带有压缩整理的能力决定。因此,当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除算法的收集器时,理论上就只能采用复杂的空闲列表来分配内存。

(2)线程安全问题

除如何划分可用空间之外,还有另外一个需要考虑的问题:对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发条件下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决这个问题有两种方案:

  • 对分配内存空间的动作进行同步处理——实际上,虚拟机是采用CAS算法与失败重试的方式保证更新操作的原子性
  • 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲【TLAB】,哪个线程要分配内存,就在哪个线程的本地线程缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:UseTLAB参数来设定。

7.2.3 默认初始化

内存分配结束后,虚拟机必须将分配到的内存空间(不包括对象头)都初始化为零值【默认初始化】。如果使用了TLAB字段的话,这一项初始化工作可以提前到TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就可以直接使用,使程序能访问到这些字段的数据类型所对应的零值。

7.2.4 设置对象头

接下来,Java还要对对象进行必要的设置。例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码【实际上对象的哈希码会延后到真正调用hashCode()方法时才会计算】、对象的GC分代年龄【Age】等信息,这些信息都是被存放在对象的对象头。根据虚拟机当前运行状态的不同,对象头会有不同的设置方式。【对象头参见7.3 对象内存布局】

7.2.5 初始化

在上面的工作都完成之后,从虚拟机角度看,一个新的对象已经产生了。但是从Java程序来看,对象创建才刚刚开始——构造函数,即Class字节码文件中的 init() 方法【即类的构造器】还没有执行,此时所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。一般来说,new指令之后会接着执行 init() 方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。

image-20201117144824048

初始化的方式:

  • 属性的显式初始化

  • 代码块中初始化

  • 构造器中初始化

7.3 对象的内存布局

在HotSpot虚拟机中,对象在堆内存中的存储布局可以划分为三个部分:

  • 对象头
  • 实例数据
  • 对齐填充

image-20201117145708804

7.3.1 对象头

HotSpot虚拟机的对象头部分包括两部分信息。

  • 运行时元数据 Mark Word【8个字节】
  • 类型指针【4个字节】

整个对象头共12个字节。

(1)Mark Word

HotSpot虚拟机的对象头部分中存储的第一类信息是用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的所、偏向线程ID、偏向时间戳等,官方称其为“Mark Word”。这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,但是对象需要存储的运行时数据很多,已经超出了32/64bit所能记录的最大限度,但对象头里的信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,MarkWord被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。

(2)类型指针

对象头的另外一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪一个类的实例。

  • 并不是所有的虚拟机都必须要在对象数据上保留类型指针,即查找元素的元数据信息并一定要经过对象本身
  • 如果对象 Java 数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通 Java 对象的元数据信息确定 Java 对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小【对象所需的内存大小在类加载完后就可以完全确定

7.3.2 实例数据

实例数据是对象真正存储的有效信息,即我们在程序代码中所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts、chars、bytes\booleans、oops,从默认分配策略中可以看出:

  • 相同宽度的字段总是被分配到一起存放
  • 在满足一起存放的前提下,在父类中定义的变量会出现在子类之前
  • 如果 CompactFields 参数为 true(默认为 true),子类的窄变量可以被允许插入到父类变量的空隙,以节省出一点点空间

7.3.3 对齐填充

对齐填充,这并不是必然存在的,也没有特别的含义,它仅仅是起着占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象的起始地址必须是8字节的整数倍,即任何对象的大小都必须是8字节的整数倍。对象的内存布局中对象头已经被设计为8字节的倍数(1倍或2倍),因此,如果对象实例数据部分没有对齐的话,就需要使用对齐填充来补全。

7.3.4 总结

public class Customer{
    int id = 100;
    String name;
    Account account;
    
    {
        name = "匿名客户";
    }
    
    public Customer(){
        account = new Account();
    }
}

class Account{
    
}
public class Test{
    public static void main(String[] args) {
        Customer cust = new Customer();
    }
}

上述代码的实例化过程如下:

image-20201117163317645

7.4 对象的访问定位

创建对象的目的是为了后续可以使用该对象,Java程序会通过栈上的reference引用来操作堆上的具体对象。也即如何在栈、堆与方法区之间建立联系:

image-20201117165446673

《Java虚拟机规范》中只规定了reference类型是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由具体的虚拟机而定的。主流的访问方式有:

  • 通过句柄访问对象
  • 通过直接指针访问对象

7.4.1 句柄访问

如果使用句柄访问的话,Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址。句柄中包含有对象实例数据和类型数据各自的具体地址信息。

image-20201117165035030

句柄访问的优势是reference中存储的是稳定的句柄地址,在对象被移动【如垃圾收集时的移动】时只会改变句柄中的实例数据指针,而reference本身不需要被修改。

7.4.2 直接指针访问

如果使用直接指针访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,栈中局部变量表中的reference引用直接指向的就是堆中实例对象的地址,在对象实例的对象头中有类型指针,该类型指针指向的是方法区中的类型数据。如果只是访问对象本身的话,就不需要多一次间接访问的开销。

image-20201117170130488

使用直接指针的优势是速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

我姓弓长那个张

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值