第三章 垃圾收集器与内存分配策略 | part 4

内存分配与回收策略

Java技术体系中所提倡的自动内存管理最终可以归结为自动化地解决了两个问题:自动给对象分配内存以及自动回收分配给对象的内存。本文主要使用 Serial + Serial Old 收集器做实验。

对象优先在 Eden 分配

大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次Minor GC。在下面的代码中,设置 Java 堆大小为 20MB,其中 10MB 分配给新生代。另外 Eden 区和 Survivor 区的比例是 8:1。

Eden 区占比一般在 25% 到 50%,太小会导致频繁的 Minor GC,太大则会压缩老年代空间,导致成本更高的 Full GC。

java 复制代码
public class TestAllocation {
    private static final int _1MB = 1024 * 1024;

    // -XX:+UseSerialGC -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
    public static void main(String[] args) {
        byte[] a1, a2, a3, a4;
        a1 = new byte[2 * _1MB];
        a2 = new byte[2 * _1MB];
        a3 = new byte[2 * _1MB];
        a4 = new byte[4 * _1MB];
    }
}

我们执行这个方法,会在 a4 分配时发生一次 Minor GC,这是因为 a1、a2、a3 都在 Eden 区,占用了 6MB 空间,导致剩余空间不足以存放 a4。垃圾收集期间,虚拟机又发现 a1、a2、a3 全都无法放入 Survivor 区,因此直接进入老年代(空间担保机制)。

这次收集结束后,4MB 的 a4 顺利分配在 Eden 区,因此得到下面的结果:

大对象直接进入老年代

大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组 。 虚拟机提供了一个 -XX:PretenureSizeThreshold 参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在 Eden 区及两个 Survivor 区之间发生大量的内存复制。

PretenureSizeThreshold 参数只对 Serial 和 ParNew 两款收集器有效,Parallel Scavenge 收集器不认识这个参数,Parallel Scavenge收集器一般并不需要设置。如果遇到必须使用此参数的场合,可以考虑 ParNew 加 CMS 的收集器组合。

下面的代码中,我们把 PretenureSizeThreshold 设置为 3MB,这样分配的 4MB 对象就会直接进入老年代中。

java 复制代码
public class TestPretenureSizeThreshold {
    private static final int _1MB = 1024 * 1024;

    // -XX:+UseSerialGC -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
    // -XX:PretenureSizeThreshold=3145728
    public static void main(String[] args) {
        byte[] a;
        a = new byte[4 * _1MB];
    }
}

得到的结果可以看到老年代正好被占用了 4MB,印证了上面的结论:

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

虚拟机给每个对象定义了一个对象年龄计数器 ,存储在对象头中(详见第二章)。如果对象在 Eden 区出生,经过第一次 Minor GC 后仍然存活,并且能被 Survivor 区容纳的话,将被移动到 Survivor 区中,并且对象年龄设为 1 。对象在 Survivor 区中每 "熬过" 一次 Minor GC,年龄就增加 1 岁 。当年龄达到晋升阈值时,就会晋升到老年代。这个可以通过参数 -XX:MaxTenuringThreshold 设置(注:这里设置的是最大值真实值是动态变化的,详见下一部分)。

Survivor to 区空间不足的话会导致提前晋升老年代,这可能使本来即用即死的对象一直占用内存直到下次 Full GC。

下面代码中,a1 需要 256KB,Survivor 区可以容纳,因此当晋升阈值为 1 时,a1 会在第二次 GC 时进入老年代

java 复制代码
public class TestTenuringThreshold {
    private static final int _1MB = 1024 * 1024;

    // -XX:+UseSerialGC -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
    // -XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution
    public static void main(String[] args) {
        byte[] a1, a2, a3;
        a1 = new byte[_1MB / 4];
        a2 = new byte[4 * _1MB];
        a3 = new byte[4 * _1MB];  // Minor GC,a1进入Survivor区,a2、a3进入老年代
        a3 = null;
        a3 = new byte[4 * _1MB];  // Minor GC,原来的a3被清理,新的a3进入Eden区
    }
}

运行结果:

动态对象年龄判定

虚拟机并不是永远地要求对象的年龄必须达到了 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 区中年龄小于等于 age 的所有对象大小的总和达到 Survivor 区设置的使用率 (通过 -XX:TargetSurvivorRatio=percent 设置),则年龄大于该年龄的对象就可以直接进入老年代 ,并且晋升阈值变成 <math xmlns="http://www.w3.org/1998/Math/MathML"> m i n ( a g e , M a x T e n u r i n g T h r e s h o l d ) min(age,MaxTenuringThreshold) </math>min(age,MaxTenuringThreshold)。

为什么要有这种动态机制?

假设有很多年龄还未达到阈值的对象依旧停留在 Survivor 区,不利于新对象从 Eden 晋升到 Survivor。因此设置 Survivor 区的目标使用率,当使用率达到时重新调整阈值,让对象尽早晋升老年代。

在下面的代码中,晋升阈值设置在了 15,但是运行结果显示 Survivor 占用仍然为 0%,这是由于 a1 和 a2 直接进入了老年代,因为它们两个是同龄的,且加起来达到了 Survivor 区的一半。

java 复制代码
public class TestTenuringThreshold2 {
    private static final int _1MB = 1024 * 1024;

    // -XX:+UseSerialGC -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
    // -XX:MaxTenuringThreshold=15 -XX:+PrintTenuringDistribution
    public static void main(String[] args) {
        byte[] a1, a2, a3, a4;
        a1 = new byte[_1MB / 4];
        a2 = new byte[_1MB / 4];
        a3 = new byte[4 * _1MB];
        a4 = new byte[4 * _1MB];
        a4 = null;
        a4 = new byte[4 * _1MB];
    }
}

运行结果:

空间担保机制

在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么 Minor GC 可以确保是安全的。如果不成立,则虚拟机会查看 -XX:HandlePromotionFailure 设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者 Handle Promotion Failure 设置不允许冒险,那这时就要改为进行一次 Full GC。

老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值。

如果出现了 Handle Promotion Failure 失败,那就只好在失败后重新发起一次 Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将 Handle Promotion Failure 开关打开,避免 Full GC 过于频繁。

JDK 6 Update 24 之后不再使用 XX:HandlePromotionFailure 参数,而是变为只要老年代的连续空间 大于新生代对象总大小 或者 历次晋升的平均大小 就会进行 Minor GC,否则将进行 Full GC。

相关推荐
HalvmånEver2 小时前
7.高并发内存池大页内存申请释放以及使用定长内存池脱离new
java·spring boot·spring
凤山老林3 小时前
SpringBoot 使用 H2 文本数据库构建轻量级应用
java·数据库·spring boot·后端
赶路人儿3 小时前
UTC时间和时间戳介绍
java·开发语言
dreamread3 小时前
【SpringBoot整合系列】SpringBoot3.x整合Swagger
java·spring boot·后端
6+h3 小时前
【java】基本数据类型与包装类:拆箱装箱机制
java·开发语言·python
一直都在5724 小时前
Spring面经
java·后端·spring
xiaoye37084 小时前
如何在Spring中使用注解配置Bean的生命周期回调方法?
java·spring
闻哥4 小时前
深入Redis的RDB和AOF两种持久化方式以及AOF重写机制的分析
java·数据库·spring boot·redis·spring·缓存·面试
jgyzl4 小时前
2026.3.12 常见的缓存读写策略
java·后端·spring
ruanyongjing5 小时前
Spring TransactionTemplate 深入解析与高级用法
java·数据库·spring