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。

往期相关:

JVM笔记-运行时内存区域划分

JVM笔记-垃圾收集算法与垃圾收集器

你点的每个赞,我都认真当成了喜欢~

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章