Java虚拟机内存分配与回收策略

Java虚拟机中的内存分配与回收策略就是 Java的自动内存管理,其最核心的部分就是内存中对象的分配与回收。所以在了解虚拟机内存分配与回收策略之前我们有必要了解一下Java堆内存的组成部分。

堆内存示意图

从上图可以得知,堆内存主要分为新生代、老年代、永久代几部分组成,其中新生代又分为一个Eden区和两个Survivor区,其比例为8:1。JDK1.8之后,用元空间(Metaspace)的区域取代了堆中的永久代区域(永久代使用的是JVM的堆内存空间,而元空间使用的是物理内存,直接受到本机的物理内存限制)。


  • 对象优先在Eden分配

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

/*虚拟机参数配置如下 : -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+PrintGCDetails  */
public class MemoryAllocate {

    public static void main(String[] args) {
       
    }
}

首先解释下 虚拟机参数配置 : -Xms20M -Xmx20M -Xmn10M 三个参数限制了Java堆大小为20MB,不可扩展(-Xms设置堆容量的最小值,-Xmx设置堆容量的最大值),其中10M分配给新生代(-Xmn设置对容量新生代的大小),剩下的10M分配给老年代;-XX:SurvivorRatio=8 决定了新生代中Eden区与一个Survivor区的空间比例为8:1;-XX:+PrintGCDetails 打印内存回收日志。接下来看一下运行结果:

Heap
 PSYoungGen      total 9216K, used 2148K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 26% used [0x00000000ff600000,0x00000000ff819270,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
  to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
 ParOldGen       total 10240K, used 0K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 0% used [0x00000000fec00000,0x00000000fec00000,0x00000000ff600000)
 Metaspace       used 3143K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 342K, capacity 388K, committed 512K, reserved 1048576K

从结果中分析得知新生代(PSYoungGen)总可用空间为Eden区与一个Survivor(from 与to其中)区的总和:(8192+1024)这里说的是新生代的可用空间,而不是总空间,总空间大小为Eden与两个Survivor区的和;老年代(ParOldGen) 的空间大小为10240k;Metaspace(元空间,JDK1.8之后用于取代永久代的空间)。因此虚拟机参数配置已经生效,另外,虽然我们什么都没做,Eden区的空间也已经被使用26%。接下来看看第二段代码:

public class MemoryAllocate {

    public static void main(String[] args) {
        //连续向堆中申请5个1M的空间
        byte[] allocate1 = new byte[1 * 1024 * 1024];
        byte[] allocate2 = new byte[1 * 1024 * 1024];
        byte[] allocate3 = new byte[1 * 1024 * 1024];
        byte[] allocate4 = new byte[1 * 1024 * 1024];
        byte[] allocate5 = new byte[1 * 1024 * 1024];
    }
}

输出结果如下 : 
Heap
 PSYoungGen      total 9216K, used 7598K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 92% used [0x00000000ff600000,0x00000000ffd6bab8,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
  to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
 ParOldGen       total 10240K, used 0K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 0% used [0x00000000fec00000,0x00000000fec00000,0x00000000ff600000)
 Metaspace       used 3231K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 352K, capacity 388K, committed 512K, reserved 1048576K

PSYongGen的使用空间从2148k增加到7598k,而两个Survivor区根本没有使用,所以新增加的5450K空间全部在Eden中,证实了对象优先在Eden区中分配的观点。接下来看第三段代码:

public class MemoryAllocate {

    public static void main(String[] args) {
        //连续向堆中申请5个1M的空间
        byte[] allocate1 = new byte[1 * 1024 * 1024];
        byte[] allocate2 = new byte[1 * 1024 * 1024];
        byte[] allocate3 = new byte[1 * 1024 * 1024];
        byte[] allocate4 = new byte[1 * 1024 * 1024];
        byte[] allocate5 = new byte[1 * 1024 * 1024];
        //再申请1一个1M的空间
        byte[] allocate6 = new byte[1 * 1024 * 1024];
    }
}

输出结果如下:
[GC (Allocation Failure) [PSYoungGen: 7270K->968K(9216K)] 7270K->6096K(19456K), 0.0035282 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 968K->0K(9216K)] [ParOldGen: 5128K->5873K(10240K)] 6096K->5873K(19456K), [Metaspace: 3218K->3218K(1056768K)], 0.0060934 secs] [Times: user=0.08 sys=0.02, real=0.01 secs] 
Heap
 PSYoungGen      total 9216K, used 1353K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 16% used [0x00000000ff600000,0x00000000ff7527c8,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
  to   space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
 ParOldGen       total 10240K, used 5873K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 57% used [0x00000000fec00000,0x00000000ff1bc6f8,0x00000000ff600000)
 Metaspace       used 3232K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 352K, capacity 388K, committed 512K, reserved 1048576K

其中触发了一次GC回收和一次Full GC:

  • [PSYoungGen: 7270K->968K(9216K)] 表示GC前年轻代占用内存7270K,GC后占用内存968K,内>存区域总容量9M;
  • 7270K->6096K(19456K) 表示GC前堆占用内存7270K,GC后占用内存6096K,堆总容量20M;
  • [Full GC (Ergonomics) [PSYoungGen: 968K->0K(9216K)] 表示进行了一次Full GC,后面会说到Full GC与GC的区别。
    接下来看结果:
    从结果中我们可以得知ParOldGen老年代中使用了5873K的内存,而eden区中的内存使用情况反而变小了。因为当给allocate6分配内存的时候,eden区中的的剩余空间已经不足分配allocate6所需的1M内存, 因此发生GC,而GC期间发现已有的5个1M大小的对象无法全部放入Survivor空间,所以只好通过担保分配机制将这五个对象提前转移到老年代中。

注:上面代码运行可能会产生不一样的结果,那就需要读者另行分析了。

  • 大对象直接进入老年代

所谓的大对象主要指,需要大量连续内存空间的Java对象,最典型的例子就是那种很长的字符串以及数组。虚拟机提供了-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。这样做的好处是避免了在Eden区和两个Survivor区之间发生大量的内存复制(新生代采用复制算法回收内存)。

/*-XX:PretenureSizeThreshold=3145728 虚拟机参数配置*/
public class MemoryAllocate {
    
    public static void main(String[] args) {
        //申请一个8M的空间
        byte[] allocate = new byte[8 * 1024 * 1024];
    }
}
输出结果 :
Heap
 PSYoungGen      total 9216K, used 2478K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 30% used [0x00000000ff600000,0x00000000ff86b970,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
  to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
 ParOldGen       total 10240K, used 8192K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 80% used [0x00000000fec00000,0x00000000ff400010,0x00000000ff600000)
 Metaspace       used 3231K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 352K, capacity 388K, committed 512K, reserved 1048576K

很明显的我们可以看到8M的空间全部分配到了老年代之中。这里有一点需要注意的PretenureSizeThreshold参数只对Serial和ParNew两款收集器有效。本列中使用的Parallel Scavenge收集器,在所申请的空间大于eden区可使用的空间时,就会直接将大对象直接分配到老年代。

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

如果对象在Eden区出生并且经历过一次Minor GC后仍然存活,并且能够被Servivor容纳,将被移动到Servivor空间中,并且把对象年龄设置成为1。对象在Servivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认15岁),就将会被晋级到老年代中。虚拟机提供了-XX:MaxTenuringThreshold参数来设置这个阈值。

  • 动态对象年龄判定

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

  • 空间分配担保

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

上面提到了Minor GC依然会有风险,是因为新生代采用复制收集算法,假如大量对象在Minor GC后仍然存活(最极端情况为内存回收后新生代中所有对象均存活),而Survivor空间是比较小的,这时就需要老年代进行分配担保,把Survivor无法容纳的对象放到老年代。老年代要进行空间分配担保,前提是老年代得有足够空间来容纳这些对象,但一共有多少对象在内存回收后存活下来是不可预知的,因此只好取之前每次垃圾回收后晋升到老年代的对象大小的平均值作为参考。使用这个平均值与老年代剩余空间进行比较,来决定是否进行Full GC来让老年代腾出更多空间。


  • Minor GC 和 Full GC
  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倍以上。

上一篇:Java虚拟机垃圾收集
下一篇:虚拟机类加载机制

你可能感兴趣的:(Java虚拟机内存分配与回收策略)