JVM-06-内存分配

JVM-06-内存分配

前序

Java是自动进行内存管理的,所谓自动化就是,不需要程序员操心,Java会自动进行内存分配内存回收这两方面。

前面我们介绍过如何通过垃圾回收器来回收内存,那么这篇聊一聊如何进行内存分配。

  • 对象的分配,往大方向上讲,就是堆上进行分配(但是也有可能经过JIT编译后被差三位标量类型并间接在栈上分配)
  • 对象主要分配在新生代 Eden 区上,如果启动了本地线程分配缓冲,将按线程优先在 TLAB 上分配
  • 少数情况下也有可能直接分配在老年代上
  • 分配的规则并不是百分之百固定的,其细节取决于当前使用哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数设置。

本篇会介绍几条最普遍的内存分配规则。通过增加 -XX:+UseParallelGC 参数,表示使用的垃圾收集器是 Parallel Scavenge + Serial Old ,通过这两个垃圾收集器组合进行校验。

1. 概念分析

  • Minor GC

也叫做Young GC,指的是新生代GC,发生在新生代(Eden区和Survivor区)的垃圾回收,因为Java对象大多是朝生夕死的,所以 Minor GC 通常很频繁,一般回收速度也很快。

  • Major GC

也叫Old GC ,指的是老年代的GC,发生在老年代的垃圾回收,该区域的对象存活时间比较长,通常来讲,发生Major GC时,会伴随着一次Minor GC,而Major GC的速度一般会比Minor GC慢十倍。

  • Full GC

指的是整个堆的垃圾回收,通常来说和 Major GC 是等价的。

2. 对象优先在Eden上分配

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package Eden;

/**
* 对象优先在Eden区上分配
*/

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

public static void main(String[] args) {
byte[] a = new byte[2 * _1MB];
byte[] b = new byte[2 * _1MB];
byte[] c = new byte[2 * _1MB];
byte[] d = new byte[3 * _1MB];
}
}

运行时的虚拟机参数设置为:

1
-XX:+UseParallelGC -XX:+PrintGCDetails -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8
  1. -XX:+UseParallelGC 参数,表示使用的垃圾收集器是 Parallel Scavenge + Serial Old ;
  2. -XX:+PrintGCDetails 参数,表示打印详细的GC日志,便于我们查看GC情况
  3. -Xms20M -Xmx20M 这两个参数分别表示设置最大堆,最小堆内存都是20M
  4. -Xmn 参数表示设置新生代大小为 10M
  5. -XX:SurvivorRatio=8 新生代中的 Eden 区和 Survivor 区的比值为8:1,注意 Survivor是有两个的。

运行打印的GC日志为:

mark

  • 首先分析设置的JVM参数,表示堆中内存时20M,新生代和老年代分别各占一半的10M。并且新生代的Eden区为8M,剩下两个 Survivor 各为 1M。
  • 在看代码,首先分配了三个大小都是2M的对象a,b,c。这时候新生代已经被占用了6M
  • 这时候来了一个对象d,大小是3M,发现新生代已经不够存放对象d了,于是发动了一次Minor GC。
  • GC期间虚拟机又发现现在有的3个2MB的对象无法放进Survivor空间(Survivor空间只有1MB),于是通过分配担保机制把对象转移到老年代中
  • 最后再把对象d新生到Eden区域中。
1
2
3
查看日志发现:
1. 在eden区中,总共8192K的空间,被使用了38%,约等于3113K,大概就是对象d(3MB)的大小。
2. 其次在老年代中,总共10240K(10MB),被使用了6865K,大概也就是a,b,c这三个对象的大小(6MB)。

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

通常大对象是指需要大量连续内存空间的Java对象,比较典型的就是那种很长的字符串和数组。

系统中出现大量大对象是很影响性能的,这样会导致还有不少空间就提前触发垃圾回收来放置这些对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package Old;

public class TestOld {
/**
* 大对象直接在老年代上分配
*/

private static final int _1MB = 1024*1024;

/**
* 虚拟机参数设置:-XX:+UseParallelGC -XX:+PrintGCDetails -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8
* @param args
*/

public static void main(String[] args) {
byte[] a = new byte[8 * _1MB];
}
}

运行时虚拟机参数还和上面一样,运行的GC日志如下:

mark

可以看到的是ParOldGen直接被使用了8192K,而新生代只被占用了1820K。

PS:

  • 可以通过设置-XX:PretenureSizeThreshold参数,大于这个参数设置值的对象直接在老年代中分配,当时这个参数只对Serial 和 ParNew两款垃圾收集器有效,Parallel Scavenge根部就不认识这个参数。

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

  • 我们知道Java虚拟机是通过分代收集的思想来管理内存,新创建的对象通常放在新生代,除此之外,还有一些对象放在老年代。
  • 为了识别哪些对象放在新生代,哪些对象放在老年代,虚拟机给每个对象定义了一个年龄计数器(Age)
    • 如果对象在新生代Eden创建,并且经历了一次Minor GC后仍然存活,同时能够被Survivor 容纳的话,虚拟机会将该对象移动到Survivor 区域,并将对象的年龄+1
    • 新生代对象每熬过一次Minor GC,年龄就增加1,当它的年龄增加到一定阈值的时候(默认是15岁),就会被晋升到老年代中。

这个年龄阈值可以通过如下参数来设置(N表示晋升到老年代的阈值):

1
-XX:MaxTenuringThreshold=N
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.ys.algorithmproject.leetcode.demo.JVM;

/**
* 新生代对象经过N次Minor GC后,晋升到老年代
*/
public class OldAgeTest {
private static final int _1MB = 1024*1024;

/**
* 虚拟机参数设置:-XX:MaxTenuringThreshold=1 -XX:+UseParallelGC -XX:+PrintGCDetails -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8
* @param args
*/
public static void main(String[] args) {
byte[] a = new byte[_1MB];
System.gc();

}

}

注意:

  • 这里我们设置 -XX:MaxTenuringThreshold=1,也就是经历一次gc,新生代对象就直接进入老年代了,
  • 然后手动调用了 System.gc() 方法,表示让虚拟机进行垃圾回收。打印的日志如下:

mark

可以发现的是,代码中我们只创建了一个1MB大小的对象,但是老年代占用了1999K大小的内存,而新生代只有246K。

接下来可以将 -XX:MaxTenuringThreshold 参数设置的更大一点,来对比打印的日志,这里读者可以自己进行验证。

4. Survior的老年代规则

  • Java虚拟机并不会死板的根据上面第3点说的,设置-XX:MaxTenuringThreshold 的阈值,只有对象经历该阈值次GC后,才会进入到老年代
  • 而是根据新生代的年龄进行动态的决定哪些对象可以进入到老年代。

也就是说,新生代经历一次Minro GC之后,Survivor 区域存活对象的所有相同年龄之和大于整个Survivor区域的所有对象之和,那么该区域大于等于这个年龄对象的就会进入老年代,而无需等待-XX:MaxTenuringThreshold设置的阈值

5. 空间分配担保原则

在前面介绍垃圾回收的时候,我们介绍过现在Java虚拟机采用的是分代回收算法

  • 新生代采用复制算法
  • 老年代采用标记整理或者标记清除算法

新生代内存分为一块 Eden区,和两块 Survivor 区域,当发生一次 Minor GC时,虚拟机会将Eden和一块Survivor区域的所有存活对象复制到另一块Survivor区域

  • 通常情况下,Java对象朝生夕死,一块 Survivor 区域是能够存放GC后剩余的对象的
  • 但是极端情况下,GC后仍然有大量存活的对象,那么一块Survivor区域就会放不下这么多对象,此时就需要老年代进行分配担保,让无法进入Survivor 区域的对象直接进入老年代(当然前提是老年代还有空间放这些对象)。
  • 但是实际情况是在完成GC之前,是不知道还有多少对象能够存活下来的,所以老年代也无法确认是否能够存放GC后新生代转移过来的对象,那么这该怎么办呢?

前面我们介绍的都是Minor GC ,那么何时会发生Full GC?

  • 在发生Minor GC时,虚拟机会检测之前每次晋升到老年代的平均大小是否大于老年代的剩余空间。
    • 如果大于,采用Full GC。
    • 如果小于,则查看HandlePromotionFailure是否允许担保失败
      • 如果允许,那就只会进行一次Minor GC
      • 如果不允许,则也要进行一次 Full GC

回到第一个问题,老年代也无法确认是否能够存放GC后新生代转移过来的对象,那么该怎么办呢?

  • 也就是取之前每一次回收晋升到老年代对象容量的平均大小为经验值。

  • 然后与老年代剩余空间进行比较,来决定是否进行Full GC,让老年代腾出更多的空间。

通常情况下,会将HandlePromotionFaile设置为允许担保失败,这样可以避免频繁的繁盛Full GC。

打赏
  • 版权声明: 本博客所有文章除特别声明外,均采用 Apache License 2.0 许可协议。转载请注明出处!
  • © 2019-2022 Zhuuu
  • PV: UV:

请我喝杯咖啡吧~

支付宝
微信