Java
Java虚拟机

Java虚拟机09 - 对象分配与回收策略

简介:Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。

1. 内存分配与回收基本策略

对象的内存分配基本规律有以下几条:

  1. 大多数情况下就是在堆上分配(但也可能经过JIT编译后被拆散为标量类型并间接地栈上分配)。
  2. 对象主要分配在新生代的Eden区上。
  3. 如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。
  4. 少数情况下也可能会直接分配在老年代中。

分配的规则并不是固定的,具体取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。

虚拟机提供了-XX:+PrintGCDetails参数用于叜虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。

注:在实际应用中,内存回收日志一般是打印到文件后通过日志工具进行分析。

2. 对象优先分配在Eden区

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。使用以下代码进行测试:

  • // 1MB大小
  • private final static int _1MB = 1024 * 1024;
  • /**
  • * JVM args -verbose:gc -XX:+PrintGCDetails -XX:+UseSerialGC -Xms20m -Xmx20m -Xmn10m -XX:SurvivorRatio=8
  • * JDK 1.6.0_45下测试
  • */
  • public static void testNewObjectAllocate() {
  • byte[] allocation1;
  • byte[] allocation2;
  • byte[] allocation3;
  • byte[] allocation4;
  • allocation1 = new byte[2 * _1MB];
  • allocation2 = new byte[2 * _1MB];
  • allocation3 = new byte[2 * _1MB];
  • allocation4 = new byte[4 * _1MB];
  • }

对于上述代码中,首先定义了大小为1MB的常量_1MB,然后在testNewObjectAllocate()方法中一共进行了4次byte数组的创建,前三次都为2MB大小,最后一次为4MB大小。

同时需要设置虚拟机参数为:

  • -verbose:gc -XX:+PrintGCDetails -XX:+UseSerialGC -Xms20m -Xmx20m -Xmn10m -XX:SurvivorRatio=8

上述参数解释如下:

  • -verbose:gc -XX:+PrintGCDetails:打印GC详细日志信息。
  • -XX:+UseSerialGC:使用Serial收集器。
  • -Xms20m -Xmx20m:限制Java堆大小为20MB。
  • -Xmn10m:新生代大小为10MB。
  • -XX:SurvivorRatio=8:设置新生代中Eden区与一个Survivor区的空间比例是8:1。

运行testNewObjectAllocate()测试代码,得到详细的GC日志如下:

  • [GC [DefNew: 7649K->332K(9216K), 0.0060650 secs] 7649K->6476K(19456K), 0.0060840 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
  • Heap
  • def new generation total 9216K, used 4923K [0x00000007f9a00000, 0x00000007fa400000, 0x00000007fa400000)
  • eden space 8192K, 56% used [0x00000007f9a00000, 0x00000007f9e7bd18, 0x00000007fa200000)
  • from space 1024K, 32% used [0x00000007fa300000, 0x00000007fa353278, 0x00000007fa400000)
  • to space 1024K, 0% used [0x00000007fa200000, 0x00000007fa200000, 0x00000007fa300000)
  • tenured generation total 10240K, used 6144K [0x00000007fa400000, 0x00000007fae00000, 0x00000007fae00000)
  • the space 10240K, 60% used [0x00000007fa400000, 0x00000007faa00030, 0x00000007faa00200, 0x00000007fae00000)
  • compacting perm gen total 21248K, used 3146K [0x00000007fae00000, 0x00000007fc2c0000, 0x0000000800000000)
  • the space 21248K, 14% used [0x00000007fae00000, 0x00000007fb112a10, 0x00000007fb112c00, 0x00000007fc2c0000)
  • No shared spaces configured.

在上述的GC日志中可以发现GC操作之后有以下的变化:

  • GC操作使新生代占用量从7649K调整到332K;总内存占用量从7649K调整到6476K,几乎没有变化。
  • GC结束后,Eden区占用56%,老年代使用6144K。

发生以上变化的原因在于,在执行allocation1allocation2allocation3前三次内存分配后,Eden区的使用量会达到6MB,当执行allocation4的内存分配时,发现Eden区域的空闲空间已经不够用了(总大小8MB),因此会发生一次Minor GC操作。Minor GC过程中,由于单个Survivor区域的大小是1MB,不足以容纳allocation4需要的4MB对象,因此会将allocation1allocation2allocation3对象全部转移到老年代中去,然后将allocation4分配在Eden区。一系列操作下来,就会导致老年代使用了6144K(容纳allocation1allocation2allocation3三个对象),而Eden区占用了56%(约4MB,容纳allocation4对象)。

3. 大对象直接分配在老年代

对于需要大量连续内存空间的Java对象,如很长的字符串以及数组,大对象的出现经常会导致GC操作。虚拟机提供了一个-XX:PretenureSizeThreshold参数,大于这个设置值的对象将直接在老年代分配,从而避免在Eden区及两个Survivor区之间发生大量的内存复制(新生代主要采用复制算法收集内存)。有以下的测试代码:

  • // 1MB大小
  • private final static int _1MB = 1024 * 1024;
  • /**
  • * JVM args -verbose:gc -XX:+PrintGCDetails -XX:+UseSerialGC -Xms20m -Xmx20m -Xmn10m -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=3145728
  • * JDK 1.7.0_07下测试
  • */
  • public static void testHugeObjectAllocate() {
  • byte[] allocation = new byte[3 * _1MB];
  • }

在上述代码中分配了一个大小为3MB的byte数组,同时在运行这个方法的时候添加了-XX:PretenureSizeThreshold=3m参数,因此大于3MB的新对象将直接分配在老年代,运行后会得到以下GC日志:

  • Heap
  • def new generation total 9216K, used 1481K [0x00000007f9a00000, 0x00000007fa400000, 0x00000007fa400000)
  • eden space 8192K, 18% used [0x00000007f9a00000, 0x00000007f9b72578, 0x00000007fa200000)
  • from space 1024K, 0% used [0x00000007fa200000, 0x00000007fa200000, 0x00000007fa300000)
  • to space 1024K, 0% used [0x00000007fa300000, 0x00000007fa300000, 0x00000007fa400000)
  • tenured generation total 10240K, used 3072K [0x00000007fa400000, 0x00000007fae00000, 0x00000007fae00000)
  • the space 10240K, 30% used [0x00000007fa400000, 0x00000007fa700010, 0x00000007fa700200, 0x00000007fae00000)
  • compacting perm gen total 21248K, used 3018K [0x00000007fae00000, 0x00000007fc2c0000, 0x0000000800000000)
  • the space 21248K, 14% used [0x00000007fae00000, 0x00000007fb0f2890, 0x00000007fb0f2a00, 0x00000007fc2c0000)
  • No shared spaces configured.

可以发现,Eden区占用量只有18%,而老年代使用大小为3072K,表示新创建的对象直接分配到了老年代。

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

虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区中每经历一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。有以下的测试代码:

  • // 1MB大小
  • private final static int _1MB = 1024 * 1024;
  • /**
  • * JVM args -verbose:gc -XX:+PrintGCDetails -XX:+UseSerialGC -Xms20m -Xmx20m -Xmn10m -XX:SurvivorRatio=8
  • * JDK 1.7.0_07下测试
  • */
  • public static void testMaxTenuredThreshold() {
  • byte[] allocation1;
  • byte[] allocation2;
  • byte[] allocation3;
  • byte[] allocation4;
  • allocation1 = new byte[_1MB / 4];
  • allocation2 = new byte[4 * _1MB];
  • allocation3 = new byte[4 * _1MB];
  • allocation3 = null;
  • allocation4 = new byte[4 * _1MB];
  • }

在这段测试代码中,allocation1只需要256KB的空间,是能够被Survivor区(1MB)容纳的,而allocation2allocation3allocation4需要4MB的空间,并不能被Survivor区容纳。

在设置-XX:MaxTenuringThreshold=1参数运行后有以下的GC日志:

  • [GC [DefNew: 5669K->602K(9216K), 0.0048940 secs] 5669K->4698K(19456K), 0.0049150 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
  • [GC [DefNew: 5026K->0K(9216K), 0.0014910 secs] 9122K->4688K(19456K), 0.0015070 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
  • Heap
  • def new generation total 9216K, used 4178K [0x00000007f9a00000, 0x00000007fa400000, 0x00000007fa400000)
  • eden space 8192K, 51% used [0x00000007f9a00000, 0x00000007f9e14820, 0x00000007fa200000)
  • from space 1024K, 0% used [0x00000007fa200000, 0x00000007fa200100, 0x00000007fa300000)
  • to space 1024K, 0% used [0x00000007fa300000, 0x00000007fa300000, 0x00000007fa400000)
  • tenured generation total 10240K, used 4688K [0x00000007fa400000, 0x00000007fae00000, 0x00000007fae00000)
  • the space 10240K, 45% used [0x00000007fa400000, 0x00000007fa8940b8, 0x00000007fa894200, 0x00000007fae00000)
  • compacting perm gen total 21248K, used 3021K [0x00000007fae00000, 0x00000007fc2c0000, 0x0000000800000000)
  • the space 21248K, 14% used [0x00000007fae00000, 0x00000007fb0f3608, 0x00000007fb0f3800, 0x00000007fc2c0000)
  • No shared spaces configured.

由于Eden区域的总大小是8MB,因此在分配allocation3时会因为Eden区空闲大小不够而发生一次Minor GC操作,这时allocation1会被移入到Survivor区中,allocation2因Survivor区并不能容纳会被提前提升到老年代。接下来在分配allocation3后分配allocation4还会触发第二次Minor GC操作,这次操作由于allocation1达到了晋升年龄,会被晋升到老年代,而allocation3会被回收,所以第二次Minor GC后新生代的已使用大小会变为0K,最后allocation4会被分配到Eden区,因此得到的最终内存空间的分配是Eden区使用51%(4MB+,用于存放allocation4),Survivor区域已使用全为0,老年代已使用4688K(4MB+,用于存放allocation2)。

而设置-XX:MaxTenuringThreshold=15后,将会得到以下的结果:

  • [GC [DefNew: 6889K->600K(9216K), 0.0054686 secs] 6889K->4696K(19456K), 0.0055130 secs] [Times: user=0.01 sys=0.01, real=0.00 secs]
  • [GC [DefNew: 5024K->597K(9216K), 0.0010105 secs] 9120K->4693K(19456K), 0.0010291 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
  • Heap
  • def new generation total 9216K, used 4857K [7f9a00000, 7fa400000, 7fa400000)
  • eden space 8192K, 52% used [7f9a00000, 7f9e28ff0, 7fa200000)
  • from space 1024K, 58% used [7fa200000, 7fa2954d8, 7fa300000)
  • to space 1024K, 0% used [7fa300000, 7fa300000, 7fa400000)
  • tenured generation total 10240K, used 4096K [7fa400000, 7fae00000, 7fae00000)
  • the space 10240K, 40% used [7fa400000, 7fa800010, 7fa800200, 7fae00000)
  • compacting perm gen total 21248K, used 4935K [7fae00000, 7fc2c0000, 800000000)
  • the space 21248K, 23% used [7fae00000, 7fb2d1e58, 7fb2d2000, 7fc2c0000)
  • No shared spaces configured.

注:如果在某些版本的JDK中不生效,可以设置-XX:TargetSurvivorRatio=90参数调大Survivor区域的使用率。

可以发现,最后Survivor区的已使用大小并不为空,这是由于allocation1还存在与Survivor区导致的。

5. 动态对象年龄判定

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

在执行下面的testMaxTenuredThreshold2()方法时,设置了-XX:MaxTenuringThreshold=15参数,会发现运行结果中Survivor的空间占用仍然为0%,而老年代比预期增加了8%,也就是说,allocation1allocation2对象都直接进入了老年代,而没有等到15岁的临界年龄。因为这两个对象加起来已经到达了512KB,并且它们是同年的,满足同年对象达到Survivor空间的一半规则。我们只要注释掉其中一个对象new操作,就会发现另外一个就不会晋升到老年代中去了。

  • // 1MB大小
  • private final static int _1MB = 1024 * 1024;
  • /**
  • * JVM args -verbose:gc -XX:+PrintGCDetails -XX:+UseSerialGC -Xms20m -Xmx20m -Xmn10m -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 -XX:TargetSurvivorRatio=90
  • * JDK 1.7.0_07下测试
  • */
  • public static void testMaxTenuredThreshold2() {
  • byte[] allocation1;
  • byte[] allocation2;
  • byte[] allocation3;
  • byte[] allocation4;
  • allocation1 = new byte[_1MB / 4];
  • allocation2 = new byte[_1MB / 4];
  • allocation3 = new byte[4 * _1MB];
  • allocation4 = new byte[4 * _1MB];
  • allocation4 = null;
  • allocation4 = new byte[4 * _1MB];
  • }

运行后的到的GC日志如下:

  • [GC [DefNew: 5925K->834K(9216K), 0.0047990 secs] 5925K->4930K(19456K), 0.0048220 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
  • [GC [DefNew: 5342K->0K(9216K), 0.0018630 secs] 9438K->4920K(19456K), 0.0018800 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
  • Heap
  • def new generation total 9216K, used 4234K [0x00000007f9a00000, 0x00000007fa400000, 0x00000007fa400000)
  • eden space 8192K, 51% used [0x00000007f9a00000, 0x00000007f9e22760, 0x00000007fa200000)
  • from space 1024K, 0% used [0x00000007fa200000, 0x00000007fa2002a8, 0x00000007fa300000)
  • to space 1024K, 0% used [0x00000007fa300000, 0x00000007fa300000, 0x00000007fa400000)
  • tenured generation total 10240K, used 4920K [0x00000007fa400000, 0x00000007fae00000, 0x00000007fae00000)
  • the space 10240K, 48% used [0x00000007fa400000, 0x00000007fa8ce0e8, 0x00000007fa8ce200, 0x00000007fae00000)
  • compacting perm gen total 21248K, used 3012K [0x00000007fae00000, 0x00000007fc2c0000, 0x0000000800000000)
  • the space 21248K, 14% used [0x00000007fae00000, 0x00000007fb0f1138, 0x00000007fb0f1200, 0x00000007fc2c0000)
  • No shared spaces configured.

6. 空间分配担保

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

新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代。与生活中的贷款担保类似,老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。

取平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然会导致担保失败(HandlePromotion Failure)。如果出现了HandlePromotionFailure失败,那就只好在失败后重新发起一次Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁,如下代码需要在JDK 6 Update 24之前的版本中运行测试:

  • /**
  • * JVM args -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:-HandlePromotionFailure
  • * JDK 1.6.0_24以前的版本下测试
  • */
  • @SuppressWarnings"unused"
  • public static void testHandlePromotion() {
  • byte[]allocation1allocation2allocation3allocation4allocation5allocation6allocation7;
  • allocation1 = new byte[2 * _1MB];
  • allocation2 = new byte[2 * _1MB];
  • allocation3 = new byte[2 * _1MB];
  • allocation1 = null;
  • allocation4 = new byte[2 * _1MB];
  • allocation5 = new byte[2 * _1MB];
  • allocation6 = new byte[2 * _1MB];
  • allocation4 = null;
  • allocation5 = null;
  • allocation6 = null;
  • allocation7 = new byte[2 * _1MB];
  • }

以HandlePromotionFailure=false参数来运行的结果:

  • [GC[DefNew:6651K->148K(9216K), 0.0078936 secs]6651K->4244K(19456K), 0.0079192 secs][Times:user=0.00 sys=0.02, real=0.02 secs]
  • [G C[D e f N e w:6 3 7 8 K->6 3 7 8 K(9 2 1 6 K), 0.0 0 0 0 2 0 6 s e c s][T e n u r e d:4096K->4244K(10240K), 0.0042901 secs]10474K->4244K(19456K), [Perm:2104K->2104K(12288K)], 0.0043613 secs][Times:user=0.00 sys=0.00, real=0.00 secs]

在JDK 6 Update 24之后,这个测试结果会有差异,HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略,观察OpenJDK中的源码变化:

  • bool TenuredGeneration:
  • promotion_attempt_is_safe(size_t max_promotion_in_bytes)const
  • {
  • // 老年代最大可用的连续空间
  • size_t available = max_contiguous_available()
  • // 每次晋升到老年代的平均大小
  • size_t av_promo = (size_t)gc_stats() - avg_promoted() - padded_average()
  • // 老年代可用空间是否大于平均晋升大小,或者老年代可用空间是否大于当此GC时新生代所有对象容量
  • bool res = (available = av_promo) || (available =
  • max_promotion_in_bytes)
  • return res
  • }

虽然源码中还定义了HandlePromotionFailure参数,但是在代码中已经不会再使用它。JDK 6 Update 24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。JDK 6 Update 24之后使用HandlePromotionFailure参数将会有以下提示:

  • Warning: The flag -HandlePromotionFailure has been EOL'd as of 6.0_24 and will be ignored
  • [GC [DefNew: 6633K->342K(9216K), 0.0050320 secs] 6633K->4438K(19456K), 0.0050789 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
  • [GC [DefNew (promotion failed) : 7008K->7007K(9216K), 0.0033242 secs][Tenured: 8192K->8533K(10240K), 0.0061525 secs] 11104K->8533K(19456K), [Perm : 4810K->4810K(21248K)], 0.0095646 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
  • Heap
  • def new generation total 9216K, used 4387K [7f9a00000, 7fa400000, 7fa400000)
  • eden space 8192K, 53% used [7f9a00000, 7f9e48fa0, 7fa200000)
  • from space 1024K, 0% used [7fa200000, 7fa200000, 7fa300000)
  • to space 1024K, 0% used [7fa300000, 7fa300000, 7fa400000)
  • tenured generation total 10240K, used 8533K [7fa400000, 7fae00000, 7fae00000)
  • the space 10240K, 83% used [7fa400000, 7fac55550, 7fac55600, 7fae00000)
  • compacting perm gen total 21248K, used 4936K [7fae00000, 7fc2c0000, 800000000)
  • the space 21248K, 23% used [7fae00000, 7fb2d21d8, 7fb2d2200, 7fc2c0000)
  • No shared spaces configured.

注:会得到提示信息:Java HotSpot(TM) 64-Bit Server VM warning: ignoring option HandlePromotionFailure; support was removed in 6.0_24。