当前位置: 首页 > 科技观察

本文带你了解经典的Java垃圾回收机制

时间:2023-03-19 16:43:19 科技观察

在Java8中,HotSpot虚拟机默认的垃圾回收器是ParallelOld。在Java11中,默认收集器变成了G1。注意:收集器开关在技术上是在Java9中制作的,但对G1的主要增强是在Java10和11中进行的。但实际上,很少有公司使用JavaLTS以外的版本。在这篇文章中,我们将讨论垃圾收集理论的一些基础知识以及它是如何在HotSpot中实现的。这也将解释为什么Java的默认垃圾收集器被切换,以及Java垃圾收集方法最近的一些变化。1.基本概念垃圾收集是一种系统“清理”活动,独立于应用程序的主处理线程,试图找到不再使用的内存并释放它,以便继续重用。Dijkstra对垃圾收集的定义明确指出,引用计数是一种自动内存管理的形式,但不是垃圾收集。引用计数在程序运行时更新每个对象的元数据(例如,在为引用类型对象的字段赋值时)。元数据的更新需要发生在应用线程上,所以不能明确的划分成一个单独的activity。收集算法从根(一组已知为活动的对象)开始并跟踪指针以确定活动对象。这些跟踪收集器实现了一种图形算法,将堆内存分为活动内存和可回收内存。在现代垃圾收集文献中,同时使用并发(Concurrent)和并行(Parallel)来描述收集算法。它们听起来像是同义词,但它们实际上具有完全不同的含义:并发——收集线程独立于应用程序线程运行;parallel-使用多个线程来执行垃圾收集算法。它们可以看作是其他两个术语的对立面——并发是停止世界(STW)的对立面,并行是单线程的对立面。实际的垃圾收集器分为多个阶段,每个阶段也可能有不同的特点。例如,一个阶段可能是单线程并发或并行STW。注意:并发收集器比STW收集器复杂得多。它们的计算成本要高得多,并且对它们的行为有一些警告。您应该知道的其他垃圾收集术语:精确-精确收集器具有足够的类型信息,能够区分int和指针。Evacuate-将活动对象移动(逐出)到内存的另一个区域。在收集周期结束时,源内存区域变空,可以重复使用。Compact——在回收周期结束时,存活的对象被不断的放置在内存的前端区域,剩下的区域可以重复使用。Exact是一种保守模式,缺乏精确信息,因此通常会造成较大的内存浪费。一些消息来源还提到移动收集器——包括压缩和驱逐算法。但是这两种类型之间的差异是如此之大,以至于将它们结合起来通常不是很有用。非移动收集器被称为就地收集器。这些算法需要知道可用内存块的列表,以便能够处理内存碎片并合并可用内存块。2.HotSpot中的一些设计考虑让我们从定义开始,考虑一些基本事实:由移动收集器分配的对象在其生命周期内没有稳定的内存地址。压缩收集器可用于避免内存碎片。逐出收集器还避免了内存碎片并允许对幸存对象进行部分压缩。如果堆只包含一个内存池,则无法使用驱逐算法回收它。分代假设基于对面向对象系统运行时行为的观察,将对象大致分为两类:短期临时对象和用于执行程序任务的长期对象。注意:分代收集器并不总是比非分代收集器更高效,但几乎所有应用程序都会受益于分代收集器。回收算法(根据Blackburn和McKinley)的mark-sweep-compact定义如下:Mark:通过跟踪对象图来识别幸存对象。清除:将幸存的对象留在原地,同时识别可用空间。Evacuate:将幸存的对象转移到另一个内存池以释放空间。Compact:通过移动同一个内存池中幸存的对象来释放空间。在分代收集算法中,新生代收集器和老年代收集器通常使用完全不同的算法。这使得我们很难准确地对不同阶段使用不同算法的收集器进行分类。例如,在CMS中,新生代是通过逐出算法收集的,而老年代是通过标记-清除算法收集的,如果并发收集失败(例如由于碎片),则回退到标记-压缩算法。3.HotSpot中的年轻代垃圾收集在HotSpot中,传统的收集器将内存分为四个内存池,分别是Eden、Survivor0、Survivor1和Tenured。前三者统称为年轻代,Tenured为老年代。年轻代空间在年轻代回收周期内被回收,使用并行STW驱逐算法将存活对象转移到一个空间中。回收算法标记当前活动内存池中的活动对象,然后将它们疏散到非活动内存池。在收集结束时,两个空间被颠倒——活动内存池变为非活动内存池(即空),非活动内存池变为活动内存池。这有时被称为“半球”回收。半球回收会浪费内存。single-pass算法无法事先知道被回收的内存区域中还有多少对象存活。这意味着用于存储被逐出对象的区域必须与被清理的区域一样大——因此该算法需要内存中实际存活对象大小的两倍。这也意味着一半的空间始终是空的。这些特性使其不适合现代工作负载中的老年代垃圾收集,因为这些老年代中的对象集合可能很大:实际上,在生产环境中,HotSpot收集器不使用半球收集算法。半球回收算法用于回收年轻代。非常适合满足分代假设的工作负载——即内存区域大部分是垃圾对象。收集器受益于活动对象总是从年轻一代提升到老一代这一事实。逐出收集器的另一个主要优点是它们处理可用空间的方式。最简单的方法是使用指向可用空间的指针,当活动对象被逐出时,这些空间会“自然地”压缩。驱逐算法是典型的OpenJDK年轻代收集器,它使用对象跟踪。然而,收集只发生在一个阶段,没有单独的标记、清除或压缩阶段。4.分代假设的后果一个对象的生命周期通常是未知的,并且在实际应用中会动态变化。因此,跟踪对象的实际生命周期是不可行的。相反,HotSpot记录对象在垃圾回收中存活的次数。它只需要在对象头中的元数据中添加几位信息。在对象经历了足够多的垃圾收集之后,它将被移动(提升)到老一代,由不同的垃圾收集器管理。这种机制与应用程序的内存分配速度有一个有趣的相互作用。如果分配速度更快,那么年轻一代将很快被填满——但“短命对象”的预期寿命(以毫秒为单位)保持不变。这会导致更多的对象在收集周期中幸存下来,导致年轻代空间充满了还没有资格提升到老年代的对象。在这种情况下,JVM别无选择,只能提前提升一些对象——这会导致“过早提升”。其中很多对象其实是短暂的,进入老年代后很快就消失了。可惜JVM没有回收它们的机制,要等到老年代空间的下一个回收周期才能回收。5.垃圾收集算法的复杂性开发人员经常对垃圾收集算法进行复杂性分析(有时称为“大O”)。然而,在实践中,这种做法其实并不是很令人满意。他们可能会天真地假设标记和压缩阶段的时间复杂度与活动对象集合的大小成线性关系,而清理阶段与整个堆大小成线性关系。然而,即使忽略在实际实现中阶段隔离可能不干净的事实(如上面讨论的HotSpot年轻代收集器),仍然存在更深层次的问题。垃圾收集本质上是一种通用算法。这意味着BigO分析中的固有假设——限制性行为随着数据集的增长而发挥作用——是不正确的。生产中的算法需要对所有可能的输入和工作负载表现出可接受的行为。它们的渐近行为与整体性能不匹配。换句话说,活动对象集合和堆大小本质上是独立变化的(例如,不同的对象图拓扑)。这意味着缩放因子可能对不同的工作负载产生非常不同的影响。例如,压缩需要复制字节,因此虽然压缩阶段与活动对象集合的大小成线性关系,但其他因素可能与被移动对象的大小有关。对于元素数量多的大数组,这种说法就更站不住脚了。对于各种形式的回收算法,还有一些众所周知的二阶效应。例如,当对只有少数活动对象(“稀疏堆”)的内存区域执行压缩时,活动对象被合并到一个更密集的区域。如果对象是长期存在的,则该区域对于后续收集周期来说将不那么稀疏。我们可以看到,与像CMS这样的就地收集器相比,长寿命对象在程序的整个生命周期中将保持稀疏分布。事实上,空闲空间会随着时间的推移变得越来越碎片化,空闲内存块列表的管理会变得越来越昂贵。一般来说,不同回收方式的时间和空间成本模型不同,简单的算法复杂度分析用处不大。在HotSpot中,如果没有足够的连续空间,就地收集器最终会退回到压缩收集器。6.总结我们讨论了Java虚拟机的垃圾回收机制。垃圾收集是计算机科学的一个成熟领域,HotSpot的垃圾收集器经过充分测试,可以很好地处理大型堆工作负载。大多数Java应用程序不需要太担心垃圾收集行为。对垃圾回收的原理(以及它是如何在JVM中实现的)有深入的了解对于对垃圾回收行为敏感的开发人员很有帮助。在最近的Java版本中,垃圾收集子系统的改进再次成为人们关注的焦点。要充分理解这些变化,需要很好地掌握基础知识。后续文章将详细讨论这些更新,例如,为什么更改默认收集器,以及这对升级到Java11的团队意味着什么。