JVM(九)内存分配策略

文章内容来自《深入理解JVM》和网络资料的整理

Java技术体系中所提倡的自动内存管理最终可以归结为自动化地解决两个问题:

  • 给对象分配内存;
  • 回收分配给对象的内存。

对象的内存分配,往大方向上讲就是在堆上的分配,对象主要分配在新生代的Eden区上。少数也可能分配在老年代,取决于哪一种垃圾收集器组合,还有虚拟机中的相关内存的参数设置。下面先简单介绍一下JVM中的年代划分:新生代、老年代、永久代(JDK1.8后称为元空间)。

一、JVM堆的结构分析(新生代、老年代、永久代)

HotSpot JVM把年轻代分为了三部分:1个Eden区和2个Survivor区(分别叫from(S1)和to(S2)),具体可参下面的JVM内存体系图。Eden和Survival的默认分配比例为8:1。一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理,后面会说到),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到年老代中。

因为年轻代中的对象基本都是朝生夕死的(80%以上),所以在年轻代的垃圾回收算法使用的是复制算法,复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片。

在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。

这里写图片描述

在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。

永久代主要用于存放静态文件,Java类、方法等。永久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate 等,在这种时候需要设置一个比较大的持永久代空间来存放这些运行过程中新增的类。永久代大小通过-XX: MaxPermSize = <N> 进行设置。

二、对象优先在Eden上分配

大多数新生代对象都在Eden区中分配。当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。

下面我们来使用代码验证一下:

运行环境:

package testJVM;

public class TestAllocation {

    private static final int _1MB = 1024 * 1024;

    /**
     * VM 参数: -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC
     */
    public static void main(String[] args) {
        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[2 * _1MB];
        allocation2 = new byte[2 * _1MB];
        allocation3 = new byte[2 * _1MB];
        allocation4 = new byte[4 * _1MB];
    }

}

代码说明:代码中,尝试分配3个2MB大小和1个4MB大小的对象,在运行时通过-Xms20M、-Xmx20M, -Xmn10M这3个参数限制了Java堆大小为20MB不可扩展,其中10MB分配给新生代,剩下的10MB分配给老年代。-XX:SurvivorRatio=8决定了新生代中Eden区与一个Survivor区的空间比例是8:1,从输出的结果也可以清晰地看到“eden space 8192K, from space 1024K, to space 1024K”的信息,新生代总可用空间为9216KB (Eden区+1个Survivor区的总容且)。

输出信息:

[GC [DefNew: 6856K->405K(9216K), 0.0045667 secs] 6856K->6549K(19456K), 0.0045916 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 def new generation   total 9216K, used 4829K [0x2ae40000, 0x2b840000, 0x2b840000)
  eden space 8192K,  54% used [0x2ae40000, 0x2b291fa0, 0x2b640000)
  from space 1024K,  39% used [0x2b740000, 0x2b7a55a0, 0x2b840000)
  to   space 1024K,   0% used [0x2b640000, 0x2b640000, 0x2b740000)
 tenured generation   total 10240K, used 6144K [0x2b840000, 0x2c240000, 0x2c240000)
   the space 10240K,  60% used [0x2b840000, 0x2be40030, 0x2be40200, 0x2c240000)
 compacting perm gen  total 12288K, used 146K [0x2c240000, 0x2ce40000, 0x30240000)
   the space 12288K,   1% used [0x2c240000, 0x2c264a80, 0x2c264c00, 0x2ce40000)
    ro space 10240K,  45% used [0x30240000, 0x306c73d8, 0x306c7400, 0x30c40000)
    rw space 12288K,  54% used [0x30c40000, 0x312cacc8, 0x312cae00, 0x31840000)

执行main函数中,分配给allocation4对象时候发生了一次Minor GC(新生代回收),这次GC的结果是新生代内存6856K->405K,然而堆上总内存的占用几乎没有改变,因为allocation1、allocation2、allocation3都存活,本次回收基本上没有找到可回收的对象。分析如下:

  1. 新生代一共被分配10M,其中Enden:8M,survivor:2M(From:1M,To:1M);
  2. 给allocation4分配内存时,Eden已经被占用6M(allocation1、2、3共6M,所以剩下2M),所以内存已经不够用了(发生GC);
  3. 然而,6M放不进Survivor的From(只有1M),所以只能通过分配担保机制提前转移到老年代。

这次GC结束后,Eden中有4M的allocation4对象(一共8M,被占用50%左右),survivor为空闲,老年代为6M(被allocation1、2、3占用),日志中显示为6144K。

Minor GC 和 Full GC 的区别:

  • 新生代GC (Minor GC):指发生在新生代的垃圾收集动作,因为Java时象大多都具备朝生夕灭的特性,所以 Minor
    GC非常倾繁,一般回收速度也比较快。
  • 老年代GC (Major GC / Full GC ):指发生在老年代的GC,出现了 Major GC,经常会伴随至少一次的 Minor
    GC Major GC的速度一般会比Minor GC慢10倍以上。
三、大对象直接进入老年代

大对象是指需要大量内存空间的Java对象,最典型的大对象就是那种很长的字符串和数组(byte[ ]就是典型的大对象)。出现大对象很容易导致内存还有不少空间就提前触发垃圾收集以获取足够的连续空间来“安置”它们。

虚拟机提供了一个-XX: PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制(新生代采用复制算法收集内存)。

下面我们来使用代码验证一下:

package testJVM;

public class TestPretenureSizeThreshold {

    private static final int _1MB = 1024 * 1024;

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

输出信息:

Heap
 def new generation   total 9216K, used 876K [0x2ae40000, 0x2b840000, 0x2b840000)
  eden space 8192K,  10% used [0x2ae40000, 0x2af1b1a0, 0x2b640000)
  from space 1024K,   0% used [0x2b640000, 0x2b640000, 0x2b740000)
  to   space 1024K,   0% used [0x2b740000, 0x2b740000, 0x2b840000)
 tenured generation   total 10240K, used 4096K [0x2b840000, 0x2c240000, 0x2c240000)
   the space 10240K,  40% used [0x2b840000, 0x2bc40010, 0x2bc40200, 0x2c240000)
 compacting perm gen  total 12288K, used 146K [0x2c240000, 0x2ce40000, 0x30240000)
   the space 12288K,   1% used [0x2c240000, 0x2c264a30, 0x2c264c00, 0x2ce40000)
    ro space 10240K,  45% used [0x30240000, 0x306c73d8, 0x306c7400, 0x30c40000)
    rw space 12288K,  54% used [0x30c40000, 0x312cacc8, 0x312cae00, 0x31840000)

执行main函数后,我们看到Eden空间几乎没有被使用,而老年代的10MB空间被使用了40%,也就是4MB的allocation对象直接就分配在老年代中,这是因为PretenureSizeThreshold被设置为3MB(就是3145728,这个参数不能像-Xmx之类的参数一样直接写3MB)。因此超过3MB的对象都会直接在老年代进行分配。

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

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

Java虚拟机采用分代收集的思想来管理虚拟机内存。虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并且经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄增加到一定程度(默认15岁),就会被晋升到老年代。对晋升到老年代的对象的阈值可以通过-XX:MaxTenuringThreshold设置。

下面我们来使用代码验证一下:

package testJVM;

public class TestTenuringThreshold {

    private static final int _1MB = 1024 * 1024;

    /**
     * VM 参数: -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution
     */
    public static void main(String[] args) {
        byte[] allocation1, allocation2, allocation3;
        allocation1 = new byte[_1MB / 4]; // 什么时候进入老年代决定于XX:MaxTenuringThreshold设置
        allocation2 = new byte[4 * _1MB];
        allocation3 = new byte[4 * _1MB];
        allocation3 = null;
        allocation3 = new byte[4 * _1MB];
    }
}

输出信息:

[GC [DefNew
Desired survivor size 524288 bytes, new threshold 1 (max 1)
- age   1:     677344 bytes,     677344 total
: 5064K->661K(9216K), 0.0031258 secs] 5064K->4757K(19456K), 0.0031871 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC [DefNew
Desired survivor size 524288 bytes, new threshold 1 (max 1)
- age   1:        136 bytes,        136 total
: 4921K->0K(9216K), 0.0008813 secs] 9017K->4757K(19456K), 0.0009391 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 def new generation   total 9216K, used 4260K [0x2ae40000, 0x2b840000, 0x2b840000)
  eden space 8192K,  52% used [0x2ae40000, 0x2b268fd8, 0x2b640000)
  from space 1024K,   0% used [0x2b640000, 0x2b640088, 0x2b740000)
  to   space 1024K,   0% used [0x2b740000, 0x2b740000, 0x2b840000)
 tenured generation   total 10240K, used 4757K [0x2b840000, 0x2c240000, 0x2c240000)
   the space 10240K,  46% used [0x2b840000, 0x2bce5520, 0x2bce5600, 0x2c240000)
 compacting perm gen  total 12288K, used 146K [0x2c240000, 0x2ce40000, 0x30240000)
   the space 12288K,   1% used [0x2c240000, 0x2c264a70, 0x2c264c00, 0x2ce40000)
    ro space 10240K,  45% used [0x30240000, 0x306c73d8, 0x306c7400, 0x30c40000)
    rw space 12288K,  54% used [0x30c40000, 0x312cacc8, 0x312cae00, 0x31840000)
六、动态对象年龄判定

虚拟并不是永远都要求对象年龄必须达到MaxTenuringThreshold才能晋升为老年代的,如果在Survivor的空间相同年龄的所有对象大小总和大于Survivor空间的一半时,年龄大于或者等于该年龄的对象直接进入老年代,无需要等到MaxTenuringThreshold中要求的年龄。

下面我们来使用代码验证一下:

package testJVM;

public class TestTenuringThreshold2 {
    private static final int _1MB = 1024 * 1024;

    /**
     * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 -XX:+PrintTenuringDistribution
     */
    @SuppressWarnings("unused")
    public static void main(String[] args) {
        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[_1MB / 4]; // allocation1+allocation2大于survivo空间一半
        allocation2 = new byte[_1MB / 4];
        allocation3 = new byte[4 * _1MB];
        allocation4 = new byte[4 * _1MB];
        allocation4 = null;
        allocation4 = new byte[4 * _1MB];
    }
}

输出信息:

[GC [DefNew
Desired survivor size 524288 bytes, new threshold 1 (max 15)
- age   1:     939504 bytes,     939504 total
: 5320K->917K(9216K), 0.0035449 secs] 5320K->5013K(19456K), 0.0035920 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC [DefNew
Desired survivor size 524288 bytes, new threshold 15 (max 15)
- age   1:        136 bytes,        136 total
: 5177K->0K(9216K), 0.0012480 secs] 9273K->5013K(19456K), 0.0012671 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 def new generation   total 9216K, used 4260K [0x2ae40000, 0x2b840000, 0x2b840000)
  eden space 8192K,  52% used [0x2ae40000, 0x2b268fd8, 0x2b640000)
  from space 1024K,   0% used [0x2b640000, 0x2b640088, 0x2b740000)
  to   space 1024K,   0% used [0x2b740000, 0x2b740000, 0x2b840000)
 tenured generation   total 10240K, used 5013K [0x2b840000, 0x2c240000, 0x2c240000)
   the space 10240K,  48% used [0x2b840000, 0x2bd25530, 0x2bd25600, 0x2c240000)
 compacting perm gen  total 12288K, used 146K [0x2c240000, 0x2ce40000, 0x30240000)
   the space 12288K,   1% used [0x2c240000, 0x2c264a88, 0x2c264c00, 0x2ce40000)
    ro space 10240K,  45% used [0x30240000, 0x306c73d8, 0x306c7400, 0x30c40000)
    rw space 12288K,  54% used [0x30c40000, 0x312cacc8, 0x312cae00, 0x31840000)

执行main函数后,会发现运行结果中Survivor的空间占用仍然为0%,而老年代比预期增加了8%.也就是说,
allocation k , allocation2对象都直接进人了老年代,而没有等到15岁的临界年龄。因为这两个对象加起来已经到达了512KB,并且它们是同年的,满足同年对象达到Survivor空间的一半规则。我们只要注释掉其中一个对象new操作,就会发现另外一个就不会晋升到老年代中去了。

七、空间分配担保

在发生Minor GC之前,虚拟机会先检查老年代可用的连续空间是否大于所有新生代的总空间,如果大于的话,那么这个GC就可以保证安全,如果不成立的,那么可能会造成晋升老年代的时候内存不足。在这样的情况下,虚拟机会先检查HandlePromotionFailure设置值是否允许担保失败,如果是允许的,那么说明虚拟机允许这样的风险存在并坚持运行,然后检查老年代的最大连续可用空间是否大于历次晋升老年代对象的平均大小,如果大于的话,就执行Minor GC,如果小于,或者HandlePromotionFailure设置不允许冒险,那么就会先进行一次Full GC将老年代的内存清理出来,然后再判断。

上面提到的风险,是由于新生代因为存活对象采用复制算法,但为了内存利用率,只使用其中的一个Survivor空间,将存活的对象备份到Survivor空间上,一旦出现大量对象在一次Minor GC以后依然存活(最坏的计划就是没有发现有对象死亡需要清理),那么就需要老年代来分担一部分内存,把在Survivor上分配不下的对象直接进入老年代,因为我们不知道实际上具体需要多大内存,我们只能估算一个合理值,这个值采用的方法就是计算出每次晋升老年代的平均内存大小作为参考,如果需要的话,那就提前进行一次Full GC.

取平均值在大多数情况下是可行的,但是因为内存分配的不确定性太多,保不定哪次运行突然出现某些大对象或者Minor GC以后多数对象依然存活,导致内存远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。如果出现了HandlePromotionFailure失败,那就只好在失败后重新发起一次Full GC。这样的情况下,担保失败是要付出代价的,大部分情况下都还是会将HandlePromotionFailure开关打开,毕竟失败的几率比较小,这样的担保可以避免Full GC过于频繁,垃圾收集器频繁的启动肯定是不好的。

这里写图片描述

  • 2
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值