Java对象的内存布局
Java对象的内存布局
多弗哥 发表于4个月前
Java对象的内存布局
  • 发表于 4个月前
  • 阅读 6
  • 收藏 0
  • 点赞 0
  • 评论 0

腾讯云实验室 1小时搭建人工智能应用,让技术更容易入门 免费体验 >>>   

一.  对象的创建

    在语言层面上,创建对象通常仅仅只是一个new关键字而已,而在虚拟机中,这包含的主要过程有(仅限于Java普通对象,不包括数组和Class对象,这两者比较特殊):类加载检查、对象分配内存、并发处理、内存空间初始化、对象设置、执行ini方法等。主要流程如下:

1.  类加载检查

    虚拟机遇到一条new指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类的加载过程。

2.  分配内存

    在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便完全确定(Java对象的内存布局决定),为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。

    根据Java堆中是否规整有两种内存的分配方式 ①:

  • 指针碰撞(Bump the pointer) 
    Java堆中的内存是规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那分配内存就仅仅是把指针向空闲空间那边移动一段与对象大小相等的距离。例如:Serial收集器、ParNew收集器等带Compact过程的收集器。
  • 空闲列表(Free List) 
    Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,就没有办法简单的进行指针碰撞了。虚拟机必须维护一张列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。例如:CMS这种基于Mark-Sweep算法的收集器。

3.  并发处理

    除如何划分可用空间之外,还需要考虑并发处理的问题:对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决这个问题有两种方案:

  • 对分配内存空间的动作进行同步处理
    虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。
  • 本地线程分配缓冲区(TLAB,Thread Local Allocation Buffer) 
    把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为TLAB。哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定 ②。

4.  内存空间初始化

    内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用了TLAB,这一工作过程也可以提前至TLAB分配时进行。

    内存空间初始化保证了对象的实例字段在Java代码中可以不赋初始值就直接使用(例如基础数据类型),程序能访问到这些字段的数据类型所对应的零值。类的成员变量可以不显示地初始化,方法中的局部变量如果只负责接收一个表达式的值,也可以不初始化,但是参与运算和直接输出等其它情况的局部变量需要初始化。

5.  必要的对象设置

    接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的HashCode、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。

    根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

6.  对象初始化

    在上面的工作都完成之后,从虚拟机的角度看,一个新的对象已经产生了。但是从Java程序的角度看,对象的创建才刚刚开始 —— init()方法还没有执行,所有的字段都还是零。所以,一般来说(由字节码中是否跟随invokespecial指令所决定 ③),执行new指令之后会接着执行init()方法,将对象按照程序员定制的方式进行初始化,这样一个真正可用的对象才算产生出来。

    下面代码是HotSpot VM bytecodeInterpreter.cpp中的代码片段(这个解释器实现很少有机会实际使用,大部分平台上都使用模板解释器;当代码通过JIT编译器执行时差异就更大了。不过这段代码用于了解HotSpot的运作过程是没有什么问题的)。  

	// 确保常量池中存放的是已解释的类
	if (!constants->tag_at(index).is_unresolved_klass()) {
	  // 断言确保是klassOop和instanceKlassOop(这部分下一节介绍)
	  oop entry = (klassOop) *constants->obj_at_addr(index);
	  assert(entry->is_klass(), "Should be resolved klass");
	  klassOop k_entry = (klassOop) entry;
	  assert(k_entry->klass_part()->oop_is_instance(), "Should be instanceKlass");
	  instanceKlass* ik = (instanceKlass*) k_entry->klass_part();
	  // 确保对象所属类型已经经过初始化阶段
	  if ( ik->is_initialized() && ik->can_be_fastpath_allocated() ) {
	    // 取对象长度
	    size_t obj_size = ik->size_helper();
	    oop result = NULL;
	    // 记录是否需要将对象所有字段置零值
	    bool need_zero = !ZeroTLAB;
	    // 是否在TLAB中分配对象
	    if (UseTLAB) {
	      result = (oop) THREAD->tlab().allocate(obj_size);
	    }
	    if (result == NULL) {
	      need_zero = true;
	      // 直接在eden中分配对象
	retry:
	      HeapWord* compare_to = *Universe::heap()->top_addr();
	      HeapWord* new_top = compare_to + obj_size;
	      // cmpxchg是x86中的CAS指令,这里是一个C++方法,通过CAS方式分配空间,并发失败的话,转到retry中重试直至成功分配为止
	      if (new_top <= *Universe::heap()->end_addr()) {
	        if (Atomic::cmpxchg_ptr(new_top, Universe::heap()->top_addr(), compare_to) != compare_to) {
	          goto retry;
	        }
	        result = (oop) compare_to;
	      }
	    }
	    if (result != NULL) {
	      // 如果需要,为对象初始化零值
	      if (need_zero ) {
	        HeapWord* to_zero = (HeapWord*) result + sizeof(oopDesc) / oopSize;
	        obj_size -= sizeof(oopDesc) / oopSize;
	        if (obj_size > 0 ) {
	          memset(to_zero, 0, obj_size * HeapWordSize);
	        }
	      }
	      // 根据是否启用偏向锁,设置对象头信息
	      if (UseBiasedLocking) {
	        result->set_mark(ik->prototype_header());
	      } else {
	        result->set_mark(markOopDesc::prototype());
	      }
	      result->set_klass_gap(0);
	      result->set_klass(k_entry);
	      // 将对象引用入栈,继续执行下一条指令
	      SET_STACK_OBJECT(result, 0);
	      UPDATE_PC_AND_TOS_AND_CONTINUE(3, 1);
	    }
	  }
	}

 

二.  对象的内存布局

    在HotSpot VM中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

1.  对象头

    HotSpot VM的对象头包括两部分信息:运行时数据和类型指针。

  • 运行时数据

    用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁标志位、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启指针压缩)中分别为32bit和64bit,官方称之为“Mark Word”。

    对象的对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,Mark Word会根据对象的状态(锁标志位 ④)复用自己的存储空间,例如:在32位的HotSpot虚拟机中,如果对象处于未被锁定的状态,那么在Mark Word的32bit空间中,25bit用于存储对象HashCode,4bit用于存储GC分代年龄,2bit用于存储锁标志位,1bit固定为0;而在其它状态下(轻量级锁定、重量级锁定、GC标记、可偏向锁定),对象头Mark Word的存储内容如下:

  • 类型指针

    即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据并不一定要经过对象本身,这是由对象的访问定位决定的。

    如果对象是一个Java数组,那在对象头中还必须拥有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中无法确定数组的大小。

    在32 位系统下,存放 Class 指针的空间大小是 4 字节,Mark Word 空间大小也是4字节,因此头部就是 8 字节。如果是数组就需要再加 4 字节表示数组的长度。在64 位系统下开启指针压缩,那么头部存放 Class 指针的空间大小还是4字节,而 Mark Word 区域会变大,变成 8 字节,也就是头部最少为 12 字节。

2.  实例数据

    实例数据是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类中继承下来的,还是在子类中定义的,都需要记录下来。

    这部分的存储顺序会受到虚拟机分配策略参数(FieldsAllocationStyle)和字段在Java源码中定义顺序的影响。HotSpot VM默认的分配策略为longs/doubles、ints、shorts/chars、bytes/booleans、oop,从分配策略中可以看出,相同宽度的字段总是分配到一起。在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。

    如果 CompactFields参数值为true(默认为true),那子类之中较窄的变量也可能会插入到父类变量的空隙之中。 这是由于对齐填充会形成gap空洞,比如使用压缩指针时,头占12字节,后面如果是long的话long的对齐要求是8字节,中间就会有4个字节的空洞。为了高效利用,可以把int/short/byte等较小的值塞进去。

 

3.  对齐填充

    对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。

    由于HotSpot VM要求对象的起始地址必须是因素8的整数倍,也就是对象的大小必须是8字节的整数倍,而对象头部分正好是8字节的倍数。因此,当对象实例数据部分没有对齐的时候,就需要通过对齐填充来补全。

    这么做是有原因的,由于CPU读取内存时,是按寄存器(64bit或32bit)大小单位载入,如果载入的数据横跨两个大小单位,要操作该数据的话至少需要两次读取,加上组合移位、JVM自身操作,就会产生效率问题。

    32bit系统下,当使用 new Object 时,JVM 将会分配8 byte空间,128个Object对象就是1个KB。如果是 new Integer,对象里还有一个int值占用4byte,那么这个对象就是 8+4=12 byte,对齐后,该对象就是 16 个字节。

    3.1.  指针压缩

    一般,Java程序运行在64bit虚拟机上需要付出较大的额外代价。

  • 内存问题

        在64位CPU下,指针的宽度是64位的(更宽的寻址),而实际的Heap区域远远用不到这么大的内存,使用64bit来存对象引用会造成很大的浪费。其次,由于指针膨胀和各种数据类型对齐补白的原因,运行于64bit系统上的Java程序内存消耗会比32位的大1.5倍。

  • 运行效率问题

        64位虚拟机的运行速度远落后于32位虚拟机(大约有15%左右的性能差距)。

    在JDK1.6 Update14以后,提供了普通对象指针压缩功能(-XX:+ UseCompressedOops ⑤)。压缩对象指针(narrow-oop)是基于某个地址的偏移量,这个基础地址(narrow-oop-base)是由Java堆内存基址减去一个内存页的大小得来的,从而支持隐式空值检测。

    由于一个对象引用不管存取都必须经过虚拟机来参与。开启指针压缩后,存取对象引用时, 首先会检查是否开启了指针压缩(UseCompressedOops),当put field时提供的对象引用是64位的,经过虚拟机的转换映射到32位,然后存入对象;当get field指定目标对象的64位地址时,内部引用字段的偏移(基于base地址的差值),取到32位的数据,然后映射到64位内存地址。

//模板函数, 如果T是oop, 则访问的是8字节; 如果是narrowKlass, 则访问的是4字节
template <class T> T* oopDesc::obj_field_addr(int offset) const { return (T*)  field_base(offset); }

//模板函数, 这里有两个分支, 核心的转换函数是oopDesc::encode_store_heap_oop(p, v);
template <class T> void oop_store(T* p, oop v) {
  if (always_do_update_barrier) {
    oop_store((volatile T*)p, v);
  } else {
    update_barrier_set_pre(p, v);
    oopDesc::encode_store_heap_oop(p, v);
    // always_do_update_barrier == false =>
    // Either we are at a safepoint (in GC) or CMS is not used. In both
    // cases it's unnecessary to mark the card as dirty with release sematics.
    update_barrier_set((void*)p, v, false /* release */);  // cast away type
  }
}

//压缩指针版本, 调用了压缩函数
// Encode and store a heap oop allowing for null.
void oopDesc::encode_store_heap_oop(narrowOop* p, oop v) {
  *p = encode_heap_oop(v);
}

//判断null, 否则压缩
narrowOop oopDesc::encode_heap_oop(oop v) {
  return (is_null(v)) ? (narrowOop)0 : encode_heap_oop_not_null(v);
}

//核心压缩函数, 对象地址与base地址的差值, 再做移位
narrowOop oopDesc::encode_heap_oop_not_null(oop v) {
  assert(!is_null(v), "oop value can never be zero");
  assert(check_obj_alignment(v), "Address not aligned");
  assert(Universe::heap()->is_in_reserved(v), "Address not in heap");
  address base = Universe::narrow_oop_base();
  int    shift = Universe::narrow_oop_shift();
  uint64_t  pd = (uint64_t)(pointer_delta((void*)v, (void*)base, 1));
  assert(OopEncodingHeapMax > pd, "change encoding max if new encoding");
  uint64_t result = pd >> shift;
  assert((result & CONST64(0xffffffff00000000)) == 0, "narrow oop overflow");
  assert(decode_heap_oop(result) == v, "reversibility");
  return (narrowOop)result;
}

//核心解压缩函数, 压缩函数反过来, base地址加上对象起始地址的偏移
oop oopDesc::decode_heap_oop_not_null(narrowOop v) {
  assert(!is_null(v), "narrow oop value can never be zero");
  address base = Universe::narrow_oop_base();
  int    shift = Universe::narrow_oop_shift();
  oop result = (oop)(void*)((uintptr_t)base + ((uintptr_t)v << shift));
  assert(check_obj_alignment(result), "address not aligned: " INTPTR_FORMAT, p2i((void*) result));
  return result;
}

//普通指针encode版本, 直接解引用进行赋值
static inline void encode_store_heap_oop(oop* p, oop v) { *p = v; }

//普通指针decode版本, 直接返回值
static inline oop decode_heap_oop(oop v) { return v; }

    那么,既然压缩后的指针是32bit,使用指针压缩的最大堆是4G吗? 并非如此,由于对象是8字节对齐的,因此对象起始地址最低三位总是0,因此存储时可以右移3bit,高位空出来的3bit可以表示更高的数值。实际上,可以使用指针压缩的maxHeapSize是4G * 8 = 32G。这在逻辑上是可以理解的。开启指针压缩其实是使用算法开销带来内存节约,Java 对象都是以 8 字节对齐的,也就是以 8 字节为内存访问的基本单元,那么在具体处理上,就有 3 个位是空闲的,这 3 个位可以用来虚拟,利用 32 位的地址指针原本最多只能寻址 4GB,但是加上 3 个位的 8 种内部运算,就可以变化出 32GB 的寻址。

    启用CompressOops后,会被压缩的对象指针:

  • 每个Class的属性指针(静态成员变量);
  • 每个对象的属性指针;
  • 普通对象数组的每个元素指针。

    针对一些特殊类型的指针,JVM是不会优化的,比如:

  • 指向Class对象指针,这部分数据存放在PermGen(永久代)中;
  • 在解释器中,一般对象指针也是不压缩的,包括JVM本地变量和栈内元素、调用参数、返回值等;
  • 重要的C++数据不会被压缩:C++对象指针(this),受托管指针的句柄(Handle类型等),JNI句柄(jobject类型);

    但是,开启指针压缩会增加执行代码数量,因为所有在Java堆里的、指向Java堆内对象的指针都会被压缩,对这些指针的访问就需要更多的代码才可以实现,而且并不只是读写字段,在实例方法调用、子类型检查等操作也会受影响,因为对象实例指向对象类型的引用也被压缩了。

    零基压缩优化(Zero Based Compressd Oops),是针对压缩/解压动作的进一步优化。它通过改变正常指针的随机地址分配特性,强制基础地址为0(从零开始分配),理论上可以省去一次寄存器上的加和操作,只需使用移位操作。而且使用零基压缩技术后,空值检测也就不需要了。

    零基压缩技术会根据堆内存的大小以及平台特性来选择不同的策略:

  1. 堆内存小于4Gb,直接使用压缩对象指针进行寻址,无需压缩和解压;
  2. 堆内存大于4Gb,则尝试分配小于32Gb的堆内存,并使用零基压缩技术;
  3. 如果仍然失败,则使用普通的对象指针压缩技术,即narrow-oop-base

 

    ∷  总结一下,一个对象的内存布局是如何排布的?

    Class A { int i; byte b; String str; } 

    其中,对象头占用 ‘Mark Word’4byte + ‘类型指针’4 = 8 字节;int 32 位长,占用 4 字节;byte 8 位长,占用 1 字节;String 只有对象引用,占用 4 字节;那么对象 A 一共占用了 8+4+1+4=17 字节,按照 8 字节对齐原则,对象大小也就是 24 字节。

    这个计算看起来没有问题,对象的大小也确实是 24 字节,但是对齐(padding)的位置不对。在HotSpot VM 中,对象排布时,间隙是在 4 字节基础上的(在 32bit和64bit开启指针压缩模式下)。上述例子中,int 后面的 byte,空隙只剩下 3 字节,接下来的 String 对象引用需要 4 字节来存放,因此 byte 和对象引用之间就会有 3 字节对齐,对象引用排布后,最后会有 4 字节对齐,因此结果上依然是 7 字节对齐。此时对象的结构示意图,如下图所示:

 

三.  对象访问定位

    Java程序需要通过栈上的reference数据来操作堆上的具体对象。由于在Java虚拟机规范里面只规定了 reference类型是一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所以对象的访问方式也是取决于虚拟机实现而定的。目前主流的访问方式有使用句柄和直接指针两种。  

1.  句柄

    句柄,可以理解为指向指针的指针,维护指向对象的指针变化,而对象的句柄本身不发生变化;指针,指向对象,代表对象的内存地址。

    如果使用句柄来访问的话,那么Java堆中会划分出一块内存来作为句柄池,引用(reference)中存储对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。

句柄访问对象

    优势:引用中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而引用本身不需要修改。

2.  直接指针

    如果使用直接指针了访问的话,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而引用中存储的直接就是对象地址。

直接内存访问对象

    优势:速度更快,它节省了一次指针定位的时间开销。由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是非常可观的执行成本。(例如HotSpot)

 

注:

   ① Java堆是否规整由所采用的垃圾收集器是否带有压缩整理功能决定。

   ② 虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。

   ③ invokevirtual ---- 表示执行该实例的方法,会编译成:invokevirtual #xx (此时栈顶为类实例引用,#xx指向常量池中一个类型为MethodRef的位置);invokespecial ---- 表示执行父类的方法,子类构造方法和私有方法。

    ④ 锁存在于Java对象头里,锁标志位与是否偏向锁对应唯一的锁状态。

    ⑤ 一般对象指针 oop(ordinary object pointer)是HotSpot VM的一个术语,表示受托管的对象指针。它的大小与本地指针一致。Java应用程序和GC会跟踪这些受托管的指针,以便在销毁对象时回收内存空间,或是在对空间进行整理时移动(复制)对象。

 

参考自:

  • 《深入理解Java虚拟机》;
  • 《JVM高级特性与最佳实践》 ;
  • Java并发编程的艺术》;
  • 《Java特种兵》;
标签: JVM
共有 人打赏支持
粉丝 6
博文 29
码字总数 106686
×
多弗哥
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
* 金额(元)
¥1 ¥5 ¥10 ¥20 其他金额
打赏人
留言
* 支付类型
微信扫码支付
打赏金额:
已支付成功
打赏金额: