HotSpot虚拟机对象

HotSpot 虚拟机对象

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

1.对象的内存布局

HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:

  • 对象头(Header)

  • 实例数据(Instance Data)

  • 对齐填充(Padding)

2.JVM(64位)对象头内部组成

下图可以更清晰的看到对象头的内部组成:

2.偏向锁、轻量级锁和重量级锁在对象头中的标识:

  • 2bit的锁标志位表示锁的状态

  • 1bit的偏向锁标志位表示是否偏向

1.无锁状态

2.偏向锁状态

3.对象的创建过程

3.1 类加载检查

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

3.2 为新生对象分配内存

对象所需内存的大小在类加载完成后便可完全确定,接下来从堆中划分一块对应大小的内存空间给新的对象。分配堆中内存有两种方式:

  • 指针碰撞 如果 Java 堆中内存绝对规整(说明采用的是“复制算法”或“标记整理法”),空闲内存和已使用内存中间放着一个指针作为分界点指示器,那么分配内存时只需要把指针向空闲内存挪动一段与对象大小一样的距离,这种分配方式称为“指针碰撞”。
  • 空闲列表 如果 Java 堆中内存并不规整,已使用的内存和空闲内存交错(说明采用的是标记-清除法,有碎片),此时没法简单进行指针碰撞, VM 必须维护一个列表,记录其中哪些内存块空闲可用。分配之时从空闲列表中找到一块足够大的内存空间划分给对象实例。这种方式称为“空闲列表”。

3.3 初始化

分配完内存后,为对象中的成员变量赋上初始值,设置对象头信息,调用对象的构造函数方法进行初始化。

4.对象的访问方式

所有对象的存储空间都是在堆中分配的,但是这个对象的引用却是在堆栈中分配的。也就是说在建立一个对象时两个地方都分配内存,在堆中分配的内存实际建立这个对象,而在堆栈中分配的内存只是一个指向这个堆对象的指针(引用)而已。 那么根据引用存放的地址类型的不同,对象有不同的访问方式。

4.1 句柄访问方式

堆中需要有一块叫做“句柄池”的内存空间,句柄中包含了对象实例数据与类型数据各自的具体地址信息。

引用类型的变量存放的是该对象的句柄地址(reference)。访问对象时,首先需要通过引用类型的变量找到该对象的句柄,然后根据句柄中对象的地址找到对象。

4.2 直接指针访问方式

引用类型的变量直接存放对象的地址,从而不需要句柄池,通过引用能够直接访问对象。但对象所在的内存空间需要额外的策略存储对象所属的类信息的地址。

需要说明的是,HotSpot 采用第二种方式,即直接指针方式来访问对象,只需要一次寻址操作,所以在性能上比句柄访问方式快一倍。但像上面所说,它需要额外的策略来存储对象在方法区中类信息的地址。

5.ClassLayouDemo

5.1 maven引入依赖

1
2
3
4
5
6
<!--classLayout:帮助打印对象的布局-->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>

5.2 ClassLayouDemo.class

1
2
3
4
5
6
7
8
9
public class ClassLayoutDemo {

public static void main(String[] args) {
//构建了一个对象实例
ClassLayoutDemo classLayoutDemo = new ClassLayoutDemo();
System.out.println(ClassLayout.parseInstance(classLayoutDemo).toPrintable());
}
}

5.3 out

1
2
3
4
5
6
7
8
9
10
com.xxx.xxx.example.ClassLayoutDemo object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
//Mark Word
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)
//Klass Pointer
8 4 (object header) 05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

一共是16个字节,其中对象头是12个字节,还有4个对齐字节(因为64位虚拟机规定:对象的大小必须是8的倍数),由于这个对象里没有任何属性和方法,所以对象的实例数据为0个字节,如果添加一个boolean字段.

1
2
3
4
5
6
7
8
9
10
11
com.xxx.xxx.example.ClassLayoutDemo object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
//Mark Word
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)
//Klass Pointer
8 4 (object header) 05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
12 1 boolean ClassLayoutDemo.happy
13 3 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

从上面的输出来看,我们很容易发现,整个对象的大小没有改变,依然是16个字节,其中对象头12个字节,boolean字段 happy 占1个字节,剩下的3个Byte是对齐字节。由此我们可以发现一个对象的布局可以粗略的分为3个部分:对象头(Object Header),对象的实例数据(Instance Data)和 对齐字节(Padding),也叫对齐填充。

5.4 指针压缩

JVM最初是32位的,随着64位系统的兴起,JVM也迎来了从32位到64位的转换,32位的JVM对比64位的内存容量比较有限。但是使用64位虚拟机的同时,带来一个问题,64位下的JVM中的对象指针占用内存会比32位的多1.5倍(这是因为对象指针在64位架构下,长度会翻倍(更宽的寻址),对于那些将要从32位平台移植到64位的应用来说,平白无辜多了1/2的内存占用),这是我们不希望看到的。于是在JDK1.6时,引入了指针压缩。

jvm参数:

1
2
-XX:+UseCompressedClassPointers     参数:**启用类指针(类元数据的指针)压缩。
-XX:+UseCompressedOops 参数:**启用普通对象指针压缩。Oops缩写于:ordinary object pointers

在Jdk1.8中默认开启,可用命令进行检测:

1
-XX:+PrintCommandLineFlags -version        

结果:

1
2
3
4
5
6
7
8
wangwang@localhost ~ % java -XX:+PrintCommandLineFlags -version
-XX:InitialHeapSize=268435456 -XX:MaxHeapSize=4294967296 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC
java version "1.8.0_261"
Java(TM) SE Runtime Environment (build 1.8.0_261-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.261-b12, mixed mode)
wangwang@localhost ~ %

参数中的+号代表开启参数,-号代表关闭参数。

5.4.1 测试

1
2
3
4
5
6
7
8
9
10
public class ClassLayoutDemo {
// -XX:-UseCompressedClassPointers -XX:-UseCompressedOops -XX:+PrintCommandLineFlags
char ch = 'c';
public static void main(String[] args) {
//构建了一个对象实例
ClassLayoutDemo classLayoutDemo = new ClassLayoutDemo();
System.out.println("-----------------");
System.out.println(ClassLayout.parseInstance(classLayoutDemo).toPrintable());
}
}

开启指针压缩(默认)

1
2
3
4
5
6
7
8
9
com.xxx.xxx.example.ClassLayoutDemo 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) 05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
12 2 char ClassLayoutDemo.ch c
14 2 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 2 bytes external = 2 bytes total

未开启指针压缩

1
2
3
4
5
6
7
8
9
10
com.xxx.xxx.example.ClassLayoutDemo 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) 80 c0 35 10 (10000000 11000000 00110101 00010000) (271958144)
12 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
16 2 char ClassLayoutDemo.ch c
18 6 (loss due to the next object alignment)
Instance size: 24 bytes
Space losses: 0 bytes internal + 6 bytes external = 6 bytes total

总结

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

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定(如何确定将在2.3.2节中介绍),为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump The Pointer)。但如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。因此,当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除(Sweep)算法的收集器时,理论上[1]就只能采用较为复杂的空闲列表来分配内存。

对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决这个问题有两种可选方案:一种是对分配内存空间的动作进行同步处理——实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性;另外一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local AllocationBuffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。


博客说明

文章所涉及的资料来自互联网整理和个人总结,意在于个人学习和经验汇总,不用于任何的商业用途。如有侵权,请联系本人删除。谢谢!


HotSpot虚拟机对象
https://nanchengjiumeng123.top/2022/10/15/java_se/jvm/2022-10-15_HotSpot 虚拟机对象/
作者
Yang Xin
发布于
2022年10月15日
许可协议