1、哪些内存需要回收

1.1引用

  要确定哪些内存需要回收,首先要了解引用。JDK1.2之后,存在四种引用:

  (1)强引用。最传统的引用,指在程序代码中普遍存在的引用赋值。

  (2)软引用。用来描述一些还有用但非必须的对象,只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收。

  (3)弱引用。被弱引用关联的对象只能生存到下一次垃圾收集发生为止。
  (4)虚引用。一个对象是否有虚引用,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。虚引用的唯一目的就是为了能在这个对象被收集器回收时收到一个系统通知。

1.2死亡对象判据

  了解了引用,那么需要找出没有被引用的对象来进行内存回收,方法如下:

  (1)引用计数。在对象中添加一个引用计数器,每有一个地方引用它,计数器就加一,当引用失效时,计数器就减一,计数为零则对象不可能再被使用,内存需要回收。但是这种方法在循环引用时会出现问题,所以java垃圾收集器没有使用这种方法。

​  (2)可达性分析。以“GC Roots”作为起始节点集,根据引用关系向下搜索,搜索过程所走路径为引用链,如果某个对象到GC Roots间没有任何引用链相连,则该对象不可能再被使用,需要回收。Java技术体系中固定可作为GC Roots的对象包括以下几种:

​  a.虚拟机栈中引用的对象。

​  b.方法区中类静态属性引用的对象。

  c.方法区中常量引用的对象。

  d.在本地方法栈中JNI(Native方法)引用的对象。

​  e.Java虚拟机内部引用。

​  f.所有被同步锁(synchronized关键字)持有的对象。
  g.反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调,本地代码缓存等。

​  有了GC Roots对象后,基于三色标记法来找到需要回收的对象,该方法会把对象标记为以下三种颜色:

  a.白色:表示未被垃圾收集器访问过的对象。

​  b.黑色:表示已经被垃圾收集器访问过,并且该对象的所有引用都已经扫描过。显然,黑色对象是安全存活的对象。

​  c.灰色:表示已经被垃圾收集器访问过,但是这个对象上至少还有一个引用没被扫描过的对象。

  使用这种算法,从GC Roots开始扫描,最终只会有黑色和白色对象,白色对象即为需要回收的对象。若该算法运行时,所有用户线程均停顿,则会导致垃圾回收延迟较高,且堆空间越大,停顿时间越长;若该算法与用户线程并发工作,则可能将原本存活的对象错误标记为已消亡:

对象消失.jpg

​  但Wilson已经证明,发生这种情况需要以下两个条件同时满足:

​  a.赋值器插入了一条或多条从黑色对象到白色对象的新引用;

​  b.赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

​  所以只要能够破坏这两个条件中的一个,就可以做到三色标记与用户线程并发执行。方法如下:
  a.增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用时,记录新插入的引用,并发扫描结束后,再Stop The World,将记录过的引用关系中的黑色对象作为根,重新扫描一次。可以理解为,黑色对象一旦插入了指向白色对象的引用后,它就变回了灰色。

​  b.原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,记录这个要删除的引用,并发扫描结束后,再Stop The World,将这些记录的引用关系中的灰色对象为根,重新扫描一遍。可以理解为,无论引用关系删除与否,都会按刚开始扫描那一刻的对象图快照来进行扫描。

​  这两种方法的实现,均需要通过写屏障实现。

​  找到了需要回收的对象后,还需要经过两次标记过程才能宣告一个对象死亡:

  (1)没有与GC Roots相连接的引用链,被第一次标记。

​  (2)调用完对象的finalize()方法后,收集器将对F-Queue中的对象进行第二次标记。

2、垃圾回收算法

2.1分代收集理论

分代收集建立在以下几个假说上:

(1)弱分代假说:绝大多数对象都是朝生夕灭的。

  基于这个假说,Java堆上常常会划分出新生代区域,用于存放刚创建或生存时间还较短的对象。

(2)强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。

​  基于这个假说,Java堆上常常会划分出老年代区域,用于存放已经生存了很久的对象。

(3)跨代引用假说:跨代引用相对于同代引用来说仅占极少数。
  假如要进行一次只局限于新生代区域内的收集,但新生代中的对象是完全有可能被老年代所引用,那么基于该假说,则没有必要遍历整个老年代中所有对象来确保可达性分析结果的正确性,只需要在新生代上建立一个全局的数据结构("记忆集",Remember Set),把老年代划分为若干小块,标识出老年代的哪一块内存会存在跨代引用,然后只需要将包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。其实这种做法本质是对可达性的遍历做了剪枝操作。

  进行了分代之后,针对不同区域的垃圾回收,有不同的名字:

(1)新生代收集(Minor GC/Young GC):只对新生代进行垃圾收集。

(2)老年代收集(Major GC/Old GC):只对老年代进行垃圾收集。

(3)混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。

(4)整堆收集(Full GC):对整个Java堆和方法区进行垃圾收集。

2.2标记-清除算法

  标记-清除(Mark-Sweep)算法分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象。

标记清除.png

  该方法有两个主要缺点:

​  (1)执行效率不稳定。

​  (2)内存空间碎片化,进而导致空间利用率下降。

2.3标记-复制算法

  针对对象朝生夕灭的特点,把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor,发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。

  该算法往往用于新生代的垃圾收集,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,还需要其它区域(往往是老年代)进行分配担保。

2.4标记-整理算法

​  标记过程和标记清除算法一样,但后续并不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。示意图如下:

标记整理.png

​  缺点是相对标记清除算法,移动存活对象开销较大。优点是分配内存开销很低,直接向后追加即可,没有内存碎片。

3、垃圾收集器

3.1 新生代垃圾收集器

3.1.1 Serial收集器

  该收集器是一个单线程工作的收集器,在它进行垃圾收集时,必须暂停其它所有工作线程,称作“Stop The World”(STW)。该收集器采用的是标记-复制算法。

3.1.2 ParNew收集器

  该收集器相较Serial收集器只是把单线程的垃圾回收工作变成了多线程进行的,但是在垃圾回收的过程中还是需要STW。

3.1.3 Parallel Scavenge收集器

​  该收集器也是基于标记-复制算法的垃圾收集器,也是STW后多线程并行收集的垃圾收集器,它与ParNew的不同之处在于它的关注点不同,大多是收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而该收集器的目标是达到一个可控制的吞吐量(吞吐量=运行代码时间/运行代码时间与垃圾收集时间之和)。
  停顿时间越短就越适合需要与用户交互或保证服务响应质量的程序;高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。

​  显然,垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的,系统把新生代调小一些,收集300MB新生代肯定比收集500MB快吧,这也直接导致垃圾收集发生得更频繁一些,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了。所以Parallel Scavenge收集器有一个参数-XX:+UseAdaptiveSizePolicy值得关注。这是一个开关参数,当这个参数打开之后,就不需要手工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomics)。只需要把基本的内存数据设置好(如-Xmx设置最大堆),然后使用MaxGCPauseMillis参数(更关注最大停顿时间)或GCTimeRatio(更关注吞吐量)参数给虚拟机设立一个优化目标,那具体细节参数的调节工作就由虚拟机完成了。自适应调节策略也是Parallel Scavenge收集器与ParNew收集器的一个重要区别。

3.2 老年代垃圾收集器

3.2.1 Serial Old收集器

​  该收集器是一个单线程工作的收集器,在它进行垃圾收集时,必须暂停其它所有工作线程,该收集器采用的是标记-整理算法。

3.2.2 Parallel Old收集器

  该收集器是Parallel Scavenge的老年代版本,支持多线程并发收集,基于标记-整理算法实现。

3.2.3 CMS(Concurrent Mark Sweep)收集器

​  CMS收集器是一种以获取最短回收停顿时间为目标的收集器,基于标记-清除算法。分为四步:

​  (1)初始标记。只是标记一下GC Roots能直接关联到的对象,需要STW,但是速度很快。

  (2)并发标记。从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程。

​  (3)重新标记。修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个过程需要STW,且时间比初始标记长,但是还是远小于并发标记阶段的耗时。

  (4)并发清理。清理删除掉标记阶段判断的已死亡对象。

​  CMS的缺点如下:
  (1)无法处理“浮动垃圾”。在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当此收集中处理掉它们,只好留待下一次垃圾收集时再清理掉。这一部分垃圾就称为“浮动垃圾”。

​  (2)CMS是基于标记-清除的,这意味着收集结束时会有大量空间碎片产生。

3.3面向全堆的垃圾收集器

3.3.1 G1(Garbage First)收集器

​  G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。另外,还有一类特殊的区域,称为Humongous区域,专门用来存储大对象。

  G1收集器有以下几个设计难点:

​  (1)为避免全堆作为GC Roots扫描,跨Region引用对象如何解决?方法是每个Region都维护自己的记忆集,记录“我指向谁”和“谁指向我”。因此,G1收集器比其它传统的垃圾收集器有着更高的内存占用负担,大约要用相当于Java堆容量10%至20%的额外内存来维持收集器工作。
  (2)如何建立可靠的停顿预测模型?用户可以通过-XX:MaxGCPauseMillis参数指定垃圾回收期望停顿时间,这一点是以衰减均值为理论基础实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息,然后根据这些信息预测现在开始回收的话,由哪些Region组成的回收集才可以在不超过期望停顿时间的约束下获得最高收益。
​  (3)如何在并发标记期间保证收集线程与用户线程互不干扰地运行?使用原始快照算法(见1.2)实现,此外,每个Region还有两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都要在这两个指针位置以上,G1收集器默认在这个地址以上的对象是存活的,不纳入回收范围。

  G1具体的运作过程如下:

  (1)初始标记。需要STW,标记GC Roots能直接关联到的对象。

​  (2)并发标记。作可达性分析。

​  (3)最终标记。STW,处理并发阶段结束后遗留下来的最后少量原始快照记录。

​  (4)筛选回收。更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户期望的停顿时间制定回收计划。最后的回收过程需要STW,并由多条收集器线程并发完成。

3.3.2Shenandoah收集器

  这是一款OpenJDK才包含而OracleJDK里不存在的收集器,可谓是G1收集器的继承者,它也使用基于Region的堆内存布局,同样有存访大对象的Humongous Region,默认的回收策略也同样是优先处理回收价值大的Region,但它与G1有至少三个明显的不同点:

  (1)支持与用户线程并发的回收算法。

​  (2)默认不使用分代收集,即没有专门的新生代、老年代Region。

​  (3)摒弃了G1中的记忆集,改用“连接矩阵”的全局数据结构来记录跨Region的引用关系。

​  Shenandoah收集器工作过程大致可以划分为以下九个阶段:

​  (1)初始标记。同G1。

​  (2)并发标记。同G1,可达性分析。

  (3)最终标记。处理并发阶段结束后遗留下来的最后少量原始快照记录,统计出回收价值最高的Region。

  (4)并发清理。清理那些整个区域连一个存活对象都没找到的Region。

​  (5)并发回收。这个阶段需要将回收集里面的存活对象复制一份到其它未被使用的Region之中,移动对象之后整个内存中所有指向该对象的引用都还是旧地址,很难一瞬间全部改变过来,所以这里通过读屏障和被称为“Brooks Pointers”的转发指针来解决。转发指针即是在原有对象布局结构最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该指针指向对象自己,如下图。
Brooks Pointer
  在回收过程,则修改转发指针的地址即可。当要读取一个对象时,会陷入读屏障,基于转发指针读取真实的对象。由于对象的访问在代码中比比皆是,所以转发指针以及读屏障会很大程度上降低程序的执行效率。

​  (6)初始引用更新。该阶段没做什么具体的处理,只是建立一个线程集合点,保证并发回收的收集器线程均完成了任务。该阶段时间很短,会产生一个非常短暂的停顿。

​  (7)并发引用更新。按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值,该过程与用户线程并发执行。

​  (8)最终引用更新。解决了堆中的引用更新后,还要修正存在于GC Roots中的引用,这里需要STW,时间与GC Roots数量有关。

​  (9)并发清理。经过前述过程,回收集中已经没有存活对象了,使用并发清理回收这些Region空间。

3.3.3 ZGC收集器

  ZGC收集器是一款基于Region内存布局,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。

​  ZGC虽然也采用Region内存布局,但它的大小是不固定的,分为小型Region,容量为2MB,用于存放小于256KB的小对象;中型Region,容量为32MB,用于存放大于等于256KB但小于4MB的对象;大型Region,容量不固定,为2MB的整数倍,用于存放大于4MB的对象。

​  Linux下64位指针只有低46位用于寻址,ZGC中的染色指针则将标志信息放在这46位的高4位上,通过这些标志,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集(即被移动过)、是否只能通过finalize()方法才能被访问到。染色指针有以下三点优势:

​  (1)一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉。

​  (2)可以大幅减少在垃圾收集过程内存屏障的使用数量。目前为止,ZGC都并未使用任何写屏障,只使用了读屏障,所以ZGC对吞吐量的影响也相对较低。

​  (3)染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。

​  为了使带有标志位染色指针可以直接用于寻址,ZGC还使用了多重映射技术,让低42位相同的指针指向同一块物理内存。

  ZGC的运作过程大致可以分为以下四个大阶段:

​  (1)并发标记。类似G1和Shenandoah,不同的是ZGC的标记是在指针上而不是对象上进行,且标记过程是针对全堆的,用范围更大的扫描成本换取省去G1中记忆集的维护成本。

​  (2)并发预备重分配。根据特定的查询条件统计出本次收集过程要清理哪些Region,将这些Region组成重分配集。

​  (3)并发重分配。这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。得益于染色指针的支持,ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(SelfHealing)能力。这样做的好处是只有第一次访问旧对象会陷入转发,也就是只慢一次,对比 Shenandoah的Brooks转发指针,那是每次对象访问都必须付出的固定开销,简单地说就是每次都慢, 因此ZGC对用户程序的运行时负载要比Shenandoah来得更低一些。还有另外一个直接的好处是由于染色指针的存在,一旦重分配集中某个Region的存活对象都复制完毕后,这个Region就可以立即释放用于新对象的分配(但是转发表还得留着不能释放掉),哪怕堆中还有很多指向这个对象的未更新指针也没有关系,这些旧指针一旦被使用,它们都是可以自愈的。

​  (4)并发重映射。重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用。

  相比G1、Shenandoah等先进的垃圾收集器,ZGC在实现细节上做了一些不同的权衡选择,譬如G1 需要通过写屏障来维护记忆集,才能处理跨代指针,得以实现Region的增量回收。记忆集要占用大量的内存空间,写屏障也对正常程序运行造成额外负担,这些都是权衡选择的代价。ZGC就完全没有使用记忆集,它甚至连分代都没有,连像CMS中那样只记录新生代和老年代间引用的卡表也不需要,因而完全没有用到写屏障,所以给用户线程带来的运行负担也要小得多。可是,ZGC的这种选择也限制了它能承受的对象分配速率不会太高,若要从根本上提升ZGC能够应对 的对象分配速率,还是需要引入分代收集。

参考文章:《深入理解Java虚拟机》