购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

3.3 老生代内存管理

老生代内存管理也可以分为两个部分:分配和回收。在3.1节介绍了分代内存管理,老生代也使用了连续的内存空间,在老生代空间的对象分配也需要一块连续的内存。分配过程非常简单,通常只有GC工作线程可以在老生代中分配。因为GC线程是单线程,所以不涉及并发操作。

对于回收,本书一直在强调:串行回收的老生代回收实际上不仅回收老生代空间,而是回收整个堆空间。为什么老生代回收如此设计?

根据分代理论,新生代的空间主要用于满足应用的请求,老生代空间主要用于新生代的垃圾回收过程中长期活跃对象的晋升。从理论上说,当老生代空间满的时候会触发老生代的垃圾回收(具体触发机制在3.3.1节会详细介绍),此时整个堆空间的状态是新生代空间已满,且在垃圾回收时无法晋升对象,老生代空间已满。那么此时的处理方法有以下两种:

1)分别对新生代和老生代采用不同的垃圾回收算法进行回收。

2)把整个堆空间看作一个整体,采用一个算法进行回收。

在介绍分代时一直在强调一个概念,即不同的内存空间存放的对象生命周期不同,采用不同的算法进行回收可以得到更好的吞吐量。为什么这里的第二种方案否定这样的做法呢?其最主要的原因与串行回收中老生代内存分配机制相关,次要原因与垃圾回收时标记的成本相关。

因为新生代回收采用单线程处理,大多数情况下只有对象晋升时才会访问老生代,所以老生代通常只有一个线程访问。针对单线程访问的场景,老生代空间按照线性分配效果会最好,同时在线性分配的管理机制下,只需要3个简单的变量就能完成分配,例如可以使用start、top、end分别指向内存空间的起始地址、当前使用的地址、内存空间的终止地址。在分配时只需要判断top和end之间的空间是否满足晋升分配请求,如果满足,直接增加top的值即可完成内存分配。在老生代的垃圾回收期间,需要遍历整个老生代,需要活跃对象。在遍历老生代时,有以下3种思路:

1)建立新生代到老生代的代际引用,类似于老生代到新生代的卡表机制。但这样的方法实际上不可行,由于新生代采用移动式内存管理,也就是说新生代回收触发后会影响应用的对象分配及卡表更新,而卡表的更新一般比较耗时,因此通常引用关系只增加不删除,而新生代回收触发又比较频繁,结果就是在老生代发生垃圾回收时,整个新生代对应的卡表都存在指向老生代的引用,完全无法起到加速老生代遍历的效果,反而是卡表中存在过多的无效引用,产生超多的浮动垃圾,从而导致较差的性能。

2)在进行老生代遍历之前,做一次新生代回收,回收完成后新生代的存活对象(位于Survivor)可以直接作为老生代的根,从而不需要遍历整个堆空间。在老生代的垃圾回收的实现中,这是一种相对常用的手法,称为“借道”。这样的设计是否适合串行回收呢?由于串行回收无论是新生代回收还是老生代回收都是单线程执行的,因此采用这样的方式需要设计两种回收算法的交互方式(在老生代的垃圾回收去触发新生代垃圾回收),且还需要再考虑代际之间的引用关系变化(实际上并行回收也有同样的问题)。“借道”方法过于复杂,通常用于增量并发的老生代回收实现中。

3)把老生代和新生代看作一个整体,采用压缩算法进行整体回收。代际关系处理在实现时可能变得简单(见3.3.2节介绍)。当然这种方法是分代回收的优化实现,适用于串行垃圾回收器和并行垃圾回收器。

根据这3种思路,串行回收采用整个堆空间回收的方法实现最为简单、高效。

3.3.1 堆空间回收的触发

老生代采用线性分配方式进行管理,在JVM的实现中,只有以下两种情况可以在老生代空间中分配对象:

因为GC工作线程和应用都会在老生代请求内存分配,所以它们都有可能触发垃圾回收。堆空间回收触发的时机是什么?

按照分代的目的,应该是在执行新生代垃圾回收时发现内存不足,此时再执行堆空间回收(简称Full GC,有时也称为“全量回收”)。但这里有一个小问题,上面提到针对Full GC和新生代回收一起进行,如果采用先进行新生代垃圾回收,当内存不足时再进行Full GC的做法,在进行Full GC时会再次处理一次新生代空间,这会导致一定的时间浪费。所以一种可能的做法是,在执行新生代回收之前,判断是否要执行Full GC,如果需要执行Full GC,则直接跳过新生代的回收。另外,当新生代回收执行以后,如果仍然不能满足应用的对象分配请求,则会再次触发Full GC。

Full GC的触发时机可以总结如下:

1)当应用显式地执行Full GC或者隐式地执行Full GC的请求时,会优先触发Full GC,而不是新生代回收;显式触发Full GC主要指的是应用代码中通过调用System.gc()触发的垃圾回收,隐式触发Full GC主要指元数据空间不足、无法满足分配触发的垃圾回收。

2)在新生代垃圾回收执行过程中,首先判断是否可以在新生代中分配对象 ,如果可以,继续执行新生代垃圾回收,如果不可以,则直接丢弃新生代垃圾回收,启动Full GC。

3)当新生代垃圾回收执行完成之后,判断是否可以触发Full GC,如果需要则触发Full GC。

4)当回收(指新生代回收,但在新生代回收中可能会触发Full GC)完成后,发现新生代空间或者老生代空间仍然无法满足应用的分配请求,则触发Full GC。

3.3.2 堆空间回收算法过程介绍

串行回收中Full GC使用的算法是标记-压缩(Mark-Compact)。下面演示一下标记-压缩算法的回收过程。

为了简化算法的演示过程,这里仅仅演示针对一个堆空间的情况(实际上两个代也是被看作一个堆空间处理)。假设堆在初始状况如图3-27所示,其中堆中包含了对象A、B、C、D、E、F、G。内存是连续使用的,这里为了区别不同的对象,在对象之间留了间隔,堆空间示意图如图3-27所示。

图3-27 堆空间初始状态

算法的第一步是标记堆空间中的活跃对象,在标记过程中通常借助一个额外的标记栈,采用深度优先遍历的方式来实现,主要原因是:发生Full GC时,堆空间本身已经没有可用的内存空间,所以无法设计像Cheney算法那样借助于堆空间进行标记的方法。

从根出发,对象B、D、G、F会依次被标记,结果如图3-28所示。

图3-28 标记完成后的状态

在实现时,还有一个小小的细节,那就是标记结果该如何保存?通常有以下两种方法:

1)通过额外的数据结构来记录结果。例如使用一个位图(bitmap),将活跃对象信息记录在位图中,当标记完成后通过该位图可以找到所有活跃对象。位图的结构也非常简单,例如使用一个位(bit)描述一个字(word)中对应对象是否是活跃对象,类似这样的设计内存浪费也并不多。在后面介绍的Parallel、G1涉及并行标记-压缩的实现都会采用这种方法。

2)使用对象头信息进行记录。直接将标记信息记录在对象头中,在遍历整个堆空间时直接访问对象并获得对象活跃信息。该方法并不需要额外的内存空间(对象活跃信息通常只需要一个位来描述,在对象地址的系统中不会引入额外的内存消耗,在JVM中存在一个MarkWord记录各种信息,对象活跃信息也存储在MarkWord中),所以适用于内存紧张的场景。串行回收只有在资源紧张的情况下才会选择使用,所以在串行回收中并未使用位图来保存标记信息,而是直接使用对象头保存信息。

在标记完成之后,要做的是计算活跃对象在压缩后空间中的位置。例如图3-29中对象A是死亡的,所以对象B在压缩后要从头开始,其位置就是对象A起始的地方。为了便于演示,将对象B新的位置记为B'。对象B'将覆盖对象A,并且会覆盖对象B的一部分内存。对象B的新位置示意图如图3-29所示。

图3-29 活跃对象B的新位置示意图

活跃对象B、D、F、G(对象按照地址顺序访问,与标记顺序无关)都会计算新的位置,假设新位置为B'、D'、F'、G'。为了演示方便,将B'、D'、F'、G'画在堆的下半部分(实际上这一部分并不存在),对象B'、D'、F'、G'最后应该位于堆的上面部分中。另外还要强调一点,该步执行完成之后,对象B'、D'、F'、G'并未被移动,只是计算对象新的地址,并将新的地址记录在MarkWord中,供下一步使用。活跃对象新位置的示意图如图3-30所示。

图3-30 活跃对象新位置示意图

在对象新地址计算完成后,需要处理对象的字段引用。例如图3-30中对象B引用对象D和对象F,而对象D和对象F将被压缩移动到D'和F'的位置,所以要做的就是修正对象B的字段,将其调整到正确的位置上。这里使用浅蓝色的实线描述对象引用应该指向的目标位置。在这一步中对象内部的数据需要更新。该步骤完成后,对象的引用关系都已经更新完成,结果如图3-31所示。

图3-31 根据对象新位置更新对象引用关系中的指针示意图

在对象的位置计算完成,并将对象的内部引用字段都更新到引用对象新的地址之后,还剩下一个重要的步骤,那就是将对象复制到新的位置。由于位置已经确定,且对象内部字段都已经更新,因此只需要将内存数据直接复制到新的位置。复制完成后整个堆的使用情况如图3-32所示。

3.3.3 适用于分代的标记压缩算法

标记压缩算法是从内存空间的起始位置开始压缩内存空间中的活跃对象。引入分代以后,标记压缩算法的实现要有所变化。最关键的问题就是标记压缩的起始位置该如何确定。

图3-32 堆压缩后的示意图

在JVM中整个内存被分为新生代和老生代,新生代又分为Eden、From和To空间。通常情况下,发生Full GC时,Eden、From和老生代空间都使用“完毕”(完毕是一个近似的说法,这3个空间内存的使用情况与Full GC的触发有关),假设这3个空间存在一定量的活跃对象,如图3-33所示。

图3-33 分代活跃对象示意图

新生代中活跃对象用黑色表示,老生代中活跃对象用蓝色表示。因为压缩仅涉及处理活跃对象,所以不活跃对象在图中并未展示。

这里存在两个内存空间,而且两个空间的作用不同,新生代存放生命周期短的对象,老生代存放生命周期长的对象。根据压缩算法,针对分代可以有以下3种不同的处理方式:

1)每个代空间分别压缩。即新生代中的Eden、From和老生代都从自己的空间起始地址开始压缩对象,效果如图3-34所示。

图3-34 分代分别压缩示意图

2)把所有的代压缩在一起,从新生代开始压缩。即先对新生代压缩,再把老生代中的活跃对象压缩到新生代中剩余的空间中,当新生代无法存储所有的对象时,再从老生代开始存储,如图3-35所示。

图3-35 分代压缩从新生代开始存储

3)把所有的代压缩在一起,从老生代开始压缩。即先对老生代压缩,再把新生代中的活跃对象压缩到老生代中剩余的空间中,当老生代无法存储所有的对象时,再从新生代开始存储,如图3-36所示。

图3-36 分代压缩从老生代开始存储

这3种方法均有优点和不足,哪一种实际效果更好一些呢?另外,不同的方案对于引用集的实现也有很大的影响。由于对象发生移动,记录对象引用关系的引用集也必须做相应的变化,不同的方案对于引用集的处理也不相同。3种压缩方案比较如表3-3所示。

表3-3 3种压缩方法比较

综合考虑内存利用、引用集重构、实现复杂度等因素,JVM中采用的是方案3,并对引用集的重构做了稍许的优化。对于执行Full GC后所有的对象都能压缩到老生代,此时不再需要引用集记录引用关系,因为新生代没有活跃对象,所以将引用集清空即可。这种情况是执行Full GC后最期望的情况,实际上也是出现概率比较多的情况。此外,执行Full GC后老生代不能完全存储所有活跃对象,部分对象压缩到新生代中,理论上需要遍历老生代重构引用集,但JVM在实现时把重构工作推迟到下一次Minor GC中。其做法是把整个老生代都视为存在指向新生代的引用,即引用集包含了整个老生代。在下一次Minor GC中,当处理引用集这个根的时候,会先清除引用关系,再遍历对象的成员变量是否存在指向新生代的引用,如果存在则标记、转移,不存在则跳过;转移后如果发现仍然存在代际引用,则重置引用集。这样的过程实际上刚好就是重构引用集的过程。

3.3.4 标记-压缩的优化

性能问题是标记压缩算法最大的问题。上述演示明确地指出整个算法需要对堆空间进行4次扫描。在分代压缩时以老生代空间的起始地址为起点开始压缩整个内存空间的活跃对象,满足强分代理论的假设。但在实现中还有一种情况,当Full GC执行多次以后,理论上整个老生代起始的内存空间存放的都是生命周期很长的对象,而且这些对象都紧密相连(为保证堆空间的可解性)。但可能出现这样的情况,其中的一个对象不再活跃(死亡),则需要把后面所有的对象都移动并填充这个死亡对象所占的空间。可以想象一个连续的数组,里面都包含了数据,如果删除数组的第一个元素,为了保证数组的紧凑,需要把数组中所有的元素都往前复制一个位置。当数组非常大的时候,为了这一个元素空间而移动大量的元素并不划算,所以一个优化是针对老生代头部的内存空间可以容忍一定数量的死亡对象,只有当死亡对象占比达到一定比例后才从头压缩,这是一种典型的用空间换时间的做法。对于这样的优化需要考虑两个问题:

1)如何统计或计算死亡对象所占的空间?

2)如何在不增加额外耗时的情况下跳过死亡对象?

要完美地解决这两个问题,需要额外的时间来统计对象的活跃信息。对于Full GC来说,引入额外的统计阶段花费的时间可能比不跳过死亡对象的成本更高,所以需要尽量避免进行对象的统计。

对于这两个问题,JVM的解决方法非常简单:设置一个阈值 ,即容忍死亡对象占内存空间的比例。在计算活跃对象位置时,会对死亡对象进行计数,若累计死亡对象大小不超过阈值,则跳过死亡对象(相当于留下一个空洞);在该过程中一旦发现累计死亡对象的大小超过该阈值,后续的对象就会按照标准的算法进行移动、压缩。在跳过死亡对象后,内存的布局如图3-37所示。

图3-37 跳过死亡对象后内存的布局

图中蓝色对象之间存在空洞,黑色对象之间不会存在空洞,都是紧密相连的。

注意

当前JVM的实现是为了避免对象统计而选择的折中方案,该方案可能会导致空间的头部存在较多的内存空洞(甚至随着时间的推移,会导致内存空间的头部中死亡对象占比超过活跃对象)。而当Full GC执行一定次数之后,跳过死亡对象内存空间的后续内存空间中也会存在同样的移动成本问题(大量对象是活跃对象,偶尔有死亡对象,但此时不再跳过死亡对象,还是会导致对象的移动)。

一个可能的修正方法是,阈值应该随着Full GC的执行次数发生变化,例如在Full GC执行次数还比较少时,阈值可以设置得稍微小一些,随着Full GC执行次数的增加,阈值可以变大,这样能在一定程度上缓解该问题。

最后再提一个小的问题:空洞该如何解决?JVM中要求内存中必须连续地分配对象,不能存在空洞,现在为了减少可能的移动产生了空洞,该如何处理?这个问题的处理思路大概如下: gWv/gCZGSPM6sE8tsiPpDPeYvY8KLbDB/bqfhBcG5r9gob9l0nBQvl9ZM67HdhEn

点击中间区域
呼出菜单
上一章
目录
下一章
×