您的位置:首页 > 编程语言 > Java开发

Java虚拟机学习笔记(二)——垃圾收集与内存分配

2014-06-10 19:24 405 查看
由于程序计数器、虚拟机栈、本地方法栈这3个区域是随线程生灭的,每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具有确定性,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就随着回收了。Java堆和方法区的回收是我们关注的重点。

1.判断对象是否已死

垃圾收集器在对堆进行回收前,第一件事情就是判断这些对象那些活着,那些已死(即不可能再被任何途径使用的对象)。

1.1 引用计数算法

给对象中添加一个引用计数器,每当一个地方引用它,计数器值加1,当引用失效时,计数器值减1。任何时刻计数器为0的对象就是不可能再被使用的对象。但Java虚拟机里边没有选用引用的计数算法来管理内存,主要是因为它很难解决对象之间的相互循环引用的问题。

下边例子是相互引用,实际上这两个对象已经不可能再被访问,但因为它们互相引用着对方,导致他们计数都不为零,于是引用计数算法无法通知GC来收集它们。

/**
* 使用 -XX:+PrintGCDetails -XX:+PrintGCTimeStamps 来看GC日志
* @author zaimeibian
*
*/
public class ReferenceCountingGC {

public Object instance = null;

private static final int _1MB = 1024*1024;

private byte[] bigSize = new byte[2* _1MB];	//2MB的内存,在GC日志中方便区分

public static void testGC(){
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;

objA = null;
objB = null;

System.gc();
}

public static void main(String[] args) {
testGC();
}

}


程序运行结果为:



Java虚拟机并没有因为这两个对象的互相引用就不回收他们,侧面说明虚拟机并不是通过计数算法来判断对象是否存活的。

1.2 可达性分析算法

主流的商用程序语言,都是通过可达性分析(Reachability Analysis)来判断对象是否存活的。这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,证明此对象是不可用的。



如图,object5,6,7 这三个对象虽然互相有关联,但是它们到GC Roots是不可达的。

1.3 关于引用

在JDK1.2以后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,引用强度依次减弱。

强引用:指代码之中普遍存在的 Object obj = new Object() 这类的引用,只要强引用还存在,GC永远不会回收掉被引用的对象。

软引用:描述一些还有用但并非必需的对象,对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。SoftReference类实现软引用。

弱引用:也是描述非必须对象的,但是它的强度比软引用更弱,只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。WeakReference类来实现弱引用。

虚引用:也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用,完全不会对其生存空间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

1.4 对象是否死亡

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。如果这个对象判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,这样做的原因是,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环,将很可能导致F-Queue中其它对象永久处于等待,甚至整个内存回收崩溃。finalize方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己赋值给某个类变量或者对象的成员变量,那么在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃出,那基本上就真的要被回收了。

/**
* 对象可以在被GC时自我拯救
* 自救的机会只有一个,因为一个对象的finalize()方法最多只会被系统自动调用一次
*/
public class FinalizeEscapeGC {

public static FinalizeEscapeGC SAVE_HOOK = null;

public void isAlive(){
System.out.println("yes, i am still alive");
}

@Override
protected void finalize() throws Throwable{
super.finalize();
System.out.println("finalize method executed");
FinalizeEscapeGC.SAVE_HOOK = this;
}

public static void main(String[] args) throws Throwable {
SAVE_HOOK = new FinalizeEscapeGC();

//对象第一次成功拯救自己
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if(SAVE_HOOK!=null){
SAVE_HOOK.isAlive();
}else{
System.out.println("no, i am dead");
}

//第二次拯救与第一次一样,但是失败
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if(SAVE_HOOK!=null){
SAVE_HOOK.isAlive();
}else{
System.out.println("no, i am dead");
}

}

}


运行结果为:



可以看到,SAVE_HOOK对象的finalize()方法确实被GC收集器触发过,并且在被收集前成功逃脱了。另外,第二次失败的原因是任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行,因此第二段代码的自救行动失败了。

1.5 回收方法区

判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则苛刻的多,需要满足以下3个条件:

1.该类的所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例。

2.加载该类的ClassLoader已经被回收。

3.该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

2.垃圾收集算法

标记——清除算法:首先标记出所有要回收的对象,在标记完成后统一回收所有被标记的对象。缺点:效率不高,清除后空间中产生大量不连续的内存碎片。

复制算法:内存划分为两块,每次只使用其中一块,当这一块内存用完了,就将还存活的对象复制到另一块上,然后把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,也没有内存碎片等情况。现在的商业虚拟机都是采用这种方法来回收新生代,一般Eden和Survivor的大小比例是8比1,将内存分为一个Eden和两个小Survivor空间,每次使用Eden和其中一个Survivor,当回收时,将Eden和Survivor中还存活的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。

标记——整理算法:根据老年代的特点,与标记清除的算法一样,但后续步骤是让所有对象都向一端移动,然后直接清理掉端边界以外的内存。

分代收集算法:一般把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。新生代中每次垃圾收集都发现有大量对象死去,只有少量存活,适合复制算法,而老年代中存活率高,没有额外空间进行担保,就必须使用其余两种算法。

3.HotSpot的算法实现

3.1 枚举根节点

可达性分析需要消耗大量的时间,而且在分析中需要整个执行系统看起来像冻结在某个时间点上,这样才保证在分析过程中对象引用关系不变动。这点是导致GC进行时必须停顿所有Java执行线程(Sun将这件事称为“Stop The World”)的一个重要原因。目前的主流Java虚拟机使用的都是准确是GC,所以当执行系统停顿下来后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置。在HotSpot的实现中,是使用一组称为OopMap的数据结构来达到这个目的的,在类加载完成的时候,HotSpot就把对象内什么偏移量是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。

3.2 安全点

在OopMap的协助下,HotSpot可以快速且准确的完成GC Roots的枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外空间,这样GC的空间成本将会变得很高。
实际上,HotSpot也的确没有为每条指令都生成OopMap,只是在“特定的位置”记录了这些信息,这些位置称为安全点(Safepoint),即程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。GC中断有抢先式中断和主动式中断两种。抢先式中断目前几乎没有虚拟机采用。
主动式中断,思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单的设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起,轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

3.3 安全区域

实际中,Safepoint保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint,但是如果线程处于sleep或者blocked状态时,这时候线程无法响应JVM的中断请求,对于这种情况,需要安全区域(Safe Region)来解决。
安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的,可以把安全区域看成扩展了的Safepoint。
在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,那样,当这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完成了,那线程就继续执行,否则它就必须等待知道收到可以安全离开Safe Region的信号为止。

4.垃圾收集器

基于JDK1.7的HotSpot虚拟机的垃圾收集器如图:



7种不同分代的收集器,两个收集器之间存在连线,就说明可以搭配使用。

4.1 Serial收集器

最基本的、历史最悠久的收集器。单线程的收集器,且收集时,其他所有工作线程必须暂停。下边是Serial/Serial Old收集器运行示意图:



到现在为止,Serial收集器依然是虚拟机运行在Client模式下的新生代收集器。简单高效。

4.2 ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余都和Serial收集器完全一样。下边是ParNew/Serial Old收集器运行示意图:



单CPU时,Serial收集器的效果更好。但随着CPU个数的增多,ParNew收集器的效果比Serial要好。

4.3 Parallel Scavenge收集器

ParallelScavenge收集器主要特点是有自适应调节策略。由于与吞吐量关系密切,也称作“吞吐量优先”处理器。

4.4 Serial Old 收集器

Serial Old是Serial收集器的老年代版本,同样是单线程收集器。

4.5 Parallel Old 收集器

Parallel Old是Parallel Scavenge收集器的老年代版本。在注重吞吐量以及CPU资源敏感的场合,都可以考虑这两者的组合:



4.6 CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。



其中,初始标记和重新标记两个过程仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能够直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。缺点有三个:对CPU资源非常敏感;无法处理浮动垃圾;收集后有大量垃圾产生。

4.7 G1收集器

G1是一款面向服务端应用的垃圾收集器。HotSpot开发团队赋予它的使命是未来可以替换掉JDK1.5中的CMS收集器。
优点有:并行与并发;分代收集;空间整合;可预测的停顿。



5. 内存分配与回收策略

5.1 对象直接在Eden分配

大多数情况下,对象在新生代Eden区中分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
/**
* -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
*/
public class MinorGC {

private static final int _1MB = 1024*1024;

public static void testAllocation(){
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];
}

public static void main(String[] args) {
testAllocation();
}

}


运行结果



在程序的配置中,设置Java堆大小为20M,且不可扩展,并将10MB分配给新生代,剩下的10M分配给老年代。 -XX:SurvivorRatio=8决定了新生代中Eden区与一个Survivor区的空间比例是8:1。从输出结果也可以看到新生代总可用空间为9216KB,即eden区和Survivor区的总容量。
执行过程中,eden区是8M空间,则前3个对象均是2M,即此时已经占用了6M的Eden空间,剩余的空间已经不够给allocation4对象(4M)分配了,此时发生MinorGC,而GC时发现3个2M大小的对象全部无法放入Survivor空间(只有1M的大小)所以,通过分配担保机制提前转移到老年代去。GC结束后,4M的allocation4对象顺利分配在Eden中,老年代被占用6M。

5.2 大对象直接进入老年代

所谓大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是很长的字符串和数组。大对象对虚拟机的内存分配是一个坏消息,更坏的是一群“朝升夕灭”的短命大对象,写程序时应尽量避免。经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。
虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配,这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制。
/**
* -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
* -XX:PretenureSizeThreshold=3145728
*/
public class PretenureSize {

private static final int _1MB = 1024*1024;

public static void testPretenureSizeThreshold(){
byte[] allocation;
allocation = new byte[4*_1MB];
}
public static void main(String[] args) {
testPretenureSizeThreshold();
}

}


运行结果:



可以看到,由于设置的门限值是3MB,所以超过3MB的对象都直接在老年代进行分配。

5.3 长期存活的对象将进入老年代

虚拟机给每个对象定义了一个对象年龄(Age)计数器,如果对象在Eden出生并经过第一次MinorGC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区中每熬过一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认15岁),就将晋升到老年代中。这个晋升阈值可以通过 -XX:MaxTenuringThreshold设置。
/**
* -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
* -XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution
*/
public class TenuringThreshold {

private static final int _1MB = 1024*1024;

public static void testTenuringThreshold(){
byte[] allocation1, allocation2, allocation3;
allocation1 = new byte[_1MB/4];
allocation2 = new byte[4*_1MB];
allocation3 = new byte[4*_1MB];
allocation3 = null;
allocation3 = new byte[4*_1MB];
}

public static void main(String[] args) {
testTenuringThreshold();
}

}


运行结果:



当晋升年龄门限设为1时,在第二次GC时,256K的allocation1就可以进入老年代了,这里survivor区0%used。
如果默认的15岁,运行结果如下:



可以看到15岁的情况下,survivor区里保存着allocation1对象。

5.4 动态对象年龄判定

为了能更好的适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到MaxTenuringThreshold才能晋升老年代,如果Survivor空间中相同年龄所有对象大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到达到阈值的年龄。
/**
* -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
* -XX:MaxTenuringThreshold=15 -XX:+PrintTenuringDistribution
*/
public class TenuringThreshold2 {

private static final int _1MB = 1024*1024;

public static void testTenuringThreshold2(){
byte[] allocation1, allocation2, allocation3, allocation4;
allocation1 = new byte[_1MB/4];
//allocation1+allocation2大于survivor空间一半
allocation2 = new byte[_1MB/4];
allocation3 = new byte[4*_1MB];
allocation4 = new byte[4*_1MB];
allocation4 = null;
allocation4 = new byte[4*_1MB];
}

public static void main(String[] args) {
testTenuringThreshold2();
}

}


运行结果:



可以看到,survivor空间占用仍然为0%,而老年代比预期增加了6%,说明allocation1和2都进入了老年代,而没有等到15岁的临界年龄。因为这两个对象加起来已经超过512KB,而且是同年的,满足规则。如果注释掉其中一个new操作,那么另一个就不会晋升到老年代了。

5.5 空间分配担保

在发生MinorGC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果这个条件成立,那么MinorGC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次MinorGC,尽管这次GC是有风险的;如果小于,或者设置值不允许担保失败,那这次要改为进行一个FullGC。
这个冒险的意思即,新生代使用复制收集算法,但为了内存利用率,只使用一个survivor来做轮换备份,如果出现大量对象在MinorGC后仍然存活的情况,那么就需要老年代进行担保,老年代要进行担保就必须确保自己本身还有剩余空间来容纳这些对象。而有多少对象存活是无法提前知道的,所以只好取之前晋升的对象容量的平均值作为经验值,与老年代的剩余空间做比较,决定是否需要一个FullGC来让老年代腾出更多空间。
/**
* -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
* -XX:-HandlePromotionFailure
*/
public class HandlePromotion {

private static final int _1MB = 1024*1024;

public static void testHandlePromotionFailure(){
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[2*_1MB];
allocation5 = new byte[2*_1MB];
allocation6 = new byte[2*_1MB];
allocation4 = null;
allocation5 = null;
allocation6 = null;
allocation7 = new byte[2*_1MB];
}
public static void main(String[] args) {
testHandlePromotionFailure();
}

}


运行结果:



如果设置为true,运行结果为:

内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: