Java 对象详解

深入理解 Java 对象到底是什么。


内部布局

Java Object

图片来源网络,其中 Class Pointer 应该为 Klass Pointer

一个 Java 对象包括对象头、实例数据和对齐字节三部分。

其中 Array Object 相比于非 Array Object 在对象头的位置还多一个 Length ,用于记录数组的长度。

注意:在实际内存里,用作对齐的字节不是直接跟在实例数据后面,下面会讲到。


对象头

对象头包括三部分:Mark Word、Klass Pointer 和 Length(只有当对象为 Array Object 才会有)

Mark Word : 包含一系列的标记位,比如轻量级锁的标记位,偏向锁标记位等等。在 32 位系统占 4 字节,在 64 位系统占 8 字节;

Klass Pointer : 用来指向对象对应的 Class 对象(其对应的元数据对象)的内存地址。在 32 位系统占 4 字节,在 64 位系统占 8 字节;

Length : 如果是数组对象,还有一个保存数组长度的空间。再 32 位系统和 64 位系统均占 4 个字节;


实例数据

对象实际数据包括了对象的所有成员变量,其大小由各个成员变量的大小决定。

类型 大小(字节)
byte 1
boolean 1
char 2
short 2
int 4
float 4
long 8
double 8
reference 4 or 8

其中 reference 引用类型比较特别:

  • 32 位系统下占 4 个字节

  • 64 位系统下 :

    • 不启用指针压缩, 大小统一为 8 个字节
    • 启用指针压缩(下面会介绍指针压缩),”普通对象指针“ 的大小占到 4 个字节,其余指针大小为 8 个字节

字节对齐

Java 对象都是 8 字节对齐。

所以当 对象头大小 + 实例数据大小 的总和不是 8 字节的整数倍,会由这部分补齐。

对齐的目的是为了提高存取效率,所以如果存在对齐部分,肯定不会添加在”实例数据“的后面。

举个例子:

1
2
3
4
5
6
7
public class Clazz {
private long aLong;

public static void main(String[] args) {
Clazz clazz = new Clazz();
}
}

64 位系统开启指针压缩的的情况下:clazz 对象的大小 = 8 字节的 Mark Word + 4 字节的 Klass Pointer + 8 字节的 long 实例数据 = 20 字节,之后补齐到 24 字节,即存在 4 个字节的对齐。

这 4 字节是加在哪里呢?是实例数据的后面吗?

我们知道,64 位系统计算机一次性可以处理 64 位的数据,也就是 8 个字节。

如果是加在实例对象的后面的话,那么当我们访问实例成员 aLong 的时候,需要读取两次

  1. 偏移 8 字节之后进行读取,第一次读取 8 个字节,读取内容为对象头的后 4 位(共 12 字节)+ 实例数据 aLong 的前 4 个字节
  2. 第二次读取为实例数据 aLong 的后 4 字节 + 补齐字节

需要花两次才能把 aLong 完全读取出来,这显然没有提高存取效率。

如果将补齐字节加载对象头之后,实例数据之前呢?是否可以实现一次性读取?

  1. 偏移 16 字节后开始读取(12 字节的对象头 + 4 字节的对齐内容)
  2. 一次性读取实例数据 aLong(8 字节)

看起来将对齐字节加到对象头和实例数据之间更为合理?如何可以证明 aLong 是从第 16 个字节开始,而不是从第 12 个字节开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Clazz {
private long aLong;

public static void main(String[] args) {
try {
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
Unsafe unsafe = (Unsafe) theUnsafe.get(null);
long offset = unsafe.objectFieldOffset(Clazz.class.getDeclaredField("aLong"));
System.out.println(offset); // 打印结果为 16
} catch (Exception e) {
e.printStackTrace();
}
}
}

如果将实例数据改为 int 类型的话,那么最终对象大小 = 8 字节的 Mark Word + 4 字节的 Klass Pointer + 4 字节的 int 实例数据 = 16 字节,不需要进行字节对齐。

这时候打印结果为 12 。读取流程为先偏移 8 个字节,再读取一次(8字节),读取内容为对象头的后 4 个字节 + 实例数据 int ,也是一次性读取。


计算对象大小

理解了 Java 的对象布局之后,

Shallow Size & Retained Size

Retained Size : 对象自身占用的内存大小,不包括它引用的对象。

Retained Size : 当前对象大小 + 当前对象可直接或间接引用到的对象的大小总和 (不包括被 GC 直接或间接引用的对象)

换句话说,Retained Size 就是当前对象被 GC 后,从 Heap 上总共能释放掉的内存

Shallow Size & Retained Size

图片来源网络:描述了对象引用关系和 GC Roots 引用

A 对象的 Retained Size = A 对象的 Shallow Size

B 对象的 Retained Size = B 对象的 Shallow Size + C 对象的 Shallow Size

B 对象的 Retained Size不包括 D 对象,因为 D 对象被 GC Roots 直接引用

指针压缩

在 JDK 1.6 之后,64 位的 JVM 可以使用 -XX:+UseCompressedOops 压缩“普通对象指针” 和 -XX:+UseCompressedClassPointer 压缩“对象头的 Klass Pointer”,起到节约内存占用的作用。

何为 ”普通对象指针“ ?

  • 对象的属性指针
  • Class 对象的属性指针(静态成员变量)
  • 普通对象数组的元素指针

1
2
3
4
5
public class Clazz {
private Object object; // 对象的属性指针
private static Object staticObject; // Class 对象的属性指针(静态成员变量)
private Object[] objects; // 普通对象数组的元素指针,这里不是指 objects 本身,而是指 objects 里面的每个成员的元素指针
}

只有这几类指针会被压缩优化。其余的本地变量、堆栈元素、入参、返回值和 NULL 指针不会被压缩。

一些内置对象的 Retained Size

包装类型及 String:

类型 开启指针压缩(字节) 关闭指针压缩(字节)
Byte 16 24
Boolean 16 24
Character 16 24
Short 16 24
Integer 16 24
Float 16 24
Long 24 24
Double 24 24
String >= 24 >= 32

String 类型比较特别,其大小取决于装载的字符串内容的大小,上面的 24 & 32 是对空字符串计算所得:

1
2
3
4
5
6
7
8
9
10
11
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {

private final char value[];

private int hash; // Default to 0

private static final long serialVersionUID = -6849794470754667710L;

// methods ...
}

查看 String 源码可以发现,主要属性有两个 value 和 hash(serialVersionUID 为静态属性,不参与大小计算)

  • 32 位系统:4 字节的 Mark Word + 4 字节的 Klass Pointer + 4 字节的 value 数组引用 + 4 字节的 hash int 类型 + 实际存储的字符串大小(value 的实际大小)>= 16 字节
  • 64 位系统:
    • 关闭指针压缩:8 字节的 Mark Word + 8 字节的 Klass Pointer + 8 字节的 value 数组引用 + 4 字节的 hash int 类型 + 实际存储的字符串大小(value 的实际大小)>= 32 字节
    • 开启指针压缩:8 字节的 Mark Word + 4 字节的 Klass Pointer + 4 字节的 value 数组引用 + 4 字节的 hash int 类型 + 实际存储的字符串大小(value 的实际大小)>= 24 字节

计算自定义类大小

计算如下对象的占用大小:

1
2
3
4
5
6
7
public class Clazz {
String string = "test";

public static void main(String[] args) {
Clazz clazz = new Clazz(); // 该对象的大小
}
}

虽然现在 32 位的系统已经几乎没有人用了,但是为了练习,还是来计算 32 位系统的大小、64 位系统开启指针压缩和 64 位系统关闭指针压缩几种情况:

  • 32 位系统:
    1. clazz 对象 Shallow Size = 4 字节的 Mark Word + 4 字节的 Klass Pointer + 4 字节的 string 引用 = 12 字节,对齐为 16 字节
    2. 内部引用到的 string 对象的 Shallow Size = 4 字节的 Mark Word + 4 字节的 Klass Pointer + 4 字节的 value 数组引用 + 4 字节的 hash int 类型 = 16 字节
    3. 计算 string 对象内部的 object 数组占用的大小 = 4 字节的 Mark Word + 4 字节的 Klass Pointer + 4 字节的 Length + 8 字节的 char = 20 字节,对齐为 24 字节
    4. 总大小 = 16 + 16 + 24 = 56 字节
  • 64 位系统:
    • 关闭指针压缩:
      1. clazz 对象 Shallow Size = 8 字节的 Mark Word + 8 字节的 Klass Pointer + 8 字节的 string 引用 = 24 字节
      2. 内部引用到的 string 对象 Shallow Size = 8 字节的 Mark Word + 8 字节的 Klass Pointer + 8 字节的 value 数组引用 + 4 字节的 hash int 类型 = 28 字节,对齐为 32 字节
      3. 计算 string 对象内部的 object 数组占用的大小 = 8 字节的 Mark Word + 8 字节的 Klass Pointer + 4 字节的 Length + 8 字节的 char = 28 字节,对齐为 32 字节
      4. 总大小 = 24 + 32 + 32 = 88 字节
    • 开启指针压缩:
      1. clazz 对象 Shallow Size = 8 字节的 Mark Word + 4 字节的 Klass Pointer + 4 字节的 string 引用 = 16 字节
      2. 内部引用到的 string 对象 Shallow Size = 8 字节的 Mark Word + 4 字节的 Klass Pointer + 4 字节的 value 数组引用 + 4 字节的 hash int 类型 = 20 字节,对齐为 24 字节
      3. 计算 string 对象内部的 object 数组占用的大小 = 8 字节的 Mark Word + 4 字节的 Klass Pointer + 4 字节的 Length + 8 字节的 char = 24 字节
      4. 总大小 = 16 + 24 + 24 = 64 字节

对象头的几种状态

无论是否开启指针压缩,对象头 Mark Word 都占 8 个字节,那么这 8 个字节到底记录些什么内容?

Java Object Mark Word

图片来源网络,其中 “对象的 hashCode” 不准确,应该为 “对象的 identityHashCode( 包括 System.identityHashCode(o) 或者未被重写的 hashCode() )” ,因为如果对象重写了 hashCode 方法,那么计算出来的 hashCode 将不会被记录到 Mark Word 中

Mark Word 中的 8 个字节记录什么内容,取决于当前对象是什么状态(由对象的后 3 位可确定对象状态):

  • 普通对象:Mark Word 的后两位为 01,第三位代表是否偏向,此时为 0。即普通对象状态下的锁对象 Mark Word 后三位为 010;之后的 4 位记录的是对象分代年龄;再之后的 25 位用于记录 identityHashCode ,只有调用了 System.identityHashCode(o) 或者未被重写的 hashCode() 才会被缓存起来,如果自身重写了 hashCode() 方法,计算结果不会被缓存到 Mark Word 中(重写 hashCode() 方法通常是使用实例变量的值计算哈希值,确保两个不同的的对象在逻辑上是相同的,也就是对象的哈希值随时会随着实例变量的值变化,这时候 Mark Word 也没有将哈希值缓存起来的必要)
  • 偏向锁:Mark Word 的后两位为 01,第三位代表是否偏向,此时为 1。即偏向锁状态时锁对象 Mark Word 的后三位为 101;之后的 4 位记录的是对象分代年龄;之后 25 位中有 23 位用于记录当前偏向的线程 id ,有 2 位是记录线程重入次数
  • 轻量级锁:Mark Word 的后两位为 00;之后的空间用于记录栈中锁记录指针。轻量级锁又称为“自旋锁”,底层是使用 CAS 锁(lock compareExchange 指令),当有大量的线程试图获取轻量级锁的时候,会很占用 CPU 资源
  • 重量级锁:Mark Word 的后两位为 10;之后的空间用于记录“重量级互斥锁”内容(ObjectMonitor)。所谓的重量级是指要向内核态申请执行指令,而之前的几种锁状态都是在用户态完成;重量级锁的底层是使用等待队列装载正在阻塞的线程,当锁释放后由操作系统从等待对象中取出线程进行调度
  • 待 GC 回收:Mark Word 的后两位为 11
不带政治色彩的港乐有多好听 更值得其他语言开发者看的《阿里Java开发手册》

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×