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 相关虚拟机参数

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

参数 说明
-XX:+UseSerialGC 使用 Serial + Serial Old 的收集器组合进行内存回收
-Xms20m 堆空间初始容量为 20MB
-Xmx20m 堆空间最大容量为 20MB
-Xmn10m 堆中新生代容量为 10MB
-XX:SurvivorRatio=8 新生代 Eden 占比为 8(两个 Survivor 分别为 1)
-XX:+PrintGCDetails GC 时打印内存回收日志,并在进程退出时输出当前各个内存区域分配情况
-verbose:gc 输出每一个 GC 事件的信息
-XX:PretenureSizeThreshold=3145728 指定老年代的阈值<br />(大于该值的对象直接在老年代分配,此处为 3MB)
-XX:MaxTenuringThreshold=1 对象晋升到老年代的年龄最大阈值为 1(默认是 15)
-XX:+PrintTenuringDistribution 打印对象年龄信息

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。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,036评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,046评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,411评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,622评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,661评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,521评论 1 304
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,288评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,200评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,644评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,837评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,953评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,673评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,281评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,889评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,011评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,119评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,901评论 2 355

推荐阅读更多精彩内容