JVM之对象的内存布局(系列五)
前言
本篇中我们会介绍对象的实例化内存布局和怎么去访问定位,这里面的细节和JVM的结构和功能也是息息相关的,下面我们一起来了解一下。
JVM内存架构
这里的内存架构画的是JDK 7版本的,JDK 8之后方法区的画法有所改变。
运行时数据区中,红色框里的是线程共享的区域,灰色的区域是每个线程独有的区域。
几种创建对象的方式
- new
最常规的方式,通过new Object()
、ClassName.methodName
、ClassName.buider
方式去创建对象。
- Class的newInstance
这种是通过反射的方式去创建对象,只能调用对应Class的空参构造器,且权限必须是public的。比如MyTest myTest = MyTest.class.newInstance();
。
- Constructor的newInstance(Xxx)
这种也是通过反射的方式去创建对象,但是可以调用空参和有参的构造器,权限也没有要求。例子如下:
1 | // 学过反射我们知道,通过这样可以调用无参的私有构造器 |
- 使用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 | public class Customer { |
1 | public class CustomerTest { |
对象的访问定位
JVM是如何通过栈帧中的对象引用访问到其内部的对象实例的呢?
通过栈上reference访问方式,如下图所示:
对象具体的访问方式主要有两种:句柄访问和直接指针(Hotspot采用)。
句柄访问
优点:reference中存储句柄地址,即使对象被移动(垃圾收集时可能会移动对象)时只会改变句柄中实例数据指针即可,reference本身不需要被修改。
缺点:需要在堆中额外开辟一块内存来做句柄池,有一点点浪费资源。
直接指针(Hotspot采用的方式)
直接指针方式访问对象如上图所示,将栈上的引用直接指向对象的实例数据,这也是Hotspot虚拟机采取的访问对象的方式。