垃圾收集器与内存分配策略

概述

前一章介绍了Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈和本地方法栈3个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊的执行着出栈和入栈操作。每一帧分配多少内存基本在类结构确定下来时就已知了,因此这几个区域的内存分配和回收具有确定性,线程结束时,内存就跟着回收了,不需要太多考虑回收问题。而Java堆和方法区则不一样,一个接口的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期时才知道会创建哪些对象,这部分内存的分配是动态的,垃圾收集器所关注的是这部分内存。

对象已死吗

在堆里存放着java中几乎所有的对象,垃圾收集器在对堆回收之前,首先确定这些对象中哪些活着,哪些已死。

引用计数法

给对象添加一个引用计数器,每当一个地方引用它时,计数器加1,当引用失效时,计数器减1。任何计数器为0的对象就是不再被使用的对象。

缺点:两个对象相互引用时,计数器永远不能减为0,即循环引用问题。

可达性分析算法

通过一系列称为“GC Roots”的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则该对象是不可用的。

在Java中可以作为GC Roots的对象包括以下几种:

1)虚拟机栈中引用的对象

2)方法区中类静态属性引用的对象

3)方法区中常量引用的对象

4)本地方法栈中JNI引用的方法。

再谈引用

JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用。

1)强引用,指在程序代码中普遍存在的,类似“O o=new O()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉这类对象。

2)软引用,用来描述一些还有用但并非必须的对象。对于软引用关联的对象,内存溢出之前对其回收,如果内存还不够才会抛异常。可以通过SoftReference类实现软引用。

3)弱引用,用来描述非必须的对象。弱引用的对象只能生存到下一次垃圾收集发生之前。WeakReference类实现弱引用。

4)虚引用,为一个对象设置虚引用的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。PhantomReference类实现虚引用。

生存还是死亡

即使在可达性分析算法中不可达的对象,也并非非死不可,这时候他们只是处于缓刑阶段。要宣告对象的死亡需要两次标记过程:如果对象在进行可达性分析时发现该对象没有与GC Roots相连的引用链,那么第一次标记该对象并且进行一次筛选。筛选条件是对此对象是否有必要执行finalize()方法。但对象没有覆盖finalize()方法或者finalize()方法已被虚拟机调用过,虚拟机把这两个情况视为“没有必要执行”。

如果对象判定为有必要执行finalize()方法,那这个对象放在F-Queue队列,并在稍后由一个虚拟机自动建立的、低优先级的Finalizer线程去执行。finalize()方法是对象拯救自己的最后一次机会。如果在finalize()方法中重新与引用链的对象发生关联,那么第二次标记时将他移除“即将回收”的集合。

回收方法区

方法区在HotSpot中是永久代,对方法区进行垃圾回收性价比较低。

永久代的垃圾回收包括两个部分:废弃常量和无用的类

垃圾收集算法

标记—清除算法

算法分为两个阶段:标记和清除。标记过程上一节已经介绍过。该算法存在两个不足:一是效率问题,标记和清除效率都很低,二是空间问题,标记清除会产生很多不连续的内存碎片。

复制算法

为了解决上面算法的效率问题,将内存按容量分为大小相等的两块,每次只是用一块。当一块用完了,就将还存活的对象复制到另一块去,然后将已使用的这块内存一次性清理掉。

实际上,并不是按照1:1划分内存空间,而是将内存划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和一个Survivor。当回收时,将Eden和Survivor的存活对象复制到另一个Survivor中去,最后清理掉Eden和该Survivor。Eden和Survivor的大小比例是8:1。也就是每次新生代可用内存为90%。如果存活对象大于10%,则Survivor内存不够,则需要依赖其他内存(指老年代)进行分配担保。即将这些存活的对象通过担保机制进入老年代。

标记—整理算法

复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所有老年代不直接使用这种算法。

根据老年代的特点,提出标记整理算法。过程和标记清除一样,但不是直接对回收对象进行清除,而是先让所有的对象都向一端移动,然后清楚掉端边界以外的内存。

分代收集算法

根据对象存活周期的不同将内存分为几块。一般将Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用适当的收集算法。

新生代中,每次垃圾收集时都会有大量对象死去,只有少量存活,选用复制算法。老年代的对象存活率较高、没有额外空间对他进行分配担保,就需要使用“标记—清理”或“标记—整理”算法。

HotSpot的算法实现

 

前两节介绍了对象存活判定算法和垃圾收集算法。HotSpot虚拟机在实现这些算法时,必须对算法的执行效率有严格的考量。

枚举根节点

以可达性分析中从GC Roots节点找引用链为例,可以作为GC Roots的节点主要位于全局性的引用(常量或类静态属性)与执行上下文(栈帧中的本地变量表)中,如果要逐个检查这里面的引用,那么必然会消耗很多时间。

此外,可达性分析对执行时间的敏感还体现在GC停顿上,因为这项工作还必须确保一致性的快照中进行。这里的一致性指分析期间,整个执行系统看起来就像被冻结在某个时间点。这导致GC执行时必须停顿所有Java执行线程。

实际上,当系统停顿下来,并不需要一个不漏的检查所有执行上下文和全局的引用位置,虚拟机有办法直接得知哪些地方存放着对象引用。在HotSpot的实现中,使用一组称为OopMap的数据结构,在类加载完成时,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。

安全点

在OopMap的协助下,HotSpot可以快速准确的完成GC Roots枚举,但问题随之而来,可能导致引用关系变化(OopMap内容变化)的指令非常多,如果为每一个指令生成一个对应的OopMap,那将会需要大量的空间。

实际上,HotSpot并没有为每一个指令生成一个对应的OopMap。前面提到只是在特定的位置记录这些信息,这些位置称为安全点。安全点的选定基本上是以程序“是否具有然程序长时间执行的特征”为标准进行选定,包括方法调用、循环跳转和异常跳转等,具有这些功能的指令才会产生安全点。GC发生时,需要让所有线程都跑到安全点上再停顿下来。这里,GC需要中断线程时,设置一个标志,所有线程执行时都会主动轮询这个标志,发现中断标志时就将自己挂起,轮询标志的地方和安全点是重合的

安全区域

当线程处于Sleep或者Blocked状态时,无法响应GC的中断请求,这时需要安全区域来解决。安全区域是指一段代码中,引用关系不会变化。在这个区域的任何地方开始GC都是安全的。在线程执行到安全区域中的代码时,首先标识自己已经进入安全区域,那样,当在这段时间内发起GC时,就不用管进入安全区域的线程了。当线程离开安全区域时,先检查是否完成了根节点枚举,如果完成了,那线程继续执行,否则等待。

 垃圾收集器

上图展示了HotSpot虚拟机的7中收集器。连线表示收集器可以搭配使用。

Serial收集器

一个单线程的收集器,“单线程”并不仅仅意味着它只会使用一个CPU或一条单线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到收集结束。

优点:简单而高效,没有线程交互的开销。

ParNew收集器

ParNew收集器就是Serial收集器的多线程版本。首选的新生代收集器。

除了Serial收集器外,ParNew收集器是唯一可以与CMS收集器配合工作的收集器。

 

Parallel Scavenge收集器

是一个新生代收集器,采用“复制”算法,又是并行的多线程收集器。

Parallel Scavenge收集器的特点是它的关注点和其他收集器不同,CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge目标则是达到一个可控的吞吐量。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值。停顿时间越短越适合与应互交互的程序,而高吞吐量可以高效利用CPU时间,尽快完成运算任务,适合后台运算。

 Serial Old收集器

Serial Old是Serial收集器的老年版本,同样是单线程收集器。使用“标记-整理”算法。这个收集器的主要意义在于给Client模式下的虚拟机使用。Server模式下,有两大用途,一是与Parallel Scavenge收集器搭配使用,另一个用途是作为CMS收集器的后备预案。

Parallel Old收集器

是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。

CMS收集器

CMS(Concurrent Mark Sweep)是一种最短回收停顿时间为目标的收集器,基于“标记-清除”算法。主要用于B/S等注重服务响应速度的应用。

运作过程相对于前面几个较为复杂,整个过程如下:

1)初始标记------------------(用时短,停掉所有线程)

2)并发标记------------------(用时长,并发进行)

3)重新标记------------------(用时短,停掉所有线程)

4)并发清除------------------(用时长,并发进行)

初始标记和重新标记都需要停掉所有线程。初始标记只是标记一下GC Roots能直接关联到的对象,速度很快。并发标记阶段就是进行GC Roots Tracing(可达性分析)。重新标记阶段则是为了修正并发标记期间因用户程序继续运行导致的对象变动的标记记录,这个阶段的停顿时间比初始标记要长,比并发标记短。

整个过程中,耗时最长的并发标记和并发清除都可以和用户线程同时工作,所以总体来说,该收集器是和用户线程一起并发执行的。

缺点:

1)CMS收集器对CPU资源敏感。并发阶段,因为占用一部分线程导致应用程序变慢,总吞吐量降低。

2)CMS收集器无法处理浮动垃圾。浮动垃圾是指并发清理过程中,用户线程运行时产生的新的垃圾,这部分垃圾出现在标记过程之后,不会被清理。因此CMS不能像其他收集器那样,等到老年代几乎填满才进行收集,而应该预留一部分空间提供并发收集时的程序运行使用。

3)CMS是“标记-清理”,所以会产生大量空间碎片。

G1收集器

一款面向服务的应用的垃圾收集器。未来可能替换掉JDK1.5中的CMS收集器。

G1具备以下特点:

1)并行与并发:部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。

2)分代收集:虽然G1不需要其他收集器配合就能管理整个GC堆,但它能够采用不同的方式去处理不同分代年龄的对象。

3)空间整合:G1从整体看起来是“标记-整理”算法实现的收集器,从局部看是基于复制算法实现的。

4)可预测的停顿:能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。

G1将整个Java堆划分为多个大小相等的Region。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的region。

虚拟机使用Remembered Set来避免全堆扫描。G1中的每个Region都有一个与之对象的Remembered Set,虚拟机发现程序在对Reference类型数据进行写操作时,会产生一个对应的Write Barrier暂时中断写操作,检查reference引用的对象是否处于不同的Region之中,如果是,便通过CardTable 把相关引用信息记录到被引用对象所属的Region的Remembered Set之中。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描。

G1的运行大致划分如下几个步骤:

1)初始标记:标记GC Roots直接关联的对象,并修改TAMS(Next Top at Mark Start),让下一阶段用户程序并发时,能在正确的Region中创建对象,这阶段需要停段,但耗时少。

2)并发标记:可达性分析,耗时长,但可和用户线程并发进行。

3)最终标记:修正并发标记期间因用户线程的运行导致的标记产生变动的记录。虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需把Remembered Set Logs数据合并到Remembered Set中,这阶段需要停顿线程。

4)筛选阶段:首先对每个Region的回收价值和成本进行排序,根据用户期望的GC停顿时间来指定回收计划。这个阶段也可以和用户程序一起并发执行。

4)筛选回收

posted @ 2017-04-17 11:12  且听风吟-wuchao  阅读(171)  评论(0编辑  收藏  举报