Java对象中成员变量布局及其实现

来源:互联网 发布:淘宝多少流量成交一单 编辑:程序博客网 时间:2024/05/16 01:44

========================== 背景 (可适当跳过) ====================

在提高程序性能的技术中,增加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;     ... ...


在上述代码中,第二行先初始化nonstatic_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) {


由于几种类型变量的处理类似,这里截取两种word和double类型的实现。
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相关的文章,如有内容上的错误或者行文上的意见,请大家不吝指出。














0 0