1. 前言
2. 垃圾收集器与内存分配策略
Java 技术体系中所提倡的自动内存管理最终可以归结为自动化地解决两个问题:
对象的内存分配,往大方向上讲就是在堆上的分配,对象主要分配在新生代的 Eden 区上。少数也可能分配在老年代,取决于哪一种垃圾收集器组合,还有虚拟机中的相关内存的参数设置。下面先介绍一下 JVM 中的年代划分:新生代、老年代、永久代(JDK1.8 后称为元空间)。
2.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 =
2.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 都存活,本次回收基本上没有找到可回收的对象。分析如下:
这次 GC 结束后,Eden 中有 4M 的 allocation4 对象(一共 8M,被占用 50% 左右),survivor 为空闲,老年代为 6M(被 allocation1、2、3 占用),日志中显示为 6146k,其中老年代采用 Mark-sweep(标志清除) 回收的方法。
[注意]:区别新生代(Minor GC)和老年代(Full GC):
2.2 大对象直接进入老年代
大对象是指需要大量内存空间的 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 这个参数的,得不到这样的结论。
2.3 长期存活对象将进入老年代
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];
}
}
测试结果如下所示:
2.4 动态对象年龄判定
虚拟并不是永远都要求对象年龄必须达到 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 空间的一半时,年龄大于或者等于该年龄的对象直接静如老年代。
2.5 空间分配担保
在发生 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 虚拟机》
来源: http://www.bubuko.com/infodetail-2429187.html