虚拟机:分代收集策略(HotSpot)

虚拟机:分代收集策略(HotSpot),第1张

虚拟机:分代收集策略(HotSpot)

后续我会陆陆续续更新虚拟机的源码,原理,和介绍。大家如果觉得对自己有用就点个关注吧。

声明

本文大部分内容摘自于《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》 — 周志明
并加上一些我自己的理解,和查阅的资料

注释

 分代收集是在考虑过如何判断对象死活的算法,之后进一步讨论的内容,所以这部分讨论的不是如果判断对象死活

 关于对象死活的判断逻辑(算法)如果有不清楚的请看我的另一篇文章,然后再回来看这篇文章会更连贯。——虚拟机:垃圾收集器如何判断对象的死活(HotSpot)

分代假说

 市面许多虚拟机,设计原则都遵循了两个假说。

  • 弱分代假说:绝大多数对象都是朝生夕灭的。
  • 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。

 多款常用的虚拟机,基于这个假说,将Java堆内存分出了不同的区域:在新生代的区域、老年代区域。并且依据对象的“年龄”分配到不同的区域。在新生代的区域,由于大部分对象都是朝生夕灭的,所以我们就可以关注如何保留少部分存活下来对象,而不是标记大量准备回收的对象。这样就可以较低系统开销并且回收大量的内存空间。

对象的年龄——对象熬过垃圾回收的过程次数。

 随着对象年龄的增长,一些难以回收的对象会被分配到老年代区域,这部分区域的内存,就可以用较低的频率去执行垃圾回收。这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。由这种分区域存放对象的方式引出了——新生代收集、老年代收集、整堆收集、混合收集的概念;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制算法”“标记-清除算法”“标记-整理算法”等针对性的垃圾收集算法。

混合收集:回收所有新生代区域和部分老年代区域,目前使用这种方式的收集器很少。

跨代引用

 其实这种分代概念也不是完美的,因为一些细节上还是存在一些问题的,比如:对象并不是孤立的,新生代区域的对象和老年代区域的对象之间难免会产生引用——跨代引用。如果我们只对新生代区域执行垃圾回收,为了找出该区域中存活的对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反之也是一样的。这样会大大增加GC行为的工作量,也同时带来了一些性能问题。为了解决这个问题,我们便产生的第三条假说:

  • 跨代引用假说:跨代引用相对于同代引用来说仅仅占极少数。

其实这个假说不难理解,根据上面的两个假说,老年代是不易死亡的对象,所以和老年代有引用关系的对象也易被回收,这样经过几次GC过程,新生代的对象也会变成老年代,这样他们就会慢慢变成同代引用。

GC Roots 涉及到一个叫可达性分析算法,用来判断Java堆中哪些对象可以被回收——虚拟机:垃圾收集器如何判断对象的死活(HotSpot)

 依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

垃圾收集算法

 上一段我们提到了三种分代算法:“标记-复制算法”、“标记-清除算法”、“标记-整理算法”,现在我们来逐个介绍一下。

标记-清除算法

 这种算法正如其名,会经历两个过程。标识和清除,标记需要清除的对象,清除已标记对象,反之亦可——标记需要保留的对象,清除没有标记的对象。这个算法是最基础的算法,因为之后的算法都是对其的优化版本。
 这样的算法有两个很明显缺点:

缺点
  1. 运行效率不稳定,他执行的快慢是由需要清理对象的数量决定的,如果Java堆中的对象数量庞大,需要清理的对象也非常多,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。
  2. 内存碎片化问题,这样的清除方式会导致Java堆内存空间产生大量不连贯的内存碎片,这样的情况会导致将来如果需要为较大的对象分配空间时,由于在堆内存中找不到足够大且连续的空间而导致提前的垃圾回收行为。

当然为对象分配内存空间不一定非要是连续的。如果不连续分配,那么可能就需要一个表来记录每一个对象被分到了哪些内存碎片,这就需要新的算法来处理这件事情,这个时候我们就需要权衡利弊,要不要用这种方式,后面还会提到这件事。

标记-复制算法

 这种算法解决了上面的内存碎片的产生,标记行为和上面一样,不同的是在复制行为,这种算法会把需要回收的内存区域平分为两个半区:“内存一”、“内存二”,为对象分配空间时只使用其中一份(比如使用内存一),在某一时刻“内存一”区域已经不能为新对象分配足够的空间了。这个时候就会触发GC(垃圾回收)行为,标记所有需要保留的对象,然后将所有标记对象按有序的方式复制到另一份内存区域(内存二),然后再将整个“内存一”区域全部回收。接下来在“内存二”中继续为新对象分配内存空间,当“内存二”被分配满了以后就重复上面的 *** 作。

 这种算法是1969年Fenichel提出的一种称为“半区复制”(Semispace Copying)的垃圾收集算法

优点
  • 因为每次GC(垃圾回收)行为都是半区清理,所以每次为新对象分配内存的时候不需要考虑内存碎片的问题,只需要移动堆上面的指针按顺序分配即可,这样实现简单,并且高效。
缺点
  • 空间浪费一半,这实在是太多了。这很明显比如虚拟机为新生代内存区域分配1G的空间,如果采用这种算法,你实际上能使用的空间只有512MB。
  • 这个不能算缺点,但是也要提一下,如果需要回收的对象很少,那就意味有大量的对象需要进行内存复制行为,这也是一笔不小的开销,所以根据上面的几个假说,这种算法比较适合在回收新生代区域时使用。
  • 弱分代假说:绝大多数对象都是朝生夕灭的。
  • 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
更优解(Appel式回收)

 IBM公司曾有一项专门研究对新生代“朝生夕灭”的特点做了更量化的诠释——新生代中的对象有98%熬不过第一轮收集。因此并不需要按照1∶1的比例来划分新生代的内存空间。
 HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局。
 Appel式回收的具体做法,把新生代分为三块——Eden空间和两块较小的Survivor空间(比例:8:1:1),每次分配内存只使用Eden和其中一块Survivor。发生GC行为时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。这种方式就可以将原来浪费50%降到10%
 这种做法一般情况下是可以处理大部分情况的。但是一旦出现需要复制的存活对象总需内存大于其中一个Survivor的内存空间,这时就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保。

分配担保——如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的

标记-整理算法

 上面的标记-复制算法,无论用哪一种都存在内存浪费的问题。如果选择只浪费内存10%的解决方案还需要拿其他区域的内存做分配担保。所以对于存活率很高的老年代一般不直接使用这种算法。
 对于这部分区域对象的特殊性就引出了“标记-整理”算法,这种算法的前半部分 *** 作和“标记-清除”是一样的,先标记区域内存所有需要清理的对象,然后将其统统回收,但是为了解决内存碎片的问题,就需要多做一步“整理”——将存活的对象都向内存空间的一端移动,然后直接清理掉边界以外的内存。

 这种方式优缺点并存。

优点
  • 很明显这种方式,不会浪费内存,并且对内存进行了整理,在为新对象分配内存空间时就会变得简单而高效。
缺点
  • 针对老年代区域这种每次回收都会有大量对象存活的情况,就意味着有大量的对象需要进行内存移动并且更新对应的引用,所以每到GC运行到这个阶段时工作是很繁重的,而且这种对象移动 *** 作必须全程暂停用户应用程序才能进行,这就更加让使用者不得不小心翼翼地权衡其弊端了。

 对比一下标记-清除算法,这种方法虽然快,但是正如我们之前说的,这样的 *** 作会产生大量的内存碎片,如果想利用这些内存碎片,只能依赖更为复杂的内存分配器和内存访问器来解决,而系统中最为频繁的 *** 作恰恰就是内存访问。如果增加了这个环节的工作量就意味着一定会影响应用程序的吞吐量

总结

 两个方案都有弊端,所以具体采用那种解决方案需要开发者在吞吐量和虚拟机暂停时间之间权衡。

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/zaji/4014539.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-10-22
下一篇 2022-10-22

发表评论

登录后才能评论

评论列表(0条)

保存