java对象在内存中的结构(HotSpot虚拟机)
一、对象的内存布局
HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
32位:
64位:
从上面的这张图里面可以看出,对象在内存中的结构主要包含以下几个部分:
- 对象头:
- Mark Word(标记字段):关于锁的信息。对象的Mark Word部分占4个字节/8个字节,表示对象的锁状态(比如轻量级锁的标记位,偏向锁标记位),另外还可以用来配合GC分代年龄、存放该对象的hashCode等。
- Klass Pointer(Class对象指针):Class对象指针的大小也是4个字节/8个字节,其指向的位置是对象对应的Class对象(其对应的元数据对象)的内存地址。
- 数组长度:如果对象是数组类型,占用4个字节/8个字节,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
- Instance Data(对象实际数据):这里面包括了对象的所有成员变量,其大小由各个成员变量的大小决定,比如:byte和boolean是1个字节,short和char是2个字节,int和float是4个字节,long和double是8个字节,reference是4个字节。
- padding data(对齐):如果上面的数据所占用的空间不能被8整除,padding则占用空间凑齐使之能被8整除。被8整除在读取数据的时候会比较快
1.2、对象头
1.2.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标记、可偏向)下对象的存储内容如下表所示。
但是如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化,变化状态如下(32位虚拟机):
我们通常说的通过synchronized实现的同步锁,真实名称叫做重量级锁。但是重量级锁会造成线程排队(串行执行),且会使CPU在用户态和核心态之间频繁切换,所以代价高、效率低。为了提高效率,不会一开始就使用重量级锁,JVM在内部会根据需要,按如下步骤进行锁的升级:
- 初期锁对象刚创建时,还没有任何线程来竞争,对象的Mark Word是下图的第一种情形,这偏向锁标识位是0,锁状态01,说明该对象处于无锁状态(无线程竞争它)。
- 当有一个线程来竞争锁时,先用偏向锁,表示锁对象偏爱这个线程,这个线程要执行这个锁关联的任何代码,不需要再做任何检查和切换,这种竞争不激烈的情况下,效率非常高。这时Mark Word会记录自己偏爱的线程的ID,把该线程当做自己的熟人。如下图第二种情形。
- 当有两个线程开始竞争这个锁对象,情况发生变化了,不再是偏向(独占)锁了,锁会升级为轻量级锁,两个线程公平竞争,哪个线程先占有锁对象并执行代码,锁对象的Mark Word就执行哪个线程的栈帧中的锁记录。如下图第三种情形。
- 如果竞争的这个锁对象的线程更多,导致了更多的切换和等待,JVM会把该锁对象的锁升级为重量级锁,这个就叫做同步锁,这个锁对象Mark Word再次发生变化,会指向一个监视器对象,这个监视器对象用集合的形式,来登记和管理排队的线程。如下图第四种情形。
JVM就根据Mark Word上32/64个bit的不同的值,把我设计为一个多功能的复用器,在bit标志位不同的时候表示的意思也不一样,我前面30bit位可能表示的意思不一样,但是最后2个bit表示的都是锁模式,对象状态分为5种状态(无锁、偏向锁、轻量锁、重量锁、GC标记)用2个bit(2bit最多只能表示4种状态,分别是:00,01,10,11),那么JVM是怎么做到呢?答案是:JVM把偏向锁和无锁状态表示为同一个状态,然后根据图中偏向锁的标识(锁标识位前面的一个bit)再去标识是无锁还是偏向锁状态。
的32位和64位的分别如下:
32位虚拟机:
64位虚拟机:
复习一下锁内容:偏向锁、轻量级锁、重量级锁等都是jdk 1.6以后引入的。
偏向锁Biased Locking:Java6引入的一项多线程优化,偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁。
如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。
自旋锁:自旋锁的目的是为了占着CPU的资源不释放,等到获取到锁立即进行处理。一直在自旋也是占用CPU的,如果自旋的线程非常多,自旋次数也非常大CPU可能会跑满,所以需要升级。
重量级锁:内核态的锁,资源开销较大。内部会将等待中的线程进行wait处理,防止消耗CPU。
1.2.2、Klass Word(类指针)
对象头的另外一部分是类型指针,这一部分用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例。该指针的位长度为JVM的一个字大小,即32位的JVM为32位,64位的JVM为64位。如果应用的对象过多,使用64位的指针将浪费大量内存,统计而言,64位的JVM将会比32位的JVM多耗费50%的内存。为了节约内存可以使用选项+UseCompressedOops开启指针压缩,其中,oop即ordinary object pointer普通对象指针。开启该选项后,下列指针将压缩至32位:
- 每个Class的属性指针(即静态变量)
- 每个对象的属性指针(即对象变量)
- 普通对象数组的每个元素指针
当然,也不是所有的指针都会压缩,一些特殊类型的指针JVM不会优化,比如指向PermGen的Class对象指针(JDK8中指向元空间的Class对象指针)、本地变量、堆栈元素、入参、返回值和NULL指针等。
1.2.3、数组长度
另外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中无法确定数组的大小。 这部分数据的长度也随着JVM架构的不同而不同:32位的JVM上,长度为32位;64位JVM则为64位。64位JVM如果开启+UseCompressedOops选项,该区域长度也将由64位压缩至32位。
以下是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)
1.2.4、使用JOL展示对象头信息
A、字节压缩对比示例
我们先了解一下Java对象在内存中的(详细)布局,这个布局与Java锁的实现息息相关。使用工具:OpenJDK里有一个jol(java object layout)的jar包。可以输出对象的信息。
引入jar
ompile 'org.openjdk.jol:jol-core:0.9'
java源码
import org.openjdk.jol.info.ClassLayout;
public class ShowJOL {
public static void main(String[] args) {
Object o = new Object();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
输出
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
Process finished with exit code 0
说明:
- OFFSET:从第几个位置开始
- size:大小,单位字节,
- TYPE DESCRIPTION:类型描述,上面的示例就是object header对象头,
- VALUE:值
- loss due to the next object alignment: 由于下一次对象对齐而造成的损失,所以这另外的4个字节并不属于类信息,而是对齐填充造成的。
看上面JOL打印出来的数据,第一个和第二个都是markword各 4个字节,第三个是class pointer4个字节,本来还有 instance data 用来存成员变量的但是我们写的没有所以为0,这些总共加起来12个字节不能被8整除,所以我们要对齐加4个字节。对象头的长度为什么是12位?
还要注意2点:
- 注这里的内存占用是默认开启字节压缩-XX:+UseCompressedClassPointers -XX:+UseCompressedOops
- 如何看字节对应的内容:因为存储是分大端、小段存储的。小端存储是反着来存储的(也就是高地址低字节),大端存储是顺着来存(高地址高字节)。所以这里是反向存储的。--详细见《详解大端模式和小端模式》
关闭字节压缩-XX:-UseCompressedClassPointers -XX:-UseCompressedOops,再对比同样的示例打印结果:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 00 1c ae 1c (00000000 00011100 10101110 00011100) (481172480)
12 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
Instance size: 16 bytes
锁标志是上面标红的部分,01:表示无锁
B、有hashCode的对比
public static void main(String[] args) { Object o = new Object(); o.hashCode(); System.out.println("hex:" + Integer.toHexString(o.hashCode())); System.out.println(ClassLayout.parseInstance(o).toPrintable()); }
输出:
hex:15db9742 java.lang.Object object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 01 42 97 db (00000001 01000010 10010111 11011011) (-610844159) 4 4 (object header) 15 00 00 00 (00010101 00000000 00000000 00000000) (21) 8 4 (object header) 00 1c 80 1c (00000000 00011100 10000000 00011100) (478157824) 12 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) Instance size: 16 bytes
hashCode的打印也是倒序的--小端模式
C、有锁的情况对象头信息与上面无锁对比
再对比一个示例,测试synchronized,同样是关闭压缩(-XX:+UseCompressedClassPointers -XX:+UseCompressedOops)
public static void main(String[] args) { Object o = new Object(); o.hashCode(); System.out.println("hex:" + Integer.toHexString(o.hashCode())); synchronized (o) { System.out.println(ClassLayout.parseInstance(o).toPrintable()); } }
输出:
hex:15db9742
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) b8 f3 94 02 (10111000 11110011 10010100 00000010) (43316152)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 00 1c c8 1b (00000000 00011100 11001000 00011011) (466099200)
12 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
Instance size: 16 bytes
对比这个的输出和第一次我们打印的输出,
1、第一、二行,我们可以得出结论synchronized锁的信息是记录在markword上。还有,这个时候markword的其它位置不在存放hashCode的信息了,而是存放“指向锁记录的指针(锁记录在哪个线程的栈空间内,则哪个线程获取了锁)”,00:是一个轻量级锁。
2、第三、四行,Klass Pointer(Class对象指针)部分和上面的保持不变。
疑问:该程序只有一个线程去调用synchronized方法,按理应该是一个偏向锁,但是从实际结果看确实一个轻量级锁。
JVM对synchronized进行优化时,针对竞争程度,有区别的使用锁。
- 无锁:当竞争程度较低,使用CAS获取或者修改共享资源。
- 偏向锁:只有一个线程时,当该线程来获取锁,直接把锁给该线程。
- 轻量级锁:偏向锁时,有其他线程来竞争时,会升级为轻量级锁,其他线程自旋获取锁。
- 重量级锁:轻量级锁时,有其他线程来竞争时,会升级为重量级锁,其他线程进入阻塞状态。
根据规则,上面的单线程是偏向锁,单打印的是轻量级锁,还是没搞明白,哎。。。
D、重量级锁对比
public static void main(String[] args) throws InterruptedException { //TimeUnit.SECONDS.sleep(5); 或者-XX:-UseCompressedOops -XX:-UseCompressedClassPointers -XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0 //Object o = new Object(); ShowJOL o = new ShowJOL(); o.hashCode(); System.out.println("hex:" + Integer.toHexString(o.hashCode())); System.out.println("刚开始,无锁"); System.out.println(ClassLayout.parseInstance(o).toPrintable()); //无锁 Thread t1 = new Thread() { public void run() { try { synchronized (o) { TimeUnit.SECONDS.sleep(10); System.out.println("thread1 release lock."); } }catch (Exception e) { } } }; t1.start(); TimeUnit.SECONDS.sleep(1); System.out.println("thread1 lock,轻量级锁"); System.out.println(ClassLayout.parseInstance(o).toPrintable()); System.out.println("main lock,重量级锁"); synchronized (o) { System.out.println(ClassLayout.parseInstance(o).toPrintable()); } System.out.println("after main lock,还是重量级锁"); System.out.println(ClassLayout.parseInstance(o).toPrintable()); System.gc(); System.out.println("after gc,变成无锁"); System.out.println(ClassLayout.parseInstance(o).toPrintable()); }
输出:
hex:15db9742 刚开始,无锁 gateway.ShowJOL object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 01 42 97 db (00000001 01000010 10010111 11011011) (-610844159) 4 4 (object header) 15 00 00 00 (00010101 00000000 00000000 00000000) (21) 8 4 (object header) 28 07 92 1c (00101000 00000111 10010010 00011100) (479332136) 12 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) Instance size: 16 bytes Space losses: 0 bytes internal + 0 bytes external = 0 bytes total thread1 lock,轻量级锁 gateway.ShowJOL object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 70 f0 ba 21 (01110000 11110000 10111010 00100001) (565899376) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) 28 07 92 1c (00101000 00000111 10010010 00011100) (479332136) 12 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) Instance size: 16 bytes Space losses: 0 bytes internal + 0 bytes external = 0 bytes total main lock,重量级锁 thread1 release lock. gateway.ShowJOL object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) ca ac f1 1c (11001010 10101100 11110001 00011100) (485600458) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) 28 07 92 1c (00101000 00000111 10010010 00011100) (479332136) 12 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) Instance size: 16 bytes Space losses: 0 bytes internal + 0 bytes external = 0 bytes total after main lock,还是重量级锁 gateway.ShowJOL object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) ca ac f1 1c (11001010 10101100 11110001 00011100) (485600458) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) 28 07 92 1c (00101000 00000111 10010010 00011100) (479332136) 12 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) Instance size: 16 bytes Space losses: 0 bytes internal + 0 bytes external = 0 bytes total after gc,变成无锁 gateway.ShowJOL object internals: OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 09 42 97 db (00001001 01000010 10010111 11011011) (-610844151) 4 4 (object header) 15 00 00 00 (00010101 00000000 00000000 00000000) (21) 8 4 (object header) 28 07 92 1c (00101000 00000111 10010010 00011100) (479332136) 12 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) Instance size: 16 bytes Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
重量级锁:
这里我们主要分析一下重量级锁也就是通常说synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)
ObjectMonitor() { _header = NULL; _count = 0; //记录个数 _waiters = 0, _recursions = 0; _object = NULL; _owner = NULL; _WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet _WaitSetLock = 0 ; _Responsible = NULL ; _succ = NULL ; _cxq = NULL ; FreeNext = NULL ; _EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表 _SpinFreq = 0 ; _SpinClock = 0 ; OwnerIsThread = 0 ; }
ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示
由此看来,monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因(关于这点稍后还会进行分析),ok~,有了上述知识基础后,下面我们将进一步分析synchronized在字节码层面的具体语义实现。
1.3、实例数据(Instance Data)
接下来实例数据部分是对象真正存储的有效信息,也既是我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的都需要记录下来。 这部分的存储顺序会受到虚拟机分配策略参数(FieldsAllocationStyle)和字段在Java源码中定义顺序的影响。HotSpot虚拟机 默认的分配策略为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),从分配策略中可以看出,相同宽度的字段总是被分配到一起。在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果 CompactFields参数值为true(默认为true),那子类之中较窄的变量也可能会插入到父类变量的空隙之中。
1.4、对齐填充(Padding)
第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍。对象头正好是8字节的倍数(1倍或者2倍),因此当对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。
二、对象的创建过程
Java是一门面向对象的编程语言,Java程序运行过程中无时无刻都有对象被创建出来。在语言层面上,创建对象通常(例外:克隆、反序列化)仅仅是一个 new关键字而已,而在虚拟机中,对象(本文中讨论的对象限于普通Java对象,不包括数组和Class对象等)的创建又是怎样一个过程呢?
虚拟机遇到一条new指令时,
1、首先jvm要检查类A是否已经被加载到了内存,即类的符号引用是否已经在常量池中,并且检查这个符号引用代表的类是否已被加载、解析和初始化过的。如果还没有,需要先触发类的加载、解析、初始化。然后在堆上创建对象。
2、为新生对象分配内存。
对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务具体便等同于一块确定大小 的内存从Java堆中划分出来,怎么划呢?假设Java堆中内存是绝对规整的,所有用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作 为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump The Pointer)。如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错,那就没有办法简单的进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。因 此在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的 收集器时(说明一下,CMS收集器可以通过UseCMSCompactAtFullCollection或 CMSFullGCsBeforeCompaction来整理内存),就通常采用空闲列表。
除如何划分可用空间之外,还有另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是 线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存。解决这个问题有两个方案,一种是对分配内存空 间的动作进行同步——实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性;另外一种是把内存分配的动作按照线程划分在不同的空间之中进行, 即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲区,(TLAB ,Thread Local Allocation Buffer),哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完,分配新的TLAB时才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。
3. 完成实例数据部分的初始化工作(初始化为0值)
内存分配完成之后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB的话,这一个工作也可以提前至TLAB分配时进行。这 步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
4、 完成对象头的填充:如对象自身的运行时数据、类型指针等。
接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
在上面工作都完成之后,在虚拟机的视角来看,一个新的对象已经产生了。但是在Java程序的视角看来,初始化才正式开始,开始调用<init>方法完成初始复制和构造函数,所有的字段都为零值。因此一般来说(由字节码中是否跟随有invokespecial指令所决定),new指令之后会接着就是执 行<init>方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全创建出来。
下面代码是HotSpot虚拟机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); } } }
三、对象的访问定位
建立对象是为了使用对象,我们的Java程序需要通过栈上的reference数据来操作堆上的具体对象。由于reference类型在Java虚拟机规范里面只规定了是一个指向对象的引用,并没有定义这个引用应该通过什么种方式去定位、访问到堆中的对象的具体位置,对象访问方式也是取决于虚拟机实现而定的。主流的访问方式有使用句柄和直接指针两种。
如果使用句柄访问的话,Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据的具体各自的地址信息。如图1所示。
图1 通过句柄访问对象
如果使用直接指针访问的话,Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如图2所示。
图2 通过直接指针访问对象
这两种对象访问方式各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。
使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问的在Java中非常频繁,因此这类开销积小成多也是一项非常可观的执行成本。从上一部分讲解的对象内存布局可以看出,就虚拟机HotSpot而言,它是使用第二种方式进行对象访问,但在整个软件开发的范围来看,各种语言、框架中使用句柄来访问的情况也十分常见。
四、示例
在Hotspot JVM中,32位机器下,Integer对象的大小是int的几倍?
我们都知道在Java语言规范已经规定了int的大小是4个字节,那么Integer对象的大小是多少呢?要知道一个对象的大小,那么必须需要知道对象在虚拟机中的结构是怎样的,根据上面的图,那么我们可以得出Integer的对象的结构如下:
Integer只有一个int类型的成员变量value,所以其对象实际数据部分的大小是4个字节,然后再在后面填充4个字节达到8字节的对齐,所以可以得出Integer对象的大小是16个字节。
因此,我们可以得出Integer对象的大小是原生的int类型的4倍。
关于对象的内存结构,需要注意数组的内存结构和普通对象的内存结构稍微不同,因为数据有一个长度length字段,所以在对象头后面还多了一个int类型的length字段,占4个字节,接下来才是数组中的数据,如下图:
关于对象内存布局更多的内容,可以看这篇文章:Java Objects Memory Structure
synchronized