3.6 内存分配与回收策略

参考书籍

(豆瓣) 深入理解Java虚拟机(第2版),以下简称为

简述

中的 3.6 节为 内存分配与回收策略,涉及以下5个小节

3.6.1 对象优先在 Eden 分配
3.6.2 大对象直接进入老年代
3.6.3 长期存活的对象将进入老年代
3.6.4 动态对象年龄判定
3.6.5 空间分配担保

本文将对其中的前3个小节进行简单的分析。本文中例子参考了中的代码

3.6.1 对象优先在 Eden 分配

下面是各个程序的堆内存分配情况(只考虑了main函数和allocate函数中产生的对象占据的堆内存)

程序名 eden from to tenured
Empty.java 0 0 0 0
Enough.java 4M 0 0 0
StillGood.java 6M 0 0 0
Old.java 2M 0 0 6M

首先是 Enough.java

Enough.java

public class Enough {
    public static void main(String[] args) {
        Object o1 = allocate(2); // 引用的对象大小为2M左右
        Object o2 = allocate(2); // 引用的对象大小为2M左右
    }

    public static Object allocate(int N) {
        // size is roughly N megabyte
        Object o = new byte[N * 1024 * 1024];
        return o;
    }
}

在命令行执行如下命令

// -Xms20M 設定起始 Java 堆集大小=20M
// -Xmx20M 設定 Java 堆集大小上限=20M
// -XX:SurvivorRatio=8 新生代中 Eden 区域与 Survivor 区域的容量比值
// -XX:+UseSerialGC 使用 Serial+Serial Old 的收集器组合进行内存回收
// -XX:+PrintGCDetails 打印 GC 的详细信息
javac Enough.java && java -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:+PrintGCDetails -cp . Enough

结果为


3.6 内存分配与回收策略_第1张图片
运行结果
  1. o1 引用的对象大小为 2M 左右
  2. o2 引用的对象大小为 2M 左右

照理说 Eden 区域被使用的空间应该为 2M+2M=4M 左右(4M/8M=50%)。但是从图中可以看到是 Eden 区域实际上被使用了 64% 左右。我猜测虚拟机运行时会有一些与main函数没有直接关系的对象分配在 Eden 区域。为了进行验证,我们可以把 Enough.javamain 方法里的语句都删除掉,得到 Empty.java 如下

Empty.java

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

    public static Object allocate(int N) {
        // size is roughly N megabyte
        Object o = new byte[N * 1024 * 1024];
        return o;
    }
}

在命令行输入以下命令

// -Xms20M 設定起始 Java 堆集大小=20M
// -Xmx20M 設定 Java 堆集大小上限=20M
// -XX:SurvivorRatio=8 新生代中 Eden 区域与 Survivor 区域的容量比值
// -XX:+UseSerialGC 使用 Serial+Serial Old 的收集器组合进行内存回收
// -XX:+PrintGCDetails 打印 GC 的详细信息
javac Empty.java && java -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:+PrintGCDetails -cp . Empty

运行结果如下

3.6 内存分配与回收策略_第2张图片
运行结果

由此可见,即使 main 函数什么都不做,Eden 区域也被占用了 14% 左右(具体数值应该和虚拟机有关)。而 Enough.javao1o2 引用的对象大小分别为 2M 左右,所以 o1 引用的对象占据 Eden 区域的比例有 25% 左右, o2 引用的对象占据 Eden 区域的比例也有 25% 左右。这样 14% + 25% + 25% = 64%,就可以解释通了。

StillGood.java

我们在 Enough.javamain 函数的注基础上再分配一个对象,得到 StillGood.java 如下

public class StillGood {
    public static void main(String[] args) {
        Object o1 = allocate(2);
        Object o2 = allocate(2);
        Object o3 = allocate(2);
    }

    public static Object allocate(int N) {
        // size is roughly N megabyte
        Object o = new byte[N * 1024 * 1024];
        return o;
    }
}

Enough.java 中的 main 相比, StillGood.javamain 函数中多了一个 o3o3 引用的对象的大小约为 2M(2M/8M=25%)。
所以如果运行的参数不变,StillGood.java 编译后运行时,Eden 区域被占用的比例应该为 64% + 25% = 89% 左右
我们在命令行输入以下命令

// -Xms20M 設定起始 Java 堆集大小=20M
// -Xmx20M 設定 Java 堆集大小上限=20M
// -XX:SurvivorRatio=8 新生代中 Eden 区域与 Survivor 区域的容量比值
// -XX:+UseSerialGC 使用 Serial+Serial Old 的收集器组合进行内存回收
// -XX:+PrintGCDetails 打印 GC 的详细信息
javac StillGood.java && java -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:+PrintGCDetails -cp . StillGood

运行结果如下


运行结果

Old.java

我们看到 StillGood.java 在运行时,Eden 空间已经被占据 89% 左右,如果 main 函数里再分配一个大小合适的对象,就会发生 Minor GC 了(因为 Eden 空间快要被分配完了)。
StillGood.java 的基础上,我们在 main 方法中再产生一个大小为 2M 左右的对象,对应的程序为 Old.java,具体如下

public class Old {
    public static void main(String[] args) {
        Object o1 = allocate(2);
        Object o2 = allocate(2);
        Object o3 = allocate(2);
        Object o4 = allocate(2);
    }

    public static Object allocate(int N) {
        // size is roughly N megabyte
        Object o = new byte[N * 1024 * 1024];
        return o;
    }
}

在命令行执行如下命令

// -Xms20M 設定起始 Java 堆集大小=20M
// -Xmx20M 設定 Java 堆集大小上限=20M
// -XX:SurvivorRatio=8 新生代中 Eden 区域与 Survivor 区域的容量比值
// -XX:+UseSerialGC 使用 Serial+Serial Old 的收集器组合进行内存回收
// -XX:+PrintGCDetails 打印 GC 的详细信息
javac Old.java && java -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:+PrintGCDetails -cp . Old

运行结果如下

3.6 内存分配与回收策略_第3张图片
运行结果

可见 o1, o2, o3 引用的对象都被挪动到老年代了。我觉得是因为 Minor GC 的时候 to survivor 空间只有1M左右,不够放置 o1, o2, o3 所引用对象中的任意一个,所以只好把它们都挪动到老年代了。

3.6.2 大对象直接进入老年代

为了便于比较,先写一个 Naive.java,代码如下

public class Naive {
    public static void main(String[] args) {
        Object o1 = allocate(1); // 约占 1M 内存
        Object o2 = allocate(1); // 约占 1M 内存
    }

    public static Object allocate(int N) {
        // size is roughly N megabyte
        Object o = new byte[N * 1024 * 1024];
        return o;
    }
}

执行的命令为

// -Xms20M 設定起始 Java 堆集大小=20M
// -Xmx20M 設定 Java 堆集大小上限=20M
// -XX:SurvivorRatio=8 新生代中 Eden 区域与 Survivor 区域的容量比值
// -XX:+UseSerialGC 使用 Serial+Serial Old 的收集器组合进行内存回收
// -XX:+PrintGCDetails 打印 GC 的详细信息
javac Naive.java && java -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:+PrintGCDetails -cp . Naive
3.6 内存分配与回收策略_第4张图片
o1,o2引用的对象分配在 Eden 区域

计算如下

  1. 在给定的命令行选项下,Eden 区域的大小约为 8M
  2. 在给定的命令行选项下,即使main函数里没有代码,Eden 区域也会被占用14%左右
  3. o1 引用的对象约有 1M 大小
  4. o2 引用的对象约有 1M 大小

所以14% + 1/8 + 1/8 = 39%,和图示数据比较吻合。

我们在main函数里再分配一个大一些的对象,具体如下

public class More {
    public static void main(String[] args) {
        Object o1 = allocate(1); // 约占 1M 内存
        Object o2 = allocate(1); // 约占 1M 内存
        Object o3 = allocate(2); // 约占 2M 内存
    }

    public static Object allocate(int N) {
        // size is roughly N megabyte
        Object o = new byte[N * 1024 * 1024];
        return o;
    }
}

执行如下命令,会看到o1,o2,o3引用的对象都分配在 Eden 区域

// -Xms20M 設定起始 Java 堆集大小=20M
// -Xmx20M 設定 Java 堆集大小上限=20M
// -XX:SurvivorRatio=8 新生代中 Eden 区域与 Survivor 区域的容量比值
// -XX:+UseSerialGC 使用 Serial+Serial Old 的收集器组合进行内存回收
// -XX:+PrintGCDetails 打印 GC 的详细信息
javac More.java && java -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:+PrintGCDetails -cp . More
3.6 内存分配与回收策略_第5张图片
o1,o2,o3引用的对象都分配在 Eden 区域

计算如下

  1. 在给定的命令行选项下,Eden 区域的大小约为 8M
  2. 在给定的命令行选项下,即使main函数里没有代码,Eden 区域也会被占用14%左右
  3. o1 引用的对象约有 1M 大小
  4. o2 引用的对象约有 1M 大小
  5. o3 引用的对象约有 2M 大小

所以14% + 1/8 + 1/8 + 2/8 = 64%,和图示数据比较吻合。

如果加上 -XX:PretenureSizeThreshold=1500000 这个选项,可以让大于 1500000 字节(约等于1.5M)的对象直接在分配在老年代。那么o1o2仍旧在 Eden 区域进行分配,而 o3 会直接分配在老年代。

中提到

PretenureSizeThreshold 参数只对 Serial 和 ParNew 两款收集器有效

完整的命令如下

// -Xms20M 設定起始 Java 堆集大小=20M
// -Xmx20M 設定 Java 堆集大小上限=20M
// -XX:SurvivorRatio=8 新生代中 Eden 区域与 Survivor 区域的容量比值
// -XX:+UseSerialGC 使用 Serial+Serial Old 的收集器组合进行内存回收
// -XX:+PrintGCDetails 打印 GC 的详细信息
// -XX:PretenureSizeThreshold 直接晋升到老年代的对象大小,设置这个参数后,大于这个参数的对象将直接在老年代分配
javac More.java && java -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:+PrintGCDetails -XX:PretenureSizeThreshold=1500000 -cp . More 
3.6 内存分配与回收策略_第6张图片
o3引用的对象直接分配在老年代

可以看到o1,o2引用的对象分配在 Eden 区域,而o3引用的对象直接分配在老年代。

计算如下

  1. 在给定的命令行选项下,Eden 区域的大小约为 8M
  2. 在给定的命令行选项下,老年代的大小约为 10M
  3. 在给定的命令行选项下,即使main函数里没有代码,Eden 区域也会被占用14%左右
  4. o1 引用的对象约有 1M 大小
  5. o2 引用的对象约有 1M 大小
  6. o3 引用的对象约有 2M 大小

所以

  1. Eden 区域被占用的比例为 14% + 1/8 + 1/8 = 39%
  2. 老年代被占用的比例为 2/10 = 20%

和图中的数据比较吻合

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

假设 Eden 空间有 8M 左右,如果我们不停地分配大小 6M 左右的对象的话,就可以不停地触发 GC(从第二次分配起,每分配一次就触发一次 GC)。

public class Age {
    public static void main(String[] args) {
        Object o = allocate(0.16); // 一直存活至程序结束

        // 在命令行执行循环执行的次数(执行 bound 次)
        int bound = Integer.parseInt(args[0]);
        if (bound < 0 || bound > 100) {// bound 的值不能太离谱
            return;
        }
        for (int i = 0; i < bound;i++) {
            // 不停地分配大小约为 6M 的内存,
            // 从而不停地触发 GC(假设 Eden 区域的总大小为 8M 左右)
            // 分配之后, 这 6M 左右的空间就立刻变为"垃圾", 可以再被回收
            allocate(6); 
        }
    }

    public static Object allocate(double N) {
        // size is roughly N megabyte
        Object o = new byte[(int)(N * 1024) * 1024];
        return o;
    }
}

bound=0 的情况

命令行执行的命令如下

// -Xms20M 設定起始 Java 堆集大小=20M
// -Xmx20M 設定 Java 堆集大小上限=20M
// -XX:SurvivorRatio=8 新生代中 Eden 区域与 Survivor 区域的容量比值
// -XX:+UseSerialGC 使用 Serial+Serial Old 的收集器组合进行内存回收
// -XX:+PrintGCDetails 打印 GC 的详细信息
// -XX:MaxTenuringThreshold  晋升到老年代的对象年龄。每个对象在坚持过一次 Minor GC 之后,年龄就增加 1,当超过这个参数值时就进入老年代
javac Age.java && java -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:+PrintGCDetails -XX:MaxTenuringThreshold=2 -cp . Age 0

如果先不看这些选项的话
本质上是在执行 java Age 0
其中 0 指定了程序中 bound 变量的值
执行结果如下

3.6 内存分配与回收策略_第7张图片
Threshold=2, bound=0

计算如下

  1. 在给定的命令行选项下,Eden 区域的大小约为 8M
  2. 在给定的命令行选项下,即使main函数里没有代码,Eden 区域也会被占用14%左右
  3. o 引用的对象约有 0.16M 大小

所以 Eden 空间被占用的比例为 14% + 0.16/8 = 16%,和图中的数据吻合

bound=1 的情况

我们再执行 java Age 1 (完整的命令如下)

// 各个选项的值没有变化,只修改了位于行尾的参数(0->1)
javac Age.java && java -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:+PrintGCDetails -XX:MaxTenuringThreshold=2 -cp . Age 1

执行结果如下


3.6 内存分配与回收策略_第8张图片
Threshold=2, bound=1

计算如下

  1. 在给定的命令行选项下,Eden 区域的大小约为 8M
  2. 在给定的命令行选项下,即使main函数里没有代码,Eden 区域也会被占用14%左右
  3. o 引用的对象约有 0.16M 大小
  4. for 循环中调用 allocate 函数时分配的对象约有 6M 大小

所以 Eden 空间被占用的比例为 14% + 0.16/8 + 6/8 = 91%,和图中的数据吻合

bound=2 的情况

我们再执行 java Age 2 (完整的命令如下)

// 各个选项的值没有变化,只修改了位于行尾的参数(1->2)
javac Age.java && java -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:+PrintGCDetails -XX:MaxTenuringThreshold=2 -cp . Age 2
3.6 内存分配与回收策略_第9张图片
Threshold=2, bound=2

计算如下

  1. 在给定的命令行选项下,Eden 区域的大小约为 8M
  2. 在给定的命令行选项下, 两个 Survivor 区域的大小都约为 1M
  3. o 引用的对象约有 0.16M 大小
  4. for 循环在最后一次执行中,调用 allocate 函数时分配的对象约有 6M 大小

bound=1 相比,由于要多分配一个 6M 左右大小的对象,所以触发了 GCGC 后,o 引用的对象会被移动到 Survivor 区域。

计算 Eden 区域和 Survivor 区域(理论上)被占用的比例

  1. Eden 区域被占用的比例为 6/8=75%
  2. To Survivor 区域(由于From/To
    Survivor 是相对的,其实这里所说的 To Survivor 就对应图中的 from space)被占用的比例为 0.24/1=24%

但是实际上可能有一些与 main 函数没有直接关系的对象也会分配在 Eden 空间(以及移动至 Survivor 区域),所以图中看到 Eden 区域被占用的比例为 77%,From Survivor 被占用的比例为 43%

o 引用的对象经历了一次 Minor GC 的过程,年龄变为1

bound=3 的情况

我们再执行 java Age 3 (完整的命令如下)

// 各个选项的值没有变化,只修改了位于行尾的参数(2->3)
javac Age.java && java -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:+PrintGCDetails -XX:MaxTenuringThreshold=2 -cp . Age 3
3.6 内存分配与回收策略_第10张图片
Threshold=2, bound=3

计算如下

  1. 在给定的命令行选项下,Eden 区域的大小约为 8M
  2. 在给定的命令行选项下, 两个 Survivor 区域的大小都约为 1M
  3. o 引用的对象约有 0.16M 大小
  4. for 循环在最后一次执行中,调用 allocate 函数时分配的对象约有 6M 大小

bound=2 相比,由于要多分配一个 6M 左右大小的对象,所以多触发了一次 GC。 第一次 GC 后,o 引用的对象会被移动到 To Survivor 区域(之后 To Survivor 会被称为 From Survivor)。第二次 GC 后,o 引用的对象会从 From Survivor 移动至 To Survivor(之后 To Survivor 会被称为 From Survivor)。

计算 Eden 区域和 Survivor 区域(理论上)被占用的比例

  1. Eden 区域被占用的比例为 6/8=75%
  2. To Survivor 区域(由于From/To
    Survivor 是相对的,其实这里所说的 To Survivor 就对应图中的 from space)被占用的比例为 0.24/1=24%

但是实际上可能有一些与 main 函数没有直接关系的对象也会分配在 Eden 空间(以及移动至 Survivor 区域),所以图中看到 Eden 区域被占用的比例为 77%,From Survivor 被占用的比例为 42%

o 引用的对象经历了两次 Minor GC 的过程,年龄变为2,已经达到 -XX:MaxTenuringThreshold=2 选项所指定的阈值,所以下次 Minor GC 的时候,o 引用的对象就会被移动至老年代。

bound=4 的情况

我们再执行 java Age 4 (完整的命令如下)

// 各个选项的值没有变化,只修改了位于行尾的参数(3->4)
javac Age.java && java -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:+PrintGCDetails -XX:MaxTenuringThreshold=2 -cp . Age 4
3.6 内存分配与回收策略_第11张图片
Threshold=2, bound=4

计算如下

  1. 在给定的命令行选项下,Eden 区域的大小约为 8M
  2. 在给定的命令行选项下, 两个 Survivor 区域的大小都约为 1M
  3. o 引用的对象约有 0.16M 大小
  4. for 循环在最后一次执行中,调用 allocate 函数时分配的对象约有 6M 大小

bound=3 相比,由于要多分配一个 6M 左右大小的对象,所以多触发了一次 GC。 第一次 GC 后,o 引用的对象会被移动到 To Survivor 区域(之后 To Survivor 会被称为 From Survivor)。第二次 GC 后,o 引用的对象会从 From Survivor 移动至 To Survivor(之后 To Survivor 会被称为 From Survivor)。由于 o 引用的对象已经经历了两次 Minor GC 的过程,所以在第三次 GC 时,o 引用的对象会从 From Survivor 移动至老年代。

计算 Eden 区域和老年代(理论上)被占用的比例

  1. Eden 区域被占用的比例为 6/8=75%
  2. 老年代区域被占用的比例为 0.24/10=2.4%

但是实际上可能有一些与 main 函数没有直接关系的对象也会分配在 Eden 空间(以及移动至 Survivor 区域),所以图中看到 Eden 区域被占用的比例为 77%,老年代被占用的比例为 4%

总结如下

bound 的值 程序结束时o引用对象所在位置 GC 发生次数
0 Eden 0
1 Eden 0
2 Survivor 1
3 Survivor 2
4 老年代 3

3.6.4 动态年龄判定

中提到

为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。

3.6.3 小节类似,我们仍旧让 Eden 区域的大小为 8M 左右,通过不停地分配大小约为 6M 的对象,来不停地触发 GC(从第二次分配起,每分配一次就触发一次 GC)。

public class Play {
    public static void main(String[] args) {
        Object o = allocate(0.16); // 一直存活至程序结束

        // 在命令行执行循环执行的次数(执行 bound 次)
        int bound = Integer.parseInt(args[0]);
        if (bound < 0 || bound > 100) {// bound 的值不能太离谱
            return;
        }
        for (int i = 0; i < bound;i++) {
            // 不停地分配大小约为 6M 的内存,
            // 从而不停地触发 GC(假设 Eden 区域的总大小为 8M 左右)
            // 分配之后, 这 6M 左右的空间就立刻变为"垃圾", 可以再被回收
            allocate(6); 
        }
    }

    public static Object allocate(double N) {
        // size is roughly N megabyte
        Object o = new byte[(int)(N * 1024) * 1024];
        return o;
    }
}

3.6.3 小节的分析类似,如果我们保持各个选项的值不变 (包括-XX:MaxTenuringThreshold=2这个选项),那么当 bound 参数的值为 0 时,

bound 的值 程序结束时o引用对象所在位置 GC 发生次数
0 Eden 0
1 Eden 0
2 Survivor 1
3 Survivor 2
4 老年代 3

bound=2 为例,完整的命令如下

javac Play.java && java -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:+PrintGCDetails -XX:MaxTenuringThreshold=2 -cp . Play 2

运行结果如下


3.6 内存分配与回收策略_第12张图片
Threshold=2, bound=2

o 引用的对象大小约为 0.16M,理论上应该占据一个 Survivor 16% 左右的空间,现在由于可能有其他对象的分配,所以实际上占据了约 43% 的空间。那么可以认为有 43% - 16% = 27% 左右的空间是被其他对象占据的。按照刚才提到 中的结论,我们希望这个 Survivor 空间可以被占据超过 50%
50% - 27% = 23%, 如果我们分配一个 0.24M 的对象,应该就可以满足要求了



未完待续

你可能感兴趣的:(3.6 内存分配与回收策略)