1. 前序
Java的技术体系中,所谓的自动内存管理,说白了主要干两件事,一件事是内存的分配,另一件事就是内存的回收。对于内存的回收,我们在前面的几篇文章里就可达性分析、垃圾收集算法以及常用的垃圾收集器做了说明。接下来我们在看一下内存的分配以及回收策略。本文是基于Serial/SerialOld收集器进行分析的,其他收集器可自行研究。
在进入正题之前,我们先将MinorGc与MajorGC了解一下。
类型 | 发生时机 | 特点 |
MinorGC | 新生代垃圾收集工作 | 频繁、速度快 |
FullGC/MajorGC | 老年代垃圾收集工作 | 1.伴随一次MinorGC(Parallel Scavenge除外,他是直接发生FullGC); 2.速度是要比MinorGC慢10倍 |
2. 对象优先分配Eden区域
一般情况下,对象在新生代Eden区域中分配,当Eden区域没有足够的空间的时候,虚拟机会发生一次MinorGC。我们先来一段代码。
/**
* vm:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC
* @author surpass
* @date 2019/10/29
*/
public class ThreeFive {
private static final int _1MB = 1024 * 1024;
public static void testAllocation(){
byte[] allocation1,allocation2,allocation3,allocation4;
allocation1 = new byte[2 * _1MB];//#1
allocation2 = new byte[3 * _1MB];//#2
allocation3 = new byte[2 * _1MB];//#3
allocation4 = new byte[4 * _1MB];//#4
}
public static void main(String[] args) {
testAllocation();
}
}
在此之前,我们先算一下各个区域的大小。
Eden:20/2*8=8192K;Survior:1024K;tenured:10240K.
接下来我们分析一下内存的变化:
1.执行代码#1,此时Eden区域变为2048+堆内其他内存,其余内存为0;
2.执行代码#2,此时Eden区域变为2048+3072+其他堆内内存=7452.
3.执行代码#3,此时要在Eden分配2048的空间,但是目前已经剩下8192-7452=740,不足分配allocation3,所以执行一次MinorGC,得到剩余内存为5786,但是发现FromSurvior内存为1024,不足以承载5786,此时会执行空间分配担保,将5120分配至老年代。并将剩余666分配至from space区域。此时eden区域为0,然后将2048分配至eden区域。
4.执行代码#4,将4096内存分配至eden区域,此时eden区域为6144,而图中为8192*0.87=7127.04,多出1M内存可以认为是堆内其他对象占用内存。from区域为1024*0.65=665.6,基本等于第一次GC新生代剩余内存。
3. 大对象直接进入老年代
所谓大对象,就是需要大量连续空间的java对象,最典型的就是很长的字符串或者数组。他的出现经常会导致内存还有不少空间就需要提前出发垃圾收集,从而发生在Eden区域以及Survivor区域进行大量复制,从而影响效率。为此,JVM提供了一个参数-XX:PretenureSizeThreshold,这个参数可以设置大于多少的对象就可以直接进入老年代,注意这个参数的单位是Byte,不能像Xms那这样直接写3M。下面我们可以通过一段代码师范一下。
/**
* -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:PretenureSizeThreshold=3145728
* @author surpass
* @date 2019/10/29
*/
public class ThreeSix {
private static final int _1MB = 1024 * 1024;
public static void testAllocation(){
byte[] allocation1;
allocation1 = new byte[4 * _1MB];
}
public static void main(String[] args) {
testAllocation();
}
}
当XX:PretenureSizeThreshold参数设置为3145728(3M)
图1
当XX:PretenureSizeThreshold参数设置为5242880(5M)
图2
我们需要分配4M内存的时候,当参数XX:PretenureSizeThreshold设置为3M的时候,如图1,老年代有4096K(4M)内存被使用,而eden区域有2496K内存被使用,这eden区域可以认为是其他对象多导致的;当参数XX:PretenureSizeThreshold设置为5M的时候,如图2,老年代有没有被使用的内存,而eden区域有6428K内存被使用,这里面包含allocation1需要的4K内存对象。
我们可以想象一下,如果老年代存在大量的大对象,而且又是朝生夕死的,对于他的回收需要进行FullGC,那是一件非常消耗性能的事,所以我们在开发过程中尽量要避免。
4. 长期存活对象放入老年代
我们知道虚拟机采用分代的思想管理内存,那么在正常情况下,一个对象究竟什么时候该待在新生代,什么时候该放入老年代。那我们人类来说,一个人多大年龄属于壮年,多大年龄属于老年,这里的老年就是退休年龄。当然,这个并不是一成不变的,随着生活条件的提高,社会的发展,这个退休年龄也在变化。那么对于虚拟机而言,每个对应也有一个年龄,每当在Survivor区域进行一次MinorGC,他的年龄就加1。当达到一定年龄,他就被放入老年代。虚拟机认为,你都渡了这么多次劫都死不了,说明你很强大,就把你放入老年代吧。为此,虚拟机提供了参数-XX:MaxTenuringThreshold(JDK1.8已经被废弃)来设置进入老年代年龄。这里,我们就贴一段代码,就不做分析。大家可以看一下值设置为1或者15的时候输出内容的差别。
我们先来看一段代码以及运行结果。
/**
* -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution
* -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:MaxTenuringThreshold=15 -XX:+PrintTenuringDistribution
* XX:MaxTenuringThreshold:表示对象经过多少次GC后放入老年代,默认为15.
* @author surpass
* @date 2019/10/29
*/
public class ThreeSeven {
private static final int _1MB = 1024 * 1024;
public static void testAllocation(){
byte[] allocation1,allocation2,allocation3;
allocation1 = new byte[_1MB / 4];
allocation2 = new byte[4 * _1MB];
allocation3 = new byte[4 * _1MB];
//什么时候进入老年代取决于XX:MaxTenuringThreshold设置
allocation3 = null;
allocation3 = new byte[4 * _1MB];
}
public static void main(String[] args) {
testAllocation();
}
}
5. 动态对象年龄判定
所有事情都不是绝对的,为了适应不同程序的内存情况,虚拟机并不是永远的要求对象必须达到15岁才进入老年代。如果在Survior空间中相同年龄所有对象的大小总和大于Survivor的一半,年龄大约或等于改年龄的对象就可以直接进入老年代。
我们先看一下代码。
/**
* -verbose:gc -Xms40M -Xmx40M -Xmn20M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:MaxTenuringThreshold=15 -XX:+PrintTenuringDistribution
* @author surpass
* @date 2019/10/29
*/
public class ThreeEight {
private static final int _1MB = 1024 * 1024;
@SuppressWarnings("unused")
public static void testAllocation(){
byte[] allocation1,allocation2,allocation3,allocation4;
allocation1 = new byte[_1MB / 4];
//allocation1 + allocation2大于survivo空间一半
allocation2 = new byte[_1MB / 4];
allocation3 = new byte[8 * _1MB];
allocation4 = new byte[8 * _1MB];
allocation4 = null;
allocation4 = new byte[8 * _1MB];
}
public static void main(String[] args) {
testAllocation();
}
}
这里我们分两种情况,情况1是allocation1和allocation2各分配1M/4内存,另外一种情况是allocation1和allocation2各分配1M/8内存。我们看一下输出结果:注意,此时我们调大了对内存大小。Eden:16M,Survior:2M,老年代:20M.
情况1
情况2
对于情况1,当发生完第一次GC后,Survior区域已经使用1203K,大于Survior(2048K)的一半,并且为第一次GC,所以对象年龄一致,当发生第二次GC的时候,发现9395->0,说明1203的内存全部进入老年代。而对于情况2,当发生完第一次GC后,Survior区域已经使用922K,小于Survior(2048K)的一半,并且为第一次GC,所以对象年龄一致,当发生第二次GC的时候,发现0278->918,说明918还保留在Survior区域。
6. 空间分配担保
在发生MinorGC之前,虚拟机会先检查老年代最大可用连续空间是否大于新生代所有对象总和,如果这个条件成立,那么MinorGC是安全的。如果不成立,则虚拟机会查看HandlepormotionFailure(JDK6 update24以后会失效)设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小。如果大于,将尝试一次MinorGC,如果小于,或者HandlePromotionFailure设置为不允许(false),那么就需要进行一次FullGC。
下面是实例代码,对于失效的参数,我这jdk1.8就不试了,有兴趣的可以用低版本的jdk试验一下。
/**
* -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:-HandlePromotionFailure
* HandlePromotionFailure:是否允许担保失败,到JDK 6 Update 24之后失效
* @author surpass
* @date 2019/10/29
*/
public class ThreeNine {
private static final int _1MB = 1024 * 1024;
@SuppressWarnings("unused")
public static void testAllocation(){
byte[] allocation1,allocation2,allocation3,allocation4,allocation5,allocation6,allocation7;
allocation1 = new byte[2 * _1MB];
allocation2 = new byte[2 * _1MB];
allocation3 = new byte[2 * _1MB];
allocation1 = null;
allocation4 = new byte[4 * _1MB];
allocation5 = new byte[4 * _1MB];
allocation6 = new byte[4 * _1MB];
allocation4 = null;
allocation5 = null;
allocation6 = null;
allocation7 = new byte[2 * _1MB];
}
public static void main(String[] args) {
testAllocation();
}
}