GC收集算法摘要

    Java内存区域运行时,程序计数器、虚拟机栈、本地方法,栈随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而执行着出栈和入栈操作,每一个栈帧中分配多少内存在类结构确定下来时(编译期)大体上是已知的。方法结束或者线程结束时,内存自然就回收了,所以这几个区域就不需要过多考虑内存分配和回收的问题。而Java堆和方法区就不一样,我们只有在程序处于运行期间才知道会需要多少内存,这部分的内存分配和回收都是动态的,GC收集器所关注的就是这部分内存。

一.  可达性分析算法

    垃圾收集(Garbage Collection,GC)在对堆内对象回收之前,第一件事情就是要确定哪些还“存活”着,哪些对象已经“死去”(即不可能再被任何途径使用的对象)。

    在ActionScript3的FlashPlay、Python语言和在游戏脚步领域广泛应用的Squirrel语言都使用了一种引用计数算法(Reference Counting)来进行内存管理:对象中添加一个引用计数器,每当有一个地方引用它时计数器值加1;当引用失效时计数器减1;任何时刻计数器都为0的对象就是不可能再被使用的。引用计数算法实现简单,判断效率高,但是它无法解决对象之间相互循环引用的问题。如果有两个对象都已经不能再被访问,但因为它们的成员变量都相互引用这对方,它们的引用计数器都不会为0,这时引用计数算法将无法通知GC收集器回收这些对象。

    在商用程序语言中(比如Java和C#),更主流的是使用可达性分析算法(GC Roots Analysis)来判断对象是否存活的。通过一系列名为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径叫做引用链(Reference Chain)。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

    对象object5、object6、object7虽然有互相关联,但是它们到GC Roots是不可达的,所以它们被判定为是可回收对象。

    在Java语言里,GC Roots的定义如下:

        Class - 系统ClassLoader加载的class,这些类不能被卸载,他们可以通过static变量属性或常量来持有对象;

        Thread - 还存活的Thread;

        JVM Stack - 虚拟机栈(栈桢中的局部变量表)中的引用的对象;

        JNI Local - Native方法的本地变量或者参数;

        JNI Global - 全局的JNI引用;

        Monitor Used - 用于同步监控的对象;

        Held By JVM - 系统的ClassLoader、一小部分JVM知道的重要的Exception、一些为处理异常而预先分配的对象。

 

二.  对象的引用

    无论是通过引用计数法算对象的引用数量,还是通过可达性分析法判断GC Roots到对象之间的引用链是否可达,判定对象是否存活都与“引用”相关。如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。这种定义很纯粹,一个对象在这种定义下只有被引用或者没有被引用两种状态,对于如何描述一些“食之无味 弃之可惜”的对象就显得无能为力。我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存之中;如果内存在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。

    从JDK1.2版本开始,对象的引用被分为四种级别,从而使程序能更加灵活的控制对象的生命周期。这四种级别由高到低依次为:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)。

1.  强引用

    强引用(Strong Reference),这是使用最普遍的引用,类似“Object obj = new Object()”这类的引用。如果一个对象具有强引用,那就类似于必不可少的生活用品,GC收集器绝不会回收它。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。

    强引用可能带来的问题就是缓存,尤其是像图片这样的大文件的缓存。假设你有一个程序需要处理用户提供的图片,通常的做法就是做图片数据缓存,因为从磁盘加载图片代价很大,同时我们也想避免在内存中同时存在两份一样的图片数据。设计缓存的目的就是避免我们去再次加载哪些的文件,你很快就会发现在缓存中会一直包含一个指向内存中图片数据的引用。就是说使用强引用会强制图片数据一直逗留在内存。这需要你来决定,什么时候图片数据不需要并且手动从缓存中移除,进而可以让GC收集器回收。所以你再一次被强制做GC收集器该做的工作,并且人为决定是该清理掉哪一个对象。

    解决上述问题最简单的办法就是使用软引用和弱引用,例如WeakHashMap类。WeakHashMap和HashMap几乎一样,唯一的区别就是它的键(不是值)使用WeakReference引用。当WeakHashMap的键标记为垃圾的时候,这个键对应的条目就会自动被移除,避免保留无限制增长的没有意义的弱引用,避免了需要人肉手动删除的问题 ①。

2.  软引用

    软引用(Soft Reference),它被用来描述一些还有用,但并非必需的对象。如果一个对象只具有软引用,那就类似于可有可无的生活用品。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围中并进行第二次回收。如果这次回收还是没有足够的内存,才会抛出内存溢出异常。软引用可用来实现内存敏感的高速缓存。

3.  弱引用

    弱引用(Weak Reference),也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下次垃圾收集发生之前(在下次回收周期时销毁)。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在GC收集器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过由于GC收集器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。

    理论上,一个即将被回收的对象是可以在一个析构方法(比如Object#finalize方法)里面重新复活,但是这个弱引用会销毁。

4.  虚引用

    虚引用(Phantom Reference),顾名思义就是形同虚设,它是最弱的一种引用关系。虚引用只有在其指向的对象从内存中移除掉之后才会加入到引用队列中。与其他几种引用都不同,一个对象是否有虚引用的存在并不会决定该对象的生命周期,也无法通过虚引用来取得一个对象实例。其get方法一直返回null就是为了阻止其指向的几乎被销毁的对象重新复活。

    如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。为一个对象设置虚引用关联的唯一目的就是希望能在这个对象被收集器回收时收到一个系统通知。虚引用主要用来跟踪对象被垃圾回收器回收的活动。

    虚引用与软引用、弱引用的一个区别在于:虚引用必须和引用队列(ReferenceQueue)联合使用。如果所引用的对象被垃圾回收,Java虚拟机就会在回收对象的内存之前,把这个引用类型加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了该引用,来了解被引用的对象是否将要被垃圾回收。
    虚引用使用场景主要由两个:它允许你知道具体何时其引用的对象从内存中移除,而实际上这是Java中唯一的方式。这一点尤其表现在处理类似图片的大文件的情况。当你确定一个图片数据对象应该被回收,你可以利用虚引用来判断这个对象回收之后在继续加载下一张图片。这样可以尽可能地避免可怕的内存溢出错误;第二点,虚引用可以避免很多析构时的问题。

 

三.  finalize() 方法

    即使在可达性分析中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程 ②。

  • 第一次标记

        如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。

        当对象没有覆盖finalize方法,或者finzlize方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”,对象被回收。

  •   第二次标记

        如果这个对象被判定为有必要执行finalize方法,那么这个对象将会被放置在一个名为:F-Queue的队列之中,并在稍后由一条虚拟机自动建立的、低优先级的Finalizer线程去执行 ③。finalize()方法是对象脱逃死亡命运的最后一次机会,稍后GC将对F-Queue队列中的对象进行第二次小规模标记,如果对象要在finalize()中成功拯救自己,只要重新与引用链上的任何的一个对象建立关联,那么在第二次标记时它将移除出“即将回收”的集合。如果对象这时候还没逃脱,那基本上它就真的被回收了。

        

        值得注意的是:

  1. 任何一个对象的finalize()只会被系统调用一次,如果对象面临下一次回收,它的finalize方法不会被再次执行。一般,并不鼓励大家使用这种方法来拯救对象,相反的,finalize方法不建议被重写。因为它不是C/C++中的析构函数,而是Java诞生时为了使C/C++程序员更容易接受所做出的一个妥协。它运行代价高昂,不确定性大,无法保证各个对象的调用顺序。finalize()能做的所有工作,使用try-finally都可以做得更好、更及时!
  2. finalize方法可以通过创建强引用指向快被销毁的对象来让这些对象重新复活。然而,一个重写了finalize方法的对象如果想要被回收掉,在第一次标记中,某个对象被标记为可回收,进而进行析构。但是因为在析构过程中仍有微弱的可能该对象会重新复活。因为析构可能并不是很及时,所以在调用对象的析构之前,需要经历数量不确定的垃圾收集周期,这就意味着在真正清理掉这个对象的时候可能会发生很大的延迟。这就是为什么当大部分堆被标记成垃圾时还是会出现烦人的内存溢出错误。
  3. 使用虚引用,上面的问题将引刃而解。当一个虚引用加入到引用队列时,你就永远无法通过虚引用得到那个对象了,因为这时候对象已经从内存中被销毁了。因为虚引用不能被用作让其指向的对象重生,所以其对象会在垃圾回收的第一个周期就将被清理掉。

 

四.  垃圾收集算法

1.  标记-清除算法

    最基础的收集算法是“标记-清除(Mark-Sweep)”算法 ④。算法分为“标记”和“清除”两个阶段:标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

    它的主要不足有两个:一个是效率问题,标记和清除两个过程的效率都不高;一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续空间而不得不提前触发另一次垃圾收集动作。

2.  复制算法

    为了解决效率问题,一种称为“复制(Copying)”的收集算法出现了。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针按顺序分配内存即可,简单高效。它的不足,简而言之就是拿空间换时间,将内存缩小为了原来的一半,这代价未免太高了。

    现在主流的商业JVM都采用这种收集算法来回收新生代,新生代中的对象98%是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块儿较大的Eden空间和两块较小的Surivior空间,每次使用Eden和其中一块Survivor空间。当需要内存回收时,将Eden和Survivor中还活着的对象一次性的复制到另一块Survivor空间上,最后清理掉Eden和刚才使用过的Survivor空间。Hotspot VM默认的Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存会被“浪费”。

    当然,98%的对象可回收只是在一般场景下,我们没有办法保证每次回收都只有不多于10%的对象存活,但Survivor空间不够用时,需要依赖其他内存(例如老年代)进行分配担保。内存的分配担保的意思是,如果另外一块Surivior没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。

3.  标记-整理算法

    复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费掉50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代中一般不直接选用这种算法。 
    根据老年代的特点,新的“标记-整理(Mark-Compact)”算法出现了,标记过程仍然与“标记-清除”算法一样,但后续步骤并不直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

4.  分代收集算法

    主流的商业JVM垃圾收集器都采用“分代收集(Generational Collection)”算法。根据对象存活周期的不同将内存划分为几块,一般是把java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用“复制”算法,只需付出少量存活对象的复制成本就可以完成收集;在老年代中,因为对象存活率高,没有额外空间对它进行分配担保,就选用“标记-整理”算法来进行回收。 

 

五.  枚举根节点

    枚举根节点对操作执行时间的敏感有两个原因:

  • 对象引用的获取

        在可达性分析中,可作为GC Roots的节点主要在全局的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,现在很多应用仅仅方法区就有数百兆,如果要逐个查找这里面的引用链,必然会消耗很多时间。

  • GC停顿   

        也叫“Stop-The-World”,枚举根节点必须在一个能确保“一致性”的快照中进行。这里的“一致性”,是指在整个分析期间整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话分析结果的准确性就无法得到保证。这点是导致GC进行时必须停顿所有Java执行线程的其中一个重要原因,即使是在号称几乎不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。

 

六.  安全点

    目前的主流JVM使用的都是“准确式GC ⑤”,所以当执行系统停顿下来后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机可以直接得知哪些地方存放着对象引用。在HotSpot的实现中,使用一组称为OopMap的数据结构来实现这个目的:当类加载完成的时候,HotSpot会把对象内哪些偏移量上是哪些类型的数据计算出来;在JIT(Just In Time)编译过程中,会在“特定的位置”记录下栈和寄存器中哪些位置是引用。就是说,OopMap指出了这个时刻,寄存器和栈内存的哪些具体的地址是引用,从而可以快速找到GC roots来进行对象的标记操作。

    “特定的位置”主要是正在:

  • 循环的末尾;
  • 方法临返回前/调用方法的call指令后;
  • 可能抛异常的位置。

    在OopMap的协助下,HotSpot快速且准确地完成GC Roots枚举,但是,如果引用关系变化或者说OopMap内容变化的指令非常多,为每一条指令都生成对应的OopMap,那将会需要大量的额外空间,这样GC的空间成本将会变得很高。实际上,HotSpot并没有为每条指令都生成OopMap,而且程序执行时并非在所有的地方都能停顿下来开始GC,只有在到达安全点(Safepoint)时才能暂停。

    什么是Safepoint呢?引用OpenJDK官网的一段原话:

A point during program execution at which all GC roots are known and all heap object contents are consistent. From a global point of view, all threads must block at a safepoint before the GC can run.

意思就是一个点,在这个点,所有GC Root的状态都是已知的并且Heap里的对象是稳定的。在这个点进行GC时,所有的线程都要block住,这就是“Stop-The-World”。

    对于Sefepoint,另外一个需要考虑的问题是:如何让GC发生时,让所有线程都跑到最近的安全点上停顿下来。主要有两种方式:

  • 抢先式中断(Preemptive Suspension)

        不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它跑到安全点上。现在几乎没有虚拟机实现会采用抢先式中断来暂停线程响应GC事件。

  • 主动式中断(Voluntary Suspension)

        也叫“主动检测”,当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个全局标志,各个线程执行时主动去轮询/检测这个标志,发现中断标志为true则主动挂起。轮询标志的地方和安全点是重合的,再加上创建对象需要分配内存的地方。

    使用主动检测的方式,实际上还可以分为两部分:

  1. 指定点执行检测代码;
  2. polling page访问异常触发;

    HotSpot支持热点代码探测技术,它会根据运行时的信息来进行统计,找出最具有编译价值的代码(热点代码,也就是执行频率很高的代码),然后通知JIT编译器以方法为单位进行编译。如果一个方法被频繁调用或方法中有效循环次数很多,将会分别触发标准编译和OSR(栈上替换)编译动作,将高频率执行的java字节码直接编译成本地代码,提高执行效率。因此,HotSpot有两种执行方式,一个是解释执行一个是编译执行,以相对减少即时编译的时间压力。Safepoint检测主要是解释执行用的,对于需要高效实现的地方,则采用polling page。

    HotSpot里面的纯C++解释器的部分代码:

/*
  Interpreter safepoint: it is expected that the interpreter will have no live
  handles of its own creation live at an interpreter safepoint. Therefore we
  run a HandleMarkCleaner and trash all handles allocated in the call chain
  since the JavaCalls::call_helper invocation that initiated the chain.
  There really shouldn't be any handles remaining to trash but this is cheap
  in relation to a safepoint.
*/
#define SAFEPOINT                                                                 \
    if ( SafepointSynchronize::is_synchronizing()) {                              \
        {                                                                         \
          /* zap freed handles rather than GC'ing them */                         \
          HandleMarkCleaner __hmc(THREAD);                                        \
        }                                                                         \
        CALL_VM(SafepointSynchronize::block(THREAD), handle_exception);           \
    }

    可以看出,检查是否需要safepoint同步,如果是,则调用block函数。那么,哪些地方会调用呢?主要是java方法返回和跳转指令(if或者循环里面)的地方会进行检测。除此以外,在解释执行的时候会采用两套字节码解释表:在正常执行下,执行不检测safepoint的解释表,达到高效执行的目的;当需要safepoint时,会由GC线程修改为检测字节码的解释表。这个过程是同时执行的,因为解释表的item是对应字节码的解释函数入口指针,也就是64位寄存器的宽度,修改是原子的,不需要同步。

    HotSpot里,polling page的代码如下:

// Mark the polling page as unreadable
void os::make_polling_page_unreadable(void) {
  if (!guard_memory((char*)_polling_page, Linux::page_size())) {
    fatal("Could not disable polling page");
  }
}

bool os::guard_memory(char* addr, size_t size) {
  return linux_mprotect(addr, size, PROT_NONE);
}

// Mark the polling page as readable
void os::make_polling_page_readable(void) {
  if (!linux_mprotect((char *)_polling_page, Linux::page_size(), PROT_READ)) {
    fatal("Could not enable polling page");
  }
}

    在编译执行的代码里,会在指定点访问一个polling page,类似与定点检测。而polling page简而言之,就是在需要safepoint时,修改该页面的权限为不可访问,这样编译的代码在访问这个页面时,会触发段违规异常(SEGEV)。HotSpot会捕获这个异常,当意识到是访问polling page导致时,则主动挂起。

    为什么不像解释执行那样在普通状态下把safepoint检测完全规避掉呢,是因为编译执行后的代码都成为本地机器指令了,而不像解释执行那样采用的是解释函数表。函数表可以挨个替换成慢速检测版,但是要将编译好的代码修改为检测safepoint的版本,还是并发修改的情况下,这将会非常困难。

 

七.  安全区域

    使用Safepoint似乎完美解决了如何进入GC的问题了,但实际情况却并不一定。Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint。但是,程序不执行的时候呢?所谓的程序不执行就是没有分配CPU时间,典型的例子就是线程处于睡眠(Sleep)状态或者阻塞(Blocked)状态的时候。这时候的线程无法响应JVM的中断请求,跑到Safepoint的地方去中断挂起,JVM也显然不太可能等待线程重新被分配CPU时间。对于这种情况,就需要安全区域(Safe Region)来解决。

    安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中任意地方开始GC都是安全的。我们也可以把Safe Region看作是被扩展了的Safepoint。

    在线程执行到Safe Region时,首先标识自己已经进入了Safe Region,这样当这段时间里JVM要发起GC,就不用管标识自己为Safe Region状态的线程了。当线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举(或者整个GC过程),如果完成了那线程就继续执行,否则它就必须等待直到收到可以安全离开Safe Region的信号为止。

 

 

注:

    ① 在Android应用的开发中,为了防止内存溢出,在处理一些占用内存大且声明周期长的对象时候(例如会用到大量的默认图片,应用中有默认的头像、默认游戏图标等),通常都会应用到软引用和弱引用技术。

    ② 标记的前提是对象在进行可达性分析后发现没有与GC Roots相连接的引用链。

    ③ 这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束。这样做的原因是,如果一个对象的finalize方法执行缓慢,或者发生死循环,将导致F-Queue队列中的其他对象永久处于等待状态,甚至导致整个内存回收系统崩溃。

    ④ 之所以说“标记-清除”算法是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其不足进行改进而得到的。

    ⑤准确式内存管理,也叫“Exact Memory Management”,或者“Non-Conservative/Accurate Memory Management”,即虚拟机可以知道内存中某个位置具体是什么类型的数据,这样才能在GC的时候准确判断堆上的数据是否可能被使用。这种方式,抛弃了最早Classic VM基于handler的对象查找方式,每次定位对象都少了一次间接查找的开销,提升了执行性能。

 

参考自:

  • 《深入理解Java虚拟机》;
  • 《JVM高级特性与最佳实践》 ;

转载于:https://my.oschina.net/duofuge/blog/917554

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值