本文详细介绍了HotSpot虚拟机中的对象的内存布局,接着介绍了压缩指针的知识,然后介绍了如何使用jol来查看和计算对象内存使用情况,最后介绍了对象的访问定位方式!
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头( Header )、实例数据( Instance Data ) 和对齐填充( Padding )。
如果对象是数组类型,则HotSpot虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即32bit;而在64位虚拟机中,1字宽等于8字节,即64bit。
HotSpot虚拟机的一般对象头包括两部分信息:“Mark Word”、“Class Pointer”,数组类对象还包括“Array Length”。
长度 | 内容 | 说明 |
32/64bit | Mark Word | 主要用来表示对象的线程锁状态,另外还可以用来配合GC、存放该对象的hashCode。 |
32/64bit | class pointer(klass) | 存储指向对象Class信息的指针,意味着该对象可随时知道自己是哪个Class类型的实例。64位JVM开启指针压缩时为32bit。 |
32/64bit | Array Length | 数组的长度(仅当当前对象为数组时存在)。64位JVM开启指针压缩时为32bit。 |
用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit。
对象需要存储的运行时数据很多,其实已经超出了32位、 64位BitMap结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率, Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间,即不同的状态存储不同的数据。
Java对象头里的Mark Word里默认存储对象的HashCode、分代年龄和锁标记位。实际上Java中的Synchronized实现的关键就是依赖了对象头中的Mark Word! 关于Mark Word的更详细解释以及与Synchronized的关系在这篇文章中有详解:Java中的synchronized的底层实现原理以及锁升级优化详解。
在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。32虚拟机的不同状态下Mark Word的大概组成如下表:
存储内容 | 标识位 | 状态 |
对象哈希码、对象分代年龄 | (0)01 | 未锁定(无锁状态) |
偏向线程ID、偏向时间戳、对象分代年龄 | (1)01 | 可偏向(偏向锁) |
指向线程中锁记录(Lock Record)的指针 | 00 | 轻量级锁 |
指向重量级锁(互斥量)的指针 | 10 | 重量级锁定 |
空 (CMS垃圾收集器用到的标记信息,其他时刻为空) | 11 | GC标记 |
Class Pointer,对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象到底是哪个类的实例。 并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身。
这部分数据的长度在 32 位和 64 位的虚拟机中分别为 32 bit 和 64 bit。
可以使用-XX:+UseCompressedClassPointers参数对64位虚拟机进行类型指针压缩,压缩后长度为32bit。
如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中却无法确定数组的大小。
这部分数据的长度在 32 位和 64 位的虚拟机中分别为 32 bit 和 64 bit。
可以使用-XX:+ UseCompressedOops参数对64位虚拟机进行对象指针压缩,压缩后64位虚拟机的length长度为32bit。
实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。
这部分的存储顺序会受到虚拟机分配策略参数( FieldsAllocationStyle ) 和字段在Java源码中定义顺序的影响。
HotSpot虚拟机默认的分配策略为long/double、int/float、short/char、byte/boolean、 oop( Ordinary Object Pointers ,即引用类型,可压缩),其中使用/分隔的类型采用定义的先后顺序排序,从分配策略中可以看出,相同宽度的字段总是被分配到一起。另外,会使用内存重排序优化空间使用,即一般如果对象头占用12bytes,那么将会选择小于等于4bytes的类型放在后面尝试补齐4bytes空间。
基本类型和引用类型指针之间以4bytes为步长的对齐填充,当实例数据填充完毕之后,在最后还有一次以8bytes为步长的对齐填充。
在满足上面的前提条件的情况下,在父类中定义的变量会出现在子类之前。如果CompactFields参数值为true ( 默认为true ) ,那么子类之中较窄的变量也可能会插入到父类变量的空隙之中。
对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用,主要用体提升读取的效率。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说, 就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍 ),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
Object o = new Object() 在内存中占用16个字节(开启压缩),其中最后4个是对齐填充;
通常64位JVM消耗的内存会比32位的大1.5倍,这是因为对象指针在64位架构下,长度会翻倍(更宽的寻址)。如果项目从32位虚拟机迁移到64位虚拟机,那么突然增大的内存需求可能会让项目崩溃。
从JDK 6 update14开始,64 位的JVM正式支持了-XX:+UseCompressedOops 参数和-XX:+UseCompressedClassPointers参数,用于压缩指针的大小,节约内存占用。我们可以使用java -XX:+PrintCommandLineFlags -version查看是否默认开启(JDK6以后都是默认开启的)。
-XX:+UseCompressedOops
即普通对象指针压缩(OOP即ordinary object pointer)。该参数默认是开启的,可以使用-XX:-UseCompressedOops关闭。
会被压缩的数据有:每个Class的属性指针(静态成员变量)、每个对象的属性指针、普通对象数组的每个元素指针。
不会被压缩的数据有:指向PermGen的Class对象指针,本地变量,堆栈元素,入参,返回值,NULL指针不会被压缩。
-XX:+UseCompressedClassPointers
即类型指针压缩,即针对klass pointer的指针压缩。使用-XX:+UseCompressedClassPointers开启参数,JDK 1.6 update14之后是默认开启的,可以使用-XX:-UseCompressedClassPointers关闭。
上面两种压缩策略,可以算出来的组合有四种:
-XX:+UseCompressedOops -XX:+UseCompressedClassPointers
-XX:+UseCompressedOops -XX:-UseCompressedClassPointers
-XX:-UseCompressedOops -XX:-UseCompressedClassPointers
-XX:-UseCompressedOops -XX:+UseCompressedClassPointers
但是使用第四种开启策略时却会出现警告:
Java HotSpot™ 64-Bit Server VM warning: UseCompressedClassPointers requires UseCompressedOops
这是因为JVM的限制:
// UseCompressedOops must be on for UseCompressedClassPointers to be on.
if (!UseCompressedOops) {
if (UseCompressedClassPointers) {
warning("UseCompressedClassPointers requires UseCompressedOops");
}
FLAG_SET_DEFAULT(UseCompressedClassPointers, false);
}
实际上,UseCompressedClassPointers参数依赖了UseCompressedOops参数,开启UseCompressedOops参数时,UseCompressedClassPointers参数默认开启,关闭UseCompressedOops时,UseCompressedClassPointers参数同样跟着关闭。
下面来看看具体的压缩和非压缩大小对比:
下面是64位虚拟机压缩和不压缩之后的数据大小对比表格:
类型 | 64位(bytes,无压缩) | 64位(bytes,压缩) |
boolean | 1 | 1 |
byte | 1 | 1 |
short | 2 | 2 |
char | 2 | 2 |
int | 4 | 4 |
float | 4 | 4 |
long | 8 | 8 |
double | 8 | 8 |
reference | 8 | 4 |
普通对象头 | 16 | 12 |
数组对象头 | 24 | 16 |
jol,即Java Object Layout,是openjdk提供的工具包,可以帮我们在运行时计算某个对象的内存布局以及对象的大小,是非常好的工具。
jol介绍:http://openjdk.java.net/projects/code-tools/jol/
maven依赖:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
首先查看JVM的基本信息:
@Test
public void test1() {
//返回有关当前 VM 模式的信息详细信息
System.out.println(VM.current().details());
}
本人的计算机输出如下:
# Running 64-bit HotSpot VM.
# Using compressed oop with 3-bit shift.
# Using compressed klass with 3-bit shift.
# Objects are 8 bytes aligned.
# Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
# Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
解释:
第一行:表示使用的是64位虚拟机;
第二行:表示启用了普通对象指针压缩,即-XX:+UseCompressedOops。
第三行:表示启用了类型指针压缩,即-XX:+UseCompressedClassPointers开启参数。
第四行:对象的大小必须8bytes对齐。
第五行:表示字段类型的指针长度(bytes),依次为引用句柄(对象指针),byte, boolean, char, short, int, float, double, long类型。
第六行:表示数组类型的指针长度(bytes),依次为引用句柄(对象指针),byte, boolean, char, short, int, float, double, long类型。
查看object对象的内存布局,这是一道经典的Java面试题:new Object()的大小是多少?
@Test
public void test2() {
//ClassLayout:class的内存内存布局
//parseInstance:表示解析传入的对象
//toPrintable:表示转换为一种可输出的格式打印
//解析object对象的内存布局
System.out.println(ClassLayout.parseInstance(new Object()).toPrintable());
}
输出如下:
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
首先解释一下对应的名词的意思:
java.lang.Object object internals:object对象的内部布局;
OFFSET:对象内部的某个偏移量,作为某部分的起始位置。
SIZE:对应的组成部分的大小,单位是bytes
TYPE DESCRIPTION:该部分的类型说明:
VALUE:字节的具体值
接下来看看object对象的具体布局和大小:
首先是4bytes的object header,即对象头;
接下来还是4bytes的object header;
接下来还是4bytes的object header;
最后是4bytes的(loss due to the next object alignment),字面意思就是“由于下一个对象对齐而造成的损失”,实际上就是对齐填充,前面说过对象的大小8bytes对齐,由于object header占用12bytes,因此后面还要需要4bytes的对齐填充。
总结一下:
object对象占有16bytes的内存大小,由两部分组成:12bytes的对象头和4bytes的对齐填充。
object对象头由两部分组成:8bytes的Mark Word+4bytes的class pointer(已被压缩)。实例数据部分为0bytes。
如果没开启类型指针压缩,那么object占用多少个字节呢?实际上还是16bytes,不过此时没有了对齐填充的4bytes。
另外注意,计算数组的内存大小时要计算数组长度大小(可被压缩)和内部数组元素的指针大小(引用类型指针可被压缩)。
这里的布局顺序主要是实例数据的布局顺序,这部分的存储顺序会受到虚拟机分配策略参数( FieldsAllocationStyle ) 和字段在Java源码中定义顺序的影响。
HotSpot虚拟机默认的分配策略为long/double、int/float、short/char、byte/boolean、 oop( Ordinary Object Pointers ),其中使用/分隔的类型采用定义的先后顺序排序,从分配策略中可以看出,相同宽度的字段总是被分配到一起。另外基本类型的引用,使用内存重排序优化空间使用,即一般如果对象头占用12bytes,那么将会选择小于等于4bytes的类型放在后面尝试补齐4bytes空间。
基本类型和引用类型指针之间以4bytes为步长的对其填充,当实例数据填充完毕之后,在最后还有一次以8bytes为步长的对其填充。
在满足上面的前提条件的情况下,在父类中定义的变量会出现在子类之前。如果CompactFields参数值为true(默认为true) ,那么子类之中较窄的变量也可能会插入到父类变量的空隙之中。
测试案例如下:
public class A {
long l;
String s1;
String s2;
String s3;
byte i;
}
public class JolOrder extends A {
String s;
long l;
double d;
int i;
short sh;
boolean bo;
char c;
byte b;
float f;
}
@Test
public void test3() {
System.out.println(ClassLayout.parseInstance(new JolOrder()).toPrintable());
}
建立对象是为了使用对象,我们的Java程序需要通过栈上的reference数据来操作堆上的具体对象。由于reference类型在Java虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所以对象访问方式也是取决于虚拟机实现而定的。目前主流的访问方式有使用句柄和直接指针两种。
如果使用句柄访问的话,那么Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息 ,如下图:
如果使用直接指针访问,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址,此时类型数据的地址就保存在对向头部的klass中。
这两种对象访问方式各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。
使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销, 由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。HotSpot虚拟机就使用直接指针方式进行对象访问的,但从整个软件开发的范围来看,各种语言和框架使用句柄来访问的情况也十分常见。
参考
《深入理解Java虚拟机》
如果有什么不懂或者需要交流,可以留言。另外希望点赞、收藏、关注,我将不间断更新各种Java学习博客!