您的位置:首页 > 其它

JVM之垃圾收集器与内存分配策略

2014-08-01 00:14 323 查看
第三章 垃圾收集器与内存分配策略
3.1 概述

程序计数器、虚拟机栈、本地方法栈三个区域随线程而生,随线程而灭,且内存分配和回收都具备确定性,而Java堆和方法区则不一样,这部分的内存分配和回收都是动态的,垃圾收集器最关注的也是这部分内存。

3.2 再谈引用

引用的定义:如果reference类型的数据中存储的数值代表的是另一块内存的起始地址,就成这块内存代表着一个引用。引用类型分为如下四种:

强引用:在程序代码中普遍存在的,类似"Object o = new Object()"这类引用,只要强引用还存在,是不会被GC回收掉的;
软引用:用来描述一些还有用,但非必需的对象。这类引用在系统将要发生内存溢出之前,将会把这些对象列入回收范围内并进行第二次回收;
弱引用:用来描述非必需的对象,被弱引用关联的对象只能生存到下一次垃圾收集之前;
虚引用:也称为幽灵引用或幻影引用,它是最弱的一种引用,不对其引用对象的生存时间构成影响,也无法通过虚引用获取一个对象实例,它唯一的目的就是希望这个对象在被回收时收到一个系统通知。

3.3 回收方法区

回收方法区中的废弃常量与回收Java堆中的对象非常类似,此处不赘述。而回收无用类则必须同时满足以下3个条件,且仅仅只是可以回收,并不保证一定回收:

该类的所有实例都已经被回收,即Java堆中不存在该类的任何实例;
加载该类的ClassLoader已被回收;
该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

在大量使用反射、动态代理、CGLib等字节码框架的场景,以及动态生成JSP和OSGI这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载功能,以保证永久代不会溢出。

3.4 垃圾收集算法

引用计数算法:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器的值就加1;当引用失效时,计数器的值就减1;任何时刻计数器的值都为0的对象就是不可能再被使用的。该算法实现简单,判定效率高,但它很难解决对象之间的相互引用的问题而未被Java语言用来进行内存管理。

根搜索算法:通过一系列的名为"GC Roots"的对象作为起点,从这些节点开始向下搜索,搜索所经过的路径成为引用链,当一个对象到GC Roots没有引用链(用图论来说就是GC Roots到这个对象不可达)时,则证明此对象是不可用的,如图3-1所示。在Java语言里,可作为GC Roots的对象包括以下几种:

1) 虚拟机栈(栈帧中的本地变量表)中的引用对象;
2) 方法区中类静态属性引用的对象;
3) 方法区中常量引用的对象;
4) 本地方法栈中JNI(即一般的Native方法)的引用对象。





在根搜索算法中的不可达对象,并不是一定会被回收,它要被回收至少要经历两个标记过程:如果对象在进行根搜索后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法,当对象没有覆盖finalize()方法或finalize()方法已被虚拟机调用过,虚拟机在两种情况下不会执行finalize()方法。如果这个对象被判定为有必要执行这个finalize()方法,那么它将会被放置到一个叫F-Queue队列中,并在稍后由一条由虚拟机自动建立的、低优先级的Finalizer线程去执行。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模标记,如果对象在finalize()方法中成功拯救自己(即重新建立引用链),那在第二次标记时它将被移出"即将删除回收"的集合;若对象此时还没有逃脱,那它离死亡就不远了。一次对象自我拯救的示例代码如下:
<pre name="code" class="java">public class Test{
private static Test a = null;
public static void main(String[] args) throws Throwable {
a = new Test();
// 对象第一次成功拯救自己
a = null ;
System. gc();
// 因为Finalizer方法的优先级很低,暂停0.5秒,以等待它
Thread. sleep(500);
if(a != null){
a.isAlive();
} else{
System. out.println("no, i am dead." );
}
// 对象第二次拯救自己
a = null ;
System. gc();
// 因为Finalizer方法的优先级很低,暂停0.5秒,以等待它
Thread. sleep(500);
if(a != null){
a.isAlive();
} else{
System. out.println("no, i am dead." );
}
}
public void isAlive(){
System. out.println("Yes, i am still alive." );
}
@Override
protected void finalize() throws Throwable{
super.finalize();
System. out.println("finalize method excuted." );
Test. a = this ;
}   // 最后的输出结果为:finalize method executed.        yes, i am still alive.     no i am dead.


}



分析:a对象的finalize()方法确实被GC触发过,并且在被收集前成功逃脱了,另外,第一次逃脱成功,第二次失败是因为任何一个对象的finalize()方法只会被系统调用一次,面临下一次回收,finalize()方法将不再执行,因此第二次自救失败。建议少使用finalize()方法。

标记-清除算法:最基础的收集算法,算法分为“标记”和“清除”两个阶段,首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。但它有两个缺点,一个是效率问题,标记和清除的效率都不高,一个是空间问题,标记清除后会产生大量的不连续的内存碎片,空间碎片太多可能会导致程序在以后的运行过程中需要分配较大的对象时无法找到足够的连续内存而不得不提前触发一次垃圾收集动作。现在的商业虚拟机都是采用这种算法来回收新生代,标记-清除算法的执行过程如图3-2。





复制算法:为了解决效率问题,复制算法将内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了,就将还存活着的对象复制到另一块上面,然后再把使用过的内存空间一次清理掉。这样使得每次都是对其中的一块进行内存回收、内存分配时无需考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。缺点是,只有一半的内存被利用,造成浪费。复制算法的的执行过程如图3-3所示。





标记-整理算法:复制收集算法在对象存活率较高时都要执行频繁的复制操作,效率将会降低。"标记-整理"算法的标记过程与"标记-清除"算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,其执行示意图如图3-4。





分代收集算法:当前商业虚拟机的垃圾收集都采用"分代收集算法",它根据对象的存活周期的不同将内存划分为几块,一般把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都有大批对象死去,只有少量存活,那就选择复制算法,而老年代中的对象因存活率高、没有额外空间对它进行分配担保,就必须采用"标记-清除"算法或"标记-整理"算法来进行回收。

3.5 垃圾收集器

如果说垃圾收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现。JVM规范中对垃圾收集器如何实现并没有任何规定,因此,不同厂商、不同版本的虚拟机所提供的垃圾收集器可以有很大差别。基于Sun HotSpot虚拟机1.6版的所有收集器如图3-5 :





Serial收集器:最基本、历史最悠久的收集器,它是一个单线程收集器,进行来及回收时,必须暂停其他所有的工作线程,直到它收集结束,这虽然会给用户带来不好的体验,但它仍是虚拟机在Client模式的默认新生代收集器,因为它没有线程交互的开销而简单高效,示意图如下:




ParNew收集器:Serial收集器的多线程版本,它是许多运行在Server模式下的虚拟机中首选的收集器,除了Serial收集器外,目前只有它能与CMS收集器配合工作,但无法与JDK1.4中已经存在的新生代收集器Parallel Scavenge配合工作。另外,ParNew有利于在JVM进行垃圾收集时提高系统资源利用率,示意图如下:




注意两个名词的概念:1)并行(parallel):指多条垃圾收集线程并行工作,但此时用户线程处于等待状态;2)并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序继续运行,而垃圾收集程序运行于另一个CPU上。

Parallel Scavenge收集器:一个新生代收集器,使用复制算法、并行的多线程收集器。CMS等收集器关注的是尽可能的缩短垃圾回收时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(吞吐量=运行用户代码时间/(运行用户代码时间+垃圾回收时间)),Parallel Scavenge收集器提供的两个用于精确控制吞吐量的参数:-XX:MaxGCPauseMillis(控制最大垃圾收集停顿时间)、-XX:GCTimeRatio(直接设置吞吐量大小)。另外,一个开关参数:-XX:UseAdativeSizePolicy,当此开关打开后,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整各个参数以提供最合适的停顿时间或最大的吞吐量,这种调节方式称为GC自适应的调节策略。

Serial Old收集器:Serial收集器的老年代版本,使用单线程和"标记-整理"算法,主要意义也是被Client模式下的虚拟机使用,Server模式下,一个是在JDK1.5及之前版本中与Parallel Scavenge收集器搭配使用,另外一个是作为CMS收集器的后背预案,在并发收集发生Concurrent Mode Failure的时候使用。Serial Old工作示意图3-8所示:





Parallel Old收集器:Parallel Scavenge收集器的老年代版本,使用多线程和"标记-整理"算法,在注重吞吐量和CPU资源敏感的场合,可优先考虑Parallel Scavenge加Parallel Old收集器,其示意图如图3-9所示:





CMS收集器:CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,分为四个阶段:初始标记、并发标记、重新标记、并发清除,其中初始标记、重新标记需要暂停用户进程。由于整个耗时最长的并发标记和并发删除的过程,收集器线程与用于线程一起工作,所以总体来说,内存回收过程与用户线程是并发执行的。示意图如图3-10所示:





优点:并发收集、低停顿;
缺点:对CPU资源非常敏感;无法处理浮动垃圾,可能出现"Concurrent Mode Failure"失败而导致另一次的Full GC;产生大量的空间碎片。

G1收集器:使用"标记-清理"算法,可非常精确的控制停顿,它可以实现在不牺牲吞吐量的前提下完成低停顿的内存回收,这是因为它能够极力地避免全区域的垃圾收集,将整个Java堆(包括新生代、老年代)划分为多个大小固定的独立区域,并且跟踪这些区域里面的垃圾堆积程度,在后台维护一个优先列表,每次根据允许的收集时间,优先回收垃圾最多的区域。

3.6 内存分配与回收策略

对象的内存分配,大多都是在堆上分配,对象主要分配在新生代的Eden区上,如果启动了本地分配缓冲,将按线程优先在TLAB上分配。

对象优先在Eden分配:大多数情况下,对象在新生代的Eden区中分配,当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC,示例代码如下:

/** VM参数: -verbose:gc - Xms20M -Xmx20M -Xmn10M - XX:SurvivorRatio=8 */
public class Test{
private static final int _1M = 1024 * 1024;
public static void main(String[] args) throws Throwable {
testAllocation();
}
public static void testAllocation(){
byte[] allocation1 = new byte [2 * _1M];
byte[] allocation2 = new byte [2 * _1M];
byte[] allocation3 = new byte [2 * _1M];
byte[] allocation4 = new byte [4 * _1M]; // 出现一次Minor GC
}
}



大对象直接进入老年代:大对象是指需要大量连续内存空间的Java对象,如很长的字符串及数组。虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配,以避免在Eden区及Survivor区之间发生大量的内存拷贝(新生代采用复制算法收集内存),示例代码如下:

/** VM参数: -verbose:gc - Xms20M -Xmx20M -Xmn10M - XX:SurvivorRatio=8
* -XX:PretenureSizeThreshold=3145728(不能直接写成3M)
*/
public class Test{
private static final int _1M = 1024 * 1024;
public static void main(String[] args) throws Throwable {
testPretenureSizeThreshold();
}
public static void testPretenureSizeThreshold(){
byte[] allocation1 = new byte [4 * _1M];
}
}



长期存活的对象将进入老年代:虚拟机采用分代收集的思想管理内存,给每个对象定义一个对象年龄计数器,若对象在Eden生成并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间,并对其年龄设为1。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定的程度(默认为15岁)时,就会被移动到老年代中。对象晋升的年龄阙值可通过参数-XX:MaxTenuringThreshold来设置,示例代码如下:

/** VM参数: -verbose:gc - Xms20M -Xmx20M -Xmn10M - XX:SurvivorRatio=8
* -XX:PretenureSizeThreshold=3145728(不能直接写成3M)
* -XX:MaxTenuringThreshold=1(分别用1和15进行测试,分析其结果的区别)
* -XX:+PrintTenuringDistribution
*/
public class Test{
private static final int _1M = 1024 * 1024;
public static void main(String[] args) throws Throwable {
testTenuringThreshold();
}
public static void testTenuringThreshold (){
byte[] allocation1 = new byte [_1M / 4];
// 何时进入老年代由-XX:MaxTenuringThreshold设置
byte[] allocation2 = new byte [4 * _1M];
byte[] allocation3 = new byte [4 * _1M];
allocation3 = null;
allocation3 = new byte [4 * _1M];
}
}



动态对象年龄判定:为了更好地使用不同程序的内存状况,虚拟机并不总是要求对象的年龄必须达到MaxTenuringThreshold才能晋升到老年代,如果在Survivor区中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等待MaxTenuringThreshold中要求的年龄,示例代码如下:

/** VM参数: -verbose:gc - Xms20M -Xmx20M -Xmn10M - XX:SurvivorRatio=8
* -XX:PretenureSizeThreshold=3145728(不能直接写成3M)
* -XX:MaxTenuringThreshold=1(分别用1和15进行测试,分析其结果的区别)
* -XX:+PrintTenuringDistribution
*/
public class Test{
private static final int _1M = 1024 * 1024;
public static void main(String[] args) throws Throwable {
testTenuringThreshold();
}
public static void testTenuringThreshold(){
byte[] allocation1 = new byte [_1M / 4];
// allocation1 + allocation2大于Survivor空间的一半,可通过注释下面一条语句观察老年代空间使用情况的变化
byte[] allocation2 = new byte [_1M / 4];
byte[] allocation3 = new byte [4 * _1M];
byte[] allocation4 = new byte [4 * _1M];
allocation4 = null;
allocation4 = new byte [4 * _1M];
}
}




空间分配担保:在发生Minor GC时,虚拟机会检测之前晋升到老年代的平均大小是否大于老年代的剩余空间,如果大于,则直接进行一次Full GC,如果小于,则查看HandlePromotionFailure设置是否允许担保失败;如果允许,那只会进行Minor GC,否则,进行一次Full GC。空间分配担保是以之前每次晋升到老年代对象容量的平均值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多的空间。若某次Minor GC存活后的对象激增,远远高于平均值的话,依然会导致担保失败,若出现HandlePromotionFailure失败,那会在失败后重新发起一次Full
GC。为了避免Full GC,过于频繁,大部分情况下会打开HandlePromotionFailure的开关,实例代码如下:

/** VM参数: -verbose:gc - Xms20M -Xmx20M -Xmn10M - XX:SurvivorRatio=8
* -XX:+HandlePromotionFailure(打开开关)
*/
public class Test{
private static final int _1M = 1024 * 1024;
public static void main(String[] args) throws Throwable {
testHandlePromotion();
}
public static void testHandlePromotion(){
byte[] allocation1 = new byte [2 * _1M];
byte[] allocation2 = new byte [2 * _1M];
byte[] allocation3 = new byte [2 * _1M];
allocation1 = null;
byte[] allocation4 = new byte [2 * _1M];
byte[] allocation5 = new byte [2 * _1M];
byte[] allocation6 = new byte [2 * _1M];
allocation4 = null;
allocation5 = null;
allocation6 = null;
byte[] allocation7 = new byte [2 * _1M];
}
}


核心内容出处:《深入理解Java虚拟机:JVM高级特性与最佳实践》
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: