专栏首页WriteOnReadJVM笔记-内存分配策略

JVM笔记-内存分配策略

1. 概述

1.1 简述

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

内存回收策略主要就是前面介绍的各种垃圾回收机制;而对象内存分配的规则并不固定,JVM 规范并未规定新对象的创建和存储细节,取决于使用哪种 JVM 以及参数设定。

本文主要以实验手段验证内存分配的几条基本原则。

1.2 环境配置

本文实验环境配置如下:

  • 操作系统:macOS Mojave 10.14.5
  • JDK 版本
$ java -version
java version "1.8.0_191"
Java(TM) SE Runtime Environment (build 1.8.0_191-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.191-b12, mixed mode)

1.3 相关虚拟机参数

本文相关的虚拟机参数及说明如下:

2. 内存分配基本原则

2.1 对象优先在 Eden 分配

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

  • JVM 参数
-XX:+UseSerialGC -verbose:gc -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails -XX:SurvivorRatio=8

参数说明:堆空间为 20MB,新生代和老年代各占 10MB,新生代可用空间:Eden 区 + 1 个 Survivor 区(即总共 8 + 1 = 9MB)。

  • 测试代码
private static final int _1M = 1024 * 1024;

private static void testAllocation() {
  // 分配三个 2MB 大小的对象(a1, a2, a3)和一个 4MB 大小的对象(a4)
  byte[] a1, a2, a3, a4;
  a1 = new byte[2 * _1M];
  a2 = new byte[2 * _1M];
  a3 = new byte[2 * _1M];
  a4 = new byte[4 * _1M]; // 触发一次 Minor GC
}

该方法执行过程中,对象的内存空间分配流程大致如下:

  1. a1, a2, a3 分配在 Eden 区;
  2. 当给 a4 分配空间时,由于 Eden 区剩余空间不足(无法容纳 a4),触发一次 Minor GC:
    1. 将 Eden 存活的对象复制到 Survivor 区(1 MB),由于 Survivor 无法容纳 a1, a2, a3,因此直接将它们转移到老年代;
    2. 回收 Eden 区,并将 a4 分配到 Eden 区。

因此,这几行代码执行完的结果是:a1, a2, a3 位于老年代(共 10MB,占用 6MB),a4 位于新生代 Eden 区(共 8MB,占用 4MB)。

下面查看和分析 GC 日志进行验证。

  • GC 日志

可以看到,Eden 共 8MB(8192K),使用 51%,老年代共 10MB(10204K),使用 60%,符合上述分析结果。

2.2 大对象直接进入老年代

  • 大对象:需要大量连续内存空间的 Java 对象。
  • 典型例子:很长的字符串,或者元素量非常大的数组。

JVM 需要尽量避免大对象的主要原因:

  1. 分配空间时,内存还有不少空间,就提前触发垃圾收集,以获取足够的空间给它们。
  2. 复制对象时,内存开销更高。
  • JVM 参数
-XX:+UseSerialGC -verbose:gc -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails -XX:SurvivorRatio=8
-XX:PretenureSizeThreshold=3145728
  • 示例代码
private static void testPretenureSizeThreshold() {
  byte[] a;
  a = new byte[4 * _1M];
}

对象 a 所需的内存空间(4MB)大于设定的阈值 PretenureSizeThreshold,直接分配在老年代。

  • GC 日志

可以看到,老年代总内存为 10MB(10240K),使用 40%,符合上述分析结果。

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

HotSpot 多数收集器采用了分代收集,这个分代是根据什么分的呢?

JVM 给每个对象定义了一个年龄(Age)计数器(存储在对象头),用于记录对象的年龄。 对象通常在 Eden 区诞生,若经历一次 Minor GC 后仍存活,则将其年龄增加 1;此后在 Survivor 区每经过一次 Minor GC,年龄都会递增 1,当年龄达到一定程度(默认 15),就会晋升到老年代中。

2.3.1 场景一
  • 虚拟机参数
-XX:+UseSerialGC -verbose:gc -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails -XX:SurvivorRatio=8
-XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution
  • 示例代码
private static void testTenuringThreshold() {
  byte[] a1, a2, a3;
  a1 = new byte[_1M / 4];

  a2 = new byte[4 * _1M];
  a3 = new byte[4 * _1M]; // 第一次 Minor GC
  a3 = null;
  a3 = new byte[4 * _1M]; // 第二次 Minor GC
}

该方法执行过程中,对象的内存空间分配流程大致如下:

  1. a1, a2 分配在 Eden 区(年龄 age=0);
  2. a3 初次在 Eden 区分配空间时,Eden 区没有足够空间,会触发一次 Minor GC:
    1. a1, a2 年龄增加 1 (age=1),并将其复制到 Survivor (to) 区;
    2. 由于 Survivor 空间(1 MB)只能容纳 a1,因此将 a1 复制到 Survivor (to) 区,a2 进入老年代;
    3. 回收 Eden 区,并将 a3 分配在 Eden 区;
  3. 执行 a3 = null 时,没有 GC 动作(此时 a3 占用的空间还未回收);
  4. 再次为 a3 分配空间时,Eden 空间不足,再次触发 Minor GC:
    1. a1 年龄增加 1(age=2),大于设定阈值(MaxTenuringThreshold),将其移入老年代;
    2. 回收 Eden 区,再次将 a3 分配到 Eden 区。

到这里,内存分配结果为:a1、a2 位于老年代,a3 位于新生代 Eden 区。下面分析 GC 日志进行验证。

  • GC 日志

可以看到,新生代 Eden 区空间(总 8 MB)占用 51%,老年代(总 10 MB)空间占用 46%,符合上述分析结果。

2.3.2 场景二
  • 上述代码不变,将参数 MaxTenuringThreshold的值修改为 15 再进行测试。

该方法执行过程中,对象的内存空间分配流程大致如下:

第二次 Minor GC 之前,流程与场景一相同,下面从第二次 Minor GC 开始(执行最后一行代码时)时分析:

  1. 再次为 a3 分配空间时,Eden 空间不足,再次触发 Minor GC:
    1. a1 年龄加 1(age=2),小于设定阈值(MaxTenuringThreshold),将其复制到 Survivor (from) 区;
    2. 回收 Eden 区空间,再次将 a3 分配到 Eden 区。

到这里,内存分配结果应为:a1 位于 Survivor (from) 区,a2 位于老年代,a3 位于新生代 Eden 区。

下面分析 GC 日志进行验证。

  • GC 日志

可以看到,新生代 Eden 区占用 51%,两个 Survivor 区都是 0%,老年代为 46%,与上述分析结果并不一致。这是为什么呢?

查看日志可以看到,第一次 GC 发生时:

new threshold 1 (max 15)

意思是晋升的阈值变成了 1,而非设定的 15!

为什么 MaxTenuringThreshold 设定是 15,但第一次 GC 时为 1 呢?

在一段 JVM 源码中可以得到答案:

int ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
  // TargetSurvivorRatio默认为50
  // desired_survivor_size = survivor的空间 * 50%
  size_t desired_survivor_size = (size_t)((((double) survivor_capacity)*TargetSurvivorRatio)/100);
  size_t total = 0;
  // 计算得出的对象年龄
  int age = 1;
  assert(sizes[0] == 0, "no objects with age zero should be recorded");
  while (age < table_size) {
    // 循环遍历所有年龄代的对象累加得到一个大小
    total += sizes[age];
    // 如果该大小大于desired_survivor_size,即survivor的空间 * 50%,那么退出循环【注意这里】
    if (total > desired_survivor_size) break;
    age++;
  }
  // 如果算出来的age大于MaxTenuringThreshold则使用MaxTenuringThreshold,否则使用计算出来的age
  int result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;

  if (PrintTenuringDistribution || UsePerfData) {
    if (PrintTenuringDistribution) {
      gclog_or_tty->cr();
      // 这里就是线上出现的那个日志所在的地方
      gclog_or_tty->print_cr("Desired survivor size %ld bytes, new threshold %d (max %d)",
        desired_survivor_size*oopSize, result, MaxTenuringThreshold);
    }
  //....
  }
  // 返回计算的年龄
  return result;
}

参考链接:https://blog.csdn.net/u013160932/article/details/84894969

从这段代码可以看出:对象实际的年龄是计算出来的,而这个年龄是 age 和 MaxTenuringThreshold 中较小的一个,参见如下代码:

int result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;

而这个 age 如何计算呢?从上述代码可以看出:

  1. age 初始值为 1;
  2. 按年龄从小到大循环遍历 Survivor 区的所有对象(累加),当它们所占空间总和大于 Survivor 一半(desired_survivor_size)的时候,跳出循环,当前 age 即为所得结果。

对于这个循环,举例说明:

  • 若 Survivor 区当前 age=1 的对象所占空间已经超过一半,则该 age 就是 1(实际晋升年龄就是 1);
  • 若遍历到 age=3 时,age 为 1、2、3 的对象所占空间总和超过 Survivor 一半,则 age=3(实际晋升年龄就是 3)。

根据上述 GC 日志第一次 GC 时 age=1,推测此时 Survivor 区 age=1 的对象已经超过了一半。

对上述代码稍作修改进行验证:

  • 测试代码
private static void testTenuringThreshold() {
  byte[] a1, a2, a3;
  a1 = new byte[_1M / 4];

  a2 = new byte[4 * _1M];
  a3 = new byte[4 * _1M]; // 第一次 Minor GC
  a3 = null;
//  a3 = new byte[4 * _1M]; // 第二次 Minor GC
}

这里将第二次触发 GC 的代码注释掉,此时该方法只发生一次 GC,日志如下:

可以看到,Survivor (from) 区已经使用 66%,超过了一半!说明推测是正确的。

2.3.3 场景三

上述 Survivor 区空间在该代码运行前已超过一半,说明在此之前已有其他对象分配了。为了进一步验证,在执行 testTenuringThreshold 方法前,先运行下面代码:

System.gc();

进行一次 Full GC,然后再执行 testTenuringThreshold 方法,此时的 GC 日志如下:

这时是符合场景一分析结果的。

注: 从官方文档 https://www.oracle.com/technetwork/java/vmoptions-jsp-140102.html 可以看到,其实参数 MaxTenuringThreshold 设置的是一个"最大"值,而非一个实际的晋升阈值。 PS: 名字的 Max 也有点这个意思。

2.4 动态对象年龄判定

实际上,HotSpot 并非要求对象年龄必须达到 -XX:MaxTenuringThreshold 才能晋升老年代,若在 Survivor 空间中年龄相同的所有对象大小总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就能直接进入老年代。

  • JVM 参数
-XX:+UseSerialGC -verbose:gc -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails -XX:SurvivorRatio=8
-XX:MaxTenuringThreshold=15 -XX:+PrintTenuringDistribution
  • 示例代码
private static void testTenuringThreshold2() {
  byte[] a1, a2, a3, a4;
  a1 = new byte[_1M / 4];
  a2 = new byte[_1M / 4];

  a3 = new byte[4 * _1M];
  a4 = new byte[4 * _1M]; // 第一次 Minor GC
  a4 = null;
  a4 = new byte[4 * _1M]; // 第二次 Minor GC
}

该方法执行中的内存分配流程大致如下:

  1. a1, a2, a3 分配在 Eden 区(age=0);
  2. 为 a4 分配内存时,Eden 区空间不足,触发一次 Minor GC:
    1. a1, a2 年龄增加 1(age=1),并复制到 Survivor (to) 区,a3 进入老年代;
    2. 回收 Eden 区,在 Eden 区为 a4 分配空间;
  3. a4 = null 未触发 GC;
  4. 为 a4 再次分配空间时,Eden 区空间不足,再次触发 Minor GC:
    1. a1, a2 年龄增加 1(age=2),虽然年龄并未到达阈值 15,但二者内存加起来超过 Survivor 空间一半,因此 a1 和 a2 都进入老年代;
    2. 回收 Eden 区,并在 Eden 区为 a4 再次分配空间。

结果:a1, a2, a3 都位于老年代,a4 位于新生代 Eden 区。

下面查看 GC 日志进行验证。

  • GC 日志

可以看到与分析结果大体相当。

2.5 空间分配担保

由于发生 Minor GC 时,可能会有一部分对象进入老年代。最极端的情况就是:Minor GC 时新生代所有对象全都存活,需要老年代进行分配担保。

因此,在发进行 Minor GC 之前,JVM 会先检查老年代的空间,流程如下:

若 Minor GC 发生时,老年代没有足够的空间进行分配担保,就会触发一次停顿更久的 Full GC。

注意:上述流程是 JDK 6 Update 24 之前的逻辑。 在此之后,规则变为:只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC。

本文分享自微信公众号 - WriteOnRead(WriteOnRead),作者:jaxer

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2020-03-09

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • JDK源码分析-LinkedHashMap

    前文「JDK源码分析-HashMap(1)」分析了 HashMap 主要方法的实现原理(其他问题以后分析),本文分析下 LinkedHashMap。

    WriteOnRead
  • JDK源码分析-LinkedList

    LinkedList 内部是一个双向链表,并且实现了 List 接口和 Deque 接口,因此它也具有 List 的操作以及双端队列和栈的性质。双向链表的结构如...

    WriteOnRead
  • JVM笔记-Java技术体系与JVM概述

    Java 的广告词为 "一次编写,到处运行",之所以能够做到"跨平台",是因为每个平台上不同的虚拟机屏蔽了硬件的差异,而 Java 程序则是运行在虚拟机之上的。

    WriteOnRead
  • 笔试面试的小结 反

    原文  http://blog.csdn.net/suky520/article/details/39641783

    bear_fish
  • java里的native方法的使用正解

    forrestlin
  • 面试题:JVM 配置常用参数和常用 GC 调优策略

    如上表所示,目前主要有串行、并行和并发三种,对于大内存的应用而言,串行的性能太低,因此使用到的主要是并行和并发两种。并行和并发 GC 的策略通过 UsePara...

    芋道源码
  • 小程序之仿抖音短视频与分布式云部署的那些事儿~

    闲来无聊,从过年一直到现在一直沉醉在抖音里,基本每天都被抖音占据着,晚上睡前也要刷到零点才躺下。。。后来吃鸡好友说,要不你开发一个差不多的得了,这么一说我就来劲...

    风间影月
  • 《深入理解 java 虚拟机》学习 -- 内存分配

    当年轻代满时就会触发Minor GC,这里的年轻代满指的是 Eden 代满,Survivor 满不会引发 GC。

    希希里之海
  • JVM面试问题系列:JVM 配置常用参数和常用 GC 调优策略

    如上表所示,目前主要有串行、并行和并发三种,对于大内存的应用而言,串行的性能太低,因此使用到的主要是并行和并发两种。并行和并发 GC 的策略通过 UsePara...

    zhisheng
  • 入门微信小程序 (一)

    小程序已经快两岁的,我才入坑,刚刚入坑那会我使劲和我朋友抱怨,太难了,学不动,不想学,不学了。硬着头皮死磕一遍文档,然后觉得也没有我想象中那么难。或许是因为语言...

    sunseekers

扫码关注云+社区

领取腾讯云代金券