【JVM】堆(Heap)上有什么?普通对象全方位解析

it2025-05-18  2

1.对象存储布局

一个Java对象在内存中包括3个部分:对象头、实例数据和对齐填充

1.1 对象头

Mark Word

状态标识。与虚拟机位数一样,一般64bit,32bit很少。下图是一个32位虚拟机的Mark Word示例: 从对象头中可以获取到两点信息:

Minor GC年龄默认15,是因为分代年龄只有4bit,最大就是15有锁状态无HashCode是因为没空间存了

Klass Pointer

指针,指向相应类信息的地址(方法区)。大小也是由虚拟机位数决定,但 64bit 时由于 Klass Pointer 开启了指针压缩(UseCompressedClassPointer),所以大小会变成32bit。注意,32bit能产生的地址约为32G,当内存大于32G时,压缩失效。

数组长度(数组)

4bit,数组才有,作用是是记录数组长度。

1.2 实例数据(=>成员变量)

实例数据占用内存大小要分为两种情况:

基本数据类型:大小随着类型不同,比如 int-32,double-64,char-1,boolean-1引用类型(Oops:普通对象指针):应该与虚拟机一样大64bit, 但存在指针压缩UseCompressedOops,变成32bit

注:若存在继承关系,还要加上父类的实例变量大小

1.3 对齐字节

对齐填充需要满足以下两个规则:

每个实例变量的偏移量是4的整数倍整体是大小是8的整数倍,因为计算机寄存器一般64bit,JVM一次读取一个内存单元(8bit)

2.对象大小

我们怎么知道一个java对象的大小呢?比如HashMap<String,Object> map = new HashMap<>();这个map对象在堆中多大呢?占多少个字节呢?

我们可以借助apache的一个类RamUsageEstimator来计算,例如下面这种:

<dependency> <groupId>org.apache.lucene</groupId> <artifactId>lucene-core</artifactId> <version>6.6.1</version> </dependency> Integer integer = new Integer(10); HashMap<String,Object> map = new HashMap<>(); System.out.println(RamUsageEstimator.shallowSizeOf(integer));// 16 System.out.println(RamUsageEstimator.shallowSizeOf(map)); //48

下面我们就来看看,为什么Integer是16,而那个map是48…

2.1 Integer:16字节

对象头 markword:64位 jvm 中 markword 占8个字节(多少位虚拟机就多大,一般是64bit,32bit的很少)klass类型的指针:4个字节(同上,默认64位,但开启了指针压缩,所以是32bit)这里不存在数组长度(4字节) 实例数据(不能算static):Integer 封装的 int value 大小是 4个字节对齐填充(JVM一次读8bit,每个变量的偏移量是4的倍数):8 + 4 + 4 = 16,不用对齐字节

2.2 HashMap:48字节

对象头 MarkWord:8个字节KlassPointer:4个字节 实例数据(代码在下边): 父类(AbstractMap):4 + 4table:Node<K,V>[] - 引用类型 4字节entrySet:Set<Map.Entry<K,V>> - 引用类型 4字节modCount:int - 4字节size:int - 4字节threshold:int - 4字节loadFactory:int - 4字节 对齐填充12 + 4*2 + 4 * 6 = 44 补4字节 --> 48 // HashMap 定义的成员变量: transient Node<K,V>[] table;//引用类型,占四个字节 transient Set<Map.Entry<K,V>> entrySet;//引用类型,占四个字节 transient int size;//int类型,4个字节 transient int modCount;//int类型,4个字节 int threshold;//int类型,4个字节 final float loadFactor;//int类型,4个字节 ------------------------------------------------------------------ // 父类 AbstractMap 的成员变量; transient Set<K> keySet; transient Collection<V> values;

3.对象创建

3.1 三步过程

创建对象:未初始化,成员变量还是默认零值对象初始化:执行<init>方法分配内存 :将对象放到指定内存

这里知道对象创建过程有什么用呢?向下看!

3.2 DCL单例

DCL单例全称 Double Check Lock,实现代码如下:

public class Singleton { private static Object lock = new Object(); private volatile static Singleton instance = null; // 为什么要volatile? private Singleton() {} // 私有构造器,防止被外部类实例化 public static Singleton getInstance() { if(instance == null) { // 第一次检查 synchronized(lock) { if(instance == null) { // 第二次检查。为什么要二检呢? instance = new Singleton(); } } } return instance; } }

代码很好理解,关键就在于那两个问题:

为什么要 Double Check?防止对象创建多次为什么要 volatile?防止半初始化,即指令重排序后,对象未初始化就被别的线程拿走使用。本来的顺序应该是1-2-3,现在重排序后成了1-3-2;如果现在1应执行完,正执行3-2期间有线程来取对象了,由于对象已经创建好,所以未经初始化的对象就直接被返回了,就出空指针等问题。

看看,这是不是就用到了对象创建时的细节。

4.对象分配位置

对象时有大小的啊,所以要创建对象前要先分配好空间,那是直接就分配在堆上吗? 从上图可以看到创建对象时不只是之前第三篇说的会在Heap上分配内存,还可能会在Java栈和老年代分配内存。

4.1 Eden区分配

条件:新对象的创建大多数都在新生代的Eden区分配内存回收:分为两种情况 符合GC条件,直接被youngGC回收不符合GC条件,移到Survivor区。若 from-to 年满(<=15)进入老年代被FullGC回收

4.2 栈上分配(效率高)

条件:满足两个条件 逃逸分析 && 标量替换 逃逸分析:没有别的对象持有当前对象的引用,例如我们在方法里new的对象标量替换:可以用对象的实例变量代表这个对象,即它的实例变量压入栈就行 回收:代表对象的实例变量都出栈

4.3 老年代分配

条件:对象所占空间很大回收:FullGC

4.4 线程本地(新生代线程私部分)

条件:Thread Local Allocation Buffer未满优势:避免了碰撞指针,即多个对象要同时分配内存时的加锁同步,

5.对象寻址

现在对象已经创建好了,但是对象中只有基本数据,类信息、方法字节码那些还在方法区呢,所以我一个对象怎么获取到呢?

从图中可以看到有两种寻址方式,下面就一一来看。

5.1 句柄方式

两个指针:实例变量数据 + 实例变量信息(类型,名)特点 优点:GC对象可能因为标记整理等算法,而进行移动/复制,指针指向方法区类型减少了指针重定位工作量缺点:两次访问

5.2 直接指针(HotSpot)

一个指针:实例变量数据 –Klass Pointer–> 实例变量信息(类型,名)特点 优点:直接访问缺点:GC后,对象移动/拷贝麻烦,指针重定位工作量大

6.对象生命周期

6.1 创建阶段(Created)

在创建阶段系统通过下面的几个步骤来完成对象的创建过程

为对象分配存储空间开始构造对象从超类到子类对static成员进行初始化超类成员变量按顺序初始化,递归调用超类的构造方法子类成员变量按顺序初始化,子类构造方法调用

一旦对象被创建,并被分派给某些变量赋值,这个对象的状态就切换到了应用阶段

6.2 应用阶段(In Use)

对象至少被一个强引用持有着。

6.3 不可见阶段(Invisible)

当一个对象处于不可见阶段时,说明程序本身不再持有该对象的任何强引用,虽然该这些引用仍然是存在着的。

简单说就是程序的执行已经超出了该对象的作用域了。

举例如下代码:本地变量count在25行时已经超出了其作用域,则在此时称之为count处于不可视阶段。当然这种情况编译器在编译的过程中会直接报错了。

boolean bool = fasle; if (bool == true) { int count = 0; count++; } System.out.println(count);

6.4 不可达阶段(Unreachable)

对象处于不可达阶段是指该对象不再被任何强引用所持有。

与“不可见阶段”相比,“不可见阶段”是指程序不再持有该对象的任何强引用,这种情况下,该对象仍可能被JVM等系统下的某些已装载的静态变量或线程或JNI等强引用持有着,这些特殊的强引用被称为”GC root”。存在着这些GC root会导致对象的内存泄露情况,无法被回收。

6.5 收集阶段(Collected)

当垃圾回收器发现该对象已经处于“不可达阶段”并且垃圾回收器已经对该对象的内存空间重新分配做好准备时,则对象进入了“收集阶段”。如果该对象已经重写了finalize()方法,则会去执行该方法的终端操作。

这里要特别说明一下:不要重载finazlie()方法!原因有两点:

会影响JVM的对象分配与回收速度

在分配该对象时,JVM需要在垃圾回收器上注册该对象,以便在回收时能够执行该重载方法;在该方法的执行时需要消耗CPU时间且在执行完该方法后才会重新执行回收操作,即至少需要垃圾回收器对该对象执行两次GC。

可能造成该对象的再次“复活”

在finalize()方法中,如果有其它的强引用再次持有该对象,则会导致对象的状态由“收集阶段”又重新变为“应用阶段”。这个已经破坏了Java对象的生命周期进程,且“复活”的对象不利用后续的代码管理。

6.6 终结阶段

当对象执行完finalize()方法后仍然处于不可达状态时,则该对象进入终结阶段。在该阶段是等待垃圾回收器对该对象空间进行回收。

6.7 对象空间重新分配阶段

垃圾回收器对该对象的所占用的内存空间进行回收或者再分配了,则该对象彻底消失了,称之为“对象空间重新分配阶段”。

最新回复(0)