Java笔记(二):垃圾收集
在上一篇文章中写了内存相关的内容,如果感兴趣的同学可以点击这里前往。在此我就不回顾了,直接开始这一篇的内容。
说起垃圾收集(Garbage Collection,GC),我们就需要考虑三个问题:
那些内存需要回收?
什么时候回收?
如何回收?
接下来我们就围绕着上面三个问题来解开GC的神秘面纱。在我上一篇提到的Java运行时内存区域中,程序计数器、虚拟机栈、本地方法栈这三个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出有条不紊地执行出栈和入栈操作,每一个栈帧分配多少内存基本上是在类结构确定下来时就已经确定的。因此这几个区域的内存分配和回收具备确定性,就不需要过多的考虑回收的问题,方法结束或者线程结束时,内存自然就跟着回收了。而我们真正要关注的重点就是堆内存。
现在我们先来回答第一个问题,就是怎么判定对象已经死亡了。在堆里存放着Java世界中几乎所有的对象实例,在这里每时每刻都会有大量的对象生成,同时又有大量的对象完成了自己的使命等待死亡。那么究竟该怎么判断哪个对象死亡了呢?
在主流的商用程序语言的主流实现中,都是通过可达性分析(Reachability Analysis)来判断对象是否存活的。这个算法的基本思路是通过一系列的称为“GC Roots”的对象作为其实点,从这些节点开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,这说明这个对象是不可达的。它就会被判定为可回收的对象,也就是被宣布死亡。而在Java语言中,可以作为GC Roots的对象包括以下四种:
虚拟机栈(栈帧中的本地变量表)中引用的对象
方法区中静态属性引用的对象
方法区中常量引用的对象
本地方法栈中JNI(即一般说的Native方法)引用的对象
在说第二个问题之前,我们先看一下第三个问题,就是关于如何回收。在此我们先接受几种垃圾收集算法,他们分别是标记清除算法、复制算法、标记整理算法、分代收集算法。不用我多说估计大家都知道分代收集算法是最先进的。确实如此,但是其他的算法也依然在使用中,所以我还是一个一个的介绍一下:
标记清除算法就像它的名字一样,算法分为标记和清除两部,先标记出来需要回收的对象,然后在一次性回收所有被标记的对象。这是最基础的算法,后面几种算法都是在它的基础上改进的。至于它的优缺点我就不赘述了,估计大家都能明白。
复制算法最初的设计思路是把内存划分为大小相等的两块,每次使用一块,回收时把还存活的对象复制到另一块,之后把刚才使用的一块全部清除。现在的商业虚拟机都采用这种收集算法来回收新生代(稍后我会解释),不同的是并非是将内存分成大小相等的两块,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间。在HotSpot虚拟机中Eden空间和Survivor空间大小比例是8:1。虚拟机每次使用Eden和其中一块Survivor空间进行内存分配,回收时把Eden和使用的Survivor中还存活的对象复制到另一块没有使用的Survivor空间上,之后清除Eden和刚才使用的Survivor空间。这样每次新生代中可用内存为总容量的90%。
标记整理算法与标记整理算法类似,只是在标记完成之后,让所有存活的对象都向一端移动,然后清除端边界以外的内存。
分代收集算法是当前商业虚拟机都采用的算法,这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个代的特点采用最适合的收集算法。在新生代中,每次收集都会有大批量的对象死去,只有少量存活,那就选用复制算法,只需付出少量存活对象的复制成本就就能完成收集。而老年代中因为对象存活率高,没有额外的空间进行分配担保,就必须使用标记清理或者标记整理算法来进行回收。
单单有算法还是远远不够的,在算法的实现过程中还有很多问题,首先就是枚举根节点的时候。在执行可达性分析的时候必须要在一个能确保一致性的快照中进行,这里的一致性的意思是指整个分析期间整个执行系统看起来像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的状况。这一点是导致GC进行时必须停顿所有Java执行线程(Stop the World)的其中一个重要原因。为了减少停顿的时间,虚拟机并不能一个不漏的检查完所有执行上下文和全局的引用位置,而是有办法直接得知哪些地方存放着引用。在HotSpot虚拟机的实现中,使用一组称为OopMap的数据结构来达到这个目的。在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来。
为了不让OopMap内容过大,HotSpot并没有为每条指令都生成OopMap,而是规定了在一些特定的位置记录这些信息,这些位置就是安全点。线程执行时必须跑到安全点时才能停下来执行GC。为了让线程能跑到安全点再停下来,GC在需要中断线程的时候会简单的设置一个标志,各个线程主动去轮询这个标志,发现中断标志时就自己中断挂起。轮询标志和安全点是完全重合的。
考虑到GC进行时线程可能处于Sleep状态或者Blocked状态,无法响应GC的中断请求走到安全点挂起。为了解决这个问题引入了安全区域来解决,安全区域是指在一段代码片段之中,引用关系不会发生变化,在这个区域中任何地方开始GC都是安全的。
在此我稍稍的提一下收集器,CMS收集器是一款优秀的收集器,它的主要优点是并发收集、低停顿。它工作过程大致分为四个步骤:初始标记、并发标记、重新标记、并发清除,其中初始标记和重新标记需要Stop the World。另外一款更加优秀收集器是G1收集器,关于这个一款收集器的具体内容感兴趣的朋友可以自己关注一下。
接下来我们来考虑第二个问题,就是什么时候回收。但在这个问题之前我还要先介绍一下两种GC:分别是新生代GC(Minor GC)指发生在新生代的垃圾收集动作,因为Java对象大多都是具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快;老年代GC(major GC/Full GC)指发生在老年代的GC,出现了Major GC经常会伴随至少一次的Minor GC。Major GC的速度一般会比Minor GC慢10倍。
一般情况下,对象在新生代Eden区中分配,当Eden区没有足够的内存进行空间分配时,虚拟机将发起一次Minor GC。大对象一般会直接进入老年代,所以大对象对虚拟机的分配来说就是一个坏消息,当然更加坏的消息是遇到一群朝生夕灭的大对象。
虚拟机给每个对象定义了一个年龄计数器,对象在Eden空间中出生并经过第一次Minor GC后仍然存活,并能被Survivor容纳的话,将被移动到Survivor中,并且年龄设置为1。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁。当它的年龄达到一定的成都(默认15岁),就将会被晋升到老年代中。为了更好的适应不同程序的状况,虚拟机并不是永远的要求对象的年龄达到阈值才能晋升到老年代。如果在Survivor空间中相同年龄的对象大小总和大于Survivor空间的一半时,年龄大于等于该年龄的对象可以直接进入老年代。
在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看是否设置允许担保失败,如果允许,那么会继续检查老年代最大联系空间是否大于历次晋升到老年代对象的平均大小,如果大于尝试进行一次Minor GC。如果小于或者设置不允许冒险,那这时要改为进行一次Full GC。
最后说一下方法区回收,也就是永久代,尽管很多人认为这里是没有垃圾收集的。这里可以回收的主要有两部分内容,废弃的常量和无用的类。废弃的常量是指没有任何引用指向的常量,这样的常量如果在内存发生回收时,而且有必要的话,这个常量会被系统清除。判定一个类是无用的相对复杂,首先该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例,其次就是加载该类的ClassLoader已经被回收,最后要求该类对应的java.lang.Class对象没有在任何地方被引用,无法再任何地方通过反射访问该类的方法。虚拟机可以对满足上述3个条件的无用类进行回收。
最后的最后我简单的介绍两个查看GC相关的命令。
jps -v 输出虚拟机进程启动时JVM参数
jstat -gc 4809 250 10 监视Java堆状况,包括Eden区、两个Survivor区、老年代、永久代的容量、已用空间、GC时间合计
jstat -gcutil 4809 监视内容与-gc基本相同,但输出主要关注已使用空间占总空间的百分比
jstat -class 4809 监视类装载、卸载数量、总空间以及类装载所耗费的时间
以上是我的理解,如果疏漏请各位指正!