在 Java 程序中,我们拥有多种新建对象的方式。除了最为常见的 new 语句之外,我们还可以通过反射机制、Object.clone 方法、反序列化以及 Unsafe.allocateInstance 方法来新建对象
Object.clone 方法和反序列化通过直接复制已有的数据,来初始化新建对象的实例字段。Unsafe.allocateInstance 方法则没有初始化实例字段,而 new 语句和反射机制,则是通过调用构造器来初始化实例字段。
new 语句为例,字节码将包含用来请求内存的 new 指令,以及用来调用构造器的 invokespecial 指令。
public class TestFoo {
public static void main(String[] args) {
TestFoo foo = new TestFoo();
}
}
对应字节码
public static main([Ljava/lang/String;)V
L0
LINENUMBER 5 L0
NEW top/zcwfeng/java/test/TestFoo
DUP
INVOKESPECIAL top/zcwfeng/java/test/TestFoo.<init> ()V
ASTORE 1
如果一个类没有定义任何构造器的话,java编译器会自动添加一个无参数的构造器
public <init>()V
L0
LINENUMBER 3 L0
ALOAD 0
INVOKESPECIAL java/lang/Object.<init> ()V
RETURN
压缩指针
在 Java 虚拟机中,每个 Java 对象都有一个对象头(object header),这个由标记字段(Mark Word)和类型指针(Klass Pointer)所构成。其中,标记字段用以存储 Java 虚拟机有关该对象的运行数据,如哈希码、GC 信息以及锁信息,而类型指针则指向该对象的类。
在 64 位的 Java 虚拟机中,对象头的标记字段占 64 位,而类型指针又占了 64 位。也就是说,每一个 Java 对象在内存中的额外开销就是 16 个字节。以 Integer 类为例,它仅有一个 int 类型的私有字段,占 4 个字节。因此,每一个 Integer 对象的额外内存开销至少是 400%。这也是为什么 Java 要引入基本类型的原因之一。
为了尽量较少对象的内存使用量,64 位 Java 虚拟机引入了压缩指针 [1] 的概念(对应虚拟机选项 -XX:+UseCompressedOops,默认开启),将堆中原本 64 位的 Java 对象指针压缩成 32 位的。
这样一来,对象头中的类型指针也会被压缩成 32 位,使得对象头的大小从 16 字节降至 12 字节。当然,压缩指针不仅可以作用于对象头的类型指针,还可以作用于引用类型的字段,以及引用类型数组。
上面是官方的解释。我们弄明白几个问题:
32位操作系统可以寻址到多大内存
答:4g 因为 2^32=4 * 1024 * 1024=4g
64位呢?
2的64次方bai:18446744073709551616
这个数有点大,计算器一般算不出来,编程的话用long值才能计算到2的62次方
答:64位过长,给我们寻址带宽和对象内引用造成了负担
一个对象占用的字节数
对象头:
32位系统,占用 8 字节(markWord4字节+kclass4字节)
64位系统,开启 UseCompressedOops(压缩指针)时,占用 12 字节,否则是16字节(markWord8字节+kclass8字节,开启时markWord8字节+kclass4字节)
实例数据
boolean 1
byte 1
short 2
char 2
int 4
float 4
long 8
double 8
引用类型
32位系统占4字节 (因为此引用类型要去方法区中找类信息,所以地址为32位即4字节同理64位是8字节)
64位系统,开启 UseCompressedOops时,占用4字节,否则是8字节
对齐填充
如果对象头+实例数据的值不是8的倍数,那么会补上一些,补够8的倍数
32位操作系统 花费的内存空间为
对象头-8字节 + 实例数据 int类型-4字节 + 引用类型-4字节+补充0字节(16是8的倍数) 16个字节
64位操作系统(未开启指针压缩)
对象头-16字节 + 实例数据 int类型-4字节 + 引用类型-8字节+补充4字节(28不是8的倍数补充4字节到达32字节) 32个字节
同样的对象需要将近两倍的容量,(实际平均1.5倍)
64位开启压缩指针
对象头-12字节 + 实例数据 int类型-4字节 + 引用类型-4字节+补充0字节=24个字节---减缓堆空间的压力(同样的内存更不容易发生oom)
JVM的实现方式是
不再保存所有引用,而是每隔8个字节保存一个引用。例如,原来保存每个引用0、1、2…,现在只保存0、8、16…。因此,指针压缩后,并不是所有引用都保存在堆中,而是以8个字节为间隔保存引用。
在实现上,堆中的引用其实还是按照0x0、0x1、0x2…进行存储。只不过当引用被存入64位的寄存器时,JVM将其左移3位(相当于末尾添加3个0),例如0x0、0x1、0x2…分别被转换为0x0、0x8、0x10。而当从寄存器读出时,JVM又可以右移3位,丢弃末尾的0。(oop在堆中是32位,在寄存器中是35位,2的35次方=32G。也就是说,使用32位,来达到35位oop所能引用的堆内存空间)
哪些信息会被压缩?
1.对象的全局静态变量(即类属性)
2.对象头信息:64位平台下,原生对象头大小为16字节,压缩后为12字节
3.对象的引用类型:64位平台下,引用类型本身大小为8字节,压缩后为4字节
4.对象数组类型:64位平台下,数组类型本身大小为24字节,压缩后16字节
哪些信息不会被压缩?
1.指向非Heap的对象指针
2.局部变量、传参、返回值、NULL指针
在JVM中(不管是32位还是64位),对象已经按8字节边界对齐了。对于大部分处理器,这种对齐方案都是最优的。所以,使用压缩的oop并不会带来什么损失,反而提升了性能。
看一个实例
class A {
long l;
int i;
}
class B extends A {
long l;
int i;
}
开启压缩指针 开启(-XX:+UseCompressedOops) 默认开启
> Task :TestFoo.main()
------------B---------------
# WARNING: Unable to get Instrumentation. Dynamic Attach failed. You may add this JAR as -javaagent manually, or supply -Djdk.attach.allowAttachSelf
top.zcwfeng.java.test.B object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 50 04 06 00 (01010000 00000100 00000110 00000000) (394320)
12 4 int A.i 0
16 8 long A.l 0
24 8 long B.l 0
32 4 int B.i 0
36 4 (loss due to the next object alignment)
Instance size: 40 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
================
关闭压缩指针 关闭(-XX:-UseCompressedOops) 可以关闭压缩指针
> Task :TestFoo.main()
------------B---------------
# WARNING: Unable to get Instrumentation. Dynamic Attach failed. You may add this JAR as -javaagent manually, or supply -Djdk.attach.allowAttachSelf
top.zcwfeng.java.test.B object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00 00 00 (00000101 00000000 00000000 00000000) (5)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 48 12 e0 a1 (01001000 00010010 11100000 10100001) (-1579150776)
12 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
16 8 long A.l 0
24 4 int A.i 0
28 4 (alignment/padding gap)
32 8 long B.l 0
40 4 int B.i 0
44 4 (loss due to the next object alignment)
Instance size: 48 bytes
Space losses: 4 bytes internal + 4 bytes external = 8 bytes total
字段重排列
Java 虚拟机重新分配字段的先后顺序,以达到内存对齐的目的。Java 虚拟机中有三种排列方法(对应 Java 虚拟机选项 -XX:FieldsAllocationStyle,默认值为 1),但都会遵循如下两个规则。
其一,如果一个字段占据 C 个字节,那么该字段的偏移量需要对齐至 NC。这里偏移量指的是字段地址与对象的起始地址差值。
以 long 类为例,它仅有一个 long 类型的实例字段。在使用了压缩指针的 64 位虚拟机中,尽管对象头的大小为 12 个字节,该 long 类型字段的偏移量也只能是 16,而中间空着的 4 个字节便会被浪费掉。
其二,子类所继承字段的偏移量,需要与父类对应字段的偏移量保持一致。
在具体实现中,Java 虚拟机还会对齐子类字段的起始位置。对于使用了压缩指针的 64 位虚拟机,子类第一个字段需要对齐至 4N;而对于关闭了压缩指针的 64 位虚拟机,子类第一个字段则需要对齐至 8N。
上面的分析,加入了工具JOL的帮助
gradle 配置
implementation 'org.openjdk.jol:jol-core:0.14'
java 环境
java 11.0.10 2021-01-19 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.10+8-LTS-162)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.10+8-LTS-162, mixed mode)
当然Java8 版本有的也可以,我的失败了,为了方便所有我选择存在的环境11
然后调用可以分析:
System.out.println("------------B---------------");
B o = new B();
String s = ClassLayout.parseInstance(o).toPrintable();
System.out.println(s);