来源:https://zhuanlan.zhihu.com/p/44948944

前言:

Java是一门面向「对象」的语言,学Java就要学会面向「对象」编程 。对象的创建在语言层面很简单,new一个即可,但是在JVM实现层面呢,是怎样实现的呢?让我们来研究下Java虚拟机中对象的创建以及内存布局和访问定位。所以,本文的内容如下:

  • 对象的创建
  • 对象的内存布局
  • 对象的访问定位

1.对象的创建

在Java中,对象是很重要的概念,宏观上来看,对象的创建在Java语言层面上通常用new关键词即可完成,创建完即可直接使用。而在微观上,对象的创建又包括哪些过程?
在Java虚拟机层面,对象的创建主要分为2个过程:

  • 对象内存的分配
  • 对象初始化

内存分配完成后在JVM层面上,对象已经创建成功了,但是仅仅分配了内存的对象并不能被我们使用,还需要一步初始化的过程。
1.1对象内存的分配
JVM通过指令来创建新对象,对象分为普通对象和数组对象,普通对象和数组对象的创建指令是不同的
创建类实例的指令:new
创建数组的指令:newarray,anewarray,multianewarray
举例:在java语法层面,int[] array = new int[]和List list = new ArrayList()都是通过new关键字的,但是在JVM里却是通过不同的指令,前者是创建一个数组对象,用的是newarray指令,而后者用的是new指令
以new一个String对象为例,看下其字节码指令:

  1. public static String testString(){
  2. String s = new String("对象测试:testString");
  3. return s;
  4. }
  5. 对应的字节码指令如下:
  6. ------------------------------
  7. Code:
  8. stack=3, locals=1, args_size=0
  9. 0: new #11 // class java/lang/String
  10. 3: dup //dup指令用于复制操作数栈顶的值,再将其入栈(结果是栈顶有2个相同值)
  11. 4: ldc #12 // String 对象测试:testString(ldc指令用于将其压入栈顶)
  12. 6: invokespecial #13 // Method java/lang/String."<init>":(Ljava/lang/String;)V
  13. 9: astore_0
  14. 10: aload_0
  15. 11: areturn

再看一下new一个ArrayList时的情形:

  1. public static List testList(){
  2. List list = new ArrayList();
  3. return list;
  4. }
  5. 对应的字节码指令如下:
  6. ------------------------------
  7. Code:
  8. stack=2, locals=1, args_size=0
  9. 0: new #14 // class java/util/ArrayList
  10. 3: dup
  11. 4: invokespecial #15 // Method java/util/ArrayList."<init>":()V
  12. 7: astore_0
  13. 8: aload_0
  14. 9: areturn

我们以new指令为例,当虚拟机遇到new指令时,首先回去检查此条指令在常量池中能否定位到一个类的符号引用,并且检查此符号引用所代表的类是否被加载、解析和初始化过。如果没有则必须先执行相应的类加载过程。在类加载检查通过后,虚拟机将为新生对象在Java堆中分配内存。分配内存的实现方式有两种:

  • 指针碰撞(Bump the Pointer)
  • 空闲列表(Free List)

具体使用哪种方式取决于Java堆中内存的排列是否规整。假设Java堆中内存是绝对规整的,即有大片连续的内存,而不是散落不均的内存空间,已使用的内存和未使用的内存空间相互挨着各占一边。那仅仅将指针向未使用空间那边挪动一段和对象大小相等的距离即可,这种方式就叫做——指针碰撞。
如果堆内存是不规整的,虚拟机就无法通过指针碰撞的方式来划分内存了,此时虚拟机需要维护一个列表用以记录整个内存空间上哪些可用,哪些不可用。此时,在分配内存时,JVM需要找到一块足够大的内存空间分配给新new出来的对象,并且更新列表上的记录。这种方式就叫做——空闲列表
Java堆中内存的排列是否规整取决于堆中垃圾收集器,如果JVM中的垃圾收集器带有空间压缩整理功能,则内存规整;否则内存不规整。在使用Serial、ParNew等等带有Compact过程(压缩整理)的垃圾收集器时,系统采用的分配算法是指针碰撞;而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。
内存分配完成后,虚拟机还要将分配到的内存空间都初始化为零值(对象头除外),这一步骤保证了对象的实例字段在Java代码中可以不赋初始值即可使用,如:byte、short、long转化为对象后初始值为0,boolean初始值为false
JMM - 图1
接下来,虚拟机要对对象进行必要的设置,如:此对象是哪个类的实例、如何才能找到类的元数据信息、对象哈希码、对象的GC分带年龄等信息。这些信息存在对象头(Object Header)中。对象头的内容将在下面↓对象的内存布局中介绍。根据虚拟机当前运行状态的不同、是否有偏向锁等,对象头的内容组成会有所不同。在设置好对象头以后,从JVM的角度看,对象的创建就正式完成了。
1.2对象初始化
对象创建完成后,还需要初始化,才能正式被我们使用,一般来说JVM中new指令之后会紧跟着有invokespecial指令,该指令用于指令对象所属类的方法。init方法完成后(对象初始化完成),对象才正式可用,至此对象创建的完整过程就结束了。


2.对象的内存布局

不同的虚拟机可能有不同的实现,这里我们以HotSpot虚拟机为例讲解对象的内存布局。在HotSpot虚拟机中,对象在内存区域中分为3块内容:

  • 对象头(Header)
  • 实例数据(Instance Data)
  • 对齐填充(Padding)

2.1对象头
HotSpot中对象头分为两部分信息:

  • 对象自身的运行时数据
  • 对象的类型指针

对象头的第一部分用于存储自身的运行时数据包括:哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。运行时数据部分的长度在32位和64位虚拟机中分别为32位和64位,官方称之为“Mark Word”,由于运行时数据这部分较多,可能会不够存放,所以此Mark Word部分被设计为非固定结构,即其结构可以随着对象状态而改变以便在较小空间内尽量复用自己的空间。
对象头的第二部分是对象的类型指针。类型指针,即对象指向它的类元数据的指针,通过这个指针,虚拟机可以确定对象是哪个类的实例。但是不同虚拟机实现不同,在有的虚拟机实现上,并不需要通过这个指针查找元数据信息,所以也就没有类型指针。另外,如果对象是个数组,在对象头部还需要一块用于记录数组长度的数据。
例如,在32位的HotSpot虚拟机中,对象非锁定状态下,Mark Word的32位空间是这样分配的:25bit用于存储对象的哈希码HashCode、4bit存储对象分代年龄、2bit存储对象锁标志位、1bit固定为0。而在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下,又是另一种布局,如下图所示:
JMM - 图2
2.2实例数据
实例数据部分是对象真正存储有效信息的区域,对象可能会包含各种类型的字段无论是从父类继承的还是在子类中定义的,这些都需要在此记录。这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的共同影响。
2.3对齐填充
这部分并不是必然存在的,也没有特别的含义,仅仅起到占位符的作用。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,即对象大小必须是8字节的整数倍,如果不够则需要通过对其填充来补全。


3.对象的访问定位

对象建立后就是为了被使用的,为了使用对象,我们需要知道对象在内存中的地址,即Java堆中的地址。我们通过操作数栈中的reference(引用类型)数据来找到对象的位置。
但是Java虚拟机规范只规定reference是一个指向对象的引用,并没有明确规定这个ref引用是直接指向对象还是指向对象的指针,所以对象访问方式还要取决于虚拟机的实现。目前,主流的实现方式有2种:

  • 句柄访问
  • 直接指针访问

1.句柄访问
使用句柄访问对象,则reference存储的引用指向Java堆中的句柄池,句柄池中存放了指向对象实例数据和类型数据的指针。在这种情况下,对象的实例数据和类型数据是分开存放的,实例数据和句柄池同样存放于Java堆中的实例池中(和句柄池同处于Java堆中,只不过区域不同);类型数据则存放于方法区中。
JMM - 图3
2.直接指针访问
直接指针访问,则reference存储的引用就是直接指向Java堆中的对象实例数据,此种情况下,对象的类型数据还是存储于方法区(在实例数据中,存储了指向对象类型数据的指针)
JMM - 图4
这两种访问方式各有优劣,使用句柄访问的优势在于reference中存储的是句柄地址,在对象被移动(垃圾收集时经常会发生)时只需改变句柄池中实例数据指针即可,reference本身无需修改。劣势在于,相对应直接指针访问,多了一道访问流程,故速度较慢。
直接指针访问的优势在于reference中存储的引用直接就指向Java堆中的对象,所以访问效率高,由于Java中对对象的访问是非常频繁的,所以累计起来节省了不少时间(相对与句柄访问)。劣势在于,如果对象一旦被修改,则reference也需要随之修改