对象的创建

本篇文章的标题虽然是「对象创建」,但内容却不仅仅只有对象的创建,以对象的创建为主线,展开对 Java 堆的详细整理总结!!

其中包括:堆的内存结构、对象的内存布局、对象创建过程、对象内存分配过程。其中「堆的内存结构」和「对象内存分配过程」可能会涉及到一些垃圾收集的内容!

堆的内存结构

首先来梳理一下「堆」「虚拟机栈」「方法区」三者之间的关系:

6

注意:到了 JDK7 的 HotSpot,已经把原本放在永久代的字符串常量池静态变量等移至 Java 堆中 (上图方法区为 JDK7 之前的布局)

在文章 运行时数据区域 说过:为了更好地回收内存,或是更快地分配内存,可以将堆划分成不同的区域

但随着技术的发展,可能有些虚拟机并没有采用分代的思想,但抱着学习的目的,本人的总结主要还是围绕 HotSpot 基于「经典分代」的内存划分,主要分两个阶段 (-∞, JDK7] 和 [JDK8, +∞) 展开讨论

在 JDK7 及以前,堆内存逻辑上分为三个部分:新生代 + 老年代 + 永久代

在 JDK8 及以后,堆内存逻辑上分为三个部分:新生代 + 老年代 + 元空间

8

约定:

通过上面的图可以知道:方法区不属于堆;不同版本的 JDK,方法区的实现上有一些不同,在 JDK7 及以前使用永久代的实现方式,而在 JDK8 及之后使用元空间的实现方式

本篇文章主要讨论 Java 堆,所以方法区详细介绍可见 方法区

堆分为「新生代」和「老年代」,它们俩默认的比例是 1 : 2,可以通过参数-XX:newRatio配置新生代与老年代在堆结构的占比

新生代又分为「Eden 区」「Survivor1 区」「Survivor2 区」,它们仨默认的比例是 8 : 1 : 1,可以通过参数-XX:SurvivorRatio设置新生代中 Eden 和 S0/S1 空间的比例

虽然新生代被划分为 3 个区域,其比例为 8 : 1 : 1,但两个 Survivor 区始终只有一个被使用,所以往往提到新生代大小时表示 Eden 区和一个 Survivor 区的和

上面的划分是为了更好的回收内存,至于为什么可以更好的回收内存,详情可见 垃圾收集算法

对象内存布局

👉 写在前面:特意为本部分制作了一个思维导图,内容与即将要介绍的相辅相成!!😝

一个对象是根据相应的类生成的,如Object obj = new Object();而一个类中主要包含:常量、类变量 (静态变量)、实例变量、方法

有些东西属于类,而非对象;换句话说,有些东西所有对象都一样,比如:常量、类变量、方法

所以也就没必要为每个对象都花内存空间去存储这些东西,而是采用一种共享的思想,把这些属于类的东西专门存放在一个地方,每个对象中就不用存了,直接在对象中用一个指针指向这些属于类的数据即可

说了这么多,其实是为了说明:对象中不会存属于类的数据,只有实例数据。除此之外,还有对象头对齐填充

在 HotSpot 虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头 (Header)、实例数据 (Instance Data)、对齐填充 (Padding),如下图所示:

8

对象头

如上图所示,对象头分为三个部分,第三个部分是可选滴!

Mark Word

长度:不同位数的虚拟机该部分的长度不一样,32 位和 64 位虚拟机分别对应 32 bit 和 64 bit

该部分用于存储对象自身的运行时数据,如:哈希码 (HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等 (PS:虽然有些名词不知道啥意思,以后会慢慢补充!!)

在实际中对象需要存储的运行时数据很多,其实已经超过了 32 或者 64 位,但是 Mark Word 被设计成一个有着动态定义的数据结构,以便在更小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。以 32 bit 为例,对象的存储内容如下图所示:

9

在上图中挑两个概念解释一下 (因为目前只会两个,哈哈哈哈)

先来介绍对象的 HashCode,肯定听过一个问题:为什么重写 equals() 的同时还得重写 hashCode() ?

重写前调用 hashCode() 方法返回的值和对象头中存储的 HashCode 值是相同的;但如果重写了该方法,那就不一样了

而且只有在对象调用了计算哈希的函数后,HashCode 才会被存储在 object header 中,下次直接从对象头中取即可;反之如果没有调用,则对象头中 HashCode 值为 0

关于 HashCode 更详细的内容可见 「equals」「hashCode」

再来介绍对象的 GC 分代年龄,这个值的作用是在 Minor GC 时,判断 Survivor 区的对象是否需要晋升到老年代中 (默认最大年龄为 15,大于该值就需要晋升到老年代)

一个对象刚刚创建时,age = 0,之后每进行一次 GC,age 就会 +1

Class Pointer

长度:32 位和 64 位虚拟机分别对应 32 bit 和 64 bit。但在 Java heap 内存小于 32GB 时默认开启 -XX:+UseCompressedOops,即:普通对象指针压缩,会将 64 bit 压缩成 32 bit

该部分时类型指针,即对象指向它的类型元数据的指针,Java 虚拟机通过这个指针来确定该对象是哪个类的实例

但是并非所有的虚拟机实现都必须在这个对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身,还可以通过句柄访问 (后面会介绍)

Array Length

长度:32 位和 64 位虚拟机都是 32 bit

此外,如果对象是一个 Java 数组,那么在对象头中还必须有一块用于记录数组长度的数据

因为虚拟机可以通过普通 Java 对象的元数据信息确定 Java 对象的大小;但是如果数组的长度是不确定的,将无法通过元数据的信息推断出数组的大小

实例数据

该部分才是对象真正存储的有效信息,即在程序中所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来

这部分存储顺序会受到虚拟机分配策略参数和字段在 Java 源码中定义顺序的影响。HotSpot 虚拟机默认的分配顺序为:long/double、int、short、char、byte/boolean、oop

从上面默认的分配顺序可以看出相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前

如果 HotSpot 虚拟机的+XX:CompactFields参数值为 true (默认就为 true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间

可以通过-XX:+CompactFields开启,或者-XX:-CompactFields关闭

注意:如果是引用类型,它的长度并非一定为 32 bit,原因同 Class Pointer 处!!!

对齐填充

该部分是对齐填充,并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用

由于 HotSpot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节 (32 bit) 的整数倍,换句话说,任何对象的大小都必须是 8 字节的整数倍

对象头部已经被精心设计成正好是 8 字节的倍数 (1 倍 or 2 倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全

实战分析

介绍完了对象的内存布局,用一个小程序来直观的看一看,也可以验证一下到底对不对!!

首先需要添加一个 Maven 依赖:

下面给出测试小程序:

下面是结果:

下面尝试不调用hashCode()方法,也不调用系统 GC,直接调用上述代码中的print()方法,重新看看对象的布局:

如果手动关闭压缩 -XX:-UseCompressedOops,重新看看对象的布局:

对象访问定位

创建对象是为了后续的使用,Java 程序会通过栈上的 reference 类型数据来操作堆上的具体对象

但是《Java 虚拟机规范》只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的,主流的访问方式由两种:

上述两种方式的结构如下图所示:

10

这两种方式各有优势,使用句柄来访问的最大好处就是 reference 中存储的是稳定的句柄地址

在对象移动 (GC 时会发生移动) 时只会改变句柄中的实例数据指针,而 reference 本身不需要被修改,减少耦合,每次仅仅只需要改堆空间的指针数据,栈中指针无需更改

假设多个方法中都有某个对象的引用,此时对象内存地址变了,如果通过句柄访问,就只需要修改句柄中的指针即可;而如果通过指针直接访问,所有包含该对象引用的方法的本地变量表都需要修改!!

使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在 Java 中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本

对于 HotSpot 虚拟机而已,它主要使用直接指针进行对象访问,但从整个软件开发的范围来看,在各种语言、框架中使用句柄来访问的情况也十分常见!!

实战分析

结合上面介绍的 对象内存布局 和这部分的 对象访问定位 来一个小综合,用一个实打实的例子,看看从「堆栈层面」一直到「对象内部」的内存结构!例子的代码如下:

上述代码对应的内存结构如下图所示:

12

对象创建过程

👉 写在前面:作为本部分的补充内容,汇总常见的对象创建方式,并制成一个思维导图😝

扯了这么多「堆的内存结构」、「对象的内存结构」、「对象的访问定位」,那一个对象的创建过程到底是什么样的呢?本部分就来详细介绍一个对象从 0 到 1 的详细过程,先给一张完整的流程图:

11

类加载检查

当 Java 虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化。如果没有,那必须先执行相应的类加载过程。关于类加载过程详细内容可见 类加载的过程

分配内存

在类检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配内存的任务实际上等同于把一块确定大小的内存块从 Java 堆中划分出来

根据 Java 堆中内存是否规整,可以有两种不同的分配方式:

选择内存分配方式,要根据内存是否规整来决定;而内存是否规整,又和所采用的垃圾收集器是否带有空间压缩整理的能力有关。关于垃圾收集算法的详细内容可见 垃圾收集算法

为对象分配内存,除了如何划分可用空间之外,还有一个需要思考的问题:对象创建操作十分频繁,每次并非只有一个线程申请分配空间,在并发情况下这个过程线程不安全

根据上面介绍的两种分配方式可以知道每一次分配过程至少对应两个步骤:一、根据原指针位置找到合适的内存空间;二、修改指针

可能出现正在给对象 A 分配内存,指针还没有来得及修改;对象 B 又同时使用了原来的指针来分配内存

解决上面的线程安全问题有两种可选方案:

初始化零值

内存分配完成之后,虚拟机必须将分配到的内存空间 (不包括对象头) 都初始化为零值,如果使用了 TLAB 的话,这一项工作可以提前到 TLAB 分配时进行

这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值

注意:局部变量必须赋初始值,否则编译不通过!

设置对象头

这一部分主要是设置对象头,如:

执行构造函数

到此为止,从虚拟机的角度来看,一个新的对象已经产生;但是从程序员的角度来看,对象的创建才刚刚开始!!

此时对象中的实例字段还都只是零值,并没有按照程序员的意图构造好。一般来说,new指令之后会接着执行<init>()方法,按照程序员的意愿对对象进行初始化

完成了上面所有的步骤后,这样一个真正可用的对象才算完全被构造出来!!

对象内存分配与回收策略

Java 技术体系的自动内存管理,最根本的目标是自动化地去解决两个问题:自动给对象分配内存自动回收分配给对象的内存

前文介绍过 分配内存 主要有两种方式:指针碰撞和空闲列表,这可以看作是具体通过什么方式划分一块内存分配给对象

而本部分介绍的是从分代的角度讨论对象在何种情况下会被分配到年轻代或者老年代中,一旦确定要分配到年轻代或者老年代后,具体如何划分出一块内存就是上面介绍到的两种方式

之所以先确定年轻代或者老年代,然后按照对象的特点分配到不同区域,是为了后续垃圾收集时可以按照区域存放的对象特点使用不同的 垃圾收集算法

而且后续介绍的 经典垃圾收集器 也都是按照分代设计的,比如有些垃圾收集器只能收集年轻代,而有些只能收集老年代,而有些可以全堆收集!

不说废话了,先来一张整体流程图:

1

前提:后续讨论的内容都默认使用 Serial + Serial Old 收集器,可通过参数-XX:+UseSerialGC指定

对象优先在 Eden 分配

大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Young GC

下面模拟一种实验场景,如下面代码所示:

通过参数可知:

当上面代码执行到第 4 步的时候,Eden 区已经使用了 6MB 的大小 (allocation1, allocation2, allocation3),对于allocation4肯定是放不下滴,所以会触发一次 Young GC

先来看看输出的 GC 日志:

下面开始分析:

[DefNew: 7016K->189K(9216K), 0.0061931 secs]表示 GC 后,新生代大小从 7016K 减少到 189K,括号中为新生代总大小 9216K,正好减少了大概 6MB 多一点

那这 6MB 大小的对象是移动到 S 区还是老年代呢?!按照理论可知应该被移动到了老年代,事实如何呢?继续看程序结束时输出的堆内存分布!!

eden space 8192K, 56% used表示 Eden 区大概使用了 4587K,约等于 4MB,刚好和allocation4差不多,说明 Young GC 后allocation4被分配到 Eden 区了

tenured generation total 10240K, used 6144K表示老年代使用了 6144K,正好 6MB,说明刚才 Young GC 时allocation1, allocation2, allocation3直接移动到了老年代中

最后结合上面给出的完整流程图,执行第 4 步时对应上图中如下流程:

13

大对象直接进入老年代

大对象就是指需要大量连续内存空间的 Java 对象,最典型的大对象就是那种很长的字符串或者元素数量很庞大的数组

在程序开发时应该避免创建一些「朝生夕死」的「短命大对象」,原因如下:

那到底多大的对象才算大对象呢???HotSpot 虚拟机提供参数-XX:PretenureSizeThreshold指定大于该设置值的对象直接在老年代分配,这样做的目的是为了避免在 Edne 区及两个 Survivor 区之间来回复制,产生大量的内存复制操作

注意:-XX:PretenureSizeThreshold参数只对 Serial 和 ParNew 两款新生代垃圾收集器有效,HotSpot 的其它新生代收集器,如:Parallel Scavenge 并不支持这个参数

继续使用上一部分的代码,但是添加了一个参数:-XX:PretenureSizeThreshold=3145728,表示如果对象大小超过了 3MB,直接进入老年代

先来看看输出的 GC 日志:

我们发现此时并没有触发 Young GC,这也不难理解,因为当执行到第 4 步为allocation4分配内存时,并没有先判断 Eden 是否放得下,而是直接在老年代分配内存

tenured generation total 10240K, used 4096K表示老年代使用了 4096K,正好 4MB,说明allocation4直接进入了老年代

最后结合上面给出的完整流程图,执行第 4 步时对应上图中如下流程:

14

当没有主动设置参数-XX:PretenureSizeThreshold时,它的默认值为 0,表示无论多大都优先在 Eden 分配,这和 对象优先在 Eden 分配 中所说的一致

但是当「对象大小」「Eden 区大小」时,在没有设置参数-XX:PretenureSizeThreshold时,该对象依然会直接被分配在老年代,具体如下代码所示:

输出的 GC 日志如下:

从日志中可以看出 8MB 的数组直接被分配在老年代!!

下面将数组改小一点:

输出的 GC 日志如下:

从日志中可以看出此时的数组优先在 Eden 分配,经过了一次 GC 后晋升到老年代

结论:使用 Serial 收集器时,若没有设置参数-XX:PretenureSizeThreshold,即在默认的情况下,若「对象的大小」「Eden 区大小」,直接被分配在老年代

可参考 细说 JVM (垃圾收集器与内存分配)JVM 对大对象分配内存的特殊处理

上面还提到过 HotSpot 的其它新生代收集器,如:Parallel Scavenge 并不支持参数-XX:PretenureSizeThreshold,那这个收集器判断多大的对象才会进入老年代呢?

当「对象大小」「Eden 区大小的一半」时,该对象会直接被分配在老年代,具体如下代码所示:

输出的 GC 日志如下:

从日志中可以看出并没有发生 GC,4MB 的数组直接被分配在老年代!!为什么会这样呢,让我们来看一看 JDK 源码

jdk/src/hotspot/share/gc/parallel/parallelScavengeHeap.cpp 中的ParallelScavengeHeap::mem_allocate方法关键代码如下:

继续看ParallelScavengeHeap::mem_allocate_old_gen方法,代码如下:

继续看ParallelScavengeHeap::should_alloc_in_eden方法,它位于 jdk/src/hotspot/share/gc/parallel/parallelScavengeHeap.inline.hpp,代码如下:

所以现在就很清晰了,刚好 4MB 是 Eden 区大小的一半,所以最终会在老年代分配,整个逻辑如下:

19

 

再来一种情况,如果将 4MB 改成 3MB,结果会如何呢?

首先发现 Eden 无法分配 3MB,然后判断 3MB 小于 Eden 区大小的一半,进而会执行 YGC,最后再次尝试在 Eden 区中分配

输出的 GC 日志如下:

结论:使用 Parallel Scavenge 收集器时,在尝试过 Eden 区无法分配的情况下,若「分配对象的大小」「Eden 区大小的一半」,则会直接被分配在老年代

可参考 多大的对象会直接进入老年代

长期存活的对象将进入老年代

分代收集理论 中介绍过:熬过越多次垃圾收集过程的对象越难以消亡,所以为了避免每次 Young GC 时都要频繁的移动 Eden 区和 Survivor 区的对象,HotSpot 虚拟机为每个对象都定义了一个对象年龄计数器,在 对象头 中也可以看出来,它对应着 GC 分代年龄

通常对象在 Eden 中诞生,如果经过第一次 Young GC 后仍然存活,并且能被 Survivor 区容纳的话,该对象会被移动到 Survivor 空间中,并且将年龄设置为 1 岁

对象在 Survivor 区中每熬过一次 Young GC,年龄就增加 1 岁,当它的年龄增加到一定程度 (默认为 15),就会被晋升到老年代中

对象晋升老年代的年龄阈值可以通过参数-XX:MaxTenuringThreshold设置,当 GC 分代年龄 > MaxTenuringThreshold 时晋升到老年代

注意:晋升老年代的默认阈值为 15,同时最大值也为 15,因为对象头中只用 4bit 来存储 GC 分代年龄,它最大只能到达 15

-XX:MaxTenuringThreshold=1

下面模拟一种实验场景,如下面代码所示:

通过参数可知晋升老年代的年龄阈值为 1 岁,即当对象 GC 年龄 > 1 时会晋升到老年代,即对象经过两轮 Young GC 时就会晋升到老年代

当第一次为allocation3分配内存时,由于超过了 Eden 区所能容纳的大小,触发第一次 Young GC:

由输出可知:allocation1被移动到 Survivor 区,且 GC 年龄为 1 岁;allocation2由于 Survivor 无法容纳直接晋升到老年代,这点和 对象优先在 Eden 分配 一致

当第二次为allocation3分配内存时,由于上一次为allocation3分配的 4MB 内存还在 Eden 区,此时再分配 4MB 时超过了 Eden 区所能容纳的大小,触发第二次 Young GC:

由输出可知:allocation1从 from 区移动到 to 区,且 GC 年龄为 2 岁,晋升到老年代;清理第一次为allocation3分配的 4MB

注意:此时还未给allocation3第二次分配内存,只是在处理内存分配前的垃圾收集工作,等垃圾收集工作完成后才开始第二次赋值。所以此时年轻代中为空,从4953K->0K(9216K)也可以看出来

最后来看看输出的 GC 日志:

和我们的分析一致:

eden space 8192K, 51% used表示第二次为allocation3分配的内存为 Eden 区

from space 1024K, 0% used表示 Survivor 区中没有任何对象,都晋升到老年代了

tenured generation total 10240K, used 4541K表示老年代使用了 4541K,其中包含allocation1allocation2引用的对象

-XX:MaxTenuringThreshold=15

下面以参数-XX:MaxTenuringThreshold=15运行的结果如下:

下面开始简要分析:

from space 1024K, 43% used表示 from 区使用了大概 440K,其中包含allocation1引用的对象,由于 GC 分代年龄没有到达晋升的条件,所以两轮垃圾收集后依旧在新生代中

tenured generation total 10240K, used 4096K表示老年代使用了 4096K,刚好是allocation2引用的对象

最后结合上面给出的完整流程图,发生对象晋升时对应上图中如下流程:

15

动态对象年龄判定

长期存活的对象将进入老年代 中说只有当对象的 GC 分代年龄 > 所设定的阈值时才会晋升到老年代

但为了能更好的适应不同程序的内存状况,HotSpot 虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代

如果在 Survivor 空间中低于或等于某年龄的所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代

说的可能有些绕,直接看图:

16

下面模拟一种实验场景,如下面代码所示:

输出的 GC 日志如下:

按理来说,allocation1allocation2引用的对象经过两轮 GC 后年龄为 2 岁,并没有超过设定的阈值,应该在 Survivor 区才对

可是输出的 GC 日志中显示 Survivor 区使用空间为 0,那只有一种可能,它俩引用的对象已经晋升到老年代了,因为它们加起来大于了 Survivor 空间的一半

如果注释掉allocation1allocation2中的一个,那么就不会晋升到老年代

空间分配担保

在发生 Young GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 Young GC 可以确保时安全的

原因:在 Young GC 时,可能会出现一部分对象晋升到老年代的情况,如果老年代没有足够的空间容纳这些对象就会触发一次 Full GC (不允许担保失败的前提下)

如果上述条件不成立,则虚拟机会先看看-XX:HandlePromotionFailure参数是否允许担保失败;如果允许,则会检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小

如果大于,将尝试进行一次 Young GC,尽管这次 GC 是有风险的;如果小于,或者-XX:HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次 Full GC

说的可能有些绕,直接看流程图:

17

取历史平均值来比较其实仍然是一种赌概率的解决办法,也就是说假如某次 Young GC 存活的对象突增,远远高于历史平均值的话,依然会保证担保失败;如果担保失败,就只好老老实实地重新发起一次 Full GC,这样的停顿时间就很长了

虽然担保失败时绕的圈子是最大的,但通常情况下还是会将-XX:HandlePromotionFailure设置为允许,为了避免 Full GC 过于频繁,只要满足x < y就会触发 Full GC

注意:在 JDK 6 Update 24 版本之后,HandlePromotionFailure 参数就没有作用了,只要老年代最大可用连续空间大于新生代所有对象总空间或者历次晋升到老年代对象的平均大小,就进行 Minor GC 否则进行 Full GC,即:始终会允许担保失败!!