1. 垃圾收集器与内存分配策略
Java技术体系中所提倡的自动内存管理最终可以归结为自动化地解决两个问题:
- 给对象分配内存;
- 回收分配给对象的内存。
对象的内存分配,往大方向上讲就是在堆上的分配,对象主要分配在新生代的Eden区上。少数也可能分配在老年代,取决于哪一种垃圾收集器组合,还有虚拟机中的相关内存的参数设置。下面先介绍一下JVM中的年代划分:新生代、老年代、永久代(JDK1.8后称为元空间)。
1.1 JVM堆的结构分析(新生代、老年代、永久代)
HotSpot JVM把年轻代分为了三部分:1个Eden区和2个Survivor区(分别叫from(S1)和to(S2)),具体可参下面的JVM内存体系图。Eden和Survival的默认分配比例为8:1。一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理,后面会说到),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到年老代中。
因为年轻代中的对象基本都是朝生夕死的(80%以上),所以在年轻代的垃圾回收算法使用的是复制算法,复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片。
在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。
在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。
永久代主要用于存放静态文件,Java类、方法等。永久代对垃圾回收没有显著影响,但是有些应 用可能动态生成或者调用一些class,例如Hibernate 等,在这种时候需要设置一个比较大的持永久代空间来存放这些运行过程中新增的类。永久代大小通过-XX: MaxPermSize = <N> 进行设置。
1.2 对象在Eden上分配
大多数新生代对象都在Eden区中分配。当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。
下面做一个测试程序demo,详细说明,新生代对象在Eden区的内存分配情况。尝试分配3个2MB大小和一个4MB大小的对象,在运行时候通过VM参数设置(看代码注释),限制java堆大小为20MB,不可扩展,其中10M分配给新生代,10M分给老年代,需要注意的是Eden区与一个Survivor区的空间比例是8:1,从输出结果也可以看出"eden space 8192K,from space 1024K,to space 1024K"的信息,新生代的总空间为9216KB(endn区+1个survivor区的总容量)。测试代码如下:
public class Minor_GC { private static final int _1MB = 1024 * 1024; /* * VM 参数配置: -Xms20M * -Xmx20M * -Xmn10M * -XX:+PrintGCDetails */ public static void main(String args[]){ byte[] allocation1,allocation2,allocation3,allocation4; allocation1 = new byte[2 * _1MB]; allocation2 = new byte[2 * _1MB]; allocation3 = new byte[2 * _1MB]; allocation4 = new byte[4 * _1MB]; // 出现一次GC回收 } }
输出GC日志如下:
上述参数可以看出: 执行main函数中,分配给allocation4对象时候发生了一次Minor GC(新生代回收),这次GC的结果是新生代内存7684k---->365k,然而堆上总内存的占用几乎没有改变,因为allocation1、allocation2、allocation3都存活,本次回收基本上没有找到可回收的对象。分析如下:
- 新生代一共被分配10M,其中Enden:8M,survivor:2M(From:1M,To:1M);
- 给allocation4分配内存时,Eden已经被占用6M(allocation1、2、3共6M,所以剩下2M),所以内存已经不够用了---->发生GC;
- 然而,6M放不进Survivor的From(只有1M),所以只能通过分配担保机制提前转移到老年代
这次GC结束后,Eden中有4M的allocation4对象(一共8M,被占用50%左右),survivor为空闲,老年代为6M(被allocation1、2、3占用),日志中显示为6146k,其中老年代采用Mark-sweep(标志清除)回收的方法。
[注意]:区别新生代(Minor GC)和老年代(Full GC):
- 新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多数都具备朝生夕灭的特性,所以Minor GC非常的频繁,一般回收速度也比较快;
- 老年代GC(Major GC/Full GC):指发生在老年代的垃圾回收动作,出现Major GC,经常会有至少一次的MinorGC(因为对象大多数都是先在Eden分配空间的,但是并非绝对)。Major GC回收的速度会比Minor GC慢十倍以上(因为Minor GC回收一般都是大面积的回收采用复制算法;而Major GC没有额外空间为他担保,只能采用标记-清理方法),这两者的回收思路是相反的,是一个空间换时间和时间换空间的关系。
1.3 大对象直接进入老年代
大对象是指需要大量内存空间的Java对象,最典型的大对象就是那种很长的字符串和数组(byte[ ]就是典型的大对象)。出现达对象很容易导致内存还有不少空间就提前触发垃圾收集以获取足够的连续空间来“安置”它们。
虚拟机提供了一个-XX:pretenureSize Threshold()参数,令大于这个设置直的对象直接在老年代分配。这样做的目的是避免Eden和Survivor区之间发生大量的内存复制(新生带采用复制的方法完成GC)。下面做个测试demo说明问题:
public class Major_GC { private static final int _1MB = 1024 * 1024; /* * VM 参数配置: -Xms20M * -Xmx20M * -Xmn10M * -XX:+PrintGCDetails * -XX:PretenureSizeThreshold=3145728(等于3M) */ public static void main(String args[]){ byte[] allocation; allocation = new byte[4 * _1MB]; // 直接会分配到老年代 } }
运行后可以看到,内存会直接在老年代分配。[说明]:这里不给出运行结果,以免产生误导,因为在Parallel Scavenge收集器是不支持PretenureSizeThreshold这个参数的,得不到这样的结论。
1.4 长期存活对象将进入老年代
Java虚拟机采用分代收集的思想来管理虚拟机内存。虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并且经过第一次Minor GC后仍然存活,并且能被Survivor的话,将被移动到Survivor空间中,并且对象年龄增加到一定程度(默认15岁),就会被晋升到老年代。对晋升到老年代的对象的阈值可以通过-XX:MaxTenuringThreshold设置。
下面给出测试demo:
public class LongTimeExistObj { private static final int _1MB = 1024 * 1024; /* * VM 参数配置: -Xms20M * -Xmx20M * -Xmn10M * -XX:+PrintGCDetails * -XX:MaxTenuringThreshold=1 * -XX:+PrintTenuringDistribution */ public static void main(String args[]){ byte[] allocation1,allocation2,allocation3; allocation1 = new byte[_1MB/4]; // 什么时候进入老年代取决于-XX:MaxTenuringThreshold的设置 allocation2 = new byte[4 * _1MB]; allocation3 = new byte[4 * _1MB]; allocation3 = null; allocation3 = new byte[4 * _1MB]; } }
测试结果如下所示:
1.5 动态对象年龄判定
虚拟并不是永远都要求对象年龄必须达到MaxTenuringThreshold才能晋升为老年代的,如果在Survivor的空间相同年龄的所有对象大小总和大于Survivor空间的一半时,年龄大于或者等于该年龄的对象直接静如老年代,无需要等到MaxTenuringThreshold中要求的年龄。
下面做一个动态年龄测试demo:
public class LongTimeExistObj { private static final int _1MB = 1024 * 1024; /* * VM 参数配置: -Xms20M * -Xmx20M * -Xmn10M * -XX:+PrintGCDetails * -XX:MaxTenuringThreshold=15 * -XX:+PrintTenuringDistribution */ @SuppressWarnings("unused") public static void main(String args[]){ byte[] allocation1,allocation2,allocation3,allocation4; allocation1 = new byte[_1MB/4]; // 使得allocation1 + allocation2 > survivor空间的一半(0.5M) allocation2 = new byte[_1MB/4]; allocation3 = new byte[4 * _1MB]; allocation4 = new byte[4 * _1MB]; allocation4 = null; allocation4 = new byte[4 * _1MB]; } }
测试结果如下:
执行代码结果中,可以看出:Survivor区占用空间仍然为0(from = 0,to = 0);而老年代的内存使用为5M,而其他对象都为4M,可以知道,alloccation1和allocation2都在没有达到15岁的时候就提前进入了老年代。验证了我们的结论---->在Survivor的空间相同年龄的所有对象大小总和大于Survivor空间的一半时,年龄大于或者等于该年龄的对象直接静如老年代。
1.6 空间分配担保
在发生Minor GC之前,虚拟机会先检查老年代可用的连续空间是否大于所有新生代的总空间,如果大于的话,那么这个GC就可以保证安全,如果不成立的,那么可能会造成晋升老年代的时候内存不足。在这样的情况下,虚拟机会先检查HandlePromotionFailure设置值是否允许担保失败,如果是允许的,那么说明虚拟机允许这样的风险存在并坚持运行,然后检查老年代的最大连续可用空间是否大于历次晋升老年代对象的平均大小,如果大于的话,就执行Minor GC,如果小于,或者HandlePromotionFailure设置不允许冒险,那么就会先进行一次Full GC将老年代的内存清理出来,然后再判断。
上面提到的风险,是由于新生代因为存活对象采用复制算法,但为了内存利用率,只使用其中的一个Survivor空间,将存活的对象备份到Survivor空间上,一旦出现大量对象在一次Minor GC以后依然存活(最坏的计划就是没有发现有对象死亡需要清理),那么就需要老年代来分担一部分内存,把在Survivor上分配不下的对象直接进入老年代,因为我们不知道实际上具体需要多大内存,我们只能估算一个合理值,这个值采用的方法就是计算出每次晋升老年代的平均内存大小作为参考,如果需要的话,那就提前进行一次Full GC.
取平均值在大多数情况下是可行的,但是因为内存分配的不确定性太多,保不定哪次运行突然出现某些大对象或者Minor GC以后多数对象依然存活,导致内存远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。如果出现了HandlePromotionFailure失败,那就只好在失败后重新发起一次Full GC。这样的情况下,担保失败是要付出代价的,大部分情况下都还是会将HandlePromotionFailure开关打开,毕竟失败的几率比较小,这样的担保可以避免Full GC过于频繁,垃圾收集器频繁的启动肯定是不好的。
上面很繁琐(详细),实在看不下去就看图吧:
文中关于新生代、老年代的概念部分内容参考了博文:https://www.cnblogs.com/E-star/p/5556188.html
本文参考书籍:《深入理解java虚拟机》