前言

本篇中我们会介绍对象的实例化内存布局和怎么去访问定位,这里面的细节和JVM的结构和功能也是息息相关的,下面我们一起来了解一下。

JVM内存架构

这里的内存架构画的是JDK 7版本的,JDK 8之后方法区的画法有所改变。

运行时数据区中,红色框里的是线程共享的区域,灰色的区域是每个线程独有的区域。

JVM内存架构

几种创建对象的方式

  • new

最常规的方式,通过new Object()ClassName.methodNameClassName.buider方式去创建对象。

  • Class的newInstance

这种是通过反射的方式去创建对象,只能调用对应Class的空参构造器,且权限必须是public的。比如MyTest myTest = MyTest.class.newInstance();

  • Constructor的newInstance(Xxx)

这种也是通过反射的方式去创建对象,但是可以调用空参和有参的构造器,权限也没有要求。例子如下:

1
2
3
// 学过反射我们知道,通过这样可以调用无参的私有构造器        
Class<?> aClass = Class.forName("com.example.demo.test.MyTest");
Constructor<?> declaredConstructor = aClass.getDeclaredConstructor();
  • 使用clone

不调用任何构造器,当前类需要实现Cloneable接口,并且手动实现clone()方法。

  • 使用反序列化

从文件中,从网络中获取一个对象的二进制流。

  • 使用第三方库Objenesis

创建对象的过程

创建对象一般分为这几步:

  • 判断对象对应的类是否加载、链接、初始化;
  • 为对象分配内存;
  • 处理并发安全问题;
  • 对象初始化到分配的空间;
  • 设置对象的对象头;
  • 执行init方法对对象进行显示初始化。

下面我们来详细了解一下这些步骤都干了些什么。

判断对象对应的类是否加载、链接、初始化

虚拟机遇到一条new 对象的指令,首先去检查这个指令的参数能否在Metaspace的常量池中定位到一个类的符号引用,并且检查这个符号饮用代表的类是否已经被加载、解析和初始化(即判断类元信息是否存在)。

如果没有,那么在双亲委派机制模式下,使用当前类加载器以ClassLoader+包名+类名为key进行查找对应的 .class文件。如果没有找到文件,则抛出ClassNotFoundException异常,如果找到,则进行类加载,并生成对应的Class类对象。

总的来说,就是需要把创建对象的类先加载到虚拟机中来。

为对象分配内存

首先计算对象占用空间大小,接着在堆中划分一块内存给新对象。
如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小。

当堆内内存规整

如果堆内内存是规整的,那么虚拟机将采用的是指针碰撞法(Bump The Pointer)来为对象分配内存。

意思就是所有用过的内存在一边,空闲的内存在另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针指向空闲那边,这样指针只需要挪动一段与对象大小相等的距离。

如果垃圾收集器选择的是Serial、ParNew这种基于压缩算法的,虚拟机就会采用这种分配方式来为对象分配内存。
一般使用带有compact(整理)过程的收集器时,也会使用指针碰撞法。

当堆内内存不规整

如果堆内内存不是规整的,已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表法来为对象分配内存。

意思就是虚拟机维护了一个列表,记录上哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。这种分配方式称为空闲列表(Free List)

总结

选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

处理并发安全问题

由于堆空间是线程共享的区域,所以分配内存时还要考虑线程安全的问题。这时候一般会用下面两种策略来处理:

  • 每个线程在创建的时候预先分配一块TLAB,小对象创建的时候会优先分配到TLAB上。
  • 若无法分配到TLAB上,会采用CAS失败重试、区域加锁保证在堆上分配内存时的原子性。

对象初始化到分配的内存空间

所有属性设置默认值,保证对象实例字段在未显示赋值时可以直接使用。

设置对象的对象头

将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于JVM实现。

执行init方法对对象进行显示初始化

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

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

对象的内存布局

新建出来的对象,内部又有什么结构呢?主要包含这三部分:

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

对象头(Header)

对象头主要包含两部分:运行时元数据(Mark World)和类型指针。

如果创建的对象是数组,还需记录数组的长度

运行时元数据(Mark World)

运行时元数据又包含下面这些内容:

  • 哈希值(HashCode)

  • GC分代年龄

  • 锁状态标志

  • 线程持有的锁

  • 偏向线程ID

  • 偏向时间戳

类型指针

指向类元数据InstanceKlass,确定该对象所属的类型;并不一定所有的对象都会保存这个类型指针。

实例数据(Instance Data)

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

实例数据的一些规则:

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

对齐填充(Padding)

不是必须的,也没什么特别的含义,仅仅起到占位符的作用。

对象内存布局图示

对象内存布局

以上图例是根据下面的代码来说明的,结合代码来看会清晰一点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Customer {
int id = 1001;
String name;
Account acct;

{
name = "匿名客户";
}
public Customer() {
acct = new Account();
}
}

class Account {

}
1
2
3
4
5
public class CustomerTest {
public static void main(String[] args) {
Customer customer = new Customer();
}
}

对象的访问定位

JVM是如何通过栈帧中的对象引用访问到其内部的对象实例的呢?

通过栈上reference访问方式,如下图所示:

栈帧对象引用

对象具体的访问方式主要有两种:句柄访问和直接指针(Hotspot采用)

句柄访问

句柄访问

优点:reference中存储句柄地址,即使对象被移动(垃圾收集时可能会移动对象)时只会改变句柄中实例数据指针即可,reference本身不需要被修改。

缺点:需要在堆中额外开辟一块内存来做句柄池,有一点点浪费资源。

直接指针(Hotspot采用的方式)

直接指针

直接指针方式访问对象如上图所示,将栈上的引用直接指向对象的实例数据,这也是Hotspot虚拟机采取的访问对象的方式。