JVM 中堆的对象转移与年龄判断

1. 前言

上节课程我们讲解了堆内存中不同内存空间模块的作用、特点及意义,本节主要讲解堆内存中对象的转移与年龄判断。本节主要知识点如下:

  • 理解并掌握对象优先在 Eden 区分配的实验案例,为本节重点内容之一;
  • 理解并掌握对象直接在老年代分配的触发条件,理解什么是大对象,为本节重点内容之一;
  • 掌握堆内存对象转移的完整流程图及触发机制,为本节核心知识点,其它所有知识点都是围绕这一知识点展开的;
  • 理解并掌握年龄判断的定义,作用及默认年龄值,为本节重点内容之一。

通篇皆为重点内容,其核心是围绕堆内存对象转移的完整流程图及触发机制,本节课程的内容会涉及到垃圾回收的相关概念,此处我们先做了解即可,后续会对垃圾回收进行专门的讲解。

2. 对象优先在Eden 区分配

Tips:标题中“优先”一次需要学习者认真品味,“优先” 意味着首先考虑,那么在一些特殊情况下,新创建的对象还是有可能不在Eden区分配的。这种特殊情况我们在讲解老年代(OldGen)的时候再进行说明。

上节课程我们学习了,Eden 区属于年轻代(YoungGen)。在创建新的对象时,大多数情况下,对象先在 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。

那我们如何进行证明,新创建的对象优先在Eden 区分配呢?为了对这个结论进行验证,我们来设计如下实验。

实验设计

  • 创建了一个类,类名称可自定义,并在类中实现一个 main 函数,为后续测试做前提准备;
  • 在运行main函数之前,通过设置 JVM 参数,设置堆内存初始大小为 20M,最大为 20M,其中年轻代大小为 10M,不需要特殊设置 Eden 区的大小;
  • 除了设置堆内存参数之外,还需要设置JVM 参数跟踪详细的垃圾回收日志,以便于观察年轻代(YoungGen)的内存使用情况;
  • 设置完成后,main 函数不写任何代码,运行空的 main 函数观察打印日志;
  • 在main函数中创建一个 2M 大小的对象,运行 main 函数观察打印日志。

Tips:实验中会用到两种JVM的参数配置,一种是配置堆内存的参数,另外一种是配置跟踪垃圾回收的参数。这两部分参数我们在之前的章节都有详细描述过。

实验要点准备

  • 设置堆内存大小为 20M,最大为 20M,其中年轻代大小为 10M,并设置垃圾跟踪日志打印。需要通过JVM参数 -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails 进行设置;
  • 不需要特殊设置 Eden 区的大小,那么年轻代中 Eden 区、from space 和 to space 将会以默认的 8:1:1进行空间分配;
  • 创建一个 2M 大小的对象,我们可以通过语句 byte[] obj = new byte[2*1024*1024] 来实现。

空运行main函数代码演示

public class DemoTest {
    public static void main(String[] args) {
    }
}

空运行mian函数日志

Heap
 PSYoungGen      total 9216K, used 2370K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 28% used [0x00000000ff600000,0x00000000ff850aa0,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
  to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
 ParOldGen       total 10240K, used 0K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 0% used [0x00000000fec00000,0x00000000fec00000,0x00000000ff600000)
 Metaspace       used 3439K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 374K, capacity 388K, committed 512K, reserved 1048576K

结果分析:我们主要关注 PSYoungGen(年轻代)下的内存分配。空运行情况下,我们看到 Eden 区的大小为 8192K,已使用 28%。为什么空运行下还会有 28% 的内存使用呢?这 28% 的内存使用,包括了支持main函数运行的对象实例。

新建 2M 对象的代码演示

public class DemoTest {
    public static void main(String[] args) {
        byte[] obj = new byte[2*1024*1024];
    }
}

新建 2M 对象的运行日志:此处我们只展示年轻代的运行日志。

PSYoungGen      total 9216K, used 4418K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 53% used [0x00000000ff600000,0x00000000ffa50ac8,0x00000000ffe00000)

结果分析:我们看到,新建 2M 的对象之后,Eden 区使用的空间从之前的 28% 增长到了 53%,净增长 25%。那么我们来进行简单的计算 Eden 区的总内存大小 8192K * 25% = 2048K = 2M。

看到这里我们应该明白了,新创建的对象确实是优先存储于年轻代(YoungGen)中的Eden区的。

3. 大对象直接进入老年代

我们在进行上一知识点讲解时提到过,新创建的对象是优先存放入 Eden 区的,那么对于新创建的大对象来说,会直接进入老年代码。

什么是大对象:2M 的对象算大吗?10M 的对象算大吗?100M 的对象呢?什么是大对象,大对象的标准是什么?大对象的标准是可以由开发者定义的,我们的 JVM 参数中,能够通过 -XX:PretenureSizeThreshold 这个参数设置大对象的标准,可惜的是这个参数只对 Serial 和 ParNew 两款新生代收集器有效。

那么如果不能够设置 -XX:PretenureSizeThreshold 参数,那什么是大对象呢?Eden 区容量不够存放的对象就是所谓的大对象。

为了验证“大对象直接进入老年代”这一结论,我们依然通过实验进行验证。

实验设计

  • 沿用上一个实验的 JVM 参数设置,并在此基础上增加参数设置 -XX:PretenureSizeThreshold = 3m
  • 将新建的 2M 对象修改为新建 6M对象;
  • 运行 main 函数,观察日志结果。

实验要点准备:本实验所需的 JVM 参数为 -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails

代码示例

public class DemoTest {
    public static void main(String[] args) {
        byte[] obj = new byte[6*1024*1024];
    }
}

运行结果

Heap
 PSYoungGen      total 9216K, used 2370K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 28% used [0x00000000ff600000,0x00000000ff850aa0,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
  to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
 ParOldGen       total 10240K, used 6020K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 58% used [0x00000000fec00000,0x00000000ff1e1010,0x00000000ff600000)
 Metaspace       used 3439K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 374K, capacity 388K, committed 512K, reserved 1048576K

结果分析:我们先来看下老年代(OldGen),total 10240K, used 6020K,说明我们新创建的对象是直接进入了老年代。然后我们来看下 Eden区 为什么不能存储 6M 大小的对象,我们进行简单的计算。

Eden 区剩余内存空间 = 总空间 8192K * (1-28%)= 5898 K < 6M。这就是我们所说的,大对象直接进入老年代。

4. 对象转移流程

上文我们学习了 Eden 区优先存放新建的独享,新建大对象不会经过Eden区,直接进入老年代,那么还剩两个区域没有进行讲解:幸存者区 from space 和 幸存者区 to space。我们在对流程图进行讲解时,会对这两块内存区域进行说明。

图片描述

从上图中可以看出,新生成的非大对象首先放到年轻代 Eden 区,当 Eden 空间满了,触发 Minor GC,存活下来的对象移动到 Survivor0 区,Survivor0 区满后触发执行 Minor GC,Survivor0 区存活对象移动到 Suvivor1 区,这样保证了一段时间内总有一个 survivor 区为空。经过多次 Minor GC 仍然存活的对象移动到老年代。

如果新生成的是大对象,会直接将该对象存放入老年代。

老年代存储长期存活的对象,GC 期间会停止所有线程等待 GC 完成,所以对响应要求高的应用尽量减少发生 Major GC,避免响应超时。

5. 对象年龄判断

对象年龄判断的作用:JVM 通过判断对象的具体年龄来判别是否该对象应存入老年代,JVM通过对年龄的判断来完成从对象从年轻代到老年代的转移。

对象年龄(Age)计数器:HotSpot 虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。

年龄增加:对象通常在 Eden 区里诞生,如果经过第一次 Minor GC 后仍然存活,并且能被Survivor容纳的话,该对象会被移动到 Survivor 空间中,并且将其对象年龄设为 1 岁。对象在Survivor区中每熬过一次 Minor GC,年龄就增加 1 岁。

年龄默认阈值:当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 设置。

6. 小结

本节我们学习了堆内存对象的转移过程以及 JVM 是如何通过判断对象年龄来决定是否将对象从年轻代转移至老年代的。通篇皆为重点内容,学习者需认真对待,本节内容与垃圾回收也息息相关,学好本节课程,也能为后续垃圾回收部分打下良好的基础。