在JVM中,对象的创建是程序运行的基础,它关联着内存分配、垃圾回收等关键环节。深入掌握对象创建的内在机制,对于高效地进行内存管理和系统性能调优具有重大意义。本文将全面解析对象的构成要素、指针压缩技术、对象尺寸的确定以及对象创建的具体流程。
1 对象组成
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:
- 对象头:HotSpot虚拟机的对象头包括Mark Word、Klass Point、数组长度三部分信息
- Mark Word:存放比如 hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID等。
- Klass Point:大小也通常为32bit,它主要指向类的数据,也就是指向方法区中的位置。
- 数组长度:只有数组对象才有,在32位或者64位JVM中,长度都是32bit。
- 实例数据:存放类的属性数据信息,包括父类的属性信息
- 对齐填充:为了寻址最优,64位机器虚拟机要求对象起始地址必须是8字节的整数倍。不是必须存在的
1.1 Mark Word
Mark Word是对象头的一部分,用于存储对象的同步状态、GC状态等元数据。它在并发控制和垃圾回收中扮演着重要角色,其包含信息如下:
2 指针压缩
现在我们虚拟机基本是64位的,而64位的对象头有点浪费空间,JVM默认会开启指针压缩,所以基本上也是按32位的形式记录对象头的。手动设置jvm启动参数为:-XX:+UseCompressedOops
为什么要进行指针压缩?
- 在64位平台的HotSpot中使用32位指针(实际存储用64位),内存使用会多出1.5倍左右,使用较大指针在主内存和缓存之间移动数据,占用较大宽带,同时GC也会承受较大压力
- 为了减少64位平台下内存的消耗,启用指针压缩功能
- 在jvm中,32位地址最大支持4G内存(2的32次方),可以通过对对象指针的存入堆内存时压缩编码、取出到cpu寄存器后解码方式进行优化(对象指针在堆中是32位,在寄存器中是35位,2的35次方=32G),使得jvm只用32位地址就可以支持更大的内存配置(小于等于32G)
- 堆内存小于4G时,不需要启用指针压缩,jvm会直接去除高32位地址,即使用低虚拟地址空间
- 堆内存大于32G时,压缩指针会失效,会强制使用64位(即8字节)来对java对象寻址,这就会出现1的问题,所以堆内存不要大于32G为好
哪些信息会被压缩?
- 对象的全局静态变量(即类属性)
- 对象头信息:64位平台下,原生对象头大小为16字节,压缩后为12字节
- 对象的引用类型:64位平台下,引|用类型本身大小为8字节,压缩后为4字节
- 对象数组类型:64位平台下,数组类型本身大小为24字节,压缩后16字节
3 对象大小
对象大小可以用jol-core包查看,引入依赖
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
import org.openjdk.jol.info.ClassLayout;
/**
* 计算对象大小
*/
public class JOLSample {
public static void main(String[] args) {
ClassLayout layout = ClassLayout.parseInstance(new Object());
System.out.println(layout.toPrintable());
System.out.println();
ClassLayout layout1 = ClassLayout.parseInstance(new int[]{});
System.out.println(layout1.toPrintable());
System.out.println();
ClassLayout layout2 = ClassLayout.parseInstance(new A());
System.out.println(layout2.toPrintable());
}
// -XX:+UseCompressedOops 默认开启的压缩所有指针
// -XX:+UseCompressedClassPointers 默认开启的压缩对象头里的类型指针Klass Pointer
// Oops : Ordinary Object Pointers
public static class A {
//8B mark word
//4B Klass Pointer 如果关闭压缩-XX:-UseCompressedClassPointers或-XX:-UseCompressedOops,则占用8B
int id; //4B
String name; //4B 如果关闭压缩-XX:-UseCompressedOops,则占用8B
byte b; //1B
Object o; //4B 如果关闭压缩-XX:-UseCompressedOops,则占用8B
}
}
运行结果:
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) //mark word
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) //mark word
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243) //Klass Pointer
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
[I 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) 6d 01 00 f8 (01101101 00000001 00000000 11111000) (-134217363)
12 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
16 0 int [I.<elements> N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
com.tuling.jvm.JOLSample$A 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) 61 cc 00 f8 (01100001 11001100 00000000 11111000) (-134165407)
12 4 int A.id 0
16 1 byte A.b 0
17 3 (alignment/padding gap)
20 4 java.lang.String A.name null
24 4 java.lang.Object A.o null
28 4 (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 3 bytes internal + 4 bytes external = 7 bytes total
在64位系统上,如果没有启用指针压缩,一个Object
的大小通常为8(Mark Word)+8(类型指针)=16字节;在开启指针压缩后,类型指针的大小会减少到4字节,但由于填充字节的存在,Object
的大小仍然是8(Mark Word)+4(类型指针)+4(字节填充)=16字节。
4 对象创建过程
- 类加载检查:虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。(具体可参考博文:【JVM】02. 类加载(一):类加载过程)
- 内存分配:在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。
- 初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头)。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
- 设置对象头:初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头之中。
- 执行init方法:即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意,这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法。
4.1 半初始化问题
一般来讲,当初始化一个对象的时候会经历:内存分配—> 初始化—>返回对象引用的过程。但是在多线程环境下,由于Java内存模型允许指令的重排序,整个过程就可能变成:内存分配—>返回对象引用—>初始化。这就可能导致一个线程看到了另一个线程创建的对象的引用地址,但是这个对象还没有完成初始化。此时,我们称这个对象为半初始化对象。
最常见的就是双重检查锁的单例模式中对对象半初始化问题:这种情况下对应到singleton = new Singleton();
,就是singleton已经不是null,而是指向了堆上的一个对象,但是该对象却还没有完成初始化动作。当后续的线程发现singleton不是null而直接使用的时候,就会出现意料之外的问题。
// 双重检查锁
class Singleton{
private static Singleton singleton;
private Singleton(){}
public static Singleton getInstance(){
if(singleton == null){
synchronized(Singleton.class){
if(singleton == null)
singleton = new Singleton(); //创建实例
}
}
return singleton;
}
}
解决方案:JDK1.5之后,可以使用volatile关键字修饰变量来解决无序写入产生的问题,因为volatile关键字的一个重要作用是禁止指令重排序,即保证不会出现内存分配->返回对象引用->初始化这样的顺序,从而使得双重检测真正发挥作用。
// 双重检查锁 解决多线程下的单例
public class Singleton{
private volatile static Singleton instance = null; // 禁止指令重排序
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
5 总结
对象创建是JVM中一个复杂而精细的操作,它涉及类加载、内存分配、对象初始化和构造函数执行等多个环节。理解对象的内存布局、指针压缩技术、对象大小的计算以及对象创建的详细步骤,对于有效地管理内存和优化系统性能具有重要意义。通过本文的深入解析,读者应能对JVM中的对象创建机制有一个全面而清晰的认识,为进一步探索内存分配和性能优化奠定坚实的基础。