本篇文章的标题虽然是「对象创建」,但内容却不仅仅只有对象的创建,以对象的创建为主线,展开对 Java 堆的详细整理总结!!
其中包括:堆的内存结构、对象的内存布局、对象创建过程、对象内存分配过程。其中「堆的内存结构」和「对象内存分配过程」可能会涉及到一些垃圾收集的内容!
注意:到了 JDK7 的 HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移至 Java 堆中 (上图方法区为 JDK7 之前的布局)
在文章 运行时数据区域 说过:为了更好地回收内存,或是更快地分配内存,可以将堆划分成不同的区域
但随着技术的发展,可能有些虚拟机并没有采用分代的思想,但抱着学习的目的,本人的总结主要还是围绕 HotSpot 基于「经典分代」的内存划分,主要分两个阶段 (-∞, JDK7] 和 [JDK8, +∞) 展开讨论
在 JDK7 及以前,堆内存逻辑上分为三个部分:新生代 + 老年代 + 永久代
在 JDK8 及以后,堆内存逻辑上分为三个部分:新生代 + 老年代 + 元空间
约定:
新生代 <-> 新生区 <-> 年轻代
养老区 <-> 老年区 <-> 老年代
永久区 <-> 永久代
通过上面的图可以知道:方法区不属于堆;不同版本的 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),如下图所示:
如上图所示,对象头分为三个部分,第三个部分是可选滴!
Mark Word
长度:不同位数的虚拟机该部分的长度不一样,32 位和 64 位虚拟机分别对应 32 bit 和 64 bit
该部分用于存储对象自身的运行时数据,如:哈希码 (HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等 (PS:虽然有些名词不知道啥意思,以后会慢慢补充!!)
在实际中对象需要存储的运行时数据很多,其实已经超过了 32 或者 64 位,但是 Mark Word 被设计成一个有着动态定义的数据结构,以便在更小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。以 32 bit 为例,对象的存储内容如下图所示:
在上图中挑两个概念解释一下 (因为目前只会两个,哈哈哈哈)
先来介绍对象的 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 依赖:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.16</version>
</dependency>
下面给出测试小程序:
xpublic class TestGCAge {
public static void main(String[] args) throws InterruptedException {
Person p = new Person();
// 不调用 hashCode() 不会记录哈希码
int hashCode = p.hashCode();
// 转 16 进制输出,与头信息中 HashCode 进行比较
String hex = Integer.toHexString(hashCode);
System.out.println("HashCode 十六进制: "+ hex);
System.gc();
print(p);
}
// 输出对象
static void print(Person p){
System.err.println(ClassLayout.parseInstance(p).toPrintable());
}
}
class Person {
private boolean flag;
private Object object;
}
下面是结果:
xxxxxxxxxx
HashCode 十六进制: 6d03e736 // 没有重写 hashCode() 方法时返回的值,和 header 中的相同
com.lfool.algorithm.Person object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000006d03e73609 (hash: 0x6d03e736; age: 1) // 8 个字节保存 mark word;进行了一次 GC,年龄 +1
8 4 (object header: class) 0x00060a20 // 4 个字节保存 class pointer (开启压缩)
12 1 boolean Person.flag false // 1 个字节保存 boolean 类型实例变量
13 3 (alignment/padding gap) // 3 个字节对齐填充
16 4 java.lang.Object Person.object null // 4 个字节保存对象引用 (开启压缩)
20 4 (object alignment gap)
Instance size: 24 bytes
Space losses: 3 bytes internal + 4 bytes external = 7 bytes total
下面尝试不调用hashCode()
方法,也不调用系统 GC,直接调用上述代码中的print()
方法,重新看看对象的布局:
xxxxxxxxxx
com.lfool.algorithm.Person object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0) // hashCode 和 age 都为 0
8 4 (object header: class) 0x00060a20
12 1 boolean Person.flag false
13 3 (alignment/padding gap)
16 4 java.lang.Object Person.object null
20 4 (object alignment gap)
Instance size: 24 bytes
Space losses: 3 bytes internal + 4 bytes external = 7 bytes total
如果手动关闭压缩 -XX:-UseCompressedOops,重新看看对象的布局:
xxxxxxxxxx
com.lfool.algorithm.Person object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000006d03e73609 (hash: 0x6d03e736; age: 1)
8 8 (object header: class) 0x0000000104d03bb0 // 8 个字节保存 class pointer (关闭压缩)
16 1 boolean Person.flag false
17 7 (alignment/padding gap) // 7 个字节对齐填充
24 8 java.lang.Object Person.object null // 8 个字节保存对象引用 (关闭压缩)
Instance size: 32 bytes
Space losses: 7 bytes internal + 0 bytes external = 7 bytes total
创建对象是为了后续的使用,Java 程序会通过栈上的 reference 类型数据来操作堆上的具体对象
但是《Java 虚拟机规范》只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的,主流的访问方式由两种:
通过句柄方法:Java 堆中将可能划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息
通过直接指针访问:Java 堆中对象的内存布局就必须考虑如何如何放置访问类型数据的相关信息,reference 中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问开销
上述两种方式的结构如下图所示:
这两种方式各有优势,使用句柄来访问的最大好处就是 reference 中存储的是稳定的句柄地址
在对象移动 (GC 时会发生移动) 时只会改变句柄中的实例数据指针,而 reference 本身不需要被修改,减少耦合,每次仅仅只需要改堆空间的指针数据,栈中指针无需更改
假设多个方法中都有某个对象的引用,此时对象内存地址变了,如果通过句柄访问,就只需要修改句柄中的指针即可;而如果通过指针直接访问,所有包含该对象引用的方法的本地变量表都需要修改!!
使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问在 Java 中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本
对于 HotSpot 虚拟机而已,它主要使用直接指针进行对象访问,但从整个软件开发的范围来看,在各种语言、框架中使用句柄来访问的情况也十分常见!!
结合上面介绍的 对象内存布局 和这部分的 对象访问定位 来一个小综合,用一个实打实的例子,看看从「堆栈层面」一直到「对象内部」的内存结构!例子的代码如下:
xxxxxxxxxx
public class Account {}
public class Customer {
// 实例数据
private int id = 1001;
private String name;
private Account account;
{
name = "LFool";
}
public Customer() {
account = new Account();
}
}
public class CustomerTest {
public static void main(String[] args) {
Customer cust = new Customer();
}
}
上述代码对应的内存结构如下图所示:
👉 写在前面:作为本部分的补充内容,汇总常见的对象创建方式,并制成一个思维导图😝
扯了这么多「堆的内存结构」、「对象的内存结构」、「对象的访问定位」,那一个对象的创建过程到底是什么样的呢?本部分就来详细介绍一个对象从 0 到 1 的详细过程,先给一张完整的流程图:
当 Java 虚拟机遇到一条字节码new
指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化。如果没有,那必须先执行相应的类加载过程。关于类加载过程详细内容可见 类加载的过程
在类检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配内存的任务实际上等同于把一块确定大小的内存块从 Java 堆中划分出来
根据 Java 堆中内存是否规整,可以有两种不同的分配方式:
指针碰撞 (内存规整):将所有已使用的内存和未使用的内存分两边,中间用一个指针作为分界点指示器,分配内存的过程是把指针向空闲空间方向移动一段与对象大小相等的距离
空闲列表 (内存不规整):维护一个列表,记录可用空闲内存块,分配内存的过程是从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录
选择内存分配方式,要根据内存是否规整来决定;而内存是否规整,又和所采用的垃圾收集器是否带有空间压缩整理的能力有关。关于垃圾收集算法的详细内容可见 垃圾收集算法
为对象分配内存,除了如何划分可用空间之外,还有一个需要思考的问题:对象创建操作十分频繁,每次并非只有一个线程申请分配空间,在并发情况下这个过程线程不安全
根据上面介绍的两种分配方式可以知道每一次分配过程至少对应两个步骤:一、根据原指针位置找到合适的内存空间;二、修改指针
可能出现正在给对象 A 分配内存,指针还没有来得及修改;对象 B 又同时使用了原来的指针来分配内存
解决上面的线程安全问题有两种可选方案:
对分配内存空间的动作进行同步处理,采用「CAS + 失败重试」的方式保证更新操作的原子性
每个线程在 Java 堆中预先分配一小块内存 (TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有缓冲区用完了,分配新的缓冲区时才需要同步锁定
内存分配完成之后,虚拟机必须将分配到的内存空间 (不包括对象头) 都初始化为零值,如果使用了 TLAB 的话,这一项工作可以提前到 TLAB 分配时进行
这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值
注意:局部变量必须赋初始值,否则编译不通过!
这一部分主要是设置对象头,如:
对象所对应的类元数据信息
对象 HashCode (HashCode 的计算会延后到调用Object::hashCode()
方法时)
对象 GC 年龄分代
......
到此为止,从虚拟机的角度来看,一个新的对象已经产生;但是从程序员的角度来看,对象的创建才刚刚开始!!
此时对象中的实例字段还都只是零值,并没有按照程序员的意图构造好。一般来说,new
指令之后会接着执行<init>()
方法,按照程序员的意愿对对象进行初始化
完成了上面所有的步骤后,这样一个真正可用的对象才算完全被构造出来!!
Java 技术体系的自动内存管理,最根本的目标是自动化地去解决两个问题:自动给对象分配内存 和 自动回收分配给对象的内存
前文介绍过 分配内存 主要有两种方式:指针碰撞和空闲列表,这可以看作是具体通过什么方式划分一块内存分配给对象
而本部分介绍的是从分代的角度讨论对象在何种情况下会被分配到年轻代或者老年代中,一旦确定要分配到年轻代或者老年代后,具体如何划分出一块内存就是上面介绍到的两种方式
之所以先确定年轻代或者老年代,然后按照对象的特点分配到不同区域,是为了后续垃圾收集时可以按照区域存放的对象特点使用不同的 垃圾收集算法
而且后续介绍的 经典垃圾收集器 也都是按照分代设计的,比如有些垃圾收集器只能收集年轻代,而有些只能收集老年代,而有些可以全堆收集!
不说废话了,先来一张整体流程图:
前提:后续讨论的内容都默认使用 Serial + Serial Old 收集器,可通过参数-XX:+UseSerialGC
指定
大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Young GC
下面模拟一种实验场景,如下面代码所示:
xxxxxxxxxx
// -verbose:gc -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC
public class TestAllocation {
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) {
byte[] allocation1, allocation2, allocation3, allocation4;
allocation1 = new byte[2 * _1MB]; // 第 1 步
allocation2 = new byte[2 * _1MB]; // 第 2 步
allocation3 = new byte[2 * _1MB]; // 第 3 步
allocation4 = new byte[4 * _1MB]; // 第 4 步: 出现一次 Young GC
}
}
通过参数可知:
使用 Serial 收集器
新生代 : 老年代 = 10m : 10m = 1 : 1
Eden : Survivor0 : Survivor1 = 8m : 1m : 1m = 8 : 1 : 1
当上面代码执行到第 4 步的时候,Eden 区已经使用了 6MB 的大小 (allocation1, allocation2, allocation3
),对于allocation4
肯定是放不下滴,所以会触发一次 Young GC
先来看看输出的 GC 日志:
xxxxxxxxxx
[GC [DefNew: 7016K->189K(9216K), 0.0061931 secs] 7016K->6333K(19456K), 0.0062197 secs] [Times: user=0.03 sys=0.02, real=0.01 secs]
Heap
def new generation total 9216K, used 4778K [0x00000000f9a00000, 0x00000000fa400000, 0x00000000fa400000)
eden space 8192K, 56% used [0x00000000f9a00000, 0x00000000f9e7b688, 0x00000000fa200000)
from space 1024K, 18% used [0x00000000fa300000, 0x00000000fa32f570, 0x00000000fa400000)
to space 1024K, 0% used [0x00000000fa200000, 0x00000000fa200000, 0x00000000fa300000)
tenured generation total 10240K, used 6144K [0x00000000fa400000, 0x00000000fae00000, 0x00000000fae00000)
the space 10240K, 60% used [0x00000000fa400000, 0x00000000faa00030, 0x00000000faa00200, 0x00000000fae00000)
compacting perm gen total 21248K, used 3440K [0x00000000fae00000, 0x00000000fc2c0000, 0x0000000100000000)
the space 21248K, 16% used [0x00000000fae00000, 0x00000000fb15c378, 0x00000000fb15c400, 0x00000000fc2c0000)
下面开始分析:
[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 步时对应上图中如下流程:
大对象就是指需要大量连续内存空间的 Java 对象,最典型的大对象就是那种很长的字符串或者元素数量很庞大的数组
在程序开发时应该避免创建一些「朝生夕死」的「短命大对象」,原因如下:
在分配内存空间时,它更容易导致明明还有不少内存空间时就提前触发垃圾收集,以获取足够的连续空间才能安置它们
在垃圾收集时,由于它在老年代,而老年代垃圾收集一般使用 标记-复制算法,在复制对象时,大对象意味着高额的内存复制开销
由于大对象在老年代,而从垃圾收集的次数来看老年代收集次数较少,间接导致短命大对象存活的时间更长,浪费内存空间,相当于是一种宽泛意义上的内存泄漏
那到底多大的对象才算大对象呢???HotSpot 虚拟机提供参数-XX:PretenureSizeThreshold
指定大于该设置值的对象直接在老年代分配,这样做的目的是为了避免在 Edne 区及两个 Survivor 区之间来回复制,产生大量的内存复制操作
注意:-XX:PretenureSizeThreshold
参数只对 Serial 和 ParNew 两款新生代垃圾收集器有效,HotSpot 的其它新生代收集器,如:Parallel Scavenge 并不支持这个参数
继续使用上一部分的代码,但是添加了一个参数:-XX:PretenureSizeThreshold=3145728
,表示如果对象大小超过了 3MB,直接进入老年代
先来看看输出的 GC 日志:
xxxxxxxxxx
Heap
def new generation total 9216K, used 7180K [0x00000000f9a00000, 0x00000000fa400000, 0x00000000fa400000)
eden space 8192K, 87% used [0x00000000f9a00000, 0x00000000fa103278, 0x00000000fa200000)
from space 1024K, 0% used [0x00000000fa200000, 0x00000000fa200000, 0x00000000fa300000)
to space 1024K, 0% used [0x00000000fa300000, 0x00000000fa300000, 0x00000000fa400000)
tenured generation total 10240K, used 4096K [0x00000000fa400000, 0x00000000fae00000, 0x00000000fae00000)
the space 10240K, 40% used [0x00000000fa400000, 0x00000000fa800010, 0x00000000fa800200, 0x00000000fae00000)
compacting perm gen total 21248K, used 3431K [0x00000000fae00000, 0x00000000fc2c0000, 0x0000000100000000)
the space 21248K, 16% used [0x00000000fae00000, 0x00000000fb159e00, 0x00000000fb159e00, 0x00000000fc2c0000)
我们发现此时并没有触发 Young GC,这也不难理解,因为当执行到第 4 步为allocation4
分配内存时,并没有先判断 Eden 是否放得下,而是直接在老年代分配内存
tenured generation total 10240K, used 4096K
表示老年代使用了 4096K,正好 4MB,说明allocation4
直接进入了老年代
最后结合上面给出的完整流程图,执行第 4 步时对应上图中如下流程:
当没有主动设置参数-XX:PretenureSizeThreshold
时,它的默认值为 0,表示无论多大都优先在 Eden 分配,这和 对象优先在 Eden 分配 中所说的一致
但是当「对象大小」-XX:PretenureSizeThreshold
时,该对象依然会直接被分配在老年代,具体如下代码所示:
xxxxxxxxxx
// -verbose:gc -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC
public class TestAllocation {
private static final int _1K = 1024;
public static void main(String[] args) {
// 恰好为 8MB
byte[] allcation1 = new byte[8192 * _1K];
}
}
输出的 GC 日志如下:
xxxxxxxxxx
Heap
def new generation total 9216K, used 2024K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
eden space 8192K, 24% used [0x00000000fec00000, 0x00000000fedfa1c8, 0x00000000ff400000)
from space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
to space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
tenured generation total 10240K, used 8192K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
the space 10240K, 80% used [0x00000000ff600000, 0x00000000ffe00010, 0x00000000ffe00200, 0x0000000100000000)
Metaspace used 3376K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 360K, capacity 388K, committed 512K, reserved 1048576K
从日志中可以看出 8MB 的数组直接被分配在老年代!!
下面将数组改小一点:
xxxxxxxxxx
// 比 8MB 小 1K
byte[] allcation1 = new byte[8191 * _1K];
输出的 GC 日志如下:
xxxxxxxxxx
[GC (Allocation Failure) [DefNew: 1520K->579K(9216K), 0.0013749 secs] 1520K->579K(19456K), 0.0335146 secs] [Times: user=0.00 sys=0.00, real=0.03 secs]
[GC (Allocation Failure) [DefNew: 8771K->0K(9216K), 0.0054070 secs] 8771K->8768K(19456K), 0.0054355 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
Heap
def new generation total 9216K, used 413K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
eden space 8192K, 5% used [0x00000000fec00000, 0x00000000fec67558, 0x00000000ff400000)
from space 1024K, 0% used [0x00000000ff400000, 0x00000000ff4000c0, 0x00000000ff500000)
to space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
tenured generation total 10240K, used 8768K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
the space 10240K, 85% used [0x00000000ff600000, 0x00000000ffe902e8, 0x00000000ffe90400, 0x0000000100000000)
Metaspace used 3198K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 340K, capacity 388K, committed 512K, reserved 1048576K
从日志中可以看出此时的数组优先在 Eden 分配,经过了一次 GC 后晋升到老年代
结论:使用 Serial 收集器时,若没有设置参数-XX:PretenureSizeThreshold
,即在默认的情况下,若「对象的大小」
可参考 细说 JVM (垃圾收集器与内存分配) 和 JVM 对大对象分配内存的特殊处理
上面还提到过 HotSpot 的其它新生代收集器,如:Parallel Scavenge 并不支持参数-XX:PretenureSizeThreshold
,那这个收集器判断多大的对象才会进入老年代呢?
当「对象大小」
xxxxxxxxxx
// -verbose:gc -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails -XX:SurvivorRatio=8
// 不指定垃圾收集器时,JDK8 中默认使用 Parallel Scavenge 收集器
public class TestAllocation {
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) throws InterruptedException {
byte[] allocation1, allocation2, allocation3, allocation4;
allocation1 = new byte[2 * _1MB];
allocation2 = new byte[2 * _1MB];
allocation3 = new byte[2 * _1MB];
allocation4 = new byte[4 * _1MB]; // 执行到此处,Eden 肯定存不下 allocation4 了
}
}
输出的 GC 日志如下:
xxxxxxxxxx
Heap
PSYoungGen total 9216K, used 8168K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
eden space 8192K, 99% used [0x00000000ff600000,0x00000000ffdfa1f8,0x00000000ffe00000)
from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
to space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
ParOldGen total 10240K, used 4096K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
object space 10240K, 40% used [0x00000000fec00000,0x00000000ff000010,0x00000000ff600000)
Metaspace used 3377K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 360K, capacity 388K, committed 512K, reserved 1048576K
从日志中可以看出并没有发生 GC,4MB 的数组直接被分配在老年代!!为什么会这样呢,让我们来看一看 JDK 源码
jdk/src/hotspot/share/gc/parallel/parallelScavengeHeap.cpp 中的ParallelScavengeHeap::mem_allocate
方法关键代码如下:
xxxxxxxxxx
// 如果对象一开始在年轻代分配失败了,首先会对一些情况做判断,满足条件的就会分配在老年代
result = young_gen()->allocate(size);
if (result != NULL) {
return result;
}
// If certain conditions hold, try allocating from the old gen.
result = mem_allocate_old_gen(size);
if (result != NULL) {
return result;
}
继续看ParallelScavengeHeap::mem_allocate_old_gen
方法,代码如下:
xxxxxxxxxx
HeapWord* ParallelScavengeHeap::mem_allocate_old_gen(size_t size) {
// 如果不应该在 eden 中分配
if (!should_alloc_in_eden(size) || GCLocker::is_active_and_needs_gc()) {
// Size is too big for eden, or gc is locked out.
return allocate_old_gen_and_record(size);
}
// If a "death march" is in progress, allocate from the old gen a limited
// number of times before doing a GC.
if (_death_march_count > 0) {
if (_death_march_count < 64) {
++_death_march_count;
return allocate_old_gen_and_record(size);
} else {
_death_march_count = 0;
}
}
return NULL;
}
继续看ParallelScavengeHeap::should_alloc_in_eden
方法,它位于 jdk/src/hotspot/share/gc/parallel/parallelScavengeHeap.inline.hpp,代码如下:
xxxxxxxxxx
inline bool ParallelScavengeHeap::should_alloc_in_eden(const size_t size) const {
// eden_size 表示 eden 区大小
const size_t eden_size = young_gen()->eden_space()->capacity_in_words();
// 如果要分配对象的大小 < eden 区大小的一半,就应该在 eden 区中分配
return size < eden_size / 2;
}
所以现在就很清晰了,刚好 4MB 是 Eden 区大小的一半,所以最终会在老年代分配,整个逻辑如下:
再来一种情况,如果将 4MB 改成 3MB,结果会如何呢?
首先发现 Eden 无法分配 3MB,然后判断 3MB 小于 Eden 区大小的一半,进而会执行 YGC,最后再次尝试在 Eden 区中分配
输出的 GC 日志如下:
xxxxxxxxxx
[GC (Allocation Failure) [PSYoungGen: 8004K->840K(9216K)] 8004K->6992K(19456K), 0.0046643 secs] [Times: user=0.08 sys=0.02, real=0.00 secs]
[Full GC (Ergonomics) [PSYoungGen: 840K->0K(9216K)] [ParOldGen: 6152K->6731K(10240K)] 6992K->6731K(19456K), [Metaspace: 3322K->3322K(1056768K)], 0.0047915 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
PSYoungGen total 9216K, used 3394K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
eden space 8192K, 41% used [0x00000000ff600000,0x00000000ff950ab0,0x00000000ffe00000)
from space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
to space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
ParOldGen total 10240K, used 6731K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
object space 10240K, 65% used [0x00000000fec00000,0x00000000ff292cb0,0x00000000ff600000)
Metaspace used 3352K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 354K, capacity 388K, committed 512K, reserved 1048576K
结论:使用 Parallel Scavenge 收集器时,在尝试过 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
下面模拟一种实验场景,如下面代码所示:
xxxxxxxxxx
// -verbose:gc -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution
public class TestTenuringThreshold {
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) {
byte[] allocation1, allocation2, allocation3;
allocation1 = new byte[_1MB / 4];
allocation2 = new byte[4 * _1MB];
allocation3 = new byte[4 * _1MB]; // 第一次 Young GC
allocation3 = null;
allocation3 = new byte[4 * _1MB]; // 第二次 Young GC
}
}
通过参数可知晋升老年代的年龄阈值为 1 岁,即当对象 GC 年龄 > 1 时会晋升到老年代,即对象经过两轮 Young GC 时就会晋升到老年代
当第一次为allocation3
分配内存时,由于超过了 Eden 区所能容纳的大小,触发第一次 Young GC:
xxxxxxxxxx
[GC [DefNew
Desired survivor size 524288 bytes, new threshold 1 (max 1)
- age 1: 456088 bytes, 456088 total
: 5224K->445K(9216K), 0.0054567 secs] 5224K->4541K(19456K), 0.0055171 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
由输出可知:allocation1
被移动到 Survivor 区,且 GC 年龄为 1 岁;allocation2
由于 Survivor 无法容纳直接晋升到老年代,这点和 对象优先在 Eden 分配 一致
当第二次为allocation3
分配内存时,由于上一次为allocation3
分配的 4MB 内存还在 Eden 区,此时再分配 4MB 时超过了 Eden 区所能容纳的大小,触发第二次 Young GC:
xxxxxxxxxx
[GC [DefNew
Desired survivor size 524288 bytes, new threshold 1 (max 1)
- age 1: 232 bytes, 232 total
: 4953K->0K(9216K), 0.0007496 secs] 9049K->4541K(19456K), 0.0007746 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
由输出可知:allocation1
从 from 区移动到 to 区,且 GC 年龄为 2 岁,晋升到老年代;清理第一次为allocation3
分配的 4MB
注意:此时还未给allocation3
第二次分配内存,只是在处理内存分配前的垃圾收集工作,等垃圾收集工作完成后才开始第二次赋值。所以此时年轻代中为空,从4953K->0K(9216K)
也可以看出来
最后来看看输出的 GC 日志:
xxxxxxxxxx
Heap
def new generation total 9216K, used 4234K [0x00000000f9a00000, 0x00000000fa400000, 0x00000000fa400000)
eden space 8192K, 51% used [0x00000000f9a00000, 0x00000000f9e22838, 0x00000000fa200000)
from space 1024K, 0% used [0x00000000fa200000, 0x00000000fa2000e8, 0x00000000fa300000)
to space 1024K, 0% used [0x00000000fa300000, 0x00000000fa300000, 0x00000000fa400000)
tenured generation total 10240K, used 4541K [0x00000000fa400000, 0x00000000fae00000, 0x00000000fae00000)
the space 10240K, 44% used [0x00000000fa400000, 0x00000000fa86f498, 0x00000000fa86f600, 0x00000000fae00000)
compacting perm gen total 21248K, used 3452K [0x00000000fae00000, 0x00000000fc2c0000, 0x0000000100000000)
the space 21248K, 16% used [0x00000000fae00000, 0x00000000fb15f3d0, 0x00000000fb15f400, 0x00000000fc2c0000)
和我们的分析一致:
eden space 8192K, 51% used
表示第二次为allocation3
分配的内存为 Eden 区
from space 1024K, 0% used
表示 Survivor 区中没有任何对象,都晋升到老年代了
tenured generation total 10240K, used 4541K
表示老年代使用了 4541K,其中包含allocation1
和allocation2
引用的对象
-XX:MaxTenuringThreshold=15
下面以参数-XX:MaxTenuringThreshold=15
运行的结果如下:
xxxxxxxxxx
// 第一次 GC
[GC [DefNew
Desired survivor size 524288 bytes, new threshold 15 (max 15)
- age 1: 456088 bytes, 456088 total
: 5224K->445K(9216K), 0.0048040 secs] 5224K->4541K(19456K), 0.0048321 secs] [Times: user=0.02 sys=0.02, real=0.01 secs]
// 第二次 GC
[GC [DefNew
Desired survivor size 524288 bytes, new threshold 15 (max 15)
- age 1: 232 bytes, 232 total
- age 2: 455816 bytes, 456048 total
: 4953K->445K(9216K), 0.0009528 secs] 9049K->4541K(19456K), 0.0009789 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
def new generation total 9216K, used 4679K [0x00000000f9a00000, 0x00000000fa400000, 0x00000000fa400000)
eden space 8192K, 51% used [0x00000000f9a00000, 0x00000000f9e22838, 0x00000000fa200000)
from space 1024K, 43% used [0x00000000fa200000, 0x00000000fa26f570, 0x00000000fa300000)
to space 1024K, 0% used [0x00000000fa300000, 0x00000000fa300000, 0x00000000fa400000)
tenured generation total 10240K, used 4096K [0x00000000fa400000, 0x00000000fae00000, 0x00000000fae00000)
the space 10240K, 40% used [0x00000000fa400000, 0x00000000fa800010, 0x00000000fa800200, 0x00000000fae00000)
compacting perm gen total 21248K, used 3460K [0x00000000fae00000, 0x00000000fc2c0000, 0x0000000100000000)
the space 21248K, 16% used [0x00000000fae00000, 0x00000000fb1611c8, 0x00000000fb161200, 0x00000000fc2c0000)
下面开始简要分析:
from space 1024K, 43% used
表示 from 区使用了大概 440K,其中包含allocation1
引用的对象,由于 GC 分代年龄没有到达晋升的条件,所以两轮垃圾收集后依旧在新生代中
tenured generation total 10240K, used 4096K
表示老年代使用了 4096K,刚好是allocation2
引用的对象
最后结合上面给出的完整流程图,发生对象晋升时对应上图中如下流程:
长期存活的对象将进入老年代 中说只有当对象的 GC 分代年龄 > 所设定的阈值时才会晋升到老年代
但为了能更好的适应不同程序的内存状况,HotSpot 虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代
如果在 Survivor 空间中低于或等于某年龄的所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代
说的可能有些绕,直接看图:
下面模拟一种实验场景,如下面代码所示:
xxxxxxxxxx
// -verbose:gc -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:MaxTenuringThreshold=15
public class TestTenuringThreshold {
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) {
byte[] allocation1, allocation2, allocation3, allocation4;
allocation1 = new byte[_1MB / 4];
allocation2 = new byte[_1MB / 4];
allocation3 = new byte[4 * _1MB];
allocation4 = new byte[4 * _1MB]; // 第一次 Young GC
allocation4 = null;
allocation4 = new byte[4 * _1MB]; // 第二次 Young GC
}
}
输出的 GC 日志如下:
xxxxxxxxxx
[GC [DefNew: 5480K->701K(9216K), 0.0043963 secs] 5480K->4797K(19456K), 0.0044630 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC [DefNew: 5209K->0K(9216K), 0.0010353 secs] 9305K->4797K(19456K), 0.0010601 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
def new generation total 9216K, used 4234K [0x00000000f9a00000, 0x00000000fa400000, 0x00000000fa400000)
eden space 8192K, 51% used [0x00000000f9a00000, 0x00000000f9e227e8, 0x00000000fa200000)
from space 1024K, 0% used [0x00000000fa200000, 0x00000000fa2000e8, 0x00000000fa300000)
to space 1024K, 0% used [0x00000000fa300000, 0x00000000fa300000, 0x00000000fa400000)
tenured generation total 10240K, used 4797K [0x00000000fa400000, 0x00000000fae00000, 0x00000000fae00000)
the space 10240K, 46% used [0x00000000fa400000, 0x00000000fa8af4a8, 0x00000000fa8af600, 0x00000000fae00000)
compacting perm gen total 21248K, used 3464K [0x00000000fae00000, 0x00000000fc2c0000, 0x0000000100000000)
the space 21248K, 16% used [0x00000000fae00000, 0x00000000fb162650, 0x00000000fb162800, 0x00000000fc2c0000)
按理来说,allocation1
和allocation2
引用的对象经过两轮 GC 后年龄为 2 岁,并没有超过设定的阈值,应该在 Survivor 区才对
可是输出的 GC 日志中显示 Survivor 区使用空间为 0,那只有一种可能,它俩引用的对象已经晋升到老年代了,因为它们加起来大于了 Survivor 空间的一半
如果注释掉allocation1
或allocation2
中的一个,那么就不会晋升到老年代
在发生 Young GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 Young GC 可以确保时安全的
原因:在 Young GC 时,可能会出现一部分对象晋升到老年代的情况,如果老年代没有足够的空间容纳这些对象就会触发一次 Full GC (不允许担保失败的前提下)
如果上述条件不成立,则虚拟机会先看看-XX:HandlePromotionFailure
参数是否允许担保失败;如果允许,则会检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小
如果大于,将尝试进行一次 Young GC,尽管这次 GC 是有风险的;如果小于,或者-XX:HandlePromotionFailure
设置不允许冒险,那这时就要改为进行一次 Full GC
说的可能有些绕,直接看流程图:
取历史平均值来比较其实仍然是一种赌概率的解决办法,也就是说假如某次 Young GC 存活的对象突增,远远高于历史平均值的话,依然会保证担保失败;如果担保失败,就只好老老实实地重新发起一次 Full GC,这样的停顿时间就很长了
虽然担保失败时绕的圈子是最大的,但通常情况下还是会将-XX:HandlePromotionFailure
设置为允许,为了避免 Full GC 过于频繁,只要满足x < y
就会触发 Full GC
注意:在 JDK 6 Update 24 版本之后,HandlePromotionFailure 参数就没有作用了,只要老年代最大可用连续空间大于新生代所有对象总空间或者历次晋升到老年代对象的平均大小,就进行 Minor GC 否则进行 Full GC,即:始终会允许担保失败!!