对象的大小


一晃都三月了,过了一个很古早的冬天呐。

冬眠回来,三月的第二周,来看 Java 对象的内存设计,这部分的学习是为之后学习 synchronized 关键字铺路的。本来想在学习锁时顺便提一嘴写完它,结果发现这坑有点大,还是要单独学。


我们这周学习的目的,是搞清楚存储在内存中的对象,它具体是如何存储的,存储时都需要存哪些信息,以及存这些信息的意义是什么。

比如看下面这段代码:

1
2
List list = new ArrayList;
list.add("hello, world!");

上面这两行代码当中的 list 对象是如何存储起来的。


要学习对象是怎么存储在内存当中的,就要从很原始的地方说起,先学习 JVM 的内存结构。

这一块我是真的很欠缺,只知道一点基础的概念,我只把本次要用到的知识写在这里,其他的知识我还要慢慢学。有关 JVM 的内存结构,我学习的起点是《【java】jvm内存模型全面解析》视频,很推荐一看。

JVM 在运行 Java 程序时,会管理一块内存区域,这一片区域被称为运行时数据区域,从结构上可以分为五个部分,分别是:

  • Java 虚拟机栈:线程私有,存储局部变量等
  • 本地方法栈:线程私有,存储本地方法的变量等
  • 程序计数器:线程私有,存储字节码的地址(程序执行到第几行了)
  • :线程共享,存储几乎所有对象
  • 方法区:线程共享,存储类的结构信息(字段、构造方法等等)

(下图来源自《CYC - Java 虚拟机 - 运行时数据区域》,其内容整理非常值得反复阅读)

JVM内存结构

我们今天要说的,只是(栈指的是 Java 虚拟机栈)。非常浅薄地讲,存放的是局部变量以及对象的地址,存放的是对象的实体。(看书发现,栈中存放的并不一定是对象地址,但这是最常见的寻找堆对象的方式)

简单制作了一张图,描述了代码、栈、堆之间的关系。

JVM内存存储对象


Tips

内存结构和内存模型并不是一个概念:

当我们说内存结构时,通常是指JVM 内存结构,这是真实存在的,指的是上文介绍的 Java 虚拟机栈、堆、本地方法栈等等那五部分构成的 JVM 运行时数据区域,这是在结构上把 JVM 的内存分成了多个部分。

当我们说内存模型时,通常是指Java 内存模型,这是虚拟存在的,指的是面对并发时 Java 是如何实现内存访问一致的,牵扯到了主内存和工作内存等知识,这是在模型和概念上,屏蔽各种硬件和操作系统的内存访问差异,来实现并发内存一致性。



简单说完了对象存放的位置,那么接下来就要进入这周学习的重点了:如何计算对象的大小。这个问题实际上可以拆成两个问题:

  1. 对象由哪些部分组成?
  2. 每部分各占多少字节?

在这两个问题的基础上,自然会问出第三个问题:

  1. 组成对象的这些基础部分,各自是做什么的?

PS:在看书的时候,发现自己所学习对象大小的这部分知识,实际上是 HotSpot 虚拟机的实现,而并非所有 Java 虚拟机的实现,但是目前基本上所有的 Java 程序都跑在 HostSpot 虚拟机上面。


所有对象都可以笼统地切分成两部分:对象头(Header)和对象内容(Instance Data)。

举一个实际的例子:

1
2
3
4
class Person {
private String name;
private int age;
}

对于上面这个 Person 类,它实例化出来的对象同样具有对象头和对象内容两部分,nameage 都是对象的内部变量,属于对象内容,而对象头是其余一些辅助信息。


我绘制了一张图,画出了在最常见情况下(64 位虚拟机开启指针压缩),对象在内存中的结构,后文都是在解释这个结构的具体信息。

对象头

对象内容

对象内容准确地讲应该叫做实例数据(Instance Data),比较简单,因此我们先讲完。

正如之前提到的 Person 对象的例子,对象内的属性(包括基本数据类型 int age 和引用的另一个对象 String name),这些属性所占的内容大小,就是对象内容的大小。在该例子中,int 类型的 age 占 4 个字节(即 32 位),引用另一个对象时,存储的是对象的地址,地址是一个 int 类型的指针,因此 String 类型的 name 存储在 Person 对象中也占 4 个字节(即 32 位),两个属性加起来一共占 8 个字节。

因此计算对象内容的大小,实际上就是分两部分,基本数据类型一类,占内容大小加起来,引用别的对象占一类,引用一个就是 4 字节(int 的大小),引用 N 个对象就占 N*4 个字节。

下面列举了 8 种基本数据类型的大小。

基本数据类型 大小(字节)
byte 1
char 2(表示一个 UTF-16be 编码单元,生僻字用两个char
short 2
int 4
flote 4
long 8
double 8
boolean 通常是1

此外还要注意的一点是,如果 A 类继承自 B 类,那么计算 A 类的对象内容大小时,继承来的 B 类的属性也是要算在内的。比如计算 ArrayList 对象大小的时候,它的父类 AbstractList 中的属性,也是要计算在内的。


对象头

对象头(Header)比较复杂,它包含着对象的“冗余信息”,这些信息或实现并发锁,或帮助垃圾分类,或包含类的信息。

从整体上看,对象头包含三部分的信息,分别是

  • 标记字段
  • 地址
  • 数组长度

标记字段

标记字段(Mark Word)是对象头中最复杂的内容,需要对照上面绘制的图来看。

由于内存空间寸土寸金,在希望对象能够记录更多信息的同时,还要尽可能地压缩空间,在这种背景之下,32 位虚拟机的对象标记字段长 4 字节,64 位虚拟机的对象标记字段长 8 字节(现在基本都是 64 位了吧),并且都有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据。32 位和 64 位的存储长度不同,仅仅是因为地址指针长度引起的变化,在存储的内容类型方面没有区别。

(具体的标记字段信息可见文末的备注)

以我当下的理解,标记字段主要实现了三个事情:

  1. 对并发情况下的 synchronized 支持
  2. GC 垃圾回收
  3. 保存 hashcode

标记字段共有五种状态,分别是对应于 synchronized 的四种状态(无锁、偏向锁、轻量级锁和重量级锁),以及一种 GC 状态,这五种状态通过 2 位标志位实现(无锁和偏向锁的标志位相同)。

因此,了解标记字段的具体信息,实际上就是在了解 synchronized 锁和垃圾回收的原理。这两部分都有点难,本文暂时不讨论了,有关 synchronized 的信息可以参考这篇文章《彻底搞懂 Java 中的偏向锁,轻量级锁,重量级锁》

地址信息

对象头中有一部分是地址信息,它实际上是一个类型指针,指向了该对象类型的地址。

例如 person 对象的对象头中的地址信息,指向了 Person 类的地址(类在方法区)。

在这种设计下,可以通过对象找到类,比如在 main() 方法中实例化一个 Person 对象 person,在内存中寻址的过程为:

  1. main() 方法的 Java 栈中记录着 person 对象的地址,
  2. 根据这个地址在堆中找到了 person 对象,
  3. person 对象的头部又记录着 Person 类的地址,根据这个地址在方法区中找到了 Person 类。

(实际上,在对象的头部中保留类的地址信息,通过对象找到类的位置,这种设计是 HotSpot 虚拟机的设计,也有别的虚拟机不这么设计,对象头中并不包含类的地址,不通过对象找类。)

地址信息的大小并不是固定的,这跟系统位数有关,32 位的虚拟机,指针是 32 位长,地址信息只需要 32 (即 4 字节),但是对于 64 位的虚拟机,指针是 64 位长,因此地址信息也需要扩增到 64 位(即 8 字节)。

32 位的虚拟机,理论上只能寻址到 4 GB 的内存空间(2^32 byte = 4 GB),而 64 位的虚拟机能寻址到更多地址。这样的提升是有代价的,一方面内存占用量变大了,原来只需要 4 个字节存储一个地址,现在需要 8 个字节了(如果不需要比 4GB 更多的内存,用这么大的空间是没有意义的),另一方面寻址时操作位数更长的指针,主内存和各级缓存移动数据时,占用的带宽也会增加。

Java 虚拟机为了处理这个问题,提出了指针压缩

指针压缩的简易原理是这样的:32 位的指针,当然只能找到 4 GB 个内存位置,如果我有一块更大的内存区域,比如 10 GB,32 位的指针就不能指向这 10 GB 中的所有位置,但实际上并不需要找到这块内存中的所有位置,它只需要找到要操作的开始位置就可以了。这意味着 32 位的指针可以引用 40 亿个对象,而不是 40 亿个字节。Java 对象的大小如果一定是 8 字节的整数倍(这个后文有讲),那么就可以使原来只能寻址 4 GB 的内存扩大 8 倍,到 32 GB 的内存。

因此对于分配内存低于 4 GB 的虚拟机,默认开启指针压缩,指针大小就是 32 位长,对于分配内存在 4 - 32 GB 之间的虚拟机,可以开启指针压缩算法,使指针大小依旧维持在 32 位长,但是对于更大的内存,无法开启指针压缩,指针大小必须是 64 位长。(因此分配内存并不是越大越好,32 GB 处会有一个门槛)

指针压缩并非毫无缺陷,这毕竟是多出来的算法,会增加 JVM 的计算量。

总结:对象头中的地址信息大小,跟系统位数以及是否开启指针压缩有关,32 位系统开启了指针压缩的 64 位系统的地址信息长 4 字节,普通 64 位系统的地址信息长 8 字节。

数组大小

数组大小并不是必须的,数组才有,非数组没有。

因为数组是 new 出来的,需要在堆上分配内存,在这个意义上讲,数组就是对象的一种。数组的长度是需要记录下来的,长度为 4 字节。

int 也是 4 字节,这就很容易让人联想在一起。Java 中 int 是有符号整型数,是有负值的,int 的最大值是 2^31 - 1,用二进制表示为 01111111111111111111111111111111。数组的理论最大长度,也应该是 int 的最大值。

实际的使用中可能会小一点。例如 ArrayList 内部维护的数组,它的最大长度是 Integer.MAX_VALUE - 8,注释称这是因为虚拟机的限制。又例如 HashMap 内部维护的数组,它的最大程度是 1 << 30,这是 1 位运算之后能获得到的最大值(二进制为 01000000000000000000000000000000)。


(还有一点需要提及)

在计算完对象头和对象内容的大小之后,二者加起来并不一定是最终占内存的大小,还要考虑内存对齐的问题。

所有对象的字节大小,必须是 8 的整数倍,如果对象头+对象内容算出来是 15 字节,那么最终对象大小为 16 字节,如果是 20 字节,那么最终对象大小是 24 字节,总之如果不满 8 的整数倍,都填充到 8 的整数倍,填充的部分叫做对齐填充(Padding),实际上就是占位符。

对齐填充的原因在于,HotSpot 虚拟机的自动内存管理系统,要求对象的起始地址必须是 8 字节的整数倍(这样寻址更高效,而且实现了指针压缩),因此对象的大小也就必须是 8 字节的整数倍。


备注

在博文《Java Object Header 和 锁》中找到了三种情况(32 位虚拟机、64 位虚拟机、64 位虚拟机开启指针压缩)下,对象头的具体存储内容,这部分内容比较难找到,备注如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
32位
|----------------------------------------------------------------------------------------|--------------------|
| Object Header (64 bits) | State |
|-------------------------------------------------------|--------------------------------|--------------------|
| Mark Word (32 bits) | Klass Word (32 bits) | |
|-------------------------------------------------------|--------------------------------|--------------------|
| identity_hashcode:25 | age:4 | biased_lock:1 | lock:2 | OOP to metadata object | Normal |
|-------------------------------------------------------|--------------------------------|--------------------|
| thread:23 | epoch:2 | age:4 | biased_lock:1 | lock:2 | OOP to metadata object | Biased |
|-------------------------------------------------------|--------------------------------|--------------------|
| ptr_to_lock_record:30 | lock:2 | OOP to metadata object | Lightweight Locked |
|-------------------------------------------------------|--------------------------------|--------------------|
| ptr_to_heavyweight_monitor:30 | lock:2 | OOP to metadata object | Heavyweight Locked |
|-------------------------------------------------------|--------------------------------|--------------------|
| | lock:2 | OOP to metadata object | Marked for GC |
|-------------------------------------------------------|--------------------------------|--------------------|


64位
|------------------------------------------------------------------------------------------------------------|--------------------|
| Object Header (128 bits) | State |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| Mark Word (64 bits) | Klass Word (64 bits) | |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 | OOP to metadata object | Normal |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:2 | OOP to metadata object | Biased |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| ptr_to_lock_record:62 | lock:2 | OOP to metadata object | Lightweight Locked |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| ptr_to_heavyweight_monitor:62 | lock:2 | OOP to metadata object | Heavyweight Locked |
|------------------------------------------------------------------------------|-----------------------------|--------------------|
| | lock:2 | OOP to metadata object | Marked for GC |
|------------------------------------------------------------------------------|-----------------------------|--------------------|


64位(开启指针压缩)
|--------------------------------------------------------------------------------------------------------------|--------------------|
| Object Header (96 bits) | State |
|--------------------------------------------------------------------------------|-----------------------------|--------------------|
| Mark Word (64 bits) | Klass Word (32 bits) | |
|--------------------------------------------------------------------------------|-----------------------------|--------------------|
| unused:25 | identity_hashcode:31 | cms_free:1 | age:4 | biased_lock:1 | lock:2 | OOP to metadata object | Normal |
|--------------------------------------------------------------------------------|-----------------------------|--------------------|
| thread:54 | epoch:2 | cms_free:1 | age:4 | biased_lock:1 | lock:2 | OOP to metadata object | Biased |
|--------------------------------------------------------------------------------|-----------------------------|--------------------|
| ptr_to_lock_record | lock:2 | OOP to metadata object | Lightweight Locked |
|--------------------------------------------------------------------------------|-----------------------------|--------------------|
| ptr_to_heavyweight_monitor | lock:2 | OOP to metadata object | Heavyweight Locked |
|--------------------------------------------------------------------------------|-----------------------------|--------------------|
| | lock:2 | OOP to metadata object | Marked for GC |
|--------------------------------------------------------------------------------|-----------------------------|--------------------|


最后用一个例子检验上文中的内容,计算一个 HashMap 对象的大小。

HashMap 类不是数组,在 64 位开启指针压缩的情况下,对象头只包括 8 字节的标记字段和 4 字节的地址指针,总共 12 字节。

HashMap 类中分别有下列属性:

  • entrySet (对象)
  • hashSeed (int)
  • loadFactor (float)
  • modCount (int)
  • size (int)
  • table (数组,当对象处理)
  • threshold (int)

检查 HashMap 的所有父类,在 AbstractMap 中发现了两个新的属性:

  • keySet (对象)
  • values (对象)

算下来一共是 9 个属性,每个属性很巧都是 4 字节,一共是 9×4 = 36 字节,因此 HashMap 的对象内容为 36 字节。

HashMap 对象的对象头 12 字节 + 对象内容 36 字节总共是 48 字节,是 8 字节的倍数,无需对齐填充。

因此一个 HashMap 对象的大小是 48 字节。