详解Java的对象
发布日期:2021-06-29 02:39:37 浏览次数:2 分类:技术文章

本文共 6720 字,大约阅读时间需要 22 分钟。

一、对象的内存布局

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

32位的JVM中对象的内存结构图:
在这里插入图片描述
从上面的这张图里面可以看出,对象在内存中的结构主要包含以下几个部分:

对象头

Mark Word(标记字段):对象的Mark Word部分占4个字节,其内容是一系列的标记位,比如轻量级锁的标记位,偏向锁标记位等等。
Klass Pointer(Class对象指针):Class对象指针的大小也是4个字节,其指向的是对象对应的Class对象(即对象对应的元数据对象)的内存地址。
实例数据:这里面包括了对象的所有成员变量,其大小由各个成员变量的大小决定,比如:byte和boolean是1个字节,short和char是2个字节,int和float是4个字节,long和double是8个字节,reference是4个字节。
对齐填充:最后一部分是对齐填充的字节,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全,使得对象的大小是8字节的整数倍。

关于对象的内存结构,需要注意数组的内存结构和普通对象的内存结构稍微不同,因为数据有一个长度length字段,所以对象头还多了一个int类型的length字段,占4个字节,接下来才是数组中的数据,如下图:

在这里插入图片描述

1.1、对象头(Header)

如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即32bit,如表2-2所示。

在这里插入图片描述

1.1.1、Mark Word(标记字段)

HotSpot虚拟机的对象头包括两部分信息,第一部分是“Mark Word”,用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,这部分数据的长度在32位和64位的虚拟机(暂不考虑开启压缩指针的场景)中分别为32个和64个Bits,官方称它为“Mark Word”。对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机中对象未被锁定的状态下,Mark Word的32个Bits空间中的25Bits用于存储对象哈希码(HashCode),4Bits用于存储对象分代年龄,2Bits用于存储锁标志位,1Bit固定为0,如下表所示:

在这里插入图片描述

在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下Mark Word的存储内容如下表所示:

在这里插入图片描述

对象头的Mark Word在各个状态下的存储内容总结:
在这里插入图片描述

以下是HotSpot虚拟机markOop.cpp中的C++代码(注释)片段,它描述了32bits下MarkWord的存储状态:

// Bit-format of an object header (most significant first, big endian layout below):  //  //  32 bits:  //  --------  //  hash:25 ------------>| age:4    biased_lock:1 lock:2 (normal object)  //  JavaThread*:23 epoch:2 age:4    biased_lock:1 lock:2 (biased object)  //  size:32 ------------------------------------------>| (CMS free block)  //  PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
偏向锁、轻量级锁、重量级锁

偏向锁、轻量级锁、重量级锁等都是jdk 1.6以后引入的。

在这里插入图片描述
其中轻量级锁和偏向锁是Java 6 对 synchronized 锁进行优化后新增加的,稍后我们会简要分析。这里我们主要分析一下重量级锁也就是通常说synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的):

//openjdk\hotspot\src\share\vm\runtime\objectMonitor.hppObjectMonitor() {
_header = NULL; //markOop对象头 _count = 0; //记录个数 _waiters = 0, //等待线程数 _recursions = 0; //重入次数 _object = NULL; //监视器锁寄生的对象。锁不是平白出现的,而是寄托存储于对象中。 _owner = NULL; //指向获得ObjectMonitor对象的线程或基础锁 _WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet _WaitSetLock = 0 ; _Responsible = NULL ; _succ = NULL ; _cxq = NULL ; FreeNext = NULL ; _EntryList = NULL ; //处于block状态(等待锁)的线程,会被加入到_EntryList _SpinFreq = 0 ; _SpinClock = 0 ; OwnerIsThread = 0 ; // _owner is (Thread *) vs SP/BasicLock _previous_owner_tid = 0; // 监视器前一个拥有者线程的ID }

ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的_owner变量设置为当前线程,同时monitor中的计数器_count加1,若线程调用 wait() 方法,将释放当前持有的monitor,_owner变量恢复为null,_count自减1,同时该线程进入 _WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor并复位变量的值,以便其他线程进入获取monitor。如下图所示:

在这里插入图片描述

由此看来,monitor对象存在于每个Java对象的对象头中(对象头中存储的指向monitor对象的指针),synchronized锁便是通过这种方式获取锁的,这也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因(关于这点稍后还会进行分析)。

更多synchronized原理相关分析可参见:

1.1.2、Klass Pointer(Class对象指针)

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

1.1.3、Array Length(数组长度)

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

1.2、实例数据(Instance Data)

接下来的实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。这部分的存储顺序会受到虚拟机分配策略参数( FieldsAllocationStyle ) 和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配策略为longs/doubles、ints、shorts/chars、bytes/booleans、 oops ( Ordinary Object Pointers ) ,从分配策略中可以看出,相同宽度的字段总是被分配到一起。在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果CompactFields参数值为true ( 默认为true ) ,那么子类之中较窄的变量也可能会插入到父类变量的空隙之中。

1.3、对齐填充(Padding)

第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。 由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说, 就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍 ),因此 ,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

二、对象的创建过程

Java是一门面向对象的编程语言,在Java程序运行过程中无时无刻都有对象被创建出来。在语言层面上,创建对象(例如克隆、反序列化)通常仅仅是一个new关键字而巳,而在虚拟机中,对象(文中讨论的对象限于普通Java对象,不包括数组和Class对象等)的创建又是怎样一个过程呢?

虚拟机遇到一条new指令时,

  1. 苜先检査参数在常量池中的符号引用
    苜先将去检査这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检査这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
  2. 为新生对象分配内存
    在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。
  3. 完成对象的实例数据的初始化工作(初始化为零值)
    内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB ,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
  4. 完成对象头的设置
    接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
  5. 执行<init>方法,进行对象的初始化
    在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生,但从java程序的视角来看,对象创建才刚刚开始——<init>方法还没有执行,所有的字段都还为零。所以,一般来说(由字节码中是否跟随invokespecial指令所决定),执行new指令之后会接着执行<init>方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

三、对象的访问定位

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

  • 如果使用句柄访问的话,那么Java堆中将会划分出一块内存来作为句池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息,如图2-2所示。
    在这里插入图片描述
  • 如果使用直接指针访问,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址,如图2-3所示。
    在这里插入图片描述

这两种对象访问方式各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。

使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。就本书讨论的主要虚拟机Sun HotSpot而言,它是使用第二种方式进行对象访问的,但从整个软件开发的范围来看,各种语言和框架使用句柄来访问的情况也十分常见。

四、Java对象到底占用多大内存

  1. 无论是32位还是64位的HotSpot,使用的都是8字节对齐。也就是说每个java对象,占用的字节数都是8的整数倍。(对象头 + 实例数据 + padding) % 8等于0且0 <= padding < 8。
  2. 基本数据类型占用的字节数,JVM规范中有明确的规定,无论是在32位还是64位的虚拟机,占用的内存大小是相同的。
  3. reference类型在32位JVM下占用4个字节,但是在64位下可能占用4个字节或8个字节,这取决于是否启用了64位JVM的指针压缩参数UseCompressedOops。
  4. new Object()这个对象在32位JVM上占8个字节,在64位JVM上占16个字节。
  5. 64位JVM上,普通对象,关闭指针压缩(-XX:-UseCompressedOops),对象头占16字节(8字节MarkWord+8字节类型指针),开启指针压缩(-XX:+UseCompressedOops),对象头占12字节(8字节MarkWord+4字节类型指针)。
  6. 64位JVM上,数组对象的对象头占用24个字节(8字节MarkWord+8字节类型指针+8字节数组长度),启用压缩之后占用16个字节(8字节MarkWord+4字节类型指针+4字节数组长度)。之所以比普通对象占用内存多是因为需要额外的空间存储数组的长度。
  7. 对象内存布局中的实例数据,不包括类的static字段的大小,因为static字段是属于类的,被该类的所有对象共享。

五、示例

在Hotspot JVM中,32位机器下,Integer对象的大小是int的几倍?

我们都知道在Java语言规范已经规定了int的大小是4个字节,那么Integer对象的大小是多少呢?要知道一个对象的大小,那么必须需要知道对象在虚拟机中的结构是怎样的,根据上面的图,那么我们可以得出Integer的对象的结构如下:

在这里插入图片描述
Integer只有一个int类型的成员变量value,所以其对象实例数据部分的大小是4个字节,然后再在后面填充4个字节达到8字节的对齐,所以可以得出Integer对象的大小是16个字节。

因此,我们可以得出Integer对象的大小是原生的int类型的4倍。

参考:

[1] Java特种兵,3.5 - 浅析Java对象的内存结构
[2] Java并发编程的艺术,2.2.1 - Java对象头
[3] 深入理解Java虚拟机,2.3.2 - 对象的内存布局

转载地址:https://blog.csdn.net/yzpbright/article/details/105450833 如侵犯您的版权,请留言回复原文章的地址,我们会给您删除此文章,给您带来不便请您谅解!

上一篇:Java并发编程的艺术 2.2 synchronized的实现原理与应用
下一篇:深入理解Java虚拟机 2.3 HotSpot虚拟机对象探秘

发表评论

最新留言

做的很好,不错不错
[***.243.131.199]2024年04月24日 01时12分15秒