一看就懂的Java对象内存布局(java内部对象)
Java对象的内存布局
1 前言
新建对象的方式:
- Object.clone,反序列化直接复制已有数据,初始化新建对象的实例字段
- Unsafe.allocateInstance没有初始化实例字段
- new
- 反射new和反射机制,都是通过调用构造器来初始化实例字段
如new编译而成的字节码包含:
- 请求内存的new指令
- 调用构造器的invokespecial指令
若一个类未定义任何构造器, 则Java编译器会自动添加一个无参构造器:
但子类构造器需调用父类构造器:
- 若父类存在无参构造器,该调用可以是隐式,即Java编译器会自动添加对父类构造器的调用
- 若父类没有无参构造器,则子类构造器需显式调用父类带参构造器
显式调用又可分为:
- super关键字调用父类构造器
- this关键字调用同一类中的其它构造器
都要作为构造器的第一条语句,以优先初始化继承得的父类字段。(但这也能通过调用其他生成参数的方法或字节码注入来绕开)
调用一个构造器时,优先调用父类构造器,直至Object类。这些构造器的调用者皆为同一对象,即通过new指令新建而来的对象。通过new指令新建的对象,其内存含所有父类实例字段。虽子类无法访问父类private实例字段或子类的实例字段隐藏了父类的同名实例字段,但子类实例还是会为这些父类实例字段分配内存。
那这些字段在内存如何分布的呢?
2 压缩指针
JVM,每个Java对象都有对象头(object header),由标记字段和类型指针构成:
- 标记字段,存储JVM有关该对象的运行数据,如哈希码、GC信息及锁信息
- 类型指针,指向该对象的类
64位JVM,对象头的标记字段占64位,类型指针又占64位。每个Java对象在内存中额外开销就是16字节。Integer类仅有一个int类型私有字段,占4字节。因此,每个Integer对象额外内存开销至少400%。这也是引入基本类型的原因之一。
为降低对象内存使用量,64位JVM引入压缩指针[1](对应虚拟机选项-XX:+UseCompressedOops,默认开启),将堆中原本64位的Java对象指针压缩成32位。
这样,对象头中的类型指针也会被压缩成32位,使对象头大小从16字节降至12字节。
2.1 作用范围
- 对象头的类型指针
- 引用类型的字段
- 引用类型数组
2.2 原理
路上全是房车,而且每辆房车恰好占据两个停车位。按序编号,停在0号和1号停车位上的叫0号车,停在2号和3号停车位上的叫1号车,依次类推。
原本内存寻址用车位号。如我有一个值为6的指针,代表第6车位,沿这指针可找到3号车。
规定指针里存的值是车号,如3指代3号车。当查找3号,可将该指针值乘2,再沿着6号车位找到3号车。
这样32位压缩指针最多可标记2的32次方辆车,对应着2的33次方个车位。房车也有大小之分。大房车占据车位可能是三个甚至是更多。不过这并不会影响寻址算法:只需跳过部分车号,便可保持原本车号*2的寻址系统。
上述模型有一个前提,就是每辆车都从偶数号车位停起。这称为内存对齐(对应虚拟机选项-XX:ObjectAlignmentInBytes,默认值为8)。
默认Java虚拟机堆中对象起始地址需对齐至8倍数。如一个对象用不到8N字节,那空白部分空间就浪费了。这些浪费的空间称为对象间的填充(padding)。
默认Java虚拟机中32位压缩指针可寻址到2的35次方个字节,即32GB地址空间(超过32GB则会关闭压缩指针)。
在对压缩指针解引用时,需将其左移3位,再加上一个固定偏移量,便可得到能寻址32GB地址空间的伪64位指针。
可通过配置刚提到的内存对齐选项(-XX:ObjectAlignmentInBytes)进步提升寻址范围。但是,这同时也可能增加对象间填充,导致压缩指针没有达到原本节省空间的效果。
举例来说,如规定每辆车都需从偶数车位号停起,那占据两个车位的小房车刚好,而对需三个车位的大房车仅是浪费一个车位。
但如规定需从4倍数号车位停起,那小房车则会浪费两车位,而大房车至多可能浪费三车位。
就算关闭压缩指针,Java虚拟机还是会进行内存对齐。内存对齐不仅存在于对象与对象间,也存在对象中的字段间。比如说,Java虚拟机要求long字段、double字段及非压缩指针状态下的引用字段地址为8的倍数。
字段内存对齐的其中一个原因是让字段只出现在同一CPU的缓存行。如字段不对齐,可能出现跨缓存行的字段,即该字段的读取可能需替换两个缓存行,而该字段的存储也会同时污染两个缓存行。这对程序执行效率都不好。
3 字段重排列
JVM重新分配字段先后顺序,以达到内存对齐。Java虚拟机中有三种排列方法(对应Java虚拟机选项-XX:FieldsAllocationStyle,默认值为1),但都遵循如下规则:
- 如一个字段占C个字节,那该字段偏移量需对齐至NC偏移量指字段地址与对象的起始地址差值。long类仅有一个long类型实例字段。在使用压缩指针的64位虚拟机,尽管对象头12字节,该long类型字段的偏移量也只能是16,而中间空着的4字节便会被浪费掉。
- 子类所继承字段的偏移量,要与父类对应字段的偏移量保持一致
JVM还会对齐子类字段的起始位置:
- 使用压缩指针的64位虚拟机,子类第一个字段需对齐至4N
- 关闭压缩指针的64位虚拟机,子类第一个字段需对齐至8N
两个类A和B,其中B继承A。A和B各自定义一个long类型的实例字段和一个int类型实例字段。
B类在启用压缩指针和未启用压缩指针时,各字段偏移量:
启用压缩指针,JVM将A类int字段置于long字段前,以填充因long字段对齐造成的4字节缺口。由于对象整体大小需对齐至8N,因此对象最后会有4字节空白填充。
当关闭压缩指针时,B类字段起始位置需对齐至8N。那B类字段前后各有4字节空白。
Java 8还引入新注释@Contended,解决对象字段之间的虚共享(false sharing)问题[2]。也影响字段排列。
4 虚共享
假设两线程分别访问同一对象中不同的volatile字段,逻辑上无共享内容,因此无需同步。但若这俩字段恰在同一缓存行,则对这些字段写操作会导致缓存行的写回,即造成了实质上的共享。
Java虚拟机会让不同@Contended字段处于独立缓存行,因此大量空间被浪费。可查阅Contended字段的内存布局。注意使用虚拟机选项-XX:-RestrictContended。
5 总结
本文介绍JVM构造对象的方式,所构造对象的大小,以及对象的内存布局。
new语句会被编译为new指令及对构造器调用。每个类的构造器都会直接或者间接调用父类构造器,且在同一实例中初始化相应字段。
JVM引入压缩指针,将原本64位指针压缩成32位。压缩指针要求JVM堆中对象的起始地址要对齐至8倍数,还会对每个类的字段进行重排列,使得字段也内存对齐。
使用JOL工具打印你工程中的类的字段分布:
curl -L -O http://central.maven.org/maven2/org/openjdk/jol/jol-cli/0.9/jol-cli-0.9-full.jar
java -cp jol-cli-0.9-full.jar org.openjdk.jol.Main internals java.lang.String
[1] https://wiki.openjdk.java.net/display/HotSpot/CompressedOops
[2] http://openjdk.java.net/jeps/142