概述

  在前面的文章中我们介绍了JVM的运行时数据区,以及类加载的相关过程。在了解每一块内存区域的作用与类加载子系统之后,我们再来看看对象时如何创建和定位的。在语言层面,比如Java语言,我们创建对象通常是用一个关键字new来进行对象的创建,但是在虚拟机中,对象时怎么创建的呢?(注:这里所说的对象,限定于普通的Java对象,不包括数据和Class对象等)

对象的创建

  虚拟机遇到一个new指令时,首先对去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析、初始化。如果没有,那必须先执行相应的类加载过程。如果类加载检查通过后,虚拟机会为新生对象分配内存,对象所需的内存大小在类加载完成后便可以完全确定。

1、对象内存空间分配
  • 指针碰撞:

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

  • 空闲列表:

    Java堆中的内存并不是规整的,已使用的内存和空间内存相互交错。这时候虚拟机就维护一个列表,记录哪些内存块可用,在分配内存的时候就从列表中找到一块足够大的空间划分给对象实例,并更新列表记录,这种分配方式就叫空闲列表。
      选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。因此在使用Serial,Parnew等带有比较过程的收集器,一般采用指针碰撞分配方式,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表法。

2、保证线程安全

  对象在虚拟机中的创建非常频繁,即使是仅仅修改一个指针,在并发情况下也不是线程安全的。比如当前的指针正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了这个指针来分配内存。通过下面两种方案可以解决:

  • 对分配内存空间的动作进行同步处理。实际上虚拟机采用CAS配上失败重试的方式,保证更新操作的原子性。
  • 把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在Java堆中预先分配一小块内存,成为本地线程分配缓冲(Tread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。虚拟机是否使用TLAB,可以用-XX:+/-UseTLAB来设定。
3、对象的必要信息设置

  完成上面的步骤之后,虚拟机要为对象设置一些必要的信息,例如这个对象是哪个类的实例,如何才能找到类的元数据信息,对象的GC分代年龄等信息。完成这些之后,从虚拟机的视角来看,一个新的对象已经产生了,但是从Java程序的视角来看,对象的创建才刚刚开始,<init>方法还没执行,所有的字段还是零。一般来说,执行new指令后会接着执行方法,这样一个真正可用的对象才算完全生成。(取决于字节码中是否跟随invokespecial指令所决定)。

对象访问定位

  创建对象是为了使用对象,Java程序需要通过栈上的reference数据来操作堆上的具体对象。由于reference类型在Java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用还通过什么样的方式去定位、访问堆中对象的具体位置。目前主流的两种方式是直接指针访问,和句柄访问两种。

1、句柄访问

  使用句柄访问,Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中存放的则是对象实例数据的地址与对象类型数据的地址。访问过程如下图所示:

  Java程序通过栈上的reference操作具体的对象,如果使用句柄方式访问对象,reference中存放的就是对象所在的句柄的地址,而句柄中存放的内容则分为两部分,一部分是对象实例指针,一部分是对象类型指针。比如上图中,有一个new Person()对象,我们称之为person1。那么句柄中存放的就是这个实例对象person1和这个对象的类型,也就是类Person的在方法区中的地址信息。

2、直接指针访问

  直接访问方式,reference中存放的就是对象的地址,这个地址中包含了实例对象,以及类型对象的地址。采用这种方式,Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息。如下图所示:
  采用直接指针访问最大的好处就是速度快,它节省了一次指针定位的时间开销。句柄访问的最大好处就是deference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动)时只会改变句柄中的实例数据指针,而reference本身不用改。