Java 对象的内存分配策略

本文主要通过几个简单的例子介绍 Java 对象的内存分配策略。

1. 对象优先在 Eden 分配

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

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

我们使用命令 java -XX:+PrintGCDetails -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 MemAllocation 运行下面代码:

MemAllocation.java

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

  public static void main(String[] args) throws InterruptedException {
    byte[] _2mb_1 = tryAllocation(2);
    System.out.println("---------- allocation 2 MB memory ----------");
    byte[] _2mb_2 = tryAllocation(2);
    System.out.println("---------- allocation 2 MB memory ----------");
    byte[] _2mb_3 = tryAllocation(2);
    System.out.println("---------- allocation 2 MB memory ----------");
    byte[] _4mb = tryAllocation(4);
    System.out.println("---------- allocation 4 MB memory ----------");
    Thread.sleep(5000);
    System.out.println("---------- allocation test finish ----------");
  }

  private static byte[] tryAllocation(int xmb) {
    return new byte[_1MB * xmb];
  }
}

其中 -Xms20M -Xmx20M -Xmn10M 限制了 Java 堆得大小为 20 MB,新生代占用 10MB ,剩下的 10 MB 分给老年代,而 -XX:SurvivorRatio=8 决定了新生代中 Eden 与单个 Survivor 的空间比例为 8:1 ,即 eden space 为 8192k, from space 和 to space 都为 1024k。运行上面程序:

---------- allocation 2 MB memory ----------
---------- allocation 2 MB memory ----------
---------- allocation 2 MB memory ----------
[GC (Allocation Failure) [PSYoungGen: 6797K->464K(9216K)] 6797K->6598K(19456K), 0.0029989 secs] [Times: user=0.01 sys=0.01, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 464K->0K(9216K)] [ParOldGen: 6134K->6436K(10240K)] 6598K->6436K(19456K), [Metaspace: 2625K->2625K(1056768K)], 0.0059105 secs] [Times: user=0.03 sys=0.00, real=0.01 secs] 
---------- allocation 4 MB memory ----------
---------- allocation test finish ----------
Heap
 PSYoungGen      total 9216K, used 4326K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 52% used [0x00000000ff600000,0x00000000ffa398d0,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
  to   space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
 ParOldGen       total 10240K, used 6436K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 62% used [0x00000000fec00000,0x00000000ff249030,0x00000000ff600000)
 Metaspace       used 2632K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 286K, capacity 386K, committed 512K, reserved 1048576K

通过控制台输出我们可以看出,在尝试分配 _4mb 对象时发生一次 Minor GC,这次 GC 的结果是:

  1. 新生代 6797KB 变为 464K,[PSYoungGen: 6797K->464K(9216K)]
  2. 总内存占用量则几乎没有减少,6797K->6598K,(因为 _2mb_1_2mb_2_2mb_3 三个对象都是存活的,虚拟机几乎没有找到可回收的对象)

这次GC 发生的原因是给 _4mb 分配内存的时候,发现 Eden 已经被占用了 6MB ,剩余空间已不足以分配 _4mb 所需的 4MB 内存,因此发生 Minor GC。GC 期间虚拟机又发现已有的 3 个2MB 大小的对象全部无法放入 Survivor 空间(Survivor 空间只有 1MB 大小),所以只好通过分配担保机制提前转移到老年代去。 GC 结束后,4MB 的 _4mb 对象顺利分配在 Eden 中,因此程序执行完的结果是:

  1. Eden 占用 4MB(被 _4mb 占用),PSYoungGen total 9216K, used 4326K
  2. Survivor 空闲,from space 1024K, 0% usedto space 1024K, 0% used
  3. 老年代被占用 6MB(被 _2mb_1_2mb_2_2mb_3 占用),ParOldGen total 10240K, used 6436K

另外有领个重要概念需要知道:

  1. 新生代 GC(Minor GC):指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。
  2. 老年代 GC(Major GC/Full GC):指发生在老年代的 GC,出现了 Major GC,经常会伴随至少一次的 Minor GC(但非绝对的,在 Parallel Scavenge 收集器的收集策略里就有直接进行 Major GC 的策略选择过程)。Major GC 的速度一般会比 Minor GC 慢10倍以上。

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

所谓的大对象是指,需要大量连续内存空间的 Java 对象,最典型的大对象就是那种很长的字符串以及数组(如上面使用的 byte[] 数组就是典型的大对象)。大对象对虚拟机的内存分配来说就是一个坏消息(替 Java 虚拟机抱怨一句,比遇到一个大对象更加坏的消息就是遇到一群“朝生夕灭”的“短命大对象”,写程序的时候应当避免),经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。虚拟机提供了一个 -XX:PretenureSizeThreshold 参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在 Eden 区及两个 Survivor 区之间发生大量的内存复制。

使用命令 java -XX:+PrintGCDetails -Xms40M -Xmx40M -Xmn20M -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=1048576 -XX:+UseSerialGC MemAllocation 再次执行上面代码(MemAllocation.java):

---------- allocation 2 MB memory ----------
---------- allocation 2 MB memory ----------
---------- allocation 2 MB memory ----------
---------- allocation 4 MB memory ----------
---------- allocation test finish ----------
Heap
 def new generation   total 18432K, used 1311K [0x00000000fd800000, 0x00000000fec00000, 0x00000000fec00000)
  eden space 16384K,   8% used [0x00000000fd800000, 0x00000000fd947d08, 0x00000000fe800000)
  from space 2048K,   0% used [0x00000000fe800000, 0x00000000fe800000, 0x00000000fea00000)
  to   space 2048K,   0% used [0x00000000fea00000, 0x00000000fea00000, 0x00000000fec00000)
 tenured generation   total 20480K, used 10210K [0x00000000fec00000, 0x0000000100000000, 0x0000000100000000)
   the space 20480K,  49% used [0x00000000fec00000, 0x00000000ff5f8840, 0x00000000ff5f8a00, 0x0000000100000000)
 Metaspace       used 2632K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 286K, capacity 386K, committed 512K, reserved 1048576K

我们看到 Eden 空间几乎没有被使用,而老年代的 20MB 空间被使用了 49% ,也就是所有对象直接就分配在老年代中,这是因为 PretenureSizeThreshold 被设置为 1MB(就是 1048576,这个参数不能像 -Xmx 之类的参数一样直接写 1MB ),因此超过 1MB 的对象都会直接在老年代进行分配。注意 PretenureSizeThreshold 参数只对 Serial 和 ParNew 两款收集器有效,Parallel Scavenge 收集器不认识这个参数,Parallel Scavenge 收集器一般并不需要设置。如果遇到必须使用此参数的场合,可以考虑 ParNew 加 CMS 的收集器组合。

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

TODO 这段代码暂时就没有测试成功

文章摘自《深入理解Java虚拟机》第二版 周志明著,仅作为学习记录,书籍中用到的案例代码及描述有部分修改,但未改变原意。