========================== 背景 (可适当跳过) ====================
在提高程序性能的技术中,增加data locality是一个常用的手段。它一般在C/C++等语言编程中应用较多。
比如,我们要实现一个结构C,它有两个int64_t变量x, y经常一起读/写的变量。
定义A如下:
struct C
{
int64_t x;
int64_t y;
int64_t dummy1[6]; /*只是为了占位用*/
int64_t dummy2[8]; /*只是为了占位用*/
};
定义为B:
struct C
{
int64_t x;
int64_t dummy[14]; /*只是为了占位用*/
int64_t y;
};
一般情况下(x,y非多核共享变量),定义A会是比定义B更好的定义。
不仅因为它在程序逻辑上把相关的变量定义放一起,
而且它对于x,y的访问有更好的locality。(对于一般64-byte的cacheline, A中x和y是在同一条cacheline上,而B中x,y在两条cacheline上。)
C/C++等语言中结构(类)中成员变量的实际布局对于程序的影响是重要的。
在这些语言中,成员变量的定义的先后顺序就是其在真实内存中的先后位置。
合理地安排这些变量顺序有时也是编程者的一个重要考虑。
但在Java语言中,由于Java/JVM Spec本身对成员变量的位置没有限制,
所以在实际中,成员变量的位置依赖于具体JVM的实现。
无论是出于好奇,
还是出于利用JVM实现达成性能优化的目的,了解某个JVM中Java中对象布局的实现都是有趣且重要的。
========================== JVM外部看布局 ====================
注:
本文源码实现的内容依据OpenJDK 7u6中hotspot的代码。
Java相关工具使用OpenJDK,64位版本。
本文不讨论有父类时内存布局的情况。
本文不讨论static成员的内存布局。
另注:
下文可能出现穿插使用中英文术语的情况,仅根据个人打字以及使用方便,请见谅。
我们先看一个Java类A的数据部分
class A
{
int a;
long b;
int x;
long y;
}
成员定义的顺序是 a, b, x, y。
那么在内存中它的布局是究竟怎样的呢?
插曲:
在看结果之前,我们可以先考虑一个问题:
如果它的顺序被改变了,那么可能被谁(工具?环境?)改变的?
这个问题的答案决定了我们去哪里找到我们要的布局。
我们可以把Java源码从编写好到执行分为这么几个过程。
Java Source => Java Bytecodes (By javac) => JVM Run Bytecodes [1. Interpreter 2.JIT] (By JVM)
可以看出,理论上,存在几种可能:
1. 在javac编译字节码时可以改变变量顺序
2. JVM基于字节码的描述再次改变变量顺序。
3. JVM出于优化目的在代码被JIT后改变变量顺序。
但实际结果是:
1. 否。javac只是简单地依照Java源码的语义生成bytecode。故对象中的变量顺序没有变化。
2. 是。JVM基于bytecode对成员变量的描述,按一定的策略改变对象的顺序并存储。
3. 否。JVM解释器和JIT使用相同的对象布局。
故下文讨论如果在JVM里查看对象的内存布局以及布局的策略。
我们先尝试从外部(不看JVM源码)先看看对象的布局。
我们知道JVM可以通过选项输出JIT后的汇编代码,以此理解各个变量的内存偏移。
命令: java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly
(或许有Java层面获取成员变量偏移的方法,但碍于个人知识有限,如有更方便的方法,请指出。)
我们使用如下类
class X
{
X()
{
a = 0xABC;
b = 0x123;
x = 0x234;
y = 0x345;
}
int a;
long b;
int x;
long y;
}
为了触发JIT,我们可以在main函数里写一个循环,一直执行new X()的操作,这样类X的构造函数将被JIT。 (代码省略)
由于这个构造函数足够简单,我们可以很容易地理解汇编内容并获取对象布局。
以下为使用上述命令执行程序后JIT的输出:(省略了无关函数的汇编代码)
红框部分为初始化成员变量的代码,对比注释(或者初始化的值),可以知道:
rsi为对象的this指针。
成员的相对偏移为: a: 0xc b: 0x10 x: 0x20 y: 0x18
顺序为: (int a), (long b), (long y), (int x)。 并且中间没有空隙。
如图:
JVM的确改变了成员变量的顺序,但是单从这个例子看,改变顺序的规则似乎不够明显。
而且有个现象值得注意,
int a的偏移是0xc (十进制12)。因为Java对象都有header,header占据了前12-byte,故data从12后开始放。 【注:在使用UseCompressedOops(默认)时,Header为12-byte,若不使用,则Header为16-byte】
通过个里例子,我们仅仅知道布局被改变了,但不知道按什么策略改变的。
并且,我们知道在C中对于较小的数据类型如char,编译期将做一定的padding使其对齐,在这里例子中我们也不能看出Java是否有类似功能。
我们再看一个例子,在这里我们使用更多的不同的数据类型,并且每种类型将有更多变量,并以此尝试分析布局的策略。
Java类数据定义:
class X
{
int i2;
char c1;
byte b1;
long l1;
short s3;
double d1;
short s1;
Object o1;
char c2;
long l2;
double d2;
byte b2;
short s2;
Object o2;
int i1;
}
直接上最终JVM内部的变量顺序:
(int i2) (long l1) (double d1) (long l2) (double d2) (int i1) (char c1) (short s3) (short s1) (char c2) (char s
如图:
通过这个例子,我们大体可以“猜测”对象的布局了。
1. 相同大小的对象放在一起。
2. 成员是整体是按从大到小排列的。比如先long/double后int,再是short/char等。
3. 对象的引用放在最后。
4. 因为header没有对齐8-byte,故之后会补上一定的成员使其对齐。
(其实我这个“猜测”有点马后炮的意思。)
但通过这个例子,我们能够大体感受一下Java对象在内存中的布局。
如果有兴趣,大家也可以尝试用其他的类来测试内存布局以验证自己的猜测。
========================== JVM内部看布局 ====================
为了了解真实地布局策略,在Java/JVM Spec没有限定的情况下,读JVM源码是适合的办法。
在Hotspot中,每个类在初始化时就会完成成员布局的初始化。
具体而言就是在class文件被解析的时候完成这个步骤的。
源码位于文件 ./hotspot/src/share/vm/classfile/classFileParser.cpp
逻辑部分在 ClassFileParser::parseClassFile(...) 函数靠后的部分中,它在解析位于解析文件的逻辑之后。
该函数主要功能就是根据JVM Spec解析class文件,它依次解析以下部分:
1. class文件的一些元信息,包括class文件的magic number以及它的minor/major版本号。
2. constant pool。
3. 类的访问标记以及类的属性(是否是class/interface,当前类的index,父类的index)。
4. interfaces的描述
5. fields的描述
6. methods的描述
5. attributes的描述
解析fields的描述对应源码的
2875行
typeArrayHandle fields = parse_fields(class_name, cp, access_flags.is_interface(), &fac, &fields_annotations,
&java_fields_count,
CHECK_(nullHandle));
在这个过程中, JVM读取class文件里成员的信息(如前文所述,这个信息就是Java源码的表现)。
可以知道原始的成员类型,名字以及顺序等。
并且JVM已经知道了各个类型(大小)的统计信息。
这样之后,它就可以根据这些信息按一定策略进行对象布局了。
2970行开始,就是真正开始进行布局的地方。
// Field size and offset computation
int nonstatic_field_size = super_klass() == NULL ? 0 : super_klass->nonstatic_field_size();
#ifndef PRODUCT
int orig_nonstatic_field_size = 0;
#endif
int static_field_size = 0;
... ...
本文不讨论有父类时内存布局的情况,故此处不做过多解释。
另外本文不讨论static成员的内存布局,相关代码也将忽略。
3013行开始,JVM先找到数据开始的偏移,即偏移header所占的位置。
first_nonstatic_field_offset = instanceOopDesc::base_offset_in_bytes() +
nonstatic_field_size * heapOopSize;
注1:instanceOopDesc::base_offset_in_bytes()即得出上述偏移。
在64位系统中,开了UseCompressedOops情况下,这个值为0xc。否则为0x10。
造成不同的原因是: klass的oop是使用narrow的4-byte还是正常8-byte。
注2:此处heapOopSize指的是oop的大小,它依赖于是否打开UseCompressedOops(默认打开)。打开时为4-byte否则为8-byte。
因为nonstatic_field_size的单位是heapOopSize故要换算成offset需要乘上它。 但其实在没父类时,nonstatic_field_size之前为0,故此处无效果。
紧接着3015行,
next_nonstatic_field_offset = first_nonstatic_field_offset;
unsigned int nonstatic_double_count = fac.count[NONSTATIC_DOUBLE];
unsigned int nonstatic_word_count = fac.count[NONSTATIC_WORD];
unsigned int nonstatic_short_count = fac.count[NONSTATIC_SHORT];
unsigned int nonstatic_byte_count = fac.count[NONSTATIC_BYTE];
unsigned int nonstatic_oop_count = fac.count[NONSTATIC_OOP];
next_nonstatic_field_offset变量相当于是一个pointer,它是当前分配给新成员的位置。
此时还没分配对象,故就是初始偏移。
之后几个nonstatic_XXX_count值分别获得当前类各个大小的成员的个数。
比如nonstatic_double_count是值大小为double(8-byte)的成员的个数,包括(java的double和long类型)
比如*_word_count包括int和float。 其他类型不多赘述。
3062前的一部分内容是跟oop_map以及NON_PRODUCT版本的jvm有关的,与本文无关。
3062行开始,就是内存布局的决策逻辑的地方。
bool compact_fields = CompactFields;
int allocation_style = FieldsAllocationStyle;
if( allocation_style < 0 || allocation_style > 2 ) { // Out of range?
assert(false, "0 <= FieldsAllocationStyle <= 2");
allocation_style = 1; // Optimistic
}
compact_fields是由全局的选项CompactFields决定的。该变量指示是否在header与下一个8-byte对齐位置之间的空隙处存放成员。 默认是true。
allocation_styel值决定了布局的样貌,由选项FieldsAllocationStyle确定,默认是1。
此处可看出,只有三种策略0、1、2供选择。
3072行开始到3091行是一些特殊类(如java.lang.Class,java.lang.String等)的决策确定,由于其不涉及一般的过程,此处不讨论。
3093行开始,对应不同决策的设置
if( allocation_style == 0 ) {
// Fields order: oops, longs/doubles, ints, shorts/chars, bytes
next_nonstatic_oop_offset = next_nonstatic_field_offset;
next_nonstatic_double_offset = next_nonstatic_oop_offset +
(nonstatic_oop_count * heapOopSize);
} else if( allocation_style == 1 ) {
// Fields order: longs/doubles, ints, shorts/chars, bytes, oops
next_nonstatic_double_offset = next_nonstatic_field_offset;
} else if( allocation_style == 2 ) {
// Fields allocation: oops fields in super and sub classes are together.
在代码中的注释可以清晰看到不同策略的语义。
分配方式0,对应的成员顺序是 oop,long/double, int, short/char 最后是byte。
其中oop是object pointer(某个o是ordinary的意思),可以理解成Java对象的指针。
(详情:Hotspot术语官方解释 请google "HotSpot Glossary of Terms"。 抱歉,似乎发不出链接)
分配方式1,顺序为long/doube, int, short/char, byte, oop。
分配方式2,把父类和子类的oops分配在一起,而实际分配顺序则为0或者1的一种。
在这里,我们以默认分配方式1为例看下之后的具体实现。
类似next_nonstatic_XXX_offset都是指示当前XXX类型当前将要分配的位置。
在上述代码中,分配方式1将把double/long放在最前,故把next_nonstatic_double_offset初始为next_nonstatic_field_offset即当前将要分配的位置。
前面提到compact_fields的选项,它决定在header后的一个gap里尝试填入一些成员。
所以之后的代码先处理这个特殊情况,然后才正常按顺序分配。
3133行开始,
if( nonstatic_double_count > 0 ) {
int offset = next_nonstatic_double_offset;
next_nonstatic_double_offset = align_size_up(offset, BytesPerLong);
if( compact_fields && offset != next_nonstatic_double_offset ) {
// Allocate available fields into the gap before double field.
int length = next_nonstatic_double_offset - offset;
assert(length == BytesPerInt, "");
nonstatic_word_space_offset = offset;
if( nonstatic_word_count > 0 ) {
nonstatic_word_count -= 1;
nonstatic_word_space_count = 1; // Only one will fit
length -= BytesPerInt;
offset += BytesPerInt;
}
nonstatic_short_space_offset = offset;
while( length >= BytesPerShort && nonstatic_short_count > 0 ) {
nonstatic_short_count -= 1;
nonstatic_short_space_count += 1;
length -= BytesPerShort;
offset += BytesPerShort;
}
首先判断double的个数,如果double个数为0,那么按正常分配顺序就已经到了word(4-byte)了,可以填入gap。使用正常逻辑即可处理。
当double个数大于0,则特殊处理:
offset即为header后的位置。 而next_nonstatic_double_offset指向double第一个能分配的位置。
在当前实现开了UseCompressedOops,offset一般为12,而next_nontstatic_double_offset为对齐8-byte的位置,即16。
之后判断开启了compact_fields,并且存在一个gap。(一般为4)。
大小4的空间可以够1个word(int或float, 4byte)或者2个short或者4个byte。
这里只贴出了处理word(无循环)和处理short的(有循环)的情况,处理byte的循环与short极其相似。
在这里nonstatic_word_count记录了word的总数,由于这个gap算一个特殊位置,故把放入这里的word从正常情况删除,并加入特殊的count --- nonstatic_word_space_count中。
之后减去表示可用gap的length,并且把当前特殊位置offset递增。
处理short的逻辑类似,只是由于short可以放两个所以采用循环,其中变量的表示也是类似的意思。
在处理完特殊情况后,就是算正常的成员分配了。
行3172:
next_nonstatic_word_offset = next_nonstatic_double_offset +
(nonstatic_double_count * BytesPerLong);
next_nonstatic_short_offset = next_nonstatic_word_offset +
(nonstatic_word_count * BytesPerInt);
next_nonstatic_byte_offset = next_nonstatic_short_offset +
(nonstatic_short_count * BytesPerShort);
其中double的位置确定,只需算出word/short/byte的位置即可。
代码逻辑很简单,由其前面元素的首位置加上前面元素所占大小即可。
行3182:
} else { // allocation_style == 1
next_nonstatic_oop_offset = next_nonstatic_byte_offset + nonstatic_byte_count;
if( nonstatic_oop_count > 0 ) {
next_nonstatic_oop_offset = align_size_up(next_nonstatic_oop_offset, heapOopSize);
}
notaligned_offset = next_nonstatic_oop_offset + (nonstatic_oop_count * heapOopSize);
}
最后更新oop的位置。由于分配方式1中,oop之前是byte可能出现不对其情况,故需要对齐。
heapOopSize的值可能为4(依然取决于UseCompressedOops),所以最终算完oop之后的位置(即对象最后的成员所占数据区间的后一个位置)可能不是8-byte对齐,
故此处用变量notaligned_offset。
行3189:
next_nonstatic_type_offset = align_size_up(notaligned_offset, heapOopSize );
nonstatic_field_size = nonstatic_field_size + ((next_nonstatic_type_offset
- first_nonstatic_field_offset)/heapOopSize);
next_nonstatic_type_offset即指向了实际的最后数据之后的位置,heapOopSize(4-byte)对齐。
而nonstatic_field_size则是表示non-static成员的总大小。(单位为heapOopSize)。
好了,到这里,我们已经确定了成员变量的数据区间了。
也知道哪种变量在哪块区域了,那么剩下的就是记录下具体某个变量的偏移了。
3196行的代码开始,顺序迭代class文件中描述的成员变量(本文只考虑non-static部分)。
for (AllFieldStream fs(fields, cp); !fs.done(); fs.next()) {
int real_offset;
FieldAllocationType atype = (FieldAllocationType) fs.offset();
switch (atype) {
3267行,
case NONSTATIC_WORD:
if( nonstatic_word_space_count > 0 ) {
real_offset = nonstatic_word_space_offset;
nonstatic_word_space_offset += BytesPerInt;
nonstatic_word_space_count -= 1;
} else {
real_offset = next_nonstatic_word_offset;
next_nonstatic_word_offset += BytesPerInt;
}
break;
case NONSTATIC_DOUBLE:
real_offset = next_nonstatic_double_offset;
next_nonstatic_double_offset += BytesPerLong;
break;
先看word的,在之前特殊处理中nonstatic_XXX_space_offset记录了XXX类型在gap中的偏移。
故此处word有在gap中的情况,那么先把当前word(即在Java定义中靠前)放在gap中,之后更新相应变量。
如果不是gap的情况,即获得当前word偏移next_nonstatic_word_offset为real_offset,并更新next_nonstatic_word_offset。
real_offset总是维护成当前变量的偏移位置。
(double情况类似)
3284行,
fs.set_offset(real_offset);
循环最后,把当前变量real_offset保存之field stream - fs即可,field会把当前名字的变量关联至该偏移以供之后的解释器或者JIT使用。
至此,我们已经看到了所有成员变量的内存布局的设置情况。
再提一下3290行代码,
// Size of instances
int instance_size;
next_nonstatic_type_offset = align_size_up(notaligned_offset, wordSize );
instance_size = align_object_size(next_nonstatic_type_offset / wordSize);
如注释所示,instance_size就是这个对象的大小。而这里wordSize是指一个HeapWord的大小,在64位系统中为8-byte。
因此,我们知道JVM的对象分配是8-byte对齐的。
总结一下,JVM内部处理对象布局
1.首先处理特殊情况,即header与第一个8-byte对齐位置之前的gap。按顺序尝试填入一定的word/short/byte变量。
2. 之后处理正常数据位置,按一定的分配顺序。
如分配类型1的顺序为 1. double 2.word 3. short 4. byte 5.oop。(注:此处类型仅代表数据大小。如double指8-byte数据而非对应Java的double类型)。
相同大小的成员放在一起,在同大小成员里,先定义的成员在内存中也在较前的位置。
3. 计算出最终的对象的大小。
4. 更新所有成员变量的实际偏移。
最后再重复一下之前较复杂的那个例子,如图,即为各种大小变量的分配示意。(b为byte)
========================== 结语 ====================
在本文未讨论的有父类的情况,其实分配类似,只是加了一些处理。有兴趣的话可以参看源代码。
对于static成员的处理,也非常类似于nonstatic成员,并且没有gap这种特殊情况。
第一次写介绍Java/JVM相关的文章,如有内容上的错误或者行文上的意见,请大家不吝指出。