对象的内存布局
Hotspot 虚拟机中,对象在内存中的布局分为三块区域:
- 对象头(Header)
- 实例数据(Instance Data)
- 对齐填充(Padding)
对象头(Header)
对象头 Header 主要包含 MarkWord 和对象指针 Klass Pointer,如果是数组的话,还要包含数组的长度。
Mark Word
在 32 位的虚拟机中 MarkWord ,Klass Pointer 和数组长度分别占用 32 位,也就是 4 字节。
如果是 64 位虚拟机的话,MarkWord ,Klass Pointer 和数组长度分别占用 64 位,也就是 8 字节。
32 位虚拟机为例,来看一下其 Mark Word 的字节具体是如何分配的:
- 无状态也就是无锁的时候,对象头开辟 25 bit 的空间用来存储对象的 hashcode ,4 bit 用于存放分代年龄,1 bit 用来存放是否偏向锁的标识位,2 bit 用来存放锁标识位为 01;
- 偏向锁 中划分更细,还是开辟 25 bit 的空间,其中 23 bit 用来存放线程ID,2bit 用来存放 epoch,4bit 存放分代年龄,1 bit 存放是否偏向锁标识, 0 表示无锁,1 表示偏向锁,锁的标识位还是 01;
- 轻量级锁中直接开辟 30 bit 的空间存放指向栈中锁记录的指针,2bit 存放锁的标志位,其标志位为 00;
虚拟机首先将在当前线程的栈帧中建立一个名为锁记录 ( Lock Record)的空间,用于存储锁对象目前的MarkWord的拷贝( 官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word )。
然后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位( Mark Word的最后2bit ) 将转变为“00”即表示此对象处于轻量级锁定状态。
- 重量级锁中和轻量级锁一样,30 bit 的空间用来存放指向重量级锁的指针,2 bit 存放锁的标识位,为 11;
- GC标记开辟 30 bit 的内存空间却没有占用,2 bit 空间存放锁标志位为 11。
其中无锁和偏向锁的锁标志位都是 01,只是在前面的 1 bit 区分了这是无锁状态还是偏向锁状态。
是不是有同样的疑问:32位在对象不同状态下,存储数据不一样?
Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。
在32位的HotSpot虚拟机中,如果对象处于未被锁定的状态下,那么Mark Word的32bit空间中的25bit用干存储对象哈希码,4bit用于存储对象分代年龄,2bit用于存储锁标志位,1bit固定为0,而在其他状态( 轻量级锁定、重量级锁定、GC标记、可偏向 )
在上面的虚拟机对象头分配表中,我们可以看到有几种锁的状态:无锁(无状态)、偏向锁、轻量级锁、重量级锁。其中轻量级锁和偏向锁是 JDK1.6 中对 synchronized 锁进行优化后新增加的,其目的就是为了大大优化锁的性能,所以在 JDK 1.6 中,使用 synchronized 的开销也没那么大了。其实从锁有无锁定来讲,还是只有无锁和重量级锁,偏向锁和轻量级锁的出现就是增加了锁的获取性能而已,并没有出现新的锁。
所以我们的重点放在对 synchronized 重量级锁的研究上。当 monitor 被某个线程持有后,它就会处于锁定状态。在 HotSpot 虚拟机中,monitor 的底层代码是由 ObjectMonitor 实现的,其主要数据结构如下(位于 HotSpot 虚拟机源码 ObjectMonitor.hpp 文件,C++ 实现的) 这段 C++ 中需要注意几个属性:_WaitSet 、 _EntryList 和 _Owner,每个等待获取锁的线程都会被封装称为 ObjectWaiter 对象。
_Owner 是指向了 ObjectMonitor 对象的线程,而 _WaitSet 和 _EntryList 就是用来保存每个线程的列表。
那么这两个列表有什么区别呢?这个问题我和你聊一下锁的获取流程你就清楚了。
锁的两个列表
当多个线程同时访问某段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的 monitor 之后,就会进入 _Owner 区域,并把 ObjectMonitor 对象的 _Owner 指向为当前线程,并使 _count + 1,如果调用了释放锁(比如 wait)的操作,就会释放当前持有的 monitor ,owner = null, _count - 1,同时这个线程会进入到 _WaitSet 列表中等待被唤醒。如果当前线程执行完毕后也会释放 monitor 锁,只不过此时不会进入 _WaitSet 列表了,而是直接复位 _count 的值。
Klass Pointer
Klass Pointer 表示的是类型指针,也就是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
你可能不是很理解指针是个什么概念,你可以简单理解为指针就是指向某个数据的地址。
实例数据(Instance Data)
实例数据部分是对象真正存储的有效信息,也是代码中定义的各个字段的字节大小,比如一个 byte 占 1 个字节,一个 int 占用 4 个字节。
对齐填充(Padding)
对齐不是必须存在的,它只起到了占位符(%d, %c 等)的作用。这就是 JVM 的要求了,因为 HotSpot JVM 要求对象的起始地址必须是 8 字节的整数倍,也就是说对象的字节大小是 8 的整数倍,不够的需要使用 Padding 补全。举个例子:new出了一个对象,内存只占用18字节,但是规定要能被8整除,所以padding=6。
对象访问定位的方式
我们创建一个对象的目的当然就是为了使用它,但是,一个对象被创建出来之后,在 JVM 中是如何访问这个对象的呢?一般有两种方式:通过句柄访问和直接指针访问。
句柄访问
如果使用句柄访问方式的话,Java 堆中可能会划分出一块内存作为句柄池,引用(reference)【栈上的reference】中存储的是对象的句柄地址,而句柄中包含了对象的实例数据与类型数据各自具体的地址信息。如下图所示。
直接指针访问
如果使用直接指针访问的话,Java 堆中对象的内存布局就会有所区别,栈区引用指示的是堆中的实例数据的地址,如果只是访问对象本身的话,就不会多一次直接访问的开销,而对象类型数据的指针是存在于方法区中,如果定位的话,需要多一次直接定位开销。如下图所示:
句柄访问和直接指针访问对比
这两种对象访问方式各有各的优势,使用句柄最大的好处就是引用中存储的是句柄地址,对象移动时只需改变句柄的地址就可以,而无需改变对象本身。
使用直接指针来访问速度更快,它节省了一次指针定位的时间开销,由于对象访问在 Java 中非常频繁,因为这类的开销也是值得优化的地方。
虚拟机Sun HotSpot而言,它是使用直接指针进行对象访问。
对象的创建
在语言层面上,创建对象通常仅仅是一个new关键字而已在虚拟机中,普通Java对象的创建又是怎样一个过程呢 ?
新生对象分配内存
虚拟机遇到一条new指合时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。类加载过程查看另外一篇文章:JVM-类加载过程&常见问题
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定。为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。
根据Java堆规整情况为对象分配空间分为一下两种方式:
- 假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”( Bump the Pointer )。
- 如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表” FreeList)。
选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞;
使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。
但是对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决这个问题有两种方案:
- 对分配内存空间的动作进行同步处理实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性:
- 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲( Thread Local Allocation Buffer,TLAB )。哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。虚拟机是否使用TLAB,可以通过
-XX: +/-UseTLAB
参数来设定。
如何查看是否使用TLAB分配方式?
JDK1.8为例,查询应用配置:
# 14372 为应用ID
$ jinfo -flag UseTLAB 14372
-XX:+UseTLAB
查看JDK默认分配方式:
$ java -XX:+PrintFlagsFinal -version|grep UseTLAB
bool UseTLAB = true {pd product}
java version "1.8.0_291"
Java(TM) SE Runtime Environment (build 1.8.0_291-b10)
Java HotSpot(TM) Client VM (build 25.291-b10, mixed mode, sharing)
由此可见,默认使用TLAB分配方式。
对象字段赋值
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值( 不包括对象头 )。
如果使用TLAB,这一作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值
接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。
这些信息存放在对象的对象头( Obiect Header )之中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从Java程序的视角来看,对象创建才刚刚开始– 方法还没有执行,所有的字段都还为零。所以,一般来说执行new指合之后会接着执行< init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
对象的创建总结
总结,对象分配步骤如下:
- 为对象分配内存;
- 将分配到的内存空间都初始化为零值;
- 设置对象类、哈希码、GC分代年龄等信息;
- 执行init方法(构造函数)。
new Object对象大小为多少字节?
New出一个object对象,占用16个字节。对象头占用12字节,由于Object中没有额外的变量,所以instance = 0,考虑要对象内存大小要被8字节整除,那么padding=4,最后new Object() 内存大小为16字节。
实战:
引入依赖:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-cli</artifactId>
<version>0.16</version>
</dependency>
代码:
public class ObjectExample {
public static void main(String[] args) throws Exception {
//打印Object大小
printObjectSize();
}
public static void printObjectSize(){
Object obj = new Object();
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}
}
JOL工具打印对象信息:
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 90 f4 61 02 (10010000 11110100 01100001 00000010) (39974032)
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