标签:
本文是《深入理解Java虚拟机》一书中第三章的读书总结。
前面介绍了Java内存结构和HotSpot虚拟机在堆内存中管理对象的过程。不过,在Java程序中对象的创建是非常频繁的,而内存的大小又是有限的,为了内存的重复利用,就需要对内存中的对象进行垃圾收集。其实,这也是Java和C++的一个区别,在Java中可以进行自动的垃圾收集,而C和C++中需要程序员手动回收不再使用的对象。
Java中的垃圾收集是虚拟机要考虑的问题。那么以虚拟机的角度考虑,如果要收集虚拟机内存中的垃圾,需要考虑哪些问题呢?
Java的垃圾收集机制是一个挺复杂的过程,涉及到的内容也很多,上面的问题一个一个解决。
1、回收区域
在前面几篇中可以知道,Java内存中的程序计数器、虚拟机栈和本地方法栈是线程私有的,线程结束也就没了。其中程序计数器负责指示下一条指令,栈中的栈帧随着方法的进入和退出不停的入栈出栈。每一个栈帧的大小在编译时就基本已经确定。所以这几个区域就不需要考虑内存回收,因为方法结束或线程停止,内存就回收了。
和上述三个区域不同的是,Java堆和方法区是线程共享的。在Java堆中存放着所有线程在运行时创建的对象,在方法区中存放着关于类的元数据信息。我们在程序运行时才能确定需要加载哪些类的元数据信息到方法区,创建哪些对象到堆中,也就是说,这部分的内存分配和回收都是动态的。也因为这样,这两个部分是垃圾收集器所关注的地方。
2、谁才是垃圾?
首先考虑一下存放对象的Java堆。
程序中创建的对象绝大多数都会在Java堆中,而程序的运行也会创建大量的对象。但这些对象并不总是使用,这样就产生了一些不会再使用的垃圾。这些垃圾占据着宝贵的内存空间,所以需要回收这些空间。不过,怎么才能确定堆中的对象是垃圾呢?
一种常见的算法是引用计数算法,它基于这样的考虑,给对象添加一个引用计数器,每当有一个地方引用它时,计数器的值就加1;当引用实效时,计数器的值就减1。当计数器的值为0时,对象就不可能再被使用。
引用计数算法实现简单,判定效率也高。不过,主流的Java虚拟机中并没有使用引用计数算法来管理内存,因为这个算法很难解决对象之间相互循环引用的问题。
考虑下面的代码:
public class ReferenceCountingGC { public Object instance=null; private static final int _1mb=1024*1024; @SuppressWarnings("unused") 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; System.gc(); } public static void main(String[] args) { ReferenceCountingGC.testGC(); } }
那么Java中使用的是什么方法呢?是可达性分析算法(Reachability Analysis)。
可达性分析算法的基本思想是通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,即从GC Roots到这个对象不可达,就说明这个对象是不可用的。如下图,左面的四个对象都有引用链到GC Roots,因此是可用的;右面的三个对象到GC Roots不可达,所以是不可用的。
在Java中,下面几种对象可以作为GC Roots:
其实这两种方法都涉及到了对象的引用,也就是说对象是否是垃圾都与引用有关,因此有必要全面的理解一下Java中的引用。
其实Java中的引用一共有四种。这是JDK 1.2 之后对引用概念的扩充,分别是强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference),这四种引用的强度依次逐渐减弱。
(1)强引用
强引用就是程序中普遍存在的,类似“Object obj=new Object()”这类的引用,只要强引用还存在,垃圾回收器就不会回收被引用的对象。
(2)软引用
软引用用来描述一些还有用但不是必须的对象。对于软引用关联的对象,在系统将要发生内存溢出异常之前,将会把这些对象列入回收范围进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。SoftReference类来实现软引用。
(3)弱引用
弱引用也用来描述非必须的对象,但是强度比软引用还弱,被引用的对象只能存活到下一次垃圾收集之前。当下一次垃圾收集器工作时,不论内存是否足够,都会回收这些对象。WeakReference类实现了弱引用。
(4)虚引用
虚引用是最弱的一种引用,也叫幽灵引用或幻影引用。一个对象是否有虚引用存在不会对其生存时间产生影响,也无法通过虚引用来取得一个对象实例。虚引用的唯一目的就是当被虚引用关联的对象被收集器收集时收到一个系统通知。PhantomReference类实现了虚引用。
3、垃圾也有可能变废为宝
垃圾也有可能变废为宝然后再利用呢。其实,即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,要真正认为一个对象是垃圾要收集,至少要经过两次标记过程:如果对象在进行可达性分析后发现不可达,那么就将它进行第一标记并进行一次筛选,筛选的条件是这个对象是否有必要执行finalize()方法。当对象没有覆盖finalize方法,或finalize方法已经被虚拟机执行过了,虚拟机任何没有必要执行finalize方法。
如果这个对象被判定为有必要执行finalize方法,那么这个对象会放置在一个叫做F-Queue的队列中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行。不过虚拟机只是会触发这个方法,但不承诺会等待执行完毕,这是因为,如果一个对象的finalize方法执行缓慢,或发生了死循环,就会导致F-Queue对象中的其他对象处于等待,甚至整个垃圾收集系统崩溃。稍后GC会在F-Queue中的对象进行第二次小规模的标记,如果这时标记为可达,就可以不被收集;如果仍然不可达,那么就被标记为垃圾了。具体的流程图如下:
下面的代码演示了上面所说的内容。
public class FinalizeEscapeGC { public static FinalizeEscapeGC SAVE_HOOK=null; public void isAlive(){ System.out.println("yes,i am still alive."); } 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(); 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."); } } }
FinalizeEscapeGC类覆盖了finalize方法,所以在GC将SAVE_HOOK第一次标记为垃圾后的筛选中认为finalize有必要执行。在覆盖的finalize方法中,将自己赋值给了类的变量SAVE_HOOK,成功拯救自己,第一次没有被收集。但是第二次虽然代码相同,但是由于虚拟机已经执行过finalize方法了,GC不认为有必要执行,在第二次标记中也标记为垃圾,所以没有能拯救自己,被当做垃圾收集了。
4、回收方法区
除了Java堆,方法区中也存在垃圾收集。只不过这里的收集效率比较低。
方法区,在HotSpot虚拟机中叫永久代,GC收集两部分内容,废弃常量和无用的类。收集废弃常量与收集Java堆中的对象类似。以常量池中字面量的收集为例,假如一个字符串“ABC”已经在常量池中,但是当前系统中没有任何一个String对象是“ABC”,即没有对象引用常量池中的“ABC”,也没有其他地方引用了这个字面量,如果这时发生内存回收,而且必要的话,“ABC”就会被清理出常量池。常量池中的其他类(接口)、方法和字段的符号引用也类似。
不过要判断一个类是否无用就麻烦很多了。要同时满足如下三个条件一个类才是无用的类:
满足上面的三个条件,虚拟机就可以回收。不过,对于HotSpot虚拟机来说,是否回收通过-Xnoclassgc参数来设置。
5、垃圾收集算法
现在我们知道了在哪里收集垃圾以及如何判定一个对象是否是垃圾。接下来就要考虑如何收集垃圾,即垃圾收集算法。不过由于垃圾收集算法涉及到大量的程序细节,所以这里仅仅介绍算法的基本思想及其发展过程。
(1)标记-清除算法
标记-清除(Mark-Sweep)算法是最基础的收集算法,算法名字表明这个算法的垃圾收集过程包括两步:标记和清除。前面介绍的判定垃圾的过程就是标记过程,在标记过后的清除过程中会清理标记为垃圾的对象。后序的垃圾收集算法都是在这个算法的基础上改进而成的。这个算法有两个不足:一个就是标记和清除的效率不高;第二个是空间问题,标记清除后会产生大量不连续的内存碎片,空间碎片太多的话可能导致以后分配大块内存时失败的问题,这样就会触发另一次垃圾收集操作。算法的执行过程如下图:
(2)复制算法
复制算法是为了解决标记-清除算法效率不高的问题的,它将可用内存按照容量分为大小相等的两部分,每次只使用其中的一块。当一块的内存用完了,就将还存活的对象复制到另一块,然后再把已经使用过的内存空间一次性清理掉。这样使得每次是对整个半区进行内存回收,内存分配时也不需要考虑内存碎片的问题,只要移动堆顶指针,按顺序进行分配就好。算法的执行过程如下图:
不过这个算法使得内存只能一半能用,代价太高了。现在的虚拟机都采用这种方法来回收新生代,不过不是1:1分配的,而是将堆内存分为以块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和一个Survivor空间。当回收时,将Eden和Survivor中还存活的对象复制到另一块Survivor中,然后清理Eden和使用过的Survivor空间。HotSpot虚拟机默认的Eden和Survivor比例是8:1,即Eden占堆的80%空间,Survivor占10%的空间,每次只能使用90%的堆空间。
不过,我们并不能保证每次回收只有不多于10%的对象存活,当Survivor空间不够时,需要使用其他内存空间(老年代)进行分配担保,即如果Survivor空间不够,存活的对象直接进入老年代。
(3)标记-整理算法
复制收集算法在对象存活率较高时就需要进行较多的复制操作,效率就会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都存活的极端情况,所以在老年代中一般不使用这种算法。
根据老年代的特点,可以使用另一种标记-整理(Mark-Compact)算法,标记过程和标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是整理存活的对象,将存活的对象都向一端移动,然后直接清理掉边界外的内存。算法的执行过程如下:
这样,也没有了内存碎片的问题。
(4)分代收集算法
现在的虚拟机都使用“分代收集”算法,这种算法只是根据对象的存活周期的不同将内存划分为几块。一般把Java堆空间分为新生代和老年代,这样就可以根据各个年代的特点采用最适合的收集算法。在新生代,每次垃圾收集都会有大量的对象死去,只有少量存活,这样就可以选择复制算法,只需复制少量存活的对象就可以完成垃圾收集。在老年代中,对象的存活率高、没有额外的空间对它进行分配担保,就必须采用标记-清除或标记-整理算法来进行回收。
6、HotSpot的算法实现
前面从理论的角度介绍了对象存活判定和垃圾收集算法,接下来介绍下HotSpot虚拟机的实现。
(1)枚举根节点
在对象存活判定中使用的是GC Roots可达性分析算法,可作为GC Roots的节点主要在全局的引用(例如常量和类静态属性)与执行上下文(例如栈帧中的本地变量表)中,不过现在很多应用仅仅方法区就有数百兆,如果逐个检查这里的引用,就必然会消耗很多时间。
另外,可达性分析对执行时间的敏感还体现在GC停顿上,因为这项工作必须在一个能确保一致性的快照中进行,就是说在整个分析过程中执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不停变化的情况,否则分析的结果就不能保证准确。这是导致GC进行时必须停顿所有Java执行线程的一个重要原因,Sun将这个事件叫做“Stop the World”。
由于目前的主流虚拟机使用的都是准确式GC,所以当执行系统停顿后,并不需要检查所有的引用和执行上下文,虚拟机有办法直接得知哪些地方存放着对象的引用。在HotSpot的实现中,使用一组OopMap的数据结构来达到这个目的,在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC在扫描时就可以直接得知这些信息了。
(2)安全点
在OopMap的协助下,HotSpot可以快速且准确的完成GC Roots的枚举,但是,这样可能导致引用关系变化,或者说OopMap内容变化的指令非常多,如果每一条指令都生成对应的OopMap,将会消耗大量的空间。
实际上,HotSpot只是在特定的位置记录OopMap的信息,这些位置称为“安全点”(Safe Point),即程序执行时并非在所有地方都能停下来开始GC,只有到达安全点时才能停止。SafePoint的选定不能太少也不能太多,所以,安全点的选定基本上是以程序“是否具有让程序长时间执行的特点”为标准进行选定。因为每条指令执行的时间都非常短,程序不太可能因为指令流长度太长而长时间执行,长时间执行最明显的特征就是指令序列复用,比如方法调用、循环跳转和异常跳转等,具有这些特征的指令才会产生安全点。
对于SafePoint来说,还有一个问题,就是如何在GC发生时让所有的线程(不包括执行JNI调度的线程)都跑到最近的安全点停下来,这里有两种方案:抢占式中断和主动式中断。抢占式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有的线程全部中断,如果有线程中断的地方不在安全点上,就恢复线程执行到安全点。不过几乎没有虚拟机采用这种方法。
另一个方法就是主动式中断,就是说当GC需要中断线程的时候,不直接对线程操作,仅仅简单的设置一个标志,各个线程执行时主动去轮询这个标志,发现为真时就自己中断挂起。轮询标志的地方和安全点重合,此外还有创建对象需要分配内存的地方。
(3)安全区域
SafePoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的SafePoint。但如果程序由于没有分配CPU时间或者线程处于sleep或blocked状态而没有执行呢?这时线程无法响应JVM的中断请求,无法跑到安全点然后挂起,JVM也不可能等着线程重新执行。这时就需要安全区域(Safe Region)了。
安全区域是指在一段代码片段中,引用关系不会发生变化。在这个区域中的任何地方开始GC都是安全的,也就是说,安全区域是扩展了的安全点。
在线程执行到安全区域中的代码时,首先标识自己已经进入安全区域,这样,当在这段时间JVM要发起GC时,就不用管已经标识进入安全区域的线程了。在线程要离开安全区域时,要检查系统是否完成了GC Roots的枚举或者整个GC过程,如果完成了,那线程就继续执行,否则就等待可以安全离开安全区域的信号。
未完待续
添加公众号Machairodus,我会不时分享一些平时学到的东西~
标签:
原文地址:http://blog.csdn.net/u012877472/article/details/51500691