理解Java对象:要从内存布局及底层机制说起,话说....

简介: 理解Java对象:要从内存布局及底层机制说起,话说....

前言


大家好,又见面了,今天是JVM专题的第二篇文章,在上一篇文章中我们说了Java的类和对象在JVM中的存储方式,并使用HSDB进行佐证,没有看过上一篇文章的小伙伴可以点这里:《类和对象在JVM中是如何存储的,竟然有一半人回答不上来!


这篇文章主要会对Java对象进行详细分析,基于上一篇文章,对Java对象的布局及其底层的一些机制进行解读,相信这些会对后期JVM调优有很大的帮助。


对象的内存布局


在上篇文章中我们提到,对象在JVM中是由一个Oop进行描述的。回顾一下,Oop由对象头(_mark、_metadata)以及实例数据区组成,而对象头中存在一个_metadata,其内部存在一个指针,指向类的元数据信息,就是下面这张图:


image.png


而今天要说的对象的内存布局,其底层实际上就是来自于这张图。


了解过对象组成的同学应该明白,对象由三部分构成,分别是:对象头实例数据对齐填充组成,而对象头和示例数据,对应的就是Oop对象中的两大部分,而对齐填充实际上是一个只在逻辑中存在的部分。


对象头


我们可以对这三个部分分别进行更深入的了解,首先是对象头


对象头分为MarkWord类型指针,MarkWord就是Oop对象中的_mark,其内部用于存储对象自身运行时的数据,例如:HashCode、GC分代年龄、锁状态标志、持有锁的线程、偏向线程Id、偏向时间戳等。


这是笔者在网上找的关于对象头的内存布局(64位操作系统,无指针压缩):


image.png


对象头占用128位,也就是16字节,其中MarkWord占8字节,Klass Point(类型指针)占8字节,MarkWord中所存储的信息,是这个对象最基本的一些信息,例如GC分代年龄,可以让JVM判断当前对象是否应该进入老年代,锁状态标志,在处理并发的过程中,可以判断当前要以什么级别的手段来保证线程安全,从而优化同步操作的性能,其他的相信大家都比较了解,这里就暂时先不一一列举了。当然,对象头在之后的并发专题依旧会有所提及。


而对象头的另外8字节,是KlassPoint,类型指针,在上一篇文章的Oop模型中,提到类型指针指向Klass对象,用于在运行时获取对象所属的类的元信息。


实例数据


何为实例数据,顾名思义,就是对象中的字段,用更严谨一点的话来说,类的非静态属性,在生成对象后,就是实例数据,而实例数据这部分的大小,就是实实在在的多个属性所占的空间的和,例如有下面这样一个类:


public class Test{
    private int a;
    private double b;
    private boolean c;
}


那么在new Test()操作之后,这个对象的实例数据区所占的空间就是4+8+1 = 13字节,以此类推。


而在Java中,基本数据类型都有其大小:


boolean  --- 1B

byte  --- 1B

short  --- 2B

char  ---  2B

int --- 4B

float --- 4B

double  --- 8B

long ---  8B


除了上述的八个基本数据类型以外,类中还可以包含引用类型对象,那么这部分如何计算呢?


这里需要分情况讨论,由于还没有说到指针压缩,那么大家就先记下好了:



如果是32位机器,那么引用类型占4字节

如果是64位机器,那么引用类型占8字节

如果是64位机器,且开启了指针压缩,那么引用类型占4字节


如果对象的实例数据区,存在别的引用类型对象,实际上只是保存了这个对象的地址,理解了这个概念,就可以对这三种情况进行理解性记忆了。


为什么32位机器的引用类型占4个字节,而64位机器引用类型占8字节


这里就要提到一个寻址的概念,既然保存了内存地址,那就是为了日后方便寻址,而32位机器的含义就是,其地址是由32个Bit位组成的,所以要记录其内存地址,需要使用4字节,64位同理,需要8字节。


对齐填充


我们提到对象是由三部分构成,但是上文只涉及了两部分,还有一部分就是对齐填充,这个是比较特殊的一个部分,只存在于逻辑中,这里需要科普一下,JVM中的对象都有一个特性,那就是8字节对齐,什么叫8字节对齐呢,就是一个对象的大小,只能是8的整数倍,如果一个对象不满8的整数倍,则会对其进行填充。


看到这里可能有同学就会心存疑惑,那假设一个对象的内容只占20字节,那么根据8字节对齐特性,这个对象不就会变成24字节吗?那岂不是浪费空间了?根据8字节对其的逻辑,这个问题的答案是肯定的,假设一个对象只有20字节,那么就会填充变成24字节,而多出的这四个字节,就是我们所说的对齐填充,笔者在这里画一张图来描述一下:


image.png


对象头在不考虑指针压缩的情况下,占用16个字节,实例数据区,我们假设是一个int类型的数据,占用4个字节,那么这里一共是20字节,那么由于8字节对齐特性,对象就会填充到24字节。


那么为什么要这么去设计呢?,刚开始笔者也有这样的疑惑,这样设计会有很多白白浪费掉的空间,毕竟填充进来的数据,在逻辑上是没有任何意义的,但是如果站在一个设计者的角度上看,这样的设计在日后的维护中是最为方便的。假设对象没有8字节对齐,而是随机大小分布在内存中,由于这种不规律,会造成设计者的代码逻辑变得异常复杂,因为设计者根本不知道你这个对象到底有多大,从而没有办法完整地取出一整个对象,还有可能在这种不确定中,取到其它对象的数据,造成系统混乱。


当然,有些同学觉得设计上的问题总能克服,这点原因还不足以让我们浪费内存,这就是我理解的第二点原因,这么设计还会有一种好处,就是提升性能,假设对象是不等长的,那么为了获取一个完整的对象,就必须一个字节一个字节地去读,直到读到结束符,但是如果8字节对齐后,获取对象就可以以8个字节为单位进行读取,快速获取到一个对象,也不失为一种以空间换时间的设计方案。


那么又有同学要问了,那既然8字节可以提升性能,那为什么不16字节对齐呢,这样岂不是性能更高吗?答案是:没有必要,有两个原因,第一,我们对象头最大是16字节,而实例数据区最大的数据类型是8个字节,所以如果选择16字节对齐,假设有一个18字节的对象,那么我们需要将其填充成为一个32字节的对象,而选择8字节填充则只需要填充到24字节即可,这样不会造成更大的空间浪费。第二个原因,允许我在这里卖一下关子,在之后的指针压缩中,我们再详细进行说明。


关于对象内存布局的证明方式


证明方式有两种,一种是使用代码的方式,还有一种就是使用上一篇文章中我们提到的,使用HSDB,可以直接了当地查看对象的组成,由于HSDB在上一篇文章中已经说过了,所以这里只说第一种方式。


首先,我们需要引入一个maven依赖:


<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-core -->
<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.10</version>
</dependency>


引入这个依赖之后,我们就可以在控制台中查看对象的内存布局了,代码如下:


public class Blog {
    public static void main(String[] args) {
        Blog blog = new Blog();
        System.out.println(ClassLayout.parseInstance(blog).toPrintable());
    }
}



首先是关闭指针压缩的情况,对齐填充为0字节,对象大小为16字节:


image.png


解释一下为什么两种情况都是16字节:


开启指针压缩,对象大小(16字节) = MarkWord(8字节)+ KlassPointer(4字节)+ 数组长度(0字节) + 实例数据(0字节)+ 对齐填充(4字节) 关闭指针压缩,对象大小(16字节)= MarkWord(8字节)+ KlassPointer(8字节)+ 数组长度(0字节)+ 实例数据(0字节) + 对齐填充(0字节)

如何计算对象的内存占用

在第一节中我们已经详细阐述了对象在内存中的布局,主要分为三部分,对象头实例数据对齐填充,并且进行了证明。这一节中来带大家计算对象的内存占用。


实际上在刚才对内存布局的阐述中,应该有很多同学都对如何计算对象内存占用有了初步的了解,其实这也并不难,无非就是把三个区域的占用求和,但是上文中我们只是说了几种简单的情况,所以这里主要来说说我们上文中没有考虑到的,我们将分情况进行讨论并证明。


对象中只存在基本数据类型


public class Blog {
    private int a = 10;
    private long b = 20;
    private double c = 0.0;
    private float d = 0.0f;
    public static void main(String[] args) {
        Blog blog = new Blog();
        System.out.println(ClassLayout.parseInstance(blog).toPrintable());
    }
}


这种情况是除了空对象以外的最简单的一种情况,假设对象中存在的属性全都是Java八种基本类型中的某一种或某几种类型,对象的大小如何计算?


不妨先来看看结果:


image.png


对于这种情况,我们只需要简单地将对象头+示例数据+对齐填充即可,由于我们在对象中存在四个属性,分别为int(4字节)+long(8字节)+double(8字节)+float(4字节),可以得出实例数据为24字节,而对象头为12字节(指针压缩开启),那么一共就是36字节,但是由于Java中的对象必须得是8字节对齐,所以对齐填充会为其补上4字节,所以整个对象就是:


对象头(12字节)+实例数据(24字节)+对齐填充(4字节) = 40字节


对象中存在引用类型(关闭指针压缩)


那么对象中存在引用类型,该如何计算?这里涉及到开启指针压缩关闭指针压缩两种情况,我们先来看看关闭指针压缩的情况,究竟有何不同。


public class Blog {
    Map<String,Object> objMap = new HashMap<>(16);
    public static void main(String[] args) {
        Blog blog = new Blog();
        System.out.println(ClassLayout.parseInstance(blog).toPrintable());
    }
}


同样,先看结果:


image.png


可以看到,对象的实例数据区存在一个引用类型属性,就像第一节中说的,只是保存了指向这个属性的指针,这个指针在关闭指针压缩的情况下,占用8字节,不妨也计算一下它的大小:


对象头(关闭指针压缩,占用16字节)+实例数据(1个对象指针8字节)+ 对齐填充(无需进行填充)=24字节


对象中存在引用类型(开启指针压缩)


那么如果是开启指针压缩的情况呢?


image.png


如果是开启指针压缩的情况,类型指针实例数据区的指针都仅占用4字节,所以其内存大小为:


MarkWord(8B)+KlassPointer(4B)+实例数据区(4B)+对齐填充(0B) = 16B


数组类型(关闭指针压缩)


如果是数组类型的对象呢?由于在上文中已经形成的定向思维,大家可能已经开始使用原先的套路开始计算数组对象的大小了,但是这里的情况就相对比普通对象要复杂很多,出现的一些现象可能要让大家大跌眼镜了。


我们这里枚举三种情况:


public class Blog {
    private int a = 10;
    private int b = 10;
    public static void main(String[] args) {
        //对象中无属性的数组
        Object[] objArray = new Object[3];
        //对象中存在两个int型属性的数组
        Blog[] blogArray = new Blog[3];
        //基本类型数组
        int[] intArray = new int[1];
        System.out.println(ClassLayout.parseInstance(blogArray).toPrintable());
        System.out.println(ClassLayout.parseInstance(objArray).toPrintable());
        System.out.println(ClassLayout.parseInstance(intArray).toPrintable());
    }
}


依旧是先看结果:


首先是第一种情况:对象中无属性的数组


image.png


同样的一个打印对象操作,除了MarkWord、KlassPointer、实例数据对齐填充以外,多了一篇空间,我们可以发现,原先在普通对象的算法,已经不适用于数组的算法了,因为在数组中出现了一个很诡异而我们从没有提到过的东西,那就是对象头的第三部分——数组长度


数组长度究竟为何物?


如果对象是一个数组,它的内部除了我们刚才说的那些以外,还会存在一个数组长度属性,用于记录这个数组的大小,数组长度为32个Bit,也就是4个字节,这里也可以关联上一个基础知识,就是Java中数组最大可以设置为多大?跟计算内存地址的表示方式类似,由于其占4个字节,所以数组的长度最大为2^32


我们再来看看实例数据区的情况,由于其存放了三个对象,而我们在对象中存在引用类型这个情况中阐述过,即使存在对象,我们也只是保存了指向其内存地址的指针,这里由于关闭了指针压缩,所以每个指针占用8个字节,一共24字节。


再回到图上,在前几个案例中,对齐填充都在实例数据区之后,但是这里对齐填充是处于对象头的第四部分。在实例数据区之前,也就是在数组对象中,出现了第二段的对齐填充,那么数组对象的内存布局就应该变成下图这样:


image.png


我们可以在另外两种情况中验证这个想法:


对象中存在两个int型属性的数组


image.png


我们可以看到,即使对象中存在两个int类型的数组,依旧保存其内存地址指针,所以依旧是4字节,而在基本类型的数组中,其保存的是实例数据的大小,也就是int类型的长度4字节,如果数组长度是3,这里的实例数据就是12字节,以此类推,而这种情况下,同样出现了两段填充的现象,由于我们代码中的数组长度设置为1,所以这里的对象大小为:


MarkWord(8B)+KlassPointer(8B)+数组长度(4B)+第一段对齐填充(4B)+实例数据区(4B)+第二段对齐填充(4B) = 32B


数组类型(开启指针压缩)


那么如果开启指针压缩又会是什么样的状况呢?有了上面的基础,大家可以先考虑一下,我这里就直接上图了。


长度为1的基本类型数组


image.png


对象中存在引用类型(开启指针压缩)中我说过只要开启了指针压缩,我们的类型指针就是占用4个字节,由于是数组,对象头中依旧多了一个存放对象的指针,但是对象头中的对齐填充消失了,所以其大小为:


MarkWord(8B)+KlassPointer(4B)+数组长度(4B)+实例数据区(4B)+对齐填充(4B) = 24B


仅存在静态变量


最后一种情况,假设类中仅存在一个静态变量(开启指针压缩):


public class Blog {
    private static Map<String,Object> mapObj = new HashMap<>(16);
    public static void main(String[] args) {
        Blog blog = new Blog();
        int[] intArray = new int[1];
        System.out.println(ClassLayout.parseInstance(blog).toPrintable());
    }
}


image.png


可以看到其内部并没有实例数据区,原因很简单,我们也说过,大家要记住,只有类的非静态属性,在生成对象后,才是实例数据,而静态变量不在其列。


总结


关于如何对象的大小,其实很简单,我们首先关注是否是开启了指针压缩,然后关注其是普通对象还是数组对象,这里做个总结。


如果是普通对象,那么只需要计算:MarkWord+KlassPointer(8B)+实例数据+对齐填充


如果是数组对象,则需要分两种情况,如果是开启指针压缩的情况,那么分为五段:


MarkWord+KlassPointer(4B)+第一段对齐填充+实例数据+第二段对齐填充


如果对象中存在引用类型数据,则保存的只是指向这个数据的指针,在开启指针压缩的情况下,为4字节,关闭指针压缩为8字节。



如果对象中存在基本数据类型,那么保存的就是其实体,这就需要按照8中基本数据类型的大小来灵活计算了。


指针压缩


在本篇文章中我们和指针压缩打过多次交道,那么究竟是什么指针压缩?


简单来说,指针压缩就是一种节约内存的技术,并且可以增强内存寻址的效率,由于在64位系统中,对象中的指针占用8字节,也就是64Bit,我们再来回顾一下,8字节指针可以表示的内存大小是多少?


2^64 = 18446744073709552000Bit = 2147483648GB


很显然,站在内存的角度,首先,在当前的硬件条件下,我们几乎不可能达到这种内存级别。其次,64位对象引用需要占用更多的对空间,留给其他数据的空间将会减少,从而加快GC的发生。站在CPU的角度,对象引用变大了,CPU能缓存的对象也就少了,每次使用时都需要去内存中取,降低了CPU的效率。所以,在设计时,就引入了指针压缩的概念。


指针压缩原理


我们都知道,指针压缩会将原先的8字节指针,压缩到4字节,那么4字节能表示的内存大小是多少?


2^32 = 4GB


这个内存级别,在当前64位机器的大环境下,在大多数的生产环境下已经是不够用了,需要更大的寻址范围,但是刚才我们看到,指针压缩之后,对象指针的大小就是4个字节,那么我们需要了解的就是,JVM是如何在指针压缩的条件下,提升寻址范围的呢?

需要注意的一点是:由于32位操作系统,能够识别的最大内存地址就是4GB,所以指针压缩后也依旧够用,所以32位操作系统不在这个讨论范畴内,这里只针对64位操作系统进行讨论。


首先我们来看看,指针压缩之后,对象的内存地址存在何种规律:


假设这里有三个对象,分别是对象A 8字节,对象B 16字节,对象C 24字节。

那么其内存地址(假设从00000000)开始,就是:

A:00000000 00000000 00000000 00000000         0x00000000

B:00000000 00000000 00000000 00001000         0x00000008

C:00000000 00000000 00000000 00010000         0x00000010


由于Java中对象存在8字节对齐的特性,所以所有对象的内存地址,后三位永远是0。那么这里就是JVM在设计上解决这个问题的精妙之处。


首先,在存储的时候,JVM会将对象内存地址的后三位的0抹去(右移3位),在使用的时候,将对象的内存地址后三位补0(左移3位),这样做有什么好处呢。


按照这种逻辑,在存储的时候,假设有一个对象,所在的内存地址已经达到了8GB,超出了4GB,那么其内存地址就是:**00000010 00000000 00000000 00000000 00000000 **

很显然,这已经超出了32位(4字节)能表示的最大范围,那么依照上文中的逻辑,在存储的时候,JVM将对象地址右移三位,变成01000000 00000000 00000000 00000000,而在使用的时候,在后三位补0(左移3位),这样就又回到了最开始的样子:**00000010 00000000 00000000 00000000 00000000 **,就又可以在内存中找到对象,并加载到寄存器中进行使用了。


由于8字节对齐,内存地址后三位永远是0这一特殊的规律,JVM使用这一巧妙地设计,将仅占有32位的对象指针,变成实际上可以使用35位,也就是最大可以表示32GB的内存地址,这一精妙绝伦的设计,笔者叹为观止。


当然,这里只是说JVM在开启指针压缩下的寻址能力,而实际上64位操作系统的寻址能力是很强大的,如果JVM被分配的内存大于32GB,那么会自动关闭指针压缩,使用8字节的指针进行寻址。


解答遗留问题:为什么不使用16字节对齐


第一节的遗留问题,为什么不用16字节对齐的第二个原因,其实学习完指针压缩之后,答案已经很明了了,我们在使用8字节对齐时并开启指针压缩的情况下,最大的内存表示范围已经达到了32GB,如果大于32GB,关闭指针压缩,就可以获取到非常强大的寻址能力。


当然,如果假设JVM中没有指针压缩,而是开始就设定了对象指针只有8字节,那么此时如果需要又超过32GB的内存寻址能力,那么就需要使用16字节对齐,原理和上面说的相同,如果是16字节对齐,那么对象的内存地址后4位一定为0,那么我们在存储和读取的时候分别左移右移4位,就可以仅用32位的指针,获取到36位的寻址能力,寻址能力也就可以达到64GB了。


结语


本篇文章是JVM系列的第二篇,主要基于上一篇的《类和对象在JVM中是如何存储的,竟然有一半人回答不上来!》来解构Java对象,主要阐述了Java对象的内存布局,对其进行了分情况讨论,并在代码中进行佐证,最后深入浅出地谈了谈关于指针压缩技术的技术场景及实现原理。


那么JVM在宏观上,究竟是一种怎样的结构,由什么区域构成,以及JVM在运行时是如何调度这些对象的,这些内容笔者会在下一篇文章中进行阐述。


欢迎大家访问博客:http://blog.objectspace.cn/


相关文章
|
14天前
|
监控 Java 应用服务中间件
高级java面试---spring.factories文件的解析源码API机制
【11月更文挑战第20天】Spring Boot是一个用于快速构建基于Spring框架的应用程序的开源框架。它通过自动配置、起步依赖和内嵌服务器等特性,极大地简化了Spring应用的开发和部署过程。本文将深入探讨Spring Boot的背景历史、业务场景、功能点以及底层原理,并通过Java代码手写模拟Spring Boot的启动过程,特别是spring.factories文件的解析源码API机制。
45 2
|
19天前
|
Java 编译器
探索Java中的异常处理机制
【10月更文挑战第35天】在Java的世界中,异常是程序运行过程中不可避免的一部分。本文将通过通俗易懂的语言和生动的比喻,带你了解Java中的异常处理机制,包括异常的类型、如何捕获和处理异常,以及如何在代码中有效地利用异常处理来提升程序的健壮性。让我们一起走进Java的异常世界,学习如何优雅地面对和解决问题吧!
|
28天前
|
安全 Java 编译器
Java对象一定分配在堆上吗?
本文探讨了Java对象的内存分配问题,重点介绍了JVM的逃逸分析技术及其优化策略。逃逸分析能判断对象是否会在作用域外被访问,从而决定对象是否需要分配到堆上。文章详细讲解了栈上分配、标量替换和同步消除三种优化策略,并通过示例代码说明了这些技术的应用场景。
Java对象一定分配在堆上吗?
|
18天前
|
Java 数据库连接 开发者
Java中的异常处理机制及其最佳实践####
在本文中,我们将探讨Java编程语言中的异常处理机制。通过深入分析try-catch语句、throws关键字以及自定义异常的创建与使用,我们旨在揭示如何有效地管理和响应程序运行中的错误和异常情况。此外,本文还将讨论一些最佳实践,以帮助开发者编写更加健壮和易于维护的代码。 ####
|
24天前
|
安全 IDE Java
Java反射Reflect机制详解
Java反射(Reflection)机制是Java语言的重要特性之一,允许程序在运行时动态地获取类的信息,并对类进行操作,如创建实例、调用方法、访问字段等。反射机制极大地提高了Java程序的灵活性和动态性,但也带来了性能和安全方面的挑战。本文将详细介绍Java反射机制的基本概念、常用操作、应用场景以及其优缺点。 ## 基本概念 ### 什么是反射 反射是一种在程序运行时动态获取类的信息,并对类进行操作的机制。通过反射,程序可以在运行时获得类的字段、方法、构造函数等信息,并可以动态调用方法、创建实例和访问字段。 ### 反射的核心类 Java反射机制主要由以下几个类和接口组成,这些类
51 2
|
29天前
|
存储 缓存 安全
🌟Java零基础:深入解析Java序列化机制
【10月更文挑战第20天】本文收录于「滚雪球学Java」专栏,专业攻坚指数级提升,希望能够助你一臂之力,帮你早日登顶实现财富自由🚀;同时,欢迎大家关注&&收藏&&订阅!持续更新中,up!up!up!!
25 3
|
29天前
|
存储 算法 Java
Go语言的内存管理机制
【10月更文挑战第25天】Go语言的内存管理机制
23 2
|
29天前
|
安全 Java UED
深入理解Java中的异常处理机制
【10月更文挑战第25天】在编程世界中,错误和意外是不可避免的。Java作为一种广泛使用的编程语言,其异常处理机制是确保程序健壮性和可靠性的关键。本文通过浅显易懂的语言和实际示例,引导读者了解Java异常处理的基本概念、分类以及如何有效地使用try-catch-finally语句来处理异常情况。我们将从一个简单的例子开始,逐步深入到异常处理的最佳实践,旨在帮助初学者和有经验的开发者更好地掌握这一重要技能。
27 2
|
25天前
|
Java 开发者
深入理解Java异常处理机制
【10月更文挑战第29天】在Java的世界中,异常处理如同生活的调味品,不可或缺。它确保了程序在遇到错误时不会崩溃,而是优雅地继续运行或者给出提示。本文将带你领略异常处理的奥秘,从基础的try-catch语句到高级的自定义异常,让你在面对程序中的各种“意外”时,能够从容应对。
|
27天前
|
SQL Java
探索Java中的异常处理机制
【10月更文挑战第26天】 在本文中,我们将深入探讨Java编程语言的异常处理机制。通过分析不同类型的异常、异常的捕获与抛出方式,以及如何自定义异常类,读者将能够更好地理解并应用Java中的异常处理机制来提高代码的健壮性和可读性。
24 0