Java虚拟机14:Java对象大小、对象内存布局及锁状态变化
一个对象占多少字节?
关于对象的大小,对于C/C++来说,都是有sizeof函数可以直接获取的,但是Java似乎没有这样的方法。不过还好,在JDK1.5之后引入了Instrumentation类,这个类提供了计算对象内存占用量的方法。至于具体Instrumentation类怎么用就不说了,可以参看这篇文章如何精确地测量java对象的大小。
不过有一点不同的是,这篇文章使用命令行传入JVM参数来指定代理,这里我通过Eclipse设置JVM参数:
后面的是我打的agent.jar的具体路径。剩下的就不说了,看一下测试代码:
1 public class JVMSizeofTest { 2 3 @Test 4 public void testSize() { 5 System.out.println("Object对象的大小:" + JVMSizeof.sizeOf(new Object()) + "字节"); 6 System.out.println("字符a的大小:" + JVMSizeof.sizeOf('a') + "字节"); 7 System.out.println("整型1的大小:" + JVMSizeof.sizeOf(new Integer(1)) + "字节"); 8 System.out.println("字符串aaaaa的大小:" + JVMSizeof.sizeOf(new String("aaaaa")) + "字节"); 9 System.out.println("char型数组(长度为1)的大小:" + JVMSizeof.sizeOf(new char[1]) + "字节"); 10 } 11 12 }
运行结果为:
Object对象的大小:16字节
字符a的大小:16字节
整型1的大小:16字节
字符串aaaaa的大小:24字节
char型数组(长度为1)的大小:24字节
接着,代码不变,加入一条虚拟机参数"-XX:-UseCompressedOops",再运行一遍测试类,运行结果为:
Object对象的大小:16字节
字符a的大小:24字节
整型1的大小:24字节
字符串aaaaa的大小:32字节
char型数组(长度为1)的大小:32字节
后文来详细解释一下原因。
Java对象大小计算方法
JVM对于普通对象和数组对象的大小计算方式有所不同,我画了一张图说明:
解释一下其中每个部分:
- Mark Word:存储对象运行时记录信息,占用内存大小与机器位数一样,即32位机占4字节,64位机占8字节
- 元数据指针:指向描述类型的Klass对象(Java类的C++对等体)的指针,Klass对象包含了实例对象所属类型的元数据,因此该字段被称为元数据指针,JVM在运行时将频繁使用这个指针定位到位于方法区内的类型信息。这个数据的大小稍后说
- 数组长度:数组对象特有,一个指向int型的引用类型,用于描述数组长度,这个数据的大小和元数据指针大小相同,同样稍后说
- 实例数据:实例数据就是8大基本数据类型byte、short、int、long、float、double、char、boolean(对象类型也是由这8大基本数据类型复合而成),每种数据类型占多少字节就不一一例举了
- 填充:不定,HotSpot的对齐方式为8字节对齐,即一个对象必须为8字节的整数倍,因此如果最后前面的数据大小为17则填充7,前面的数据大小为18则填充6,以此类推
为了保证效率,Java编译期在编译Java对象的时候,通过字段类型对Java对象的字段会进行排序,具体顺序如下表所示:
了解这个是很有用的,我们可以通过在字段时间通过填充长整型变量的方式把热点变量隔离在不同的缓存行中,减少伪同步,在多核CPU中极大地提升效率,这个以后有机会写文章专门讲解。
最后再说说元数据指针的大小。元数据指针是一个引用类型,因此正常来说64位机元数据指针应当为8字节,32位机元数据指针应当为4字节,但是HotSpot中有一项优化是对元数据类型指针进行压缩存储,使用JVM参数:
- -XX:+UseCompressedOops开启压缩
- -XX:-UseCompressedOops关闭压缩
HotSpot默认是前者,即开启元数据指针压缩,当开启压缩的时候,64位机上的元数据指针将占据4个字节的大小。换句话说就是当开启压缩的时候,64位机上的引用将占据4个字节,否则是正常的8字节。
Java对象内存大小计算
有了上面的理论基础,我们就可以分析JVMSizeofTest类的执行结果及为什么加入了"-XX:-UseCompressedOops"这条参数后同一个对象的大小会有差异了。
首先是Object对象的大小:
- 开启指针压缩时,8字节Mark Word + 4字节元数据指针 = 12字节,由于12字节不是8的倍数,因此填充4字节,对象Object占据16字节内存
- 关闭指针压缩时,8字节Mark Word + 8字节元数据指针 = 16字节,由于16字节正好是8的倍数,因此不需要填充字节,对象Object占据16字节内存
接着是字符'a'的大小:
- 开启指针压缩时,8字节Mark Word + 4字节元数据指针 + 1字节char = 13字节,由于13字节不是8的倍数,因此填充3字节,字符'a'占据16字节内存
- 关闭指针压缩时,8字节Mark Word + 8字节元数据指针 + 1字节char = 17字节,由于17字节不是8的倍数,因此填充7字节,字符'a'占据24字节内存
接着是整型1的大小:
- 开启指针压缩时,8字节Mark Word + 4字节元数据指针 + 4字节int = 16字节,由于16字节正好是8的倍数,因此不需要填充字节,整型1占据16字节内存
- 关闭指针压缩时,8字节Mark Word + 8字节元数据指针 + 4字节int = 20字节,由于20字节正好是8的倍数,因此填充4字节,整型1占据24字节内存
接着是字符串"aaaaa"的大小,所有静态字段不需要管,只关注实例字段,String对象中实例字段有"char value[]"与"int hash",由此可知:
- 开启指针压缩时,8字节Mark Word + 4字节元数据指针 + 4字节引用 + 4字节int = 20字节,由于20字节不是8的倍数,因此填充4字节,字符串"aaaaa"占据24字节内存
- 关闭指针压缩时,8字节Mark Word + 8字节元数据指针 + 8字节引用 + 4字节int = 28字节,由于28字节不是8的倍数,因此填充4字节,字符串"aaaaa"占据32字节内存
最后是长度为1的char型数组的大小:
- 开启指针压缩时,8字节的Mark Word + 4字节的元数据指针 + 4字节的数组大小引用 + 1字节char = 17字节,由于17字节不是8的倍数,因此填充7字节,长度为1的char型数组占据24字节内存
- 关闭指针压缩时,8字节的Mark Word + 8字节的元数据指针 + 8字节的数组大小引用 + 1字节char = 25字节,由于25字节不是8的倍数,因此填充7字节,长度为1的char型数组占据32字节内存
Mark Word
Mark Word前面已经看到过了,它是Java对象头中很重要的一部分。Mark Word存储的是对象自身的运行数据,如哈希码(HashCode)、GC分代年龄、锁状态标识、线程持有的锁、偏向线程ID、偏向时间戳等等。
不过由于对象需要存储的运行时数据很多,其实已经超出了32位、64位Bitmap结构所能记录的限度,但是对象头是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息。例如在32位的HotSpot虚拟机中对象未被锁定的状态下,Mark Word的32个Bits空间中的25Bits用于存储对象哈希码(HashCode),4Bits用于存储对象分代年龄,2Bits用于存储锁标识位,1Bit固定位0。在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下图所示:
这里要特别关注的是锁状态,后文将对锁状态及锁状态的变化进行研究。
锁的升级
如上图所示,锁的状态共有四种:无锁态、偏向锁、轻量级锁和重量级锁,其中偏向锁和轻量级锁是JDK1.6开始为了减少获得锁和释放锁带来的性能消耗而引入的。
四种锁的状态会随着竞争情况逐渐升级,锁可以升级但是不能降级,意味着偏向锁可以升级为轻量级锁但是轻量级锁不能降级为偏向锁,目的是为了提高获得锁和释放锁的效率。用一张图表示这种关系:
偏向锁
HotSpot作者经过以往的研究发现大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代码更低因此引入了偏向锁。偏向锁的获取过程为:
- 访问Mark Word中偏向锁的标识是否设置为1,所标志位是否为01----确认为可偏向状态
- 如果为可偏向状态,则测试线程id是否指向当前线程,如果是,执行(5),否则执行(3)
- 如果线程id并为指向当前线程,通过CAS操作竞争锁。如果竞争成功,则将Mark Word中的线程id设置为当前线程id,然后执行(5);如果竞争失败,执行(4)
- 如果CAS获取偏向锁失败,则表示有竞争。当达到全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁(因为偏向锁是假设没有竞争,但是这里出现了竞争,要对偏向锁进行升级),然后被阻塞在安全点的线程继续往下执行同步代码
- 执行同步代码
有获取就有释放,偏向锁的释放点在于上述的第(4)步,只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的释放过程为:
- 需要等待全局安全点(在这个时间点上没有字节码正在执行)
- 它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态
- 偏向锁释放后恢复到未锁定(标识位为01)或轻量级锁(标识位为00)状态
轻量级锁
轻量级锁的加锁过程为:
- 在代码进入同步块的时候,如果同步对象锁状态为无锁状态,JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为Displaced Mark Word,此时线程堆栈与对象头的状态如图所示
- 拷贝对象头中的Mark Word复制到锁记录中
- 拷贝成功后,JVM将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向Object Mark Word,如果更新成功,则执行步骤(4),否则执行步骤(5)
- 如果更新动作成功,那么当前线程就拥有了该对象的锁,并且对象Mark Word的锁标识位设置为00,即表示此对象处于轻量级锁状态,此时线堆栈与对象头的状态如图所示
- 如果更新动作失败,JVM首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标识的状态值变为10,Mark Word中存储的就是指向重量级锁的指针,后面等待锁的线程也要进入阻塞状态。而当前线程变尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程
偏向锁、轻量级锁与重量级锁的对比
下面用一张表格来对比一下偏向锁、轻量级锁与重量级锁,网上看到的,我觉得写得非常好,为了加深记忆我自己又手打了一遍:
我不能保证写的每个地方都是对的,但是至少能保证不复制、不黏贴,保证每一句话、每一行代码都经过了认真的推敲、仔细的斟酌。每一篇文章的背后,希望都能看到自己对于技术、对于生活的态度。
我相信乔布斯说的,只有那些疯狂到认为自己可以改变世界的人才能真正地改变世界。面对压力,我可以挑灯夜战、不眠不休;面对困难,我愿意迎难而上、永不退缩。
其实我想说的是,我只是一个程序员,这就是我现在纯粹人生的全部。
==================================================================================