深入JVM-内存分配策略

JVM中对象的内存分配,基本上是在堆上分配(也可能经过JIT编译后被拆散为标量类型并间接地在栈上分配),对象主要分配在新生代的Eden区,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配,少数情况下也可能会直接分配在老年代中,分配的规则取决于使用的垃圾收集器的组合及其参数配置。

1.对象优先在Eden区分配

1.1.介绍

        大多数情况下,对象在新生代Eden区中分配,当Eden区没有足够的内存进行分配时,会进行一次Minor GC(新生代GC)。

  • Minor GC
    指发生在新生代的垃圾收集动作,因为java对象大多都具有朝生夕死的特性,所以Minor GC非常频繁,一般回收速度也比较快。
  • Major GC/Full GC
    老年代GC,指发生在老年代的GC,出现Major GC,经常会伴随至少一次的Minor GC。Major GC速度一般比Minor GC慢10倍上。

1.2.测试代码

package com.glt.gc;

/**
 * 测试对象优先在Eden区分配(使用Serial与Serial Old的组合的内存分配与回收策略)
 *
 * JVM args:
 *  -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC
 *  -Xms20M 限制java堆大小为20M
 *  -Xmx20M 限制java堆最大值为 20M
 *  -Xmx和-Xms保持一致限制java堆不可扩展
 *  -Xmn10M 设置java堆中新生代为10M
 *  -XX:SurvivorRatio=8 设置新生代中Eden区和一个Survivor区比例为8:1,即Eden区为8M,两个Survivor各1M
 *  -XX:+UseSerialGC 使用Serial与Serial Old的组合的内存分配与回收策略
 */
public class TestAllocationEden {
    private static final int _1M = 1024 * 1024;

    public static void main(String[] args) {

        byte[] byte1, byte2, byte3, byte4;

        byte1 = new byte[_1M * 2];
        byte2 = new byte[_1M * 2];
        byte3 = new byte[_1M * 2];
        byte4 = new byte[_1M * 4];//出现一次Minor GC

    }
}

输出如下:
在这里插入图片描述
结果分析:

以上代码在执行byte4 时候发生一次Minor GC,这次结构是新生代7008K->200K,而内存总量几乎没变7008K->6344K,因为byte1 、byte2 、byte3对象都是存活的,几乎没有对象可以回收。这次GC发生原因是给byte4分配内存时候,发现Eden区(新生代10M,Eden区8M)已经占用了6M,剩余空间不够4M,因此发生的Minor GC。
GC之后又发现三个2M大对象放不进Survivor空间,因此直接进入老年代中,最终看到新生代使用4790K,其中Eden区总共8M使用56%,年老代使用6144K,占年老代总大小60%。

2.大对象直接进入老年代

2.1.介绍

        大对象是指需要大量连续内存空间的Java对象,典型的大对象就是长字符串和数组,经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来安置它们。
        虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代中分配,这样能够避免在Eden区和Survivor区之间发生大量的内存拷贝。

2.2.测试代码

package com.glt.gc;

/**
 * JVM args:
 *  -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=3145728 -XX:+UseSerialGC
 *  -Xms20M 限制java堆大小为20M
 *  -Xmx20M 限制java堆最大值为 20M
 *  -Xmx和-Xms保持一致限制java堆不可扩展
 *  -Xmn10M 设置java堆中新生代为10M
 *  -XX:SurvivorRatio=8 设置新生代中Eden区和一个Survivor区比例为8:1,即Eden区为8M,两个Survivor各1M
 *  -XX:+UseSerialGC 使用Serial与Serial Old的组合的内存分配与回收策略
 *  -XX:PretenureSizeThreshold 设置大于这个值的对象直接在年老代分配  (此属性只对Serial与ParNew收集器有效)
 */
public class TestAllocationTenured {
    private static final int _1M = 1024 * 1024;

    public static void main(String[] args) {

        byte[] byte1 = new byte[_1M * 4];
    }
}

输出如下:
在这里插入图片描述
结果分析:

运行结果能看到没有进行GC,年老代最终使用了4M,即byte1对象直接在年老代进行的创建

3.长期存活的对象直接进入老年代

3.1.介绍

虚拟机分代回收思想:

  1. 能够区分哪些对象能够放在新生代,哪些对象能够放在年老代
  2. 为了区分对象应该放在新生代或者年老代,虚拟机给每个对象定义了对象年龄计数器
  3. 如果对象在Eden区创建,经过第一次Minor GC能够存活,并且能被Survivor区容纳,则会被移动到Survivor区,并将对象年龄设为1
  4. 对象年龄增加到一定程度(默认15)时,将会移动至年老代中,控制阈值的参数为-XX:MaxTenuringThreshold

3.2.测试代码

package com.glt.gc;

/**
 * JVM args:
 *  -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution
 *  -Xms20M 限制java堆大小为20M
 *  -Xmx20M 限制java堆最大值为 20M
 *  -Xmx和-Xms保持一致限制java堆不可扩展
 *  -Xmn10M 设置java堆中新生代为10M
 *  -XX:SurvivorRatio=8 设置新生代中Eden区和一个Survivor区比例为8:1,即Eden区为8M,两个Survivor各1M
 *  -XX:+UseSerialGC 使用Serial与Serial Old的组合的内存分配与回收策略
 *  -XX:MaxTenuringThreshold 控制对象经过多少次GC之后移动至年老代。
 *      特殊情况:如果在Survivor中相同年龄的所有对象大小的总和大于Survivor空间的一半,
 *              或者年龄大于或等于该年龄的对象就可以直接进入老年代,而无需等到MaxTenuringThreshold的设定值
 *  -XX:+PrintTenuringDistribution 打印对象年龄信息
 */
public class TestAllocationMaxTenuringThreshold {
    private static final int _1M = 1024 * 1024;

    public static void main(String[] args) {
        /**
         * 以下测试代码可以通过控制阈值为1和阈值为15分别测试查看执行结果
         */
        byte[] byte1, byte2, byte3;

        byte1 = new byte[_1M / 4];
        byte2 = new byte[_1M * 4];
        byte3 = new byte[_1M * 4];
        byte3 = null;
        byte3 = new byte[_1M * 4];
    }
}

  • -XX:MaxTenuringThreshold=1输出如下:
    在这里插入图片描述
    结果分析:
  1. byte1,byte2对象创建在Eden区,创建byte3对象时发现Eden区存储不够,进行了一次GC
  2. GC后发现byte1可以放入Survivor区,byte2太大放不进Survivor区,所以将byte1放入Survivor区并且将年龄设为1,byte2移至年老代,并将byte3放入Eden区
  3. 将byte3置为null,但是没有触发GC,即此时Eden区还是被之前的byte3占用了4M
  4. 对byte3重新赋值时,发现Eden区存储不够,所以又进行一次GC,发现之前的byte3已经置为null,故直接清理占用的内存区域,并将新的byte3放在Eden区,所以最终Eden区占用4M,年老代占用4M+4M/4
  • -XX:MaxTenuringThreshold=15输出如下:
    在这里插入图片描述
    结果分析:
  1. byte1,byte2对象创建在Eden区,创建byte3对象时发现Eden区存储不够,进行了一次GC
  2. GC后发现byte1可以放入Survivor区,byte2太大放不进Survivor区,所以将byte1放入Survivor区并且将年龄设为1,byte2移至年老代,并将byte3放入Eden区
  3. 将byte3置为null,但是没有触发GC,即此时Eden区还是被之前的byte3占用了4M
  4. 对byte3重新赋值时,发现Eden区存储不够,所以又进行一次GC,
  5. GC后byte1在Survivor中年龄+1,变为2,发现之前的byte3已经置为null,故直接清理占用的内存区域,并将新的byte3放在Eden区,所以最终新生代占用4M+4M/4(其中Eden区占用4M,Survivor占用4M/4),年老代占用4M

4.动态对象年龄判定

4.1.介绍

        Survivor中相同年龄的所有对象大小的总和大于Survivor空间的一半,或者年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到MaxTenuringThreshold的设定值(使用Serial与Serial Old的组合的内存分配与回收策略)

4.2.测试代码

package com.glt.gc;

/**
 * JVM args:
 *  -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:MaxTenuringThreshold=15 -XX:+PrintTenuringDistribution
 *  -XX:MaxTenuringThreshold 控制对象经过多少次GC之后移动至年老代。
 *      特殊情况:如果在Survivor中相同年龄的所有对象大小的总和大于Survivor空间的一半,
 *              或者年龄大于或等于该年龄的对象就可以直接进入老年代,而无需等到MaxTenuringThreshold的设定值
 *  -XX:+PrintTenuringDistribution 打印对象年龄信息
 */
public class TestAllocationMaxTenuringThresholdSum {
    private static final int _1M = 1024 * 1024;

    public static void main(String[] args) {

        byte[] byte1, byte2, byte3, byte4, byte5;

        byte1 = new byte[_1M / 4];
        byte2 = new byte[_1M / 4];
        byte3 = new byte[_1M * 4];
        byte4 = new byte[_1M * 4];
        byte5 = new byte[_1M * 4];
    }
}

输出如下:
在这里插入图片描述
结果分析:

  1. byte1,byte2,byte3对象创建在Eden区,创建byte4对象时发现Eden区存储不够,进行了一次GC
  2. GC后发现byte1,byte2可以放入Survivor区,byte3太大放不进Survivor区,所以将byte1,byte2放入Survivor区并且将年龄设为1,byte3移至年老代,并将byte4放入Eden区
  3. 将byte3置为null,但是没有触发GC,即此时Eden区还是被之前的byte3占用了4M
  4. 创建byte5时,发现Eden区存储不够,所以又进行一次GC,发现byte1和byte2对象大小的总和大于Survivor空间的一半,所以直接将byte1和byte2移至年老代,byte4太大放不进Survivor区,也移至年老代,并将byte5放入Eden区,所以最终新生代占用4M(其中Eden区占用4M,即byte5对象),年老代占用(1/4M+1/4M+4M+4M,即byte1,byte2,byte3,byte4)

5.空间分配担保

5.1.介绍

        发生Minor GC时,虚拟机会检测之前每次晋升到老年代的平均大小是否大于老年代的剩余大小,如果大于,则改为直接进行一次Full GC,如果小于,则查看HandlePromotionFailure设置是否允许担保失败:如果允许,则只会进行Minor GC,如果不允许,则要改为一次Full GC。
        新生代使用的是复制收集算法,为了内存利用率只使用其中一个Survivor空间作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况时,就需要老年代进行担保,让无法进入Survivor的对象直接进入到老年代中。

        老年代进行担保就需要老年代本身还有能够容纳这些对象的剩余空间,但是通过担保进入老年代的对象大小是无法确定的,所以只好取之前每次进入到老年代的对象容量的平均大小来作为经验值,与老年代剩余空间进行比较,来决定是否需要进行Full GC来让老年代腾出更多空间。

        取平均值比较其实是一种动态概率手段,如果某次Minor GC之后存活的对象突增大于平均值的话,还会导致担保失败,如果出现了担保失败,就只能在之后重新进行一次Full GC。大部分情况下还是需要将HandlePromotionFailure打开,来避免Full GC过于频繁。

5.2.测试代码

package com.glt.gc;

/**
 * JVM args:
 *  -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:-HandlePromotionFailure
 *  -XX:-HandlePromotionFailure 关闭空间分配担保
 *  -XX:+HandlePromotionFailure 打开空间分配担保
 *
 */
public class TestAllocationHandlePromotion {
    private static final int _1M = 1024 * 1024;

    public static void main(String[] args) {

        byte[] byte1, byte2, byte3, byte4, byte5, byte6, byte7;

        byte1 = new byte[_1M * 2];
        byte2 = new byte[_1M * 2];
        byte3 = new byte[_1M * 2];
        byte1 = null;
        byte4 = new byte[_1M * 2];
        byte5 = new byte[_1M * 2];
        byte6 = new byte[_1M * 2];
        byte4 = null;
        byte5 = null;
        byte6 = null;
        byte7 = new byte[_1M * 2];
    }
}

输出如下:
在这里插入图片描述
结果分析:
执行完结果总是会提示一行警告:
Warning: The flag -HandlePromotionFailure has been EOL’d as of 6.0_24 and will be ignored
好像是这个参数没生效,造成这个结果也看不出来有什么明显区别,因为都没有进行Full GC,正常情况感觉应该进行一次Full GC才对,此处留个疑问,后续找时间看看。

发布了61 篇原创文章 · 获赞 85 · 访问量 17万+

猜你喜欢

转载自blog.csdn.net/bluuusea/article/details/89753035