我们说Java是自动进行内存管理的,所谓自动化就是,不需要程序员操心,Java会自动进行内存分配和内存回收这两方面。
前面我们介绍过如何通过垃圾回收器来回收内存,那么本篇博客我们来聊聊如何进行分配内存。
对象的内存分配,往大方向上讲,就是堆上进行分配(但也有可能经过JIT编译后被拆散为标量类型并间接的在栈上分配),对象主要分配在新生代 Eden 区上,如果启动了本地线程分配缓冲,将按线程优先在 TLAB 上分配。少数情况下也可能会直接分配在老年代上(下面会详细介绍),分配的规则并不是百分之百固定的,其细节取决于当前使用哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数设置。
本篇博客会介绍几条最普遍的内存分配规则。通过增加 -XX:+UseParallelGC 参数,表示使用的垃圾收集器是 Parallel Scavenge + Serial Old ,通过这两个垃圾收集器组合进行校验。
0、Minor GC 、Major GC 和 Full GC
下面会出现这几个概念,所以这里首先介绍一下。
①、Minor GC
也叫Young GC,指的是新生代 GC,发生在新生代(Eden区和Survivor区)的垃圾回收。因为Java对象大多是朝生夕死的,所以 Minor GC 通常很频繁,一般回收速度也很快。
②、Major GC
也叫Old GC,指的是老年代的 GC,发生在老年代的垃圾回收,该区域的对象存活时间比较长,通常来讲,发生 Major GC时,会伴随着一次 Minor GC,而 Major GC 的速度一般会比 Minor GC 慢10倍。
③、Full GC
指的是全区域(整个堆)的垃圾回收,通常来说和 Major GC 是等价的。
1、对象优先在 Eden 上分配
大多数情况下,对象优先在 Eden 上分配。当 Eden 区没有足够的空间进行分配时,虚拟机将会发起一次 Minor GC(新生代GC)。
package com.ys.algorithmproject.leetcode.demo.JVM;
/**
* Create by YSOcean
* 对象优先在Eden区上分配
*/
public class EdenTest {
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[2*_1MB];
byte[] b = new byte[2*_1MB];
byte[] c = new byte[2*_1MB];
byte[] d = new byte[3*_1MB];
}
}
运行时的虚拟机参数设置为:
-XX:+UseParallelGC -XX:+PrintGCDetails -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8
①、 -XX:+UseParallelGC 参数,表示使用的垃圾收集器是 Parallel Scavenge + Serial Old ;
②、-XX:+PrintGCDetails 参数,表示打印详细的GC日志,便于我们查看GC情况
③、-Xms20M -Xmx20M 这两个参数分别表示设置最大堆,最小堆内存都是20M
④、-Xmn 参数表示设置新生代大小为 10M
⑤、-XX:SurvivorRatio=8 新生代中的 Eden 区和 Survivor 区的比值为8:1,注意 Survivor是有两个的。
运行打印的GC日志为:
我们首先分析设置的JVM参数,表示堆中内存为20M,新生代和老年代分别各占一半为10M,并且新生代的Eden区为8M,剩下两个 Survivor 各为 1M。
在看代码,首先分配了三个大小都为2M的对象 a,b,c。这时候新生代对象的 Eden区已经被占用了6M,这时候来了一个对象d,大小为3M,发现新生代Eden区已经不足以分配对象d了,于是发起一次Minor GC。GC期间虚拟机又发现现在已有3个 2MB对象无法全部放入Survivor空间(Survivor空间只有1MB),所以只好通过分配担保机制提前转移到老年代中,然后将这个对象d分配到新生代Eden区中。
我们查看日志,在eden区中,总共8192K的空间,被使用了38%,约等于3113K,大概就是对象d(3MB)的大小。其次在老年代中,总共10240K(10MB),被使用了6865K,大概也就是a,b,c这三个对象的大小(6MB)。
2、大对象直接进行老年代
通常大对象是指需要大量连续内存空间的Java对象,比较典型的就是那种很长的字符串以及数组。
系统中出现大量大对象是很影响性能的,这样会导致还有不少空间时就提前触发垃圾回收来放置这些对象。
package com.ys.algorithmproject.leetcode.demo.JVM;
/**
* Create by YSOcean
* 大对象直接在老年代上分配
*/
public class OldTest {
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日志如下:
可以看到老年代 ParOldGen直接被使用了 8192K,而新生代只被占用了1820K。
PS:可以通过设置-XX:PretenureSizeThreshold 参数,大于这个参数设置值的对象直接在老年代中分配,但是这个参数只对 Serial 和 ParNew 这两款垃圾收集器有效,Parallel Scavenge 收集器不认识这个参数。
3、长期存活的对象将进入老年代
我们知道Java虚拟机是通过分代收集的思想来管理内存,新创建的对象通常放在新生代,除此之外,还有一些对象放在老年代。为了识别哪些对象放在新生代,哪些对象放在老年代,虚拟机给每个对象定义了一个年龄计数器(Age),如果对象在新生代Eden创建,并经历一次 Minor GC 后仍然存活,并且能够被 Survivor 容纳的话,虚拟机会将该对象移动到 Survivor 区域,并将对象的年龄Age+1。
新生代对象每熬过一次 Minor GC,年龄就增加1,当它的年龄增加到一定阈值时(默认是15岁),就会被晋升到老年代中。
这个年龄阈值可以通过如下参数来设置(N表示晋升到老年代的阈值):
-XX:MaxTenuringThreshold=N
验证代码如下:
package com.ys.algorithmproject.leetcode.demo.JVM;
/**
* Create by YSOcean
* 新生代对象经过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() 方法,表示让虚拟机进行垃圾回收。打印的日志如下:
注意看,代码中我们只创建了一个 1MB大小的对象,但是老年代占用了1999K的内存,而新生代确只有246K。
接下来可以将 -XX:MaxTenuringThreshold 参数设置的更大一点,来对比打印的日志,这里读者可以自己进行验证。
4、新生代Survivor 区相同年龄所有对象之和大于 Survivor 所有对象之和的一半,大于等于该年龄的对象进入老年代
Java虚拟机并不会死板的根据上面第3点说的,设置-XX:MaxTenuringThreshold 的阈值,只有对象经历该阈值次GC后,才会进入到老年代。而是会根据新生代对象的年龄来动态的决定哪些对象可以进入到老年代。
也就是说,新生代经历一次 Minor 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。
-XX:-HandlePromotionFailure
回到第一个问题,老年代也无法确认是否能够存放GC后新生代转移过来的对象,那么这该怎么办呢?
也就是取之前每一次回收晋升到老年代对象容量的平均大小作为经验值,然后与老年代剩余空间进行比较,来决定是否进行 Full GC,从而让老年代腾出更多的空间。
通常情况下,我们会将 HandlePromotionFaile 设置为允许担保失败,这样能够避免频繁的发生 Full GC。