程序员躺平也要学习的JVM垃圾回收器详解串行回收老生代内存管理

本文详细解释了老生代内存管理的分配和回收过程,特别是强调了串行回收时老生代回收的全局性质。文章探讨了分代理论下的内存分配策略,以及为何采用整个堆空间回收而非单独回收,还介绍了标记-压缩算法在分代环境中的优化措施。
摘要由CSDN通过智能技术生成

老生代内存管理

老生代内存管理也可以分为两个部分:分配和回收。

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

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

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

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

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

在介绍分代时一直在强调一个概念,即不同的内存空间存放的对象生命周期不同,采用不同的算法进行回收可以得到更好的吞吐量。

为什么这里的第二种方案否定这样的做法呢?其最主要的原因与串行回收中老生代内存分配机制相关,次要原因与垃圾回收时标记的成本相关。

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

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

2)在进行老生代遍历之前,做一次新生代回收,回收完成后新生代的存活对象(位于Survivor)可以直接作为老生代的根,从而不需要遍历整个堆空间。在老生代的垃圾回收的实现中,这是一种相对常用的手法,称为“借道”。

这样的设计是否适合串行回收呢?由于串行回收无论是新生代回收还是老生代回收都是单线程执行的,因此采用这样的方式需要设计两种回收算法的交互方式(在老生代的垃圾回收去触发新生代垃圾回收),且还需要再考虑代际之间的引用关系变化(实际上并行回收也有同样的问题)。“借道”方法过于复杂,通常用于增量并发的老生代回收实现中。

3)把老生代和新生代看作一个整体,采用压缩算法进行整体回收。代际关系处理在实现时可能变得简单。当然这种方法是分代回收的优化实现,适用于串行垃圾回收器和并行垃圾回收器。根据这3种思路,串行回收采用整个堆空间回收的方法实现最为简单、高效。

堆空间回收的触发

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

GC工作线程在Minor GC需要晋升对象时,可以在老生代中分配对象。

应用程序在特殊情况下可以直接在老生代中分配对象。特殊情况通常指的是新生代无法满足应用程序分配请求,老生代有较大的空间,或者应用程序请求一个较大的对象,对象大小超过一定阈值。

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

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

堆空间回收算法过程介绍

串行回收中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-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中,当处理引用集这个根的时候,会先清除引用关系,再遍历对象的成员变量是否存在指向新生代的引用,如果存在则标记、转移,不存在则跳过;转移后如果发现仍然存在代际引用,则重置引用集。这样的过程实际上刚好就是重构引用集的过程。

标记-压缩的优化

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

当数组非常大的时候,为了这一个元素空间而移动大量的元素并不划算,所以一个优化是针对老生代头部的内存空间可以容忍一定数量的死亡对象,只有当死亡对象占比达到一定比例后才从头压缩,这是一种典型的用空间换时间的做法。对于这样的优化需要考虑两个问题:

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

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

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

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

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

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

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

最后再提一个小的问题:空洞该如何解决?

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

保持原来的死亡对象不变(相当于把死亡对象再次激活),这是一个最自然的方案。但是该方案存在一定的问题,就是在垃圾回收的过程中,当对象死亡之后,对象关联的元数据也可能死亡,所以仅仅保持死亡对象不变还是不够的,需要把该死亡对象关联的元数据对象也保留,不能回收,这样的处理方法就非常复杂了。

填充一个Dummy对象,只要确保Dummy对象的元数据对象都存活就可以了(没有任何的根或者其他对象指向这些Dummy对象,Dummy对象仅仅是为了保证堆的可解析性)。

有一些基本类型的Java对象会一直存活,即便没有使用也不会回收,这里就可以利用这些对象产生Dummy的Java对象。为了高效地填充这些空洞,可以直接利用Java的int[]类型进行填充(内存连续分配)。这也是我们在进行内存分析的时候(例如使用MAT工具),应用中可能并未使用任何int[]类型来产生对象,但整个堆空间中存在大量136的int[]类型对象。除了TLAB可能填充Dummy外,标记压缩回收也会填充。

本文给大家讲解的内容是JVM垃圾回收器详解:串行回收,老生代内存管理

  • 29
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值