《深入理解jvm》day4.垃圾收集器与内存分配策略
1.概述
垃圾收集(Garbage Collection, GC)。这可以说成一门独立的技术。
1960年,MIT的Lisp是第一门真正使用内存动态分配和垃圾收集技术的语言,GC需要思考三件事:
①哪些内存需要回收?
②什么时候回收?
③如何回收?
如今,GC技术都已经达到“自动化”的水平。学习GC的目的,主要用于排查OOM异常,ML问题。
把GC技术关联到Java语言,jvm运行时数据区,其中程序计数器、虚拟机栈、本地方法栈3个区随线程或生或死;栈中的栈帧随方法的进入和结束而相应执行出栈和入栈操作。
这几个区的内存分配和回收具有确定性。方法结束或线程结束,内存回收。
而方法堆,和方法区有所不同。只有在程序运行期才知道会创建哪些对象,内存的回收和分配都是动态的。这部分内存是讨论的重点。
2.对象死了吗?
Java堆中存放java对象实例,在回收之前,需要判断对象是否还“活着”,哪些活着?
2.1.引用计数算法
算法原理:
给对象中添加一个引用计数器,每当有一个地方引用它时,计数器加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象表示不能再被使用。
但是,主流的JVM中并没有使用该算法来管理内存,因为它很难解决对象之间相互循环引用的问题。
下面创建测试代码:
package com.jvm; /** * 引用计数器算法 * @author wf *vm args://-verbose:gc: -Xms20M -Xmx20M -Xmn10M -verbose:gc -XX:+PrintGCDetails -XX:SurvivorRatio=8 */ public class ReferenceCountingGC { public Object instance = null; private static final int _1MB = 1024 * 1024; /** * 成员属性的意义:占点内存,方便在GC日志中看清楚是否被回收过 */ private byte[] bigsize = new byte[2 * _1MB]; public static void testGC() { ReferenceCountingGC objA = new ReferenceCountingGC(); ReferenceCountingGC objB = new ReferenceCountingGC(); objA.instance = objB; objB.instance = objA; objA = null; objB = null; //假设这行发生GC,objA和objB是否能被回收? System.gc(); } public static void main(String[] args) { testGC(); } }
运行结果如下:
[GC (System.gc()) [PSYoungGen: 5247K->632K(9216K)] 5247K->640K(19456K), 0.0517311 secs] [Times: user=0.02 sys=0.03, real=0.05 secs]
[Full GC (System.gc()) [PSYoungGen: 632K->0K(9216K)] [ParOldGen: 8K->551K(10240K)] 640K->551K(19456K), [Metaspace: 2733K->2733K(1056768K)], 0.0074127 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]
Heap
PSYoungGen total 9216K, used 82K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
eden space 8192K, 1% used [0x00000000ff600000,0x00000000ff614920,0x00000000ffe00000)
from space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
to space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
ParOldGen total 10240K, used 551K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
object space 10240K, 5% used [0x00000000fec00000,0x00000000fec89e80,0x00000000ff600000)
Metaspace used 2740K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 300K, capacity 386K, committed 512K, reserved 1048576K
总结:
上面的testGC()方法中对象objA和objB都有字段instance,除了
objA.instance = objB;
objB.instance = objA;
之外,两个对象再无任何引用,实际上两个对象不可能再被访问。但是它们因为相互引用,导致它们的引用计数都不为0,于是引用计数无法通知GC回收它们。
从GC日志来看,5247K->640K,意味着虚拟机并没有因为这两个对象相互引用,而不回收它们。
这也说明JVM并不是通过引用计数法来判断对象是否存活的。
2.2.可达性分析算法Reachability Analysis
算法思想:
通过一系列称为“GC roots”的对象作为起点,从这些节点开始向下搜索,搜索走的路径称为引用链。当一个对象到GC roots没有任何引用链(不可达),证明该对象是不可用的(也就是可被回收)。
在java语言中,可作为GC roots的对象有以下几种:
虚拟机栈(栈帧中的本地变量表)中引用的对象。
方法区中类静态属性引用的对象。
方法区中常量引用的对象。
本地方法栈中JNI(即一般说的Native方法)引用的对象。
什么叫引用?
jdk1.2之前,有较为狭隘的定义,如果reference类型的数据存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。
jdk1.2之后,对引用的概念作了扩展,引用分为:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)。
强引用:类似“Object obj = new Object();"这类引用,只要存在,就不能回收。
软引用:描述一些还有用但非必需的对象。这种对象在发生OOM异常之前,被列入回收范围,如果这回收后还没有足够的内存,才会抛出OOM。jdk1.2之后使用SoftReference类来实现软引用。
弱引用:用来描述非必需的对象。这类对象只能生存到下一次GC之前,使用WeakReference来实现。
虚引用:不会影响对象生命周期。它的作用是,这个对象被回收时可以收到系统通知。使用PhantomReference来实现。
对象死亡判断:
一个对象即使经过可达性算法分析后为不可达,也不能说明该对象真正死亡。对象真正死亡需要经历两次标记过程:
如果一个对象经过可达性算法分析后,发现没有到GC roots的引用链,那么该对象会被第一次标记并且进行一次筛选,筛选条件:该对象是否有必要执行finalize()方法。
当对象没有覆盖finalize()方法,或finalize()方法已经被jvm调用过,jvm将这两种情况视为"没必要执行"。
如果对象被判定为有必要执行finalize()方法,那么该对象将会放置在F-Queque队列中,并在稍后由一个jvm自动创建,低优先级的Finalizer线程去执行(jvm会触发这个方法,并不一定等待它运行结束)。
原因是,一个对象在finalize()方法中执行缓慢,或发生死循环(极端),很可能导致F-Queque队列中其它对象永久处于等待,甚至导致整个内存系统崩溃。
稍后GC将对F-Queque中的对象进行第二次小规模的标记,对象可以在finalize()方法中让自己存活,只要重新与引用链上任何一个对象建立关联即可,如:把自己(this关键字)赋值给某个类变量或对象的成员变量。
如果对象在第二次标记中,仍然没有得到拯救,它基本就要被回收。
下面一段代码,来演示finalize()方法中拯救对象:
package com.jvm; /** * 测试finalize()方法,拯救对象,不被回收 * @author wf * */ public class FinalizeEscapeGC { public static FinalizeEscapeGC SAVE_HOOK = null; /** * 表示还存活着 */ public void isAlive() { System.out.println("yes, I am still alive:)"); } @Override//覆盖finalize()方法 protected void finalize() throws Throwable { super.finalize(); System.out.println("finalize method executed!"); FinalizeEscapeGC.SAVE_HOOK = this; } public static void main(String[] args) throws InterruptedException { SAVE_HOOK = new FinalizeEscapeGC(); //对象第一次标记中,拯救自己 SAVE_HOOK = null; System.gc(); //因为finalize方法优先级很低,所以暂停0.5秒等待它 Thread.sleep(500);//抛异常 if(null != SAVE_HOOK) { SAVE_HOOK.isAlive(); }else { System.out.println("no, I am dead:("); } //下面代码,重复上面代码,但是这次自救却失败 SAVE_HOOK = null; System.gc(); //因为finalize方法优先级很低,所以暂停0.5秒等待它 Thread.sleep(500);//抛异常 if(null != SAVE_HOOK) { SAVE_HOOK.isAlive(); }else { System.out.println("no, I am dead:("); } } }
代码运行结果:
finalize method executed!
yes, I am still alive:)
no, I am dead:(
总结:
上面的代码中,执行System.gc()方法(即垃圾回收)时触发了finalize()方法,因为finalize()方法在当前类被重写,SAVE_HOOK对象有必要执行finalize()方法。
所以,该对象逃脱了回收。
但是,第二次没能逃脱回收,因为任何一个类的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()不会被再次执行。
另外,并不建议使用finalize()方法来拯救对象,因为它不是C/C++中的折构函数,而是java诞生之初,为了c/c++程序员接受它而做的一个妥协。
它的运行代价高昂,无法保证各个对象的调用顺序。基本可以忘记它的存在。
回收方法区:
很多人认为方法区(或HotSpot VM中的永久代)是没有垃圾回收的。jvm规范中确实不作回收要求,并且回收效率较低。
永久代的垃圾回收主要分为两部分:废弃常量和无用的类。
回收废弃常量,和java堆中对象回收很相似。如:一个字符串“abc”已经进入常量池,但当前系统中没有任何String对象引用常量池中的“abc”常量,就可以回收该常量。
回收无用类,相对较为复杂,判定一个类是否为“无用的类”需要同时满足3个条件:
①java堆中不存在该类的任何实例。
②加载该类的ClassLoader已经被回收。
③该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
注意:jvm可以对同时满足上面3个条件的无用类进行回收,但不一定必然回收。
是否对该类进行回收,HotSpot vm提供了-Xnoclassgc参数进行控制。还可以使用-verbose:class以及-XX:+TraceClassLoading、-XX:+TraceClassUnLoading查看类加载和卸载信息。
其中,-verbose:class以及-XX:+TraceClassLoading可以在Product版的VM中使用,-XX:+TraceClassUnLoading参数需要FastDebug版的VM支持。
在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要VM具备类卸载的功能,以保证永久代不会溢出。
3.垃圾回收算法
这里重点介绍算法思想及发展过程,不过多提及程序实现细节。
3.1.标记-清除算法Mark-SWeep
见名知义,算法分为“标记”和“清除”两个阶段。首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
该算法的不足之处在于:效率和空间问题。标记清除之后会产生大量不连续的内存碎片。如果以后在程序中需要分配较大的对象,很可能无法找到足够连续的内存。
3.2.复制算法
为了解决“标记-清除算法”的效率问题而提出,算法思想如下:
将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块的内存使用完了,就将还存活着的对象复制到另一块上,然后再已使用过的内存空间一次清理掉。
简单说,就是“等量分块,活对象复制,一次清内存”。
这样一来,每次都是对整个半区进行内存回收,该算法的代价是需要把内存压缩一半。
现代商业VM都采用这种算法来回收新生代,根据IBM公司的研究表明,新生代的对象98%是“朝生夕死”。所以对于分块比例不必要1:1,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。
当回收时,将Eden和Survivor中还存活着的对象一次性复制到另外一块Survivor空间上,最后清理掉Eden和刚用过的Survivor空间。
HotSpot VM默认Eden和Survivor的分块比例为8:1,也就是只会有10%的空间浪费掉,如果有超过10%的对象存活,Survivor空间不够用时,需要依赖其它内存(这里指老年代)进行分配担保。
内存的分配担保,和银行借款需要担保人一样(如果借款人信誉好,98%的情况下都能按时偿还,银行默认下一次也能按时偿还,只需要一个担保人,保证如果不能还款,可以从其它帐户扣款)。
如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。
3.3.标记-整理算法
复制收集算法,在对象存活率较高时,需要进行较多的复制操作,效率会降低。另外,如果不想浪费50%的空间,需要有额外的空间来进行分配担保,老年代一般不能直接选用该算法。
针对老年代的特点,提出“标记-整理算法”,算法思想:
一样是标记和清理两个阶段,清理阶段不是直接对可回收对象进行清理,而让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存。
3.4.分代收集算法
当前商业VM的垃圾收集,都采用“分代收集”算法,算法思想:
根据对象存活周期的不同,将内存划分为几块。一般把java堆分为新生代和老年代,新生代的特点是少量对象存活,采用复制算法;而老年代对象存活率高,一般采用“标记-清理”或“标记-整理”算法来进行回收。
4.HotSpot的算法实现
前面,学习了对象存活判定算法和垃圾收集算法,在HotSpot VM的实现算法上,必须要考虑算法的执行效率。
4.1.枚举根节点
可达性分析中,可作为GC roots的节点主要在全局性的引用(如常量或静态属性)与执行上下文(如栈帧中的本地变量表)中,但是,很多应用中仅仅方法区就有数百兆。逐个检查里面的引用,时间消耗太长。
可达性分析中对执行时间的消耗还体现在GC停顿上,也就是GC进行时必须停止所有java执行线程。
根节点的获取:
如何在这么多的全局变量和栈中的局部变量表中找到栈上的根节点呢?
在栈中只有一部分数据是reference(引用)类型,其它非引用类型数据对找到根节点没有用处,如果全部扫描一遍,会很浪费时间。
那要如何才能减少回收时间呢?可能会想到以空间换取时间,我们可以在某个位置把栈上代表引用的位置记录下来,这样gc进行时就不用全部扫描了。
在HotSpot中,是使用一组称为OopMap的数据结构来记录的,可以简单理解为存放调试信息的对象。在类加载完成时,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来 ,在JIT编译过程中,也会在特定位置记录下栈和寄存器中哪些位置是引用类型。这样GC扫描时就可以直接得到这些信息。
安全点:
在OopMap的协助下,HotSpot可以快速定位GC Roots枚举,但是也不能随时随地都生成OopMap,如此一来就需要更多空间来存放这些对象,效率也会下降。因此,只会在特定的位置来记录下,主要是正在:
①循环的末尾
②方法临返回前/调用方法的call指令后
③可能抛异常的位置
这些位置,称为安全点。
在gc进行时,需要让jvm停在某个时间点,如果不这们的话,我们在分析对象间引用关系时,引用关系还在不断变化,引用关系的准确性又如何能得到保证。
安全点,就是gc时所有线程要停顿的位置。那么如何让所有线程都到安全点上再停顿下来呢?有两种方案:抢先式中断(Preemptive Suspendsion)和主动式中断(Voluntary Suspendsion)。
抢先式中断,不需要线程的执行代码主动去配合,在gc发生时,首先把所有线程全部中断,如果发现有线程中断的位置不在安全点上,就恢复线程,让它“跑”到安全点上。
目前几乎没有采用抢先式中断方式。
主动式中断,在GC需要中断线程时,不直接对线程操作,仅仅是设置一个标志,各个线程执行时主动去轮询这个标志,如果发现中断标志为真时让自己中断。
安全区域:
使用SafePoint似乎已经完美解决了如何进行GC的问题,实际上并不一定。
SafePoint机制保证了程序执行时,在较短的时间内就会遇到可进入GC的SafePoint。但是,如果程序“不执行”的时候,也就是没有分配cpu时间,如:线程处于sleep状态或blocked状态,这个时候线程无法响应jvm中断请求,针对这种情况,就需要安全区域(Safe Region)来解决。
安全区域,是指在一段代码片段中,引用关系不会发生变化。在这个区域中的任意地方开始gc都是安全的。
在线程执行到Safe Region中的代码时,首先标识自己已经进入了safe Region,那样,当在这段时间里jvm要发起gc时,就不用关心标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举(或整个gc过程)。如果完成,线程继续执行。否则等待收到可以安全离开信号为止。
上面,主要介绍Jvm如何发起内存回收的问题。而如何具体进行内存回收动作,由gc收集器决定。
5.垃圾收集器
垃圾收集器就是内存回收的具体实现。jvm规范中没有规定垃圾收集器是如何实现的。不同厂商、不同版本的jvm所提供垃圾收集器也有所不同。
这里介绍是基于jdk 1.7 update14之后的hotspot vm。
5.1.Seria收集器
这是一个单线程收集器,当它进行垃圾收集时,必须暂停其他所有的工作线程,直到收集结束。
它是jvm运行在client模式下的默认新生代收集器,特点是简单高效。
5.2.ParNew收集器
它其实是Seria收集器的多线程版本,它使用多线程进行垃圾收集。它是许多运行在Server模式下的jvm中首选的新生代收集器。
除了Seria之外,只有ParNew能够和CMS配合工作。
jdk1.5时,HotSpot推出CMS(Concurrent Mark Sweep)收集器,它是HotSpot VM第一款真正意义上的并发收集器。第一次实现用户线程与垃圾回收线程同时工作,CMS是作为老年代收集器。
并发与并行与收集器:
并行:指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
并发:指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户线程在继续运行,而垃圾收集程序运行于另一个CPU上。
5.3.Parallel Scavenge收集器
它是一个新生代收集器,使用复制算法,又是并行的多线程收集器。
它的关注点在于,达到一个可控制的吞吐量(Throughput)。吞吐量是指cpu用于运行用户代码的时间与Cpu总消耗时间的比值。
即吞吐量= 运行用户代码时间/(运行用户代码时间表+ 垃圾收集时间)。如:jvm总共运行100min,其中垃圾加收耗时1min,吞吐量就是99%。
停顿时间越短越适合需要与用户交互的程序,提高用户体验。而高吞吐量则可高效利用CPU时间,主要适合后台运算。
该收集器提供了两个参数用于精确控制吞吐量,控制最大垃圾收集停顿时间-XX:MaxGCPauseMillis,以及直接设置吞吐量大小-XX:GCTimeRatio参数。