只是不愿随波逐流 ...|

lidongdongdong~

园龄:2年7个月粉丝:14关注:8

9、对象

内容来自王争 Java 编程之美

在平时的开发中,在项目上线之前,我们需要合理的预估项目运行所需的内存空间,以便合理地设置 JVM 内存的大小
JVM 内存分为很多部分:栈内存、堆内存、方法区等

  • 栈内存中存储的数据的生命周期很短,函数结束之后就释放了
  • 方法区存储的是代码,几乎是固定不变的,而且占用的空间也比较少,所以,分析的重点就成了堆内存
  • 堆内存中主要存储对象,所以,想要合理估算项目运行所需的内存空间,就需要知道如何计算一个对象所占内存的大小

本节,我们就来讲一讲,Java 对象在内存中的存储结构,以及如何统计对象大小

1、整体结构

1.1、介绍

Java 对象在内存中的存储结构包含三部分:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)

  • 对象头又包含标记字(Mark Word)、类指针、数组长度
  • 实例数据为对象中的非静态成员变量
  • 对齐填充是为了保证对象存储地址按照 8 字节对齐(64 位 JVM)或 4 字节对齐(32 位 JVM)而做的填充

image

1.2、引入 JOL

对于对象的内存结构,我们可以使用 JOL(Java Object Layout)工具来查看,在接下来的讲解中,我们也会反复用到这个工具
它的使用非常简单,跟引入其他类库一样,我们可以通过 Maven 或 Gradle 将其引入自己的项目中,如下所示

Jar 包下载地址
IDEA 添加 lib

// Maven
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>
// Gradle
implementation 'org.openjdk.jol:jol-core:0.17'

1.3、举例

在项目中,我们需要编程来查看某个对象的存储结构,如下示例代码所示

// MyObject.java
public class MyObject {
private long b;
private int a;
private Integer e;
private static final int s = 1;
public void f() {
}
// ... 省略方法 ...
}
// Demo9_1.java
import org.openjdk.jol.info.ClassLayout;
public class Demo9_1 {
public static void main(String[] args) {
System.out.println(ClassLayout.parseInstance(new MyObject()).toPrintable());
}
}

MyObject 类的对象的内存存储结构,经过上面的代码,打印出来如下所示
image
对于上图中的内容,我们简单介绍一下

  • OFFSET 表示字段相对于对象的首地址的偏移地址,单位是字节
  • SIZE 表示字段的大小,单位是字节
  • TYPE 表示字段的类型
  • VALUE 为字段值

上图中的其他信息,比如 space losses(空间损失),我们在后面慢慢讲解,接下来,我们依次详细讲解对象头、实例数据、对齐填充这部分

2、对象头

对象头又分为三部分:标记字(Mark Word)、类指针、数组长度

2.1、标记字

标记字在 32 位 JVM 中占 4 字节长度,在 64 位 JVM 中占 8 字节长度,其存储对象在运行过程中的一些信息
比如 GC 分代年龄(age)、锁标志位(lock)、是否偏向锁(biased_lock)、线程 ID(thread)、时间戳(epoch)、哈希值(hashcode)等
标记字应该是 Java 对象存储结构中最复杂的部分,标记字记录的大部分信息,都用于多线程和 JVM 垃圾回收
关于标记字的具体存储结构和作用,我们在多线程和 JVM 模块中详细讲解

2.2、类指针

对象所属的类的信息存储在方法区,为了知道某个对象的类信息,对象头中存储了类指针,指向方法区中的类信息,也就是对应类信息在方法区中的内存地址
关于类在内存中的存储结构,我们在后面的章节中讲解

不过,这里我有一个小问题,为啥叫 "类指针",而不是 "类引用" 呢?毕竟,Java 中并没有指针,存储内存地址的是引用
实际上,这里的指针是指 C++ 指针,因为 JVM 是用 C++ 语言实现的,对象的存储结构、类的存储结构都是由 C++ 代码来定义的

2.3、数组长度

在 JVM 实现数组时,JVM 将数组作为一种特殊的对象来看待,其内存存储结构跟普通对象几乎一样
唯一的区别是,数组的对象头中多了数组长度这样一个字段,在 32 位 JVM 和 64 位 JVM 中,此字段均占 4 字节长度
从而,我们也可以得知,在 Java 中,可以申请的数组的最大长度为 2 ^ 32 - 1

3、实例数据

3.1、介绍

实例数据存储的是对象里的非静态成员变量,可以是基本类型,也可以是引用类型
因为静态成员变量属于类,而非对象,所以,静态成员变量并非存储在对象中,具体存储的位置,我们在后面的章节中讲解
在 64 位 JVM 中,各个类型的字节长度如下所示

类型 字节大小
double 8
long 8
float 4
int 4
short 2
char 2
byte 1
boolean 1
引用 8

在内存,每个属性存储的内存地址,必须是自身字节长度的倍数
比如,long 型、int 型、char 型属性的内存地址必须分别是 8、4、2 的倍数,如果不是,需要补齐
这样的存储要求叫做 "字节对齐",补齐的方法叫做 "字节填充"

除了属性对齐填充之外,对象整体也要对齐填充,对象整体按照 8 字节对齐,不足 8 字节的,在对象末尾补足 8 字节
这样每个对象都是从 8 的倍数的地址开始存储,为什么要字节对齐和字节填充,我们在下一小节「对齐填充」中讲解

对象中的属性并非按照定义的先后顺序来存储的,有了字节对齐和对齐填充,对于对象中的各个属性,以不同的顺序来存储,占用内存大小会不同,如下示例所示
image

3.2、存储规则

为了尽量减少内存占用,JVM 采用如下规则来安排字段的存储顺序,以下规则不需要记忆,只需要理解,在必要的时候,能够参照规则给出正确的内存排布即可

  • 规则一:先存储父类的属性,再存储子类的属性
  • 规则二:类中的属性默认按照如下先后顺序来存储:double / long、float / int、short / char、byte / boolean、object reference
    此顺序受 JVM 参数 -XX:FieldsAllocationStyle 影响,不过此参数在高版本 JDK 中被废弃,以上默认排序方式就是最优排序方式
  • 规则三:任何属性的存储地址都是按照类型的字节长度,进行字节对齐和填充
    比如 long 类型的属性的存储地址按 8 字节对齐,不足的补齐,对象整体按照 8 字节对齐和填充
  • 规则四:父类的属性和子类的属性之间 4 字节对齐,不足 4 字节的补齐 4 字节
  • 规则五:在应用规则 4 之后,父类的属性和子类的属性之间仍有间隙
    比如子类第一个属性的长度为 8 个字节,父类结尾 4 字节对齐,父类的属性和子类的属性之间要填充 4 字节,才能 8 字节对齐
    我们将子类属性按照 float / int、short / char、byte / boolean、object reference 的顺序,依次拿来填充间隙,直到间隙填充满或无法继续填充为止
    同理,如果在对象头和类的属性之间有间隙,我们同样应用此条规则进行填充
    此规则受 JVM 参数 -XX:CompactFields 的影响,默认为 true,如果我们将 -XX:CompactFields 参数设置为 false,此条规则将不再使用

总结:先存储父类的属性,再存储子类的属性,父类的属性和子类的属性之间 4 字节对齐;任何属性的存储地址都是按照类型的字节长度对齐;对象整体 8 字节对齐

3.3、举例

关于以上规则,我们举例来解释一下,示例代码如下

public class A {
private char a;
private long b;
private float c;
// ... 省略方法 ...
}
public class B extends A {
private boolean a;
private char b;
private long c;
private String d = "abc";
// ... 省略方法 ...
}
public class Demo9_1 {
public static void main(String[] args) {
System.out.println(ClassLayout.parseInstance(new B()).toPrintable());
}
}

上述代码 B 类的对象的内存结构,使用 JOL 打印出来,如下所示
这里特别说明一下,JVM 开启了指针压缩,所以,本应该占 8 字节的类指针和引用类型属性,现在只占 4 字节,关于指针压缩,稍后会详细讲解
image
我们结合规则,分析一下 JOL 打印出来的内存结构

按照规则一:先存储类 A 的属性,后存储类 B 的属性

按照规则二:类 A 中属性的存储顺序为:b、c、a,但对象头和类 A 的属性之间会有间隙,所以,附加应用规则五,将属性 c 提前来填充间隙

根据规则四:类 A 的属性和类 B 的属性之间不满足 4 字节对齐,所以,补齐了 2 字节填充

根据规则二:类 B 中的属性的存储顺序为:c、b、a、d,但这样类 A 的属性和类 B 的属性之间就会有 4 字节间隙,所以,附加应用规则五,把属性 b、a 提前来填充间隙
不过,填充之后,仍然有 1 字节间隙,所以,在存储 long 类型的 c 之前,需要对齐填充

最后,整个对象需要 8 字节对齐,所以,在对象末尾填充 4 字节,整个对象总共占用 48 个字节,其中包括 7 字节的填充,也就是上图中的 Space losses(空间损失)

4、对齐填充

4.1、介绍

前面频繁提到字节对齐和对齐填充,现在,我们就来看下,为什么要进行字节对齐和对齐填充?

这是因为 CPU 按照字(Word)为单位从内存中读取数据,对于 64 位的 CPU,字的大小为 8 字节,也就是说,内存以 8 字节为单位,切分为很多块,CPU 每次读取一块内存
接下来,我们按照 64 位 CPU 和 64 位 JVM 来讲解

对于长度为 8 字节的 long、double、引用类型数据,为了能一次性将其从内存中读入 CPU 缓存,必须将其存储在划分好的一个 8 字节内存块中
如果不做内存对齐,一个数据有可能横跨两个内存块
这样,CPU 就进行两次读取,才能将数据加载到 CPU 缓存中,读取之后,还需要从两个内存块中拼接出我们需要的数据
这样效率低,并且不能保证数据访问(读写)的原子性,如下图所示
image

对于长度小于 8 字节的数据类型,比如 float、int、short、char、byte、boolean,为了让它们能一次性从内存读取到 CPU 缓存,只需要按照类型长度对齐即可,并不需要 8 字节对齐

对于对象,因为其 Mark Word 占 8 个字节,为了能做到 8 字节对齐,对于大小不是 8 的整数的对象,JVM 在对象的末尾对齐填充,补齐 8 字节

4.2、伪共享

避免伪共享的方法

实际上,对于对象中的属性,除了以上对齐方式之外,对于特别注重执行效率的项目,比如 Disruptor
为了避免 CPU 缓存的 "伪共享(false sharing)",程序员会手动进行 64 字节或 128 字节对齐

  • 对象头、a、p1 ~ p6 占一个 64 字节 CPU 缓存行
  • b、p7 ~ p13 占一个 64 字节的缓存行

"伪共享" 属于多线程部分的知识点,我们在多线程模块中讲解

// 针对大小为 64 字节的缓存行, 保证属性 a、b 各自独占一个缓存行
public class DemoFalseSharing {
volatile long a;
long p1, p2, p3, p4, p5, p6; // 对齐填充
volatile long b;
long p7, p8, p9, p10, p11, p12, p13; // 对齐填充
}

5、压缩类指针和引用

5.1、介绍

在上述 JOL 打印的结果中,我们发现,类指针、引用类型为 4 字节大小,而非 8 字节大小
这是因为 JVM 默认开启了类指针压缩(-XX:+UseCompressedClassPointers 参数)和引用压缩(-XX:+UseCompressedOops 参数)
当然,我们也可以通过设置 JVM 参数 -XX:-UseCompressedClassPointers 和 -XX:-UseCompressedOops,将类指针压缩和引用压缩关闭
注意,在 JDK 8 中,类指针压缩开启的前提是引用压缩也已开启

5.2、如何压缩

那么,如何把 8 字节的地址压缩为 4 字节呢?类指针的压缩方式跟引用的压缩方式相似,我们拿引用类型举例解释

压缩之后的引用类型属性只占 4 字节,引用类型属性中存储的是对象的内存地址,4 字节可以寻址的内存大小为 2 ^ 32 个字节(一个字节一个地址),也就是 4GB
如果设置的堆大小超过 4GB,显然,有些对象的地址就无法在引用类型属性中存储了

但是,前面讲到对齐填充时,对象是按照 8 字节对齐的,也就是说,对象的首地址是 8 的倍数,表示成二进制之后,后三位二进制位均为 0
引用类型属性存储的是对象的首地址,既然后三位都为 0,那也就没必要存储了
这样,32 位二进制可以存储长度为 35 个二进制位的地址(后三位不存)
因此,4 字节的引用类型可以寻址的内存空间大小变成了 2 ^ 35 个字节,也就是 32GB
当我们要读取引用类型属性所引用的对象时,先从引用类型属性中读取压缩之后的对象首地址,然后左移 3 位,得到真正的对象首地址,再访问相应的内存块获取对象

不过,如果我们设置的 JVM 堆内存大小超过 32GB,即便设置了开启引用压缩,引用压缩也不会生效,我们怎么才能突破 32GB 这个限制呢?

我们想下,之所以能用 4 字节寻址 32GB 的内存空间,主要是因为对象按照 8 字节对齐
如果我们让对象按照 16 字节对齐,那么对象的内存地址末尾的 4 位二进制位都为 0,这样我们就可以用 32 位二进制存储长度为 36 个二进制位的地址
4 字节引用类型能寻址的内存空间大小变成了 2 ^ 36 个字节,也就是 64GB,如果想继续扩大寻址范围,我们只需要调大对象的对齐长度即可

我们可以使用 -XX:ObjectAlignmentInBytes 参数配置对象的对齐长度,参数取值范围为 [8, 256],并且必须为 2 的幂次方(2 ^ n 形式)
在支持引用压缩的前提下,最大可设置的堆大小的计算公式为如下所示,例如,当对象对齐为 32 个字节时,通过压缩指针最多可以使用 128GB 的堆空间

4GB * ObjectAlignmentInBytes

5.3、为何压缩

那么,为什么要压缩引用和类指针呢?

在编程开发时,我们会频繁地使用引用类型,在 64 位 JVM 中,引用类型占用 8 字节,如果将其压缩至 4 字节,将大大节省存储空间
很多程序在 32 位 JVM 中运行正常,迁移至 64 位 JVM 中,设置了更大的堆空间,反倒执行的更慢了
究其原因就是,引用类型大小在 64 位 JVM 中是 32 位 JVM 中的 2 倍
同样的对象,在内存中,占用更多的空间,更加容易频繁触发 GC,所以就表现为整个程序更慢了

在前面章节中讲到,Java 之所以在有 Integer 等包装类的情况下,仍然引入 int 等基本类型,其中一个重要原因就是节省内存
现在,我们就可以更加准确的分析一下,到底节省了多少内存
一个 int 数据占据 4 字节内存,一个 Integer 对象,对象头占据 8(Mark Word)+ 4(类指针)= 12 个字节,然后加上仅有的 int 类型属性,总共占据 16 字节
对比来看,一个 Integer 类对象所占内存大小,是 int 型数据所占内存大小的 4 倍
如果在项目中大量使用数值,那么使用基本类型变量来表示,就比包装类对象,节省大量内存空间了

6、课后思考题

计算一个 D 类对象所占用的总内存空间大小

public class A {
private char a;
private long b;
private float c;
private static final int d = 1;
// ... 省略方法 ...
}
public class C {
private int a;
private char b;
private A c;
private double d;
// ... 省略方法 ...
}
public class D extends C {
private boolean a;
private long b;
private char c;
// ... 省略方法 ...
}

image

对象占用总内存大小为 48 字节
12 字节对象头
- 8 字节标记字
- 4 字节 D 类指针
20 字节父类实例属性
- 4 字节 C.a
- 8 字节 C.d
- 2 字节 C.b
- 2 字节填充
- 4 字节 C.a
11 字节子类实例属性
- 8 字节 D.b
- 2 字节 D.c
- 1 字节 D.a
5 字节对象填充
posted @   lidongdongdong~  阅读(75)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
展开