likes
comments
collection
share

别再头秃了:一路向北,彻底整明白JVM堆内存分配机制

作者站长头像
站长
· 阅读数 24

前言

上一篇介绍了Java对象的栈上分配机制,JVM会通过逃逸分析,如果确定对象不会逃逸,会优先考虑将对象分配在栈上,从而减少垃圾回收的频率和开销,提高应用程序的性能。 但是,我们知道,栈内存一般都很小,所以大多数情况下,新生对象是在堆内存年轻代的Eden区分配。 这部分是JVM系列的重要内容,也是面试灵魂拷问之重,接下来,一起学习,彻底整明白。

 别再头秃了:一路向北,彻底整明白JVM堆内存分配机制

对象在Eden区分配

先来看Java堆内存的分区,默认情况下如下图:

 别再头秃了:一路向北,彻底整明白JVM堆内存分配机制

按照垃圾收集,将Java堆划分为**新生代(Young Generation)老年代(Old Generation)**两个区域,新生代存放存活时间短的对象,而每次垃圾回收后存活下来的少量对象,将会逐步晋升到老年代中存放。

默认情况下,新生代和老年代空间大小是1:2;新生代中,Eden与Survivor区默认8:1:1

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

Minor GC和Full GC

  • Minor GC/Young GC:指发生新生代的的垃圾收集动作,Minor GC非常频繁,回收速度一般也比较快。
  • Major GC/Full GC:一般会回收老年代 ,年轻代,方法区的垃圾,Major GC的速度一般会比Minor GC的慢10倍以上。

大量的对象被分配在Eden区,Eden区满后会触发Minor GC,可能99%以上的对象已经成为垃圾对象被回收掉,剩余存活下来的对象(幸存)会被挪到为空的那块Survivor区。下一次Eden区满后又会触发Minor GC,把Eden区和Survivor区垃圾对象回收,然后把本次剩余存活的对象一次性再挪到另外一块为空的Survivor区。因为新生对象基本都是朝生夕死的,存活时间很短,所以JVM默认的8:1:1的比例是很合适的,Eden区尽量的大,survivor区够用即可。

JVM默认有这个参数-XX:+UseAdaptiveSizePolicy(默认开启),会导致这个8:1:1比例自动变化,如果不想这个比例有变化可以设置参数-XX:-UseAdaptiveSizePolicy。

接下来通过实际示例测试一下。 实例:

package com.jvm;

//添加运行JVM参数: -XX:+PrintGCDetails
public class GCTest {
   public static void main(String[] args) throws InterruptedException {
      byte[] allocation1, allocation2, allocation3, allocation4, allocation5, allocation6;
      allocation1 = new byte[29000*1024]; // 29M 根据当前可用内存调整为合适的来观察

//     allocation2 = new byte[8000*1024];
//
//     allocation3 = new byte[1000*1024];
//     allocation4 = new byte[1000*1024];
//     allocation5 = new byte[1000*1024];
//     allocation6 = new byte[1000*1024];
   }
}

运行结果:
Heap
 PSYoungGen      total 38400K, used 32994K [0x0000000795580000, 0x0000000798000000, 0x00000007c0000000)
  eden space 33280K, 99% used [0x0000000795580000,0x00000007975b88f0,0x0000000797600000)
  from space 5120K, 0% used [0x0000000797b00000,0x0000000797b00000,0x0000000798000000)
  to   space 5120K, 0% used [0x0000000797600000,0x0000000797600000,0x0000000797b00000)
 ParOldGen       total 87552K, used 0K [0x0000000740000000, 0x0000000745580000, 0x0000000795580000)
  object space 87552K, 0% used [0x0000000740000000,0x0000000740000000,0x0000000745580000)
 Metaspace       used 3232K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 353K, capacity 388K, committed 512K, reserved 1048576K

我们可以看到,Eden区几乎已经被分配满。 另外,可能发现占用的大小,不完全等于我们程序设置的29M,这主要是,即使程序什么也不做,新生代也会被占用至少几M内存,因为JVM内部的对象也需要占用一定的内存空间。另外这个输出的值,是一个估算值,不是100%精确的,有一定的上下浮动。

此时,如果我们再为allocation2分配内存会出现什么情况呢?

package com.jvm;

//添加运行JVM参数: -XX:+PrintGCDetails
public class GCTest {
   public static void main(String[] args) throws InterruptedException {
      byte[] allocation1, allocation2, allocation3, allocation4, allocation5, allocation6;
      allocation1 = new byte[29000*1024]; // 29M

      allocation2 = new byte[8000*1024]; //8M
//
//     allocation3 = new byte[1000*1024];
//     allocation4 = new byte[1000*1024];
//     allocation5 = new byte[1000*1024];
//     allocation6 = new byte[1000*1024];
   }
}

运行结果:
[GC (Allocation Failure) [PSYoungGen: 32328K->688K(38400K)] 32328K->29688K(125952K), 0.0239290 secs] [Times: user=0.02 sys=0.01, real=0.03 secs] 
Heap
 PSYoungGen      total 38400K, used 9021K [0x0000000795580000, 0x0000000798000000, 0x00000007c0000000)
  eden space 33280K, 25% used [0x0000000795580000,0x0000000795da34b8,0x0000000797600000)
  from space 5120K, 13% used [0x0000000797600000,0x00000007976ac010,0x0000000797b00000)
  to   space 5120K, 0% used [0x0000000797b00000,0x0000000797b00000,0x0000000798000000)
 ParOldGen       total 87552K, used 29000K [0x0000000740000000, 0x0000000745580000, 0x0000000795580000)
  object space 87552K, 33% used [0x0000000740000000,0x0000000741c52010,0x0000000745580000)
 Metaspace       used 3224K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 353K, capacity 388K, committed 512K, reserved 1048576K

我们一步步来解释一下这个GC日志:

  1. [GC (Allocation Failure):表示这次垃圾回收的原因是"Allocation Failure",也就是内存分配失败。这通常发生在新对象分配时,没有足够的连续内存来容纳新对象时触发垃圾回收。

  2. [PSYoungGen: 32328K->688K(38400K)]:PSYoungGen表示使用的是Parallel Scavenge(并行清理)垃圾回收器。在这个年轻代垃圾回收中,原本占用的内存从32328K减少到688K,表示在清理后,年轻代内存从32MB减少到0.68MB。年轻代的总内存容量是38400K。

  3. 32328K->29688K(125952K):这部分提供了整个堆内存的使用情况。在整个垃圾回收过程中,堆内存的使用从32328K减少到29688K,表示垃圾回收后的堆内存占用。总的堆内存容量是125952K。

  4. 0.0239290 secs:这是本次垃圾回收的耗时。

  5. [Times: user=0.02 sys=0.01, real=0.03 secs]:这一部分提供了不同类型的CPU时间以及实际时间。user表示用户态CPU时间,sys表示内核态CPU时间,real表示实际经过的时间。

  6. Heap:这一部分提供了整个堆内存的详细信息。

    • PSYoungGen:这是年轻代的信息,包括总内存容量、使用情况以及空间分布。在这里,年轻代的总内存容量是38400K,其中eden space(Eden区)占用了33280K,from space(Survivor区中的一个)占用了5120K,to space(Survivor区中的另一个)也占用了5120K。可以看到Eden区使用了25%,from space使用了13%,而to space使用了0%。
    • ParOldGen:这是老年代的信息,包括总内存容量、使用情况以及空间分布。老年代的总内存容量是87552K,使用了29000K,占用了33%。
  7. Metaspace:这一部分提供了关于Metaspace(元空间)的信息。

    • class space:这是类空间的信息,包括已使用的内存、容量、已分配的内存和保留的内存。

那对于我们的代码实例,具体来说,发生了如下情况:

  1. 因为要给allocation2分配内存的时候,Eden区内存几乎已经被分配完了。我们前面说到当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。
  2. 在Minor GC 中,又发现allocation1无法存入Survior空间,所以只好把新生代的对象提前转移到老年代中去,即allocation1被移到了老年代。老年代足够容纳allocation1,所以不会出现Full GC。
  3. 执行Minor GC后,后面分配的对象如果能够存在eden区的话,还是会在eden区分配内存,所以allocation2 在Minor GC 之后被分配到了年轻代的Eden区。
  4. 由于年轻代的对象已经经历了一次垃圾回收,年轻代的空间得以最大限度地重新整理和清理。 这也在这个垃圾回收事件中,从我们的代码程序上看,发现其实并没有对象真正变成垃圾被回收,但GC 日志2328K->29688K,表示整个堆内存使用情况从32328K减少到了29688K。 主要原因是年轻代的垃圾回收导致内存重新组织和重新分配。尽管没有对象被回收,但是对象的移动和年轻代的清理导致了整体内存使用情况的变化。

此时,如果我们把allocation3、allocation4、allocation5、allocation6代码放开,这几个对象的内存分配将是怎样呢?

package com.jvm;

//添加运行JVM参数: -XX:+PrintGCDetails
public class GCTest {
   public static void main(String[] args) throws InterruptedException {
      byte[] allocation1, allocation2, allocation3, allocation4, allocation5, allocation6;
      allocation1 = new byte[29000*1024]; // 29M

      allocation2 = new byte[8000*1024]; // 8M

     allocation3 = new byte[1000*1024]; // 1M
     allocation4 = new byte[1000*1024]; // 1M
     allocation5 = new byte[1000*1024]; // 1M
     allocation6 = new byte[1000*1024]; // 1M
   }
}

运行结果:
[GC (Allocation Failure) [PSYoungGen: 32328K->736K(38400K)] 32328K->29744K(125952K), 0.0200314 secs] [Times: user=0.03 sys=0.01, real=0.02 secs] 
Heap
 PSYoungGen      total 38400K, used 13714K [0x0000000795580000, 0x0000000798000000, 0x00000007c0000000)
  eden space 33280K, 38% used [0x0000000795580000,0x000000079622c918,0x0000000797600000)
  from space 5120K, 14% used [0x0000000797600000,0x00000007976b8000,0x0000000797b00000)
  to   space 5120K, 0% used [0x0000000797b00000,0x0000000797b00000,0x0000000798000000)
 ParOldGen       total 87552K, used 29008K [0x0000000740000000, 0x0000000745580000, 0x0000000795580000)
  object space 87552K, 33% used [0x0000000740000000,0x0000000741c54010,0x0000000745580000)
 Metaspace       used 3228K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 353K, capacity 388K, committed 512K, reserved 1048576K

从结果上可以看到,这4个对象(总共4M)都被分配在Eden区,因为Eden区还能完全容纳。

对象什么时候会进入老年代?

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

大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。 JVM参数 -XX:PretenureSizeThreshold 可以设置大对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在 Serial 和ParNew两个收集器下有效。

比如我们设置JVM参数:-XX:PretenureSizeThreshold=1000000 (单位是字节) -XX:+UseSerialGC ,再执行下上面的第一个程序会发现大对象直接进了老年代。

package com.jvm;

//添加运行JVM参数: -XX:+PrintGCDetails
public class GCTest {
   public static void main(String[] args) throws InterruptedException {
      byte[] allocation1, allocation2, allocation3, allocation4, allocation5, allocation6;
      allocation1 = new byte[29000*1024]; // 29M

//      allocation2 = new byte[8000*1024];
//
//     allocation3 = new byte[1000*1024];
//     allocation4 = new byte[1000*1024];
//     allocation5 = new byte[1000*1024];
//     allocation6 = new byte[1000*1024];
   }
}
运行结果:
Heap
 def new generation   total 39296K, used 4198K [0x0000000740000000, 0x0000000742aa0000, 0x000000076aaa0000)
  eden space 34944K,  12% used [0x0000000740000000, 0x0000000740419830, 0x0000000742220000)
  from space 4352K,   0% used [0x0000000742220000, 0x0000000742220000, 0x0000000742660000)
  to   space 4352K,   0% used [0x0000000742660000, 0x0000000742660000, 0x0000000742aa0000)
 tenured generation   total 87424K, used 29000K [0x000000076aaa0000, 0x0000000770000000, 0x00000007c0000000) // 29M直接进入到了老年代
   the space 87424K,  33% used [0x000000076aaa0000, 0x000000076c6f2010, 0x000000076c6f2200, 0x0000000770000000)
 Metaspace       used 3226K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 353K, capacity 388K, committed 512K, reserved 1048576K


为什么要这样呢?大致有以下几点原因:

  • 大对象占据着新生代大量的内存空间,空间会很快不够用,导致Minor GC更提前更频繁。
  • 新生代的垃圾回收是基于复制算法的,如果大对象分配在新生代,一直回收不掉,每次Minor GC,意味着会在两个Survivor区来回复制,会降低垃圾回收的效率。
  • 如果把大对象分配在新生代,除了导致提前触发Minor GC,而在Minor GC后存活下来的对象被移到Survivor区,Survivor区放不下,又会把存活下来的对象挪到老年代,这样会加快老年代的GC发生时间,老年代的GC对系统会造成更大的性能影响。

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

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。对象年龄(Age)是存储在对象的对象头信息中。

如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1。对象在 Survivor 中每熬过一次 MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。

对象晋升到老年代的年龄阈值,可以通过参数来设置:

```
    -XX:MaxTenuringThreshold
```

3. 对象动态年龄判定

HotSpot虚拟机并不是永远要等到对象的年龄必须达到- XX:MaxTenuringThreshold才能晋升老年代。 当前放对象的Survivor区域里(其中一块区域,放对象的那块S区),一批对象的总大小大于这块Survivor区域内存大小的50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了。

例如Survivor区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。

对象动态年龄判断机制一般是在已经执行了一次完全的Minor GC之后进行的。在Minor GC执行后,存活的对象会被移动到Survivor区域(通常是S0或S1)中。然后,动态年龄判定机制会考察这个Survivor区域中的对象,并根据它们的存活情况和大小来判断是否将它们晋升到老年代。

 别再头秃了:一路向北,彻底整明白JVM堆内存分配机制 如上图,触发Minor GC后,假设Eden区有一个(一批更恰当,一个只是为了方便表示)20M的对象存活下来,S0区有如图示的3个对象存活下来。那么这些对象会被挪动到S1区,接着JVM判断发现这些对象的总大小加起来超过了S1区内存大小的50%,(eden区的20m对象)+原年龄为1(S1区10M)+原年龄为2(S1区30M)=60M,60M > 50M(大于S1区大小的50%),因此,将原age>=2的对象都挪动到老年代,即S1区30M对象和S1区1M对象,都被移到老年代。

4. 老年代空间担保机制

  • 年轻代每次minor gc之前, JVM首先会检查老年代的剩余可用空间是否足够容纳年轻代中所有对象的大小

  • 如果这个剩余可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象)

  • 就会看一个“-XX:+HandlePromotionFailure”(在JDK 7及更早版本中,HandlePromotionFailure 参数用于控制老年代空间担保机制,允许开发人员在一些情况下禁用它。在JDK 8中,该参数已被移除,老年代空间担保机制的行为是默认开启,无法通过该参数进行配置)的参数是否设置了

  • 如果设置这个参数(如果开启老年代空间担保机制),就会看看老年代的可用内存大小,是否大于之前每一次minor gc后进入老年代的对象的平均大小。

  • 如果上一步结果是小于或者之前说的参数没有设置,那么就会触发一次Full gc,对老年代和年轻代一起回收一次垃圾,如果回收完还是没有足够空间存放新的对象就会发生"OOM"

  • 如果minor gc之后剩余存活的需要挪动到老年代的对象大小还是大于老年代可用空间,那么也会触发Full gc,Full gc完之后如果还是没有空间放Minor gc后存活下来的对象,则也会发生“OOM”。

 别再头秃了:一路向北,彻底整明白JVM堆内存分配机制

Java对象的内存分配流程总结

至此,对于Java对象的内存分配机制应该都有了一定的了解,那么我们来做一个整体的总结吧。如下图:

 别再头秃了:一路向北,彻底整明白JVM堆内存分配机制

  1. 当JVM遇到new指令时,Java对象的分配会首先尝试栈上分配。如果逃逸分析后对象不会逃逸出方法,那么它可能会分配在方法的栈帧上。这样的对象在方法调用结束时就会被出栈并销毁,无需进行垃圾回收。

  2. 如果无法在栈上分配对象,则对象将在堆上分配。

  3. 在堆上分配时,首先会判断对象是否是大对象。大对象可能会直接分配到老年代,以避免在年轻代的复制过程中浪费时间和空间。

  4. 如果对象不是大对象,它将被分配到年轻代的Eden区。

  5. 对于Eden区分配,JVM可以选择使用线程本地分配缓冲区(TLAB)来提高分配性能。如果启用了TLAB,对象尝试在TLAB内分配。如果TLAB分配失败或者TLAB未启用,对象将在Eden区的空闲空间中分配。

  6. 如果Eden区空间不足,将触发Minor GC。在Minor GC期间,存活下来的对象将被移到Survivor区(通常是S0区),然后会进行年龄判断。一种情况是,长期存活的对象,例如某个对象的年龄达到一定阈值(通常为15),它将被晋升到老年代;另一种情况是动态年龄判定机制,即在Minor GC之后,存活下来的对象会被移到Survivor区域(通常是S0区或S1区),JVM会监测Survivor区域的使用情况,如果Survivor区域中某批对象的年龄总和(年龄1 + 年龄2 + ... + 年龄n)大于等于Survivor区域大小的一半(或根据 -XX:TargetSurvivorRatio 参数设置的比例),那么这些对象中年龄大于等于n的对象将被晋升到老年代。

  7. 如果老年代剩余可用空间不足,将触发Full GC。

  8. 下一次触发Minor GC时,Eden区和S0区中的存活对象会被移动到S1区。这个过程会循环进行。